diff --git a/.vscode/launch.json b/.vscode/launch.json index 9ae82f1..38989c1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "type": "dart", "program": "lib/main.dart", "args": [ - "--dart-define=BASE_URL=http://192.168.1.105:8080", + "--dart-define=BASE_URL=http://192.168.1.104:8080", "--target-platform=android-arm64" ] }, diff --git a/assets/images/badge/bronze.png b/assets/images/badge/bronze.png new file mode 100644 index 0000000..6115b5a Binary files /dev/null and b/assets/images/badge/bronze.png differ diff --git a/assets/images/badge/diamond.png b/assets/images/badge/diamond.png new file mode 100644 index 0000000..2508d94 Binary files /dev/null and b/assets/images/badge/diamond.png differ diff --git a/assets/images/badge/gold.png b/assets/images/badge/gold.png new file mode 100644 index 0000000..07982b5 Binary files /dev/null and b/assets/images/badge/gold.png differ diff --git a/assets/images/badge/king.png b/assets/images/badge/king.png new file mode 100644 index 0000000..afccd06 Binary files /dev/null and b/assets/images/badge/king.png differ diff --git a/assets/images/badge/silver.png b/assets/images/badge/silver.png new file mode 100644 index 0000000..a4cb371 Binary files /dev/null and b/assets/images/badge/silver.png differ diff --git a/assets/images/cards/10C.png b/assets/images/cards/10C.png deleted file mode 100644 index 18af741..0000000 Binary files a/assets/images/cards/10C.png and /dev/null differ diff --git a/assets/images/cards/10D.png b/assets/images/cards/10D.png deleted file mode 100644 index 3bbc4e0..0000000 Binary files a/assets/images/cards/10D.png and /dev/null differ diff --git a/assets/images/cards/10H.png b/assets/images/cards/10H.png deleted file mode 100644 index 3eb83d7..0000000 Binary files a/assets/images/cards/10H.png and /dev/null differ diff --git a/assets/images/cards/10S.png b/assets/images/cards/10S.png deleted file mode 100644 index 0b3d294..0000000 Binary files a/assets/images/cards/10S.png and /dev/null differ diff --git a/assets/images/cards/2C.png b/assets/images/cards/2C.png deleted file mode 100644 index 291ed97..0000000 Binary files a/assets/images/cards/2C.png and /dev/null differ diff --git a/assets/images/cards/2D.png b/assets/images/cards/2D.png deleted file mode 100644 index 4deee7c..0000000 Binary files a/assets/images/cards/2D.png and /dev/null differ diff --git a/assets/images/cards/2H.png b/assets/images/cards/2H.png deleted file mode 100644 index 75a014f..0000000 Binary files a/assets/images/cards/2H.png and /dev/null differ diff --git a/assets/images/cards/2S.png b/assets/images/cards/2S.png deleted file mode 100644 index 1ce0ffe..0000000 Binary files a/assets/images/cards/2S.png and /dev/null differ diff --git a/assets/images/cards/3C.png b/assets/images/cards/3C.png deleted file mode 100644 index 076ab31..0000000 Binary files a/assets/images/cards/3C.png and /dev/null differ diff --git a/assets/images/cards/3D.png b/assets/images/cards/3D.png deleted file mode 100644 index 8ee0b4b..0000000 Binary files a/assets/images/cards/3D.png and /dev/null differ diff --git a/assets/images/cards/3H.png b/assets/images/cards/3H.png deleted file mode 100644 index 8e74673..0000000 Binary files a/assets/images/cards/3H.png and /dev/null differ diff --git a/assets/images/cards/3S.png b/assets/images/cards/3S.png deleted file mode 100644 index f9e06b4..0000000 Binary files a/assets/images/cards/3S.png and /dev/null differ diff --git a/assets/images/cards/4C.png b/assets/images/cards/4C.png deleted file mode 100644 index 8be9e08..0000000 Binary files a/assets/images/cards/4C.png and /dev/null differ diff --git a/assets/images/cards/4D.png b/assets/images/cards/4D.png deleted file mode 100644 index 70e82e8..0000000 Binary files a/assets/images/cards/4D.png and /dev/null differ diff --git a/assets/images/cards/4H.png b/assets/images/cards/4H.png deleted file mode 100644 index ceecbfe..0000000 Binary files a/assets/images/cards/4H.png and /dev/null differ diff --git a/assets/images/cards/4S.png b/assets/images/cards/4S.png deleted file mode 100644 index 95abe3e..0000000 Binary files a/assets/images/cards/4S.png and /dev/null differ diff --git a/assets/images/cards/5C.png b/assets/images/cards/5C.png deleted file mode 100644 index bde9777..0000000 Binary files a/assets/images/cards/5C.png and /dev/null differ diff --git a/assets/images/cards/5D.png b/assets/images/cards/5D.png deleted file mode 100644 index bb92525..0000000 Binary files a/assets/images/cards/5D.png and /dev/null differ diff --git a/assets/images/cards/5H.png b/assets/images/cards/5H.png deleted file mode 100644 index d923456..0000000 Binary files a/assets/images/cards/5H.png and /dev/null differ diff --git a/assets/images/cards/5S.png b/assets/images/cards/5S.png deleted file mode 100644 index 53a1aad..0000000 Binary files a/assets/images/cards/5S.png and /dev/null differ diff --git a/assets/images/cards/6C.png b/assets/images/cards/6C.png deleted file mode 100644 index a9660a0..0000000 Binary files a/assets/images/cards/6C.png and /dev/null differ diff --git a/assets/images/cards/6D.png b/assets/images/cards/6D.png deleted file mode 100644 index 78a80ad..0000000 Binary files a/assets/images/cards/6D.png and /dev/null differ diff --git a/assets/images/cards/6H.png b/assets/images/cards/6H.png deleted file mode 100644 index 361643e..0000000 Binary files a/assets/images/cards/6H.png and /dev/null differ diff --git a/assets/images/cards/6S.png b/assets/images/cards/6S.png deleted file mode 100644 index 40242a7..0000000 Binary files a/assets/images/cards/6S.png and /dev/null differ diff --git a/assets/images/cards/7C.png b/assets/images/cards/7C.png deleted file mode 100644 index 9d6b545..0000000 Binary files a/assets/images/cards/7C.png and /dev/null differ diff --git a/assets/images/cards/7D.png b/assets/images/cards/7D.png deleted file mode 100644 index 6ad5f15..0000000 Binary files a/assets/images/cards/7D.png and /dev/null differ diff --git a/assets/images/cards/7H.png b/assets/images/cards/7H.png deleted file mode 100644 index 19b89a2..0000000 Binary files a/assets/images/cards/7H.png and /dev/null differ diff --git a/assets/images/cards/7S.png b/assets/images/cards/7S.png deleted file mode 100644 index b9f1b93..0000000 Binary files a/assets/images/cards/7S.png and /dev/null differ diff --git a/assets/images/cards/8C.png b/assets/images/cards/8C.png deleted file mode 100644 index cec743c..0000000 Binary files a/assets/images/cards/8C.png and /dev/null differ diff --git a/assets/images/cards/8D.png b/assets/images/cards/8D.png deleted file mode 100644 index ed12951..0000000 Binary files a/assets/images/cards/8D.png and /dev/null differ diff --git a/assets/images/cards/8H.png b/assets/images/cards/8H.png deleted file mode 100644 index fb39723..0000000 Binary files a/assets/images/cards/8H.png and /dev/null differ diff --git a/assets/images/cards/8S.png b/assets/images/cards/8S.png deleted file mode 100644 index b6b3b38..0000000 Binary files a/assets/images/cards/8S.png and /dev/null differ diff --git a/assets/images/cards/9C.png b/assets/images/cards/9C.png deleted file mode 100644 index 2174db5..0000000 Binary files a/assets/images/cards/9C.png and /dev/null differ diff --git a/assets/images/cards/9D.png b/assets/images/cards/9D.png deleted file mode 100644 index 0b933fb..0000000 Binary files a/assets/images/cards/9D.png and /dev/null differ diff --git a/assets/images/cards/9H.png b/assets/images/cards/9H.png deleted file mode 100644 index 7b196d6..0000000 Binary files a/assets/images/cards/9H.png and /dev/null differ diff --git a/assets/images/cards/9S.png b/assets/images/cards/9S.png deleted file mode 100644 index 3c3b5ff..0000000 Binary files a/assets/images/cards/9S.png and /dev/null differ diff --git a/assets/images/cards/AC.png b/assets/images/cards/AC.png deleted file mode 100644 index 42bf5ec..0000000 Binary files a/assets/images/cards/AC.png and /dev/null differ diff --git a/assets/images/cards/AD.png b/assets/images/cards/AD.png deleted file mode 100644 index 79cd3b8..0000000 Binary files a/assets/images/cards/AD.png and /dev/null differ diff --git a/assets/images/cards/AH.png b/assets/images/cards/AH.png deleted file mode 100644 index b422124..0000000 Binary files a/assets/images/cards/AH.png and /dev/null differ diff --git a/assets/images/cards/AS.png b/assets/images/cards/AS.png deleted file mode 100644 index fbc3a2d..0000000 Binary files a/assets/images/cards/AS.png and /dev/null differ diff --git a/assets/images/cards/JC.png b/assets/images/cards/JC.png deleted file mode 100644 index 5e003be..0000000 Binary files a/assets/images/cards/JC.png and /dev/null differ diff --git a/assets/images/cards/JD.png b/assets/images/cards/JD.png deleted file mode 100644 index 131a977..0000000 Binary files a/assets/images/cards/JD.png and /dev/null differ diff --git a/assets/images/cards/JH.png b/assets/images/cards/JH.png deleted file mode 100644 index bf342bc..0000000 Binary files a/assets/images/cards/JH.png and /dev/null differ diff --git a/assets/images/cards/JS.png b/assets/images/cards/JS.png deleted file mode 100644 index f539c19..0000000 Binary files a/assets/images/cards/JS.png and /dev/null differ diff --git a/assets/images/cards/KC.png b/assets/images/cards/KC.png deleted file mode 100644 index 68e5774..0000000 Binary files a/assets/images/cards/KC.png and /dev/null differ diff --git a/assets/images/cards/KD.png b/assets/images/cards/KD.png deleted file mode 100644 index e21d6a0..0000000 Binary files a/assets/images/cards/KD.png and /dev/null differ diff --git a/assets/images/cards/KH.png b/assets/images/cards/KH.png deleted file mode 100644 index 1d3c468..0000000 Binary files a/assets/images/cards/KH.png and /dev/null differ diff --git a/assets/images/cards/KS.png b/assets/images/cards/KS.png deleted file mode 100644 index 2edbbc1..0000000 Binary files a/assets/images/cards/KS.png and /dev/null differ diff --git a/assets/images/cards/QC.png b/assets/images/cards/QC.png deleted file mode 100644 index 7be5f9a..0000000 Binary files a/assets/images/cards/QC.png and /dev/null differ diff --git a/assets/images/cards/QD.png b/assets/images/cards/QD.png deleted file mode 100644 index 928f650..0000000 Binary files a/assets/images/cards/QD.png and /dev/null differ diff --git a/assets/images/cards/QH.png b/assets/images/cards/QH.png deleted file mode 100644 index 21839e6..0000000 Binary files a/assets/images/cards/QH.png and /dev/null differ diff --git a/assets/images/cards/QS.png b/assets/images/cards/QS.png deleted file mode 100644 index 7983d03..0000000 Binary files a/assets/images/cards/QS.png and /dev/null differ diff --git a/assets/images/cards/README.md b/assets/images/cards/README.md deleted file mode 100644 index d236e6e..0000000 --- a/assets/images/cards/README.md +++ /dev/null @@ -1,5 +0,0 @@ -دسته‌کارت تصویری را اینجا بگذارید: یک فایل PNG برای هر کارت با نام کد کوتاه: - AS.png 2S.png ... 10S.png JS.png QS.png KS.png (پیک/spades = S) - ...H (دل/hearts), ...D (خشت/diamonds), ...C (گشنیز/clubs) -و back.png برای پشت کارت. -تا وقتی این تصاویر نباشند، صحنه به‌صورت برداری (vector) کارت‌ها را می‌کشد. diff --git a/assets/images/cards/back.jpg b/assets/images/cards/back.jpg deleted file mode 100644 index 2a5ce27..0000000 Binary files a/assets/images/cards/back.jpg and /dev/null differ diff --git a/lib/app.dart b/lib/app.dart index 4ad21c7..17e777c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -19,6 +19,8 @@ import 'feature/game/presentation/screen/tier_list_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'; +import 'feature/ranked/presentation/bloc/leaderboard_bloc.dart'; +import 'feature/ranked/presentation/screen/leaderboard_screen.dart'; import 'feature/shop/presentation/bloc/shop_bloc.dart'; import 'feature/shop/presentation/bloc/shop_event.dart'; import 'feature/shop/presentation/screen/shop_screen.dart'; @@ -46,6 +48,14 @@ class HakemApp extends StatelessWidget { child: const ProfileScreen(), ), ), + GoRoute( + path: '/leaderboard', + builder: (_, __) => BlocProvider( + create: (_) => + locator()..add(LoadLeaderboardEvent()), + child: const LeaderboardScreen(), + ), + ), GoRoute( path: '/shop', builder: (_, __) => BlocProvider( diff --git a/lib/core/config.dart b/lib/core/config.dart index 901693b..7eb8809 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -8,7 +8,7 @@ class AppConfig { /// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید. static const String baseUrl = String.fromEnvironment( 'BASE_URL', - defaultValue: 'http://192.168.1.105:8080', + defaultValue: 'http://192.168.1.104:8080', ); static String get apiUrl => '$baseUrl/api'; diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart index f0315c6..00112ba 100644 --- a/lib/core/locator/locator.dart +++ b/lib/core/locator/locator.dart @@ -24,6 +24,11 @@ import '../../feature/profile/domain/repository/profile_repository.dart'; 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/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/repository/shop_repository_impl.dart'; import '../../feature/shop/domain/repository/shop_repository.dart'; @@ -56,6 +61,7 @@ Future setupLocator() async { locator.registerSingleton(WalletApiProvider()); locator.registerSingleton(ShopApiProvider()); locator.registerSingleton(ProfileApiProvider()); + locator.registerSingleton(RankedApiProvider()); locator.registerSingleton(GameApiProvider()); locator.registerSingleton(GameWsProvider(locator())); @@ -67,6 +73,7 @@ Future setupLocator() async { locator.registerSingleton(ShopRepositoryImpl(locator())); locator.registerSingleton( ProfileRepositoryImpl(locator())); + locator.registerSingleton(RankedRepositoryImpl(locator())); locator.registerSingleton( GameRepositoryImpl(locator(), locator())); @@ -85,6 +92,8 @@ Future setupLocator() async { locator.registerSingleton(AdRewardUseCase(locator())); locator.registerSingleton(GetProfileUseCase(locator())); locator.registerSingleton(SaveProfileUseCase(locator())); + locator.registerSingleton( + GetLeaderboardUseCase(locator())); locator.registerSingleton(GetTiersUseCase(locator())); locator.registerSingleton( GetTablesInfoUseCase(locator())); @@ -97,6 +106,7 @@ Future setupLocator() async { ShopBloc(locator(), locator(), locator(), locator(), locator())); locator.registerFactory( () => ProfileBloc(locator(), locator())); + locator.registerFactory(() => LeaderboardBloc(locator())); locator.registerFactory(() => GameBloc(locator())); locator.registerFactory(() => TierBloc(locator())); locator.registerFactory(() => PrivateInfoBloc(locator())); diff --git a/lib/core/network/card_images.dart b/lib/core/network/card_images.dart new file mode 100644 index 0000000..29f962b --- /dev/null +++ b/lib/core/network/card_images.dart @@ -0,0 +1,65 @@ +import 'dart:ui' as ui; + +import 'package:dio/dio.dart'; +import 'package:flame/components.dart'; +import 'package:flutter/foundation.dart'; + +import '../config.dart'; + +/// بارگذاریِ تصاویرِ کارت از backend (نه bundle داخل اپ) تا حجم اپ کم بماند. +/// هر اسکین یک پوشه روی سرور دارد: `/cards//.png` و `/cards//back.jpg`. +/// تصاویرِ دانلودشده در حافظه کش می‌شوند تا هر کارت فقط یک‌بار از شبکه بیاید. +class CardImages { + CardImages._(); + + static const _fallbackSkin = 'simple'; + static final Dio _dio = Dio(BaseOptions( + responseType: ResponseType.bytes, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 12), + validateStatus: (s) => s != null && s >= 200 && s < 300, + )); + static final Map> _cache = {}; + + static String _url(String skin, String file) => + '${AppConfig.baseUrl}/cards/$skin/$file'; + + /// اسپرایتِ رویِ یک کارت (مثل "AS"). در صورت خطا/۴۰۴ به اسکینِ پیش‌فرض و + /// سپس به `null` (کشیدنِ برداری در CardComponent) برمی‌گردد. + static Future front(String skin, String code) => + _load(skin, '$code.png'); + + /// اسپرایتِ پشتِ کارت برای اسکین داده‌شده. + static Future back(String skin) => _load(skin, 'back.jpg'); + + static Future _load(String skin, String file) { + final key = '$skin/$file'; + return _cache.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; + } + + static Future _fetchFrom(String skin, String file) async { + 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); + } catch (e) { + debugPrint('CardImages: $skin/$file failed: $e'); + return null; + } + } + + static Future _decode(Uint8List bytes) async { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + return frame.image; + } +} diff --git a/lib/core/widgets/rank_badge.dart b/lib/core/widgets/rank_badge.dart new file mode 100644 index 0000000..29108a8 --- /dev/null +++ b/lib/core/widgets/rank_badge.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.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; + return Container( + padding: EdgeInsets.symmetric(horizontal: size * 0.6, vertical: size * 0.25), + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(size), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Image.asset( + 'assets/images/badge/$tier.png', + width: size * 1.5, + height: size * 1.5, + errorBuilder: (_, __, ___) => + Icon(Icons.military_tech, color: Colors.white, size: size * 1.1), + ), + SizedBox(width: size * 0.3), + Text( + points == null ? label : '$label · $points', + style: TextStyle( + color: Colors.white, + fontSize: size * 0.85, + fontWeight: FontWeight.bold, + ), + ), + ]), + ); + } +} diff --git a/lib/feature/auth/presentation/screen/mobile_screen.dart b/lib/feature/auth/presentation/screen/mobile_screen.dart index 5aba454..15286d5 100644 --- a/lib/feature/auth/presentation/screen/mobile_screen.dart +++ b/lib/feature/auth/presentation/screen/mobile_screen.dart @@ -39,8 +39,9 @@ class _MobileScreenState extends State { if (s is LoginSuccess) { context.push('/otp'); } else if (s is LoginError) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(s.message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(s.message))); } }, builder: (context, state) { @@ -58,33 +59,43 @@ class _MobileScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('برای ورود شماره موبایلت رو وارد کن', - style: TextStyle(color: Colors.white70)), + const Text( + 'برای ورود شماره موبایلت رو وارد کن', + style: TextStyle(color: Colors.white70), + ), const SizedBox(height: 20), TextField( controller: _controller, keyboardType: TextInputType.phone, textAlign: TextAlign.center, - style: - const TextStyle(fontSize: 20, letterSpacing: 2), + textDirection: TextDirection.ltr, + style: const TextStyle( + fontSize: 20, + letterSpacing: 2, + ), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(11), ], - decoration: - const InputDecoration(hintText: '09xxxxxxxxx'), + decoration: const InputDecoration( + hintText: '09xxxxxxxxx', + ), onChanged: (_) => setState(() {}), ), const SizedBox(height: 20), GameButton( label: 'دریافت کد', width: double.infinity, - colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], - onTap: (!_valid || loading) - ? null - : () => context - .read() - .add(LoginOtpEvent(_controller.text.trim())), + colors: const [ + Color(0xFF3FA34D), + Color(0xFF1B5E20), + ], + onTap: + (!_valid || loading) + ? null + : () => context.read().add( + LoginOtpEvent(_controller.text.trim()), + ), ), ], ), diff --git a/lib/feature/game/domain/entities/game_entities.dart b/lib/feature/game/domain/entities/game_entities.dart index 1c90a21..60e7e3d 100644 --- a/lib/feature/game/domain/entities/game_entities.dart +++ b/lib/feature/game/domain/entities/game_entities.dart @@ -5,12 +5,16 @@ class GamePlayer { final String name; final bool bot; final bool connected; + final int coins; + final String rankTier; // bronze..king (خالی برای بات) GamePlayer.fromJson(Map j) : seat = (j['seat'] ?? 0) as int, name = (j['name'] ?? '') as String, bot = (j['bot'] ?? false) as bool, - connected = (j['connected'] ?? false) as bool; + connected = (j['connected'] ?? false) as bool, + coins = (j['coins'] ?? 0) as int, + rankTier = (j['rank_tier'] ?? '') as String; } class TrickCard { diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 014db6a..4994551 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/service/app_sounds.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart'; +import '../../../wallet/presentation/bloc/wallet_status.dart'; import '../../domain/entities/game_entities.dart'; import '../bloc/game_bloc.dart'; import '../bloc/game_state.dart'; @@ -33,7 +34,12 @@ class _GameScreenState extends State { @override void initState() { super.initState(); - _game = HokmGame(context.read()); + final ws = context.read().state.walletStatus; + final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple'; + _game = HokmGame( + context.read(), + skin: skin.isEmpty ? 'simple' : skin, + ); } @override diff --git a/lib/feature/game/presentation/widgets/flame/card_component.dart b/lib/feature/game/presentation/widgets/flame/card_component.dart index 8cc0226..46dc549 100644 --- a/lib/feature/game/presentation/widgets/flame/card_component.dart +++ b/lib/feature/game/presentation/widgets/flame/card_component.dart @@ -3,25 +3,30 @@ import 'package:flame/effects.dart'; import 'package:flame/events.dart'; import 'package:flutter/material.dart'; +import '../../../../../core/network/card_images.dart'; + /// یک کارت روی میز؛ اگر تصویر `assets/images/cards/.png` موجود باشد از آن /// استفاده می‌کند، وگرنه نسخه‌ی برداری می‌کشد. برای پشت کارت `back.jpg`. /// کارت‌های قابل‌بازی فقط با **کشیدن (drag)** به سمت زمین بازی می‌شوند (نه tap). class CardComponent extends PositionComponent with DragCallbacks, HasGameReference { final String code; // مثل "AS"؛ برای پشت کارت خالی - final bool faceUp; + final String skin; // اسکینِ کارت؛ تصاویر از backend بارگذاری می‌شوند + bool faceUp; // قابلِ تغییر: برای انیمیشنِ برگرداندنِ کارت هنگام پخش VoidCallback? onPlay; // در صورت مجاز بودن، بازیِ این کارت bool dimmed; // کارت غیرمجاز/غیرفعال Vector2? home; // موقعیت اصلی در دست (برای برگشت پس از کشیدنِ ناقص) int restPriority = 0; // ترتیب لایه‌ی اصلی در دست (برای بازگردانی پس از کشیدن) Rect? dropZone; // ناحیه‌ی وسط میز؛ رهاکردن کارت در آن یعنی بازی - Sprite? _sprite; + Sprite? _front; + Sprite? _back; bool _dragging = false; bool _overZone = false; // کارت روی ناحیه‌ی انداختن است (برای هایلایت) CardComponent({ required this.code, required this.faceUp, + this.skin = 'simple', this.onPlay, this.dimmed = false, super.position, @@ -35,11 +40,24 @@ class CardComponent extends PositionComponent @override Future onLoad() async { - try { - _sprite = await game.loadSprite(faceUp ? 'cards/$code.png' : 'cards/back.jpg'); - } catch (_) { - _sprite = null; // fallback برداری + if (code.isNotEmpty) { + _front = await CardImages.front(skin, code); } + _back = await CardImages.back(skin); + } + + /// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخش‌شدن از روی دسته). + void flipToFront({double delay = 0}) { + if (faceUp) return; + add(ScaleEffect.to( + Vector2(0.04, scale.y == 0 ? 1 : scale.y), + EffectController(duration: 0.13, startDelay: delay, curve: Curves.easeIn), + onComplete: () { + faceUp = true; // در لبه‌ی نازک، رو می‌شود + add(ScaleEffect.to( + Vector2(1, 1), EffectController(duration: 0.13, curve: Curves.easeOut))); + }, + )); } // بزرگ‌نماییِ کارتِ انتخاب‌شده هنگام کشیدن (واضح‌تر برای دیدن). @@ -126,10 +144,11 @@ class CardComponent extends PositionComponent Paint()..color = Color(_dragging ? 0x66000000 : 0x44000000)); } - if (_sprite != null) { + final sprite = faceUp ? _front : _back; + if (sprite != null) { canvas.save(); canvas.clipRRect(rrect); - _sprite!.render(canvas, size: size); + sprite.render(canvas, size: size); canvas.restore(); } else if (faceUp) { _drawVectorFace(canvas, rrect); diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 0624a82..40b4755 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -26,6 +26,9 @@ import 'turn_timer.dart'; class HokmGame extends FlameGame { final GameBloc cubit; + /// اسکینِ کارت (از فروشگاه/کیف‌پول انتخاب می‌شود)؛ تصاویر از backend می‌آیند. + final String skin; + // وضعیت بازی و اشتراکِ stream. GameState? _s; StreamSubscription? _sub; @@ -35,6 +38,7 @@ class HokmGame extends FlameGame { final Map _trick = {}; final List _backs = []; final List _info = []; + final Map _badge = {}; // نشان‌های رتبه (پیش‌بارگذاری) // ابعادِ کارت‌های روی میز (بر اساس عرض صفحه محاسبه می‌شود). double _cardW = 60; @@ -44,6 +48,7 @@ class HokmGame extends FlameGame { double _shake = 0; double _t = 0; List _prevTrick = const []; + bool _cutThisTrick = false; // افکتِ بریدن فقط یک‌بار در هر دست int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف» @@ -64,7 +69,7 @@ class HokmGame extends FlameGame { bool _beatPlayed = false; // یک‌بار در هر نوبت، هنگام رسیدن به آستانه bool _wasMyTurn = false; - HokmGame(this.cubit); + HokmGame(this.cubit, {this.skin = 'simple'}); @override Color backgroundColor() => const Color(0xFF2C0A10); @@ -72,6 +77,12 @@ class HokmGame extends FlameGame { @override Future onLoad() async { add(Felt()); + // پیش‌بارگذاریِ نشان‌های رتبه (اگر فایلی نبود، بی‌نشان رد می‌شود). + for (final t in const ['bronze', 'silver', 'gold', 'diamond', 'king']) { + try { + _badge[t] = await loadSprite('badge/$t.png'); + } catch (_) {} + } _sub = cubit.stream.listen((ui) { if (ui.state == null) return; final s = ui.state!; @@ -132,6 +143,7 @@ class HokmGame extends FlameGame { } final addedOne = now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now); + if (now.length <= 1) _cutThisTrick = false; // دستِ جدید ⇒ ریستِ افکتِ بریدن if (addedOne) { final card = s.trick.last; final isCut = @@ -139,12 +151,14 @@ class HokmGame extends FlameGame { s.leadSuit != null && s.leadSuit != s.trump && suitName(card.card) == s.trump; - if (isCut) { + // افکتِ بریدن فقط برای اولین بریدنِ هر دست (بریدن‌های بعدی فقط صدای گذاشتن). + if (isCut && !_cutThisTrick) { + _cutThisTrick = true; _shake = 1.0; add(Lightning()); AppSounds.cut(); // بریدن با حکم (شمشیر) } else { - AppSounds.placeCard(); // کارت روی میز نشست (همه‌ی بازیکنان) + AppSounds.placeCard(); // کارت روی میز نشست } } else if (now.isEmpty && _prevTrick.isNotEmpty) { AppSounds.takeCard(); // دستِ کامل جمع شد @@ -382,11 +396,13 @@ class HokmGame extends FlameGame { final target = Vector2(cx + t * stepX, baseY + norm * norm * dip); final legal = _legal(code); var c = _hand[code]; + final isNew = c == null; if (c == null) { - // کارت جدید ⇒ از مرکز میز پخش می‌شود. + // کارت جدید ⇒ پشت‌رو از مرکز میز (دسته) پخش می‌شود و سپس رو می‌گردد. c = CardComponent( code: code, - faceUp: true, + faceUp: false, + skin: skin, size: Vector2(hw, hh), position: size / 2, ); @@ -403,6 +419,7 @@ class HokmGame extends FlameGame { c.restPriority = 10 + i; c.priority = 10 + i; _moveTo(c, target); + if (isNew) c.flipToFront(delay: 0.28 + i * 0.04); // پس از رسیدن، رو شود } } @@ -451,6 +468,7 @@ class HokmGame extends FlameGame { c = CardComponent( code: tc.card, faceUp: true, + skin: skin, size: Vector2(_cardW, _cardH), position: _seatOrigin(_rel(tc.seat)), ); @@ -618,6 +636,32 @@ class HokmGame extends FlameGame { color: isTurn ? const Color(0xFFE9B949) : Colors.white70, bold: isTurn, ); + // نشانِ رتبه (راستِ نام) و سکه (چپِ نام). جایگاهِ پایین روی همان خطِ نام، + // بقیه کمی زیرِ نام تا خارج از صفحه نرود. + if (!p.bot) { + final isBottom = pos.y > size.y * 0.75; + final y = isBottom ? pos.y : pos.y + size.y * 0.032; + // سکه: چپِ نام (متن به سمتِ چپ کشیده می‌شود). + _addLabel( + '${p.coins}', + Vector2(pos.x - size.x * 0.06, y), + size.x * 0.034, + color: const Color(0xFFE9B949), + anchor: Anchor.centerRight, + ); + // نشانِ رتبه: راستِ نام. + final spr = _badge[p.rankTier]; + if (spr != null) { + final b = SpriteComponent( + sprite: spr, + anchor: Anchor.centerLeft, + size: Vector2.all(size.x * 0.06), + position: Vector2(pos.x + size.x * 0.07, y), + )..priority = 22; + _info.add(b); + add(b); + } + } // تاجِ حاکم (روی همه‌ی فازها نشان داده می‌شود تا حاکم مشخص باشد). // y را داخلِ صفحه نگه می‌داریم؛ برای بازیکنِ بالا، تاج زیرِ نام می‌نشیند. if (s.hakem == p.seat) { @@ -660,6 +704,7 @@ class HokmGame extends FlameGame { final c = CardComponent( code: '', faceUp: false, + skin: skin, size: sz, position: pos, priority: priority, diff --git a/lib/feature/profile/data/model/profile_model.dart b/lib/feature/profile/data/model/profile_model.dart index 1ee93f2..3bafa0c 100644 --- a/lib/feature/profile/data/model/profile_model.dart +++ b/lib/feature/profile/data/model/profile_model.dart @@ -19,6 +19,8 @@ class ProfileModel { xpInto: (wallet['xp_into_level'] ?? 0) as int, xpNext: (wallet['xp_for_next'] ?? 1) as int, vip: (stats['vip'] ?? false) as bool, + rankPoints: (wallet['rank_points'] ?? 0) as int, + rankTier: (wallet['rank_tier'] ?? 'bronze') as String, stats: s == null ? null : ProfileStats( diff --git a/lib/feature/profile/domain/entities/profile_entity.dart b/lib/feature/profile/domain/entities/profile_entity.dart index 510064e..c24a7b9 100644 --- a/lib/feature/profile/domain/entities/profile_entity.dart +++ b/lib/feature/profile/domain/entities/profile_entity.dart @@ -28,6 +28,8 @@ class ProfileEntity { final int xpInto; final int xpNext; final bool vip; + final int rankPoints; + final String rankTier; // bronze..king final ProfileStats? stats; // null یعنی قفل (غیر VIP) const ProfileEntity({ @@ -39,6 +41,8 @@ class ProfileEntity { required this.xpInto, required this.xpNext, required this.vip, + required this.rankPoints, + required this.rankTier, required this.stats, }); } diff --git a/lib/feature/profile/presentation/screen/profile_screen.dart b/lib/feature/profile/presentation/screen/profile_screen.dart index 69267f8..d5b5464 100644 --- a/lib/feature/profile/presentation/screen/profile_screen.dart +++ b/lib/feature/profile/presentation/screen/profile_screen.dart @@ -6,6 +6,7 @@ import 'package:random_avatar/random_avatar.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/game_ui.dart'; +import '../../../../core/widgets/rank_badge.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart'; import '../../domain/entities/profile_entity.dart'; @@ -146,6 +147,8 @@ class ProfileScreen extends StatelessWidget { if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()], ], ), + const SizedBox(height: 8), + RankBadge(tier: d.rankTier, points: d.rankPoints), if (d.mobile.isNotEmpty) Text( d.mobile, diff --git a/lib/feature/ranked/data/data_source/remote/ranked_api_provider.dart b/lib/feature/ranked/data/data_source/remote/ranked_api_provider.dart new file mode 100644 index 0000000..26d53d8 --- /dev/null +++ b/lib/feature/ranked/data/data_source/remote/ranked_api_provider.dart @@ -0,0 +1,10 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; + +class RankedApiProvider { + ApiProviderImp get _api => locator(); + + Future getLeaderboard() => _api.get('/leaderboard'); +} diff --git a/lib/feature/ranked/data/repository/ranked_repository_impl.dart b/lib/feature/ranked/data/repository/ranked_repository_impl.dart new file mode 100644 index 0000000..01b38e3 --- /dev/null +++ b/lib/feature/ranked/data/repository/ranked_repository_impl.dart @@ -0,0 +1,22 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/error/custom_error.dart'; +import '../../../../core/resources/data_state.dart'; +import '../../domain/entities/leaderboard.dart'; +import '../../domain/repository/ranked_repository.dart'; +import '../data_source/remote/ranked_api_provider.dart'; + +class RankedRepositoryImpl extends RankedRepository { + final RankedApiProvider api; + RankedRepositoryImpl(this.api); + + @override + Future> getLeaderboard() async { + final Response res = await api.getLeaderboard(); + if (res.statusCode == 200) { + return DataSuccess( + Leaderboard.fromJson(Map.from(res.data as Map))); + } + return DataError(errorConvertor(res.statusCode, null)); + } +} diff --git a/lib/feature/ranked/domain/entities/leaderboard.dart b/lib/feature/ranked/domain/entities/leaderboard.dart new file mode 100644 index 0000000..b0c7b63 --- /dev/null +++ b/lib/feature/ranked/domain/entities/leaderboard.dart @@ -0,0 +1,29 @@ +/// یک ردیف از جدولِ رتبه‌بندی. +class RankEntry { + final int rank; + final String name; + final String avatar; + final int rankPoints; + final String tier; // bronze..king + + RankEntry.fromJson(Map j) + : rank = (j['rank'] ?? 0) as int, + name = (j['name'] ?? '') as String, + avatar = (j['avatar'] ?? '') as String, + rankPoints = (j['rank_points'] ?? 0) as int, + tier = (j['tier'] ?? 'bronze') as String; +} + +/// جدولِ رتبه‌بندیِ فصلِ جاری. +class Leaderboard { + final int season; + final List entries; + const Leaderboard(this.season, this.entries); + + factory Leaderboard.fromJson(Map j) => Leaderboard( + (j['season'] ?? 1) as int, + ((j['entries'] as List?) ?? []) + .map((e) => RankEntry.fromJson(Map.from(e as Map))) + .toList(), + ); +} diff --git a/lib/feature/ranked/domain/repository/ranked_repository.dart b/lib/feature/ranked/domain/repository/ranked_repository.dart new file mode 100644 index 0000000..4732a30 --- /dev/null +++ b/lib/feature/ranked/domain/repository/ranked_repository.dart @@ -0,0 +1,6 @@ +import '../../../../core/resources/data_state.dart'; +import '../entities/leaderboard.dart'; + +abstract class RankedRepository { + Future> getLeaderboard(); +} diff --git a/lib/feature/ranked/domain/use_cases/get_leaderboard_usecase.dart b/lib/feature/ranked/domain/use_cases/get_leaderboard_usecase.dart new file mode 100644 index 0000000..9012ff4 --- /dev/null +++ b/lib/feature/ranked/domain/use_cases/get_leaderboard_usecase.dart @@ -0,0 +1,14 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/leaderboard.dart'; +import '../repository/ranked_repository.dart'; + +class GetLeaderboardUseCase + implements UseCase, NoParams> { + final RankedRepository repository; + GetLeaderboardUseCase(this.repository); + + @override + Future> call(NoParams params) => + repository.getLeaderboard(); +} diff --git a/lib/feature/ranked/presentation/bloc/leaderboard_bloc.dart b/lib/feature/ranked/presentation/bloc/leaderboard_bloc.dart new file mode 100644 index 0000000..0296e39 --- /dev/null +++ b/lib/feature/ranked/presentation/bloc/leaderboard_bloc.dart @@ -0,0 +1,41 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/entities/leaderboard.dart'; +import '../../domain/use_cases/get_leaderboard_usecase.dart'; + +abstract class LeaderboardEvent {} + +class LoadLeaderboardEvent extends LeaderboardEvent {} + +abstract class LeaderboardState {} + +class LeaderboardInitial extends LeaderboardState {} + +class LeaderboardLoading extends LeaderboardState {} + +class LeaderboardLoaded extends LeaderboardState { + final Leaderboard data; + LeaderboardLoaded(this.data); +} + +class LeaderboardError extends LeaderboardState { + final String message; + LeaderboardError(this.message); +} + +class LeaderboardBloc extends Bloc { + final GetLeaderboardUseCase getLeaderboardUseCase; + LeaderboardBloc(this.getLeaderboardUseCase) : super(LeaderboardInitial()) { + on((event, emit) async { + emit(LeaderboardLoading()); + final res = await getLeaderboardUseCase(const NoParams()); + if (res is DataSuccess) { + emit(LeaderboardLoaded(res.data!)); + } else { + emit(LeaderboardError(res.error!)); + } + }); + } +} diff --git a/lib/feature/ranked/presentation/screen/leaderboard_screen.dart b/lib/feature/ranked/presentation/screen/leaderboard_screen.dart new file mode 100644 index 0000000..522091e --- /dev/null +++ b/lib/feature/ranked/presentation/screen/leaderboard_screen.dart @@ -0,0 +1,132 @@ +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/rank_badge.dart'; +import '../../domain/entities/leaderboard.dart'; +import '../bloc/leaderboard_bloc.dart'; + +/// جدولِ رتبه‌بندیِ فصل: برترین بازیکنان بر اساس امتیازِ رتبه. +class LeaderboardScreen extends StatelessWidget { + const LeaderboardScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: Column( + children: [ + Row(children: [ + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: AppColors.gold), + ), + const Spacer(), + const GlowText('رتبه‌بندی فصل', size: 22), + const Spacer(), + const SizedBox(width: 48), + ]), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is LeaderboardError) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(state.message, + style: const TextStyle(color: Colors.white70)), + TextButton( + onPressed: () => context + .read() + .add(LoadLeaderboardEvent()), + child: const Text('تلاش مجدد'), + ), + ]), + ); + } + if (state is! LeaderboardLoaded) { + return const Center( + child: + CircularProgressIndicator(color: AppColors.gold)); + } + final lb = state.data; + if (lb.entries.isEmpty) { + return const Center( + child: Text('هنوز کسی در این فصل امتیاز نگرفته است', + style: TextStyle(color: Colors.white54)), + ); + } + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text('فصل ${lb.season}', + style: const TextStyle( + color: AppColors.gold, fontSize: 14)), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 20), + itemCount: lb.entries.length, + separatorBuilder: (_, __) => + const SizedBox(height: 8), + itemBuilder: (_, i) => _row(lb.entries[i]), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _row(RankEntry e) { + final medal = e.rank <= 3; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF4A0C16), Color(0xFF2A0710)], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: medal ? AppColors.gold : AppColors.goldDark, + width: medal ? 1.6 : 1), + ), + child: Row(children: [ + SizedBox( + width: 28, + child: Text('${e.rank}', + textAlign: TextAlign.center, + style: TextStyle( + color: medal ? AppColors.gold : Colors.white70, + fontWeight: FontWeight.bold, + fontSize: 16)), + ), + const SizedBox(width: 8), + SizedBox( + width: 40, + height: 40, + child: ClipOval( + child: RandomAvatar(e.avatar.isEmpty ? e.name : e.avatar)), + ), + const SizedBox(width: 10), + Expanded( + child: Text(e.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 15)), + ), + RankBadge(tier: e.tier, points: e.rankPoints, size: 13), + ]), + ); + } +} diff --git a/lib/feature/shop/presentation/screen/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart index 198e917..4f478ff 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/config.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/game_ui.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart'; @@ -320,6 +321,7 @@ class _CardsTab extends StatelessWidget { title: c.title, glowColor: const Color(0xFF0E3C73), icon: Icons.style, + art: _CardSkinArt(skin: c.id, selected: data.selectedCard == c.id), subtitle: c.priceCoins > 0 ? '${c.priceCoins} سکه' : 'پیش‌فرض', action: _cardAction(context, c, data), ), @@ -381,6 +383,7 @@ class _ItemCard extends StatelessWidget { final String subtitle; final String? ribbon; final Widget action; + final Widget? art; // هنرِ سفارشی (مثل پیش‌نمایشِ اسکینِ کارت) به‌جای آیکن const _ItemCard({ required this.title, required this.icon, @@ -388,6 +391,7 @@ class _ItemCard extends StatelessWidget { required this.subtitle, required this.action, this.ribbon, + this.art, }); @override @@ -418,7 +422,7 @@ class _ItemCard extends StatelessWidget { ), ), const SizedBox(height: 6), - Expanded(child: _GlowArt(color: glowColor, icon: icon)), + Expanded(child: art ?? _GlowArt(color: glowColor, icon: icon)), const SizedBox(height: 4), Text( subtitle, @@ -481,6 +485,71 @@ class _GlowArt extends StatelessWidget { } } +/// پیش‌نمایشِ اسکینِ کارت در فروشگاه: دو کارت فن‌شده (پشت + تک‌خال پیک) +/// که مستقیماً از backend بارگذاری می‌شوند تا کاربر هنرِ واقعی را ببیند. +class _CardSkinArt extends StatelessWidget { + final String skin; + final bool selected; + const _CardSkinArt({required this.skin, required this.selected}); + + String _url(String file) => '${AppConfig.baseUrl}/cards/$skin/$file'; + + Widget _card(String file, {double angle = 0, double dx = 0}) { + return Transform.translate( + offset: Offset(dx, 0), + child: Transform.rotate( + angle: angle, + child: AspectRatio( + aspectRatio: 0.7, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + _url(file), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: const Color(0xFF0E3C73), + child: const Icon(Icons.style, color: AppColors.gold), + ), + loadingBuilder: (ctx, child, progress) => progress == null + ? child + : Container(color: const Color(0xFF0A2547)), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: const RadialGradient( + colors: [Color(0xFF12508F), Colors.black87], + radius: 0.95, + ), + border: Border.all( + color: selected ? AppColors.gold : Colors.black26, + width: selected ? 2 : 1, + ), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + _card('back.jpg', angle: -0.22, dx: -16), + _card('AS.png', angle: 0.22, dx: 16), + ], + ), + ), + ); + } +} + class _PriceButton extends StatelessWidget { final String label; final VoidCallback? onTap; diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.dart b/lib/feature/wallet/presentation/screen/lobby_screen.dart index f4139e4..e57223c 100644 --- a/lib/feature/wallet/presentation/screen/lobby_screen.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.dart @@ -84,6 +84,14 @@ class _LobbyScreenState extends State { }, ), const SizedBox(height: 16), + GameButton( + label: 'رتبه‌بندی', + icon: Icons.leaderboard, + width: double.infinity, + colors: const [Color(0xFFE9952F), Color(0xFF9C5A00)], + onTap: () => context.push('/leaderboard'), + ), + const SizedBox(height: 16), GameButton( label: 'فروشگاه', icon: Icons.storefront, diff --git a/pubspec.yaml b/pubspec.yaml index fa8bd31..3de5c1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,7 +72,7 @@ flutter: assets: - assets/images/ - - assets/images/cards/ + - assets/images/badge/ - assets/audio/ fonts: