feat: get cards from backend
@@ -7,7 +7,7 @@
|
|||||||
"type": "dart",
|
"type": "dart",
|
||||||
"program": "lib/main.dart",
|
"program": "lib/main.dart",
|
||||||
"args": [
|
"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"
|
"--target-platform=android-arm64"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 201 KiB |
@@ -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) کارتها را میکشد.
|
|
||||||
|
Before Width: | Height: | Size: 181 KiB |
@@ -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_bloc.dart';
|
||||||
import 'feature/profile/presentation/bloc/profile_event.dart';
|
import 'feature/profile/presentation/bloc/profile_event.dart';
|
||||||
import 'feature/profile/presentation/screen/profile_screen.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_bloc.dart';
|
||||||
import 'feature/shop/presentation/bloc/shop_event.dart';
|
import 'feature/shop/presentation/bloc/shop_event.dart';
|
||||||
import 'feature/shop/presentation/screen/shop_screen.dart';
|
import 'feature/shop/presentation/screen/shop_screen.dart';
|
||||||
@@ -46,6 +48,14 @@ class HakemApp extends StatelessWidget {
|
|||||||
child: const ProfileScreen(),
|
child: const ProfileScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/leaderboard',
|
||||||
|
builder: (_, __) => BlocProvider(
|
||||||
|
create: (_) =>
|
||||||
|
locator<LeaderboardBloc>()..add(LoadLeaderboardEvent()),
|
||||||
|
child: const LeaderboardScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/shop',
|
path: '/shop',
|
||||||
builder: (_, __) => BlocProvider(
|
builder: (_, __) => BlocProvider(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class AppConfig {
|
|||||||
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیشفرض را عوض کنید.
|
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیشفرض را عوض کنید.
|
||||||
static const String baseUrl = String.fromEnvironment(
|
static const String baseUrl = String.fromEnvironment(
|
||||||
'BASE_URL',
|
'BASE_URL',
|
||||||
defaultValue: 'http://192.168.1.105:8080',
|
defaultValue: 'http://192.168.1.104:8080',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get apiUrl => '$baseUrl/api';
|
static String get apiUrl => '$baseUrl/api';
|
||||||
|
|||||||
@@ -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/get_profile_usecase.dart';
|
||||||
import '../../feature/profile/domain/use_cases/save_profile_usecase.dart';
|
import '../../feature/profile/domain/use_cases/save_profile_usecase.dart';
|
||||||
import '../../feature/profile/presentation/bloc/profile_bloc.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/data_source/remote/shop_api_provider.dart';
|
||||||
import '../../feature/shop/data/repository/shop_repository_impl.dart';
|
import '../../feature/shop/data/repository/shop_repository_impl.dart';
|
||||||
import '../../feature/shop/domain/repository/shop_repository.dart';
|
import '../../feature/shop/domain/repository/shop_repository.dart';
|
||||||
@@ -56,6 +61,7 @@ Future<void> setupLocator() async {
|
|||||||
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
|
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
|
||||||
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
|
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
|
||||||
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
|
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
|
||||||
|
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
|
||||||
locator.registerSingleton<GameApiProvider>(GameApiProvider());
|
locator.registerSingleton<GameApiProvider>(GameApiProvider());
|
||||||
locator.registerSingleton<GameWsProvider>(GameWsProvider(locator()));
|
locator.registerSingleton<GameWsProvider>(GameWsProvider(locator()));
|
||||||
|
|
||||||
@@ -67,6 +73,7 @@ Future<void> setupLocator() async {
|
|||||||
locator.registerSingleton<ShopRepository>(ShopRepositoryImpl(locator()));
|
locator.registerSingleton<ShopRepository>(ShopRepositoryImpl(locator()));
|
||||||
locator.registerSingleton<ProfileRepository>(
|
locator.registerSingleton<ProfileRepository>(
|
||||||
ProfileRepositoryImpl(locator()));
|
ProfileRepositoryImpl(locator()));
|
||||||
|
locator.registerSingleton<RankedRepository>(RankedRepositoryImpl(locator()));
|
||||||
locator.registerSingleton<GameRepository>(
|
locator.registerSingleton<GameRepository>(
|
||||||
GameRepositoryImpl(locator(), locator()));
|
GameRepositoryImpl(locator(), locator()));
|
||||||
|
|
||||||
@@ -85,6 +92,8 @@ Future<void> setupLocator() async {
|
|||||||
locator.registerSingleton<AdRewardUseCase>(AdRewardUseCase(locator()));
|
locator.registerSingleton<AdRewardUseCase>(AdRewardUseCase(locator()));
|
||||||
locator.registerSingleton<GetProfileUseCase>(GetProfileUseCase(locator()));
|
locator.registerSingleton<GetProfileUseCase>(GetProfileUseCase(locator()));
|
||||||
locator.registerSingleton<SaveProfileUseCase>(SaveProfileUseCase(locator()));
|
locator.registerSingleton<SaveProfileUseCase>(SaveProfileUseCase(locator()));
|
||||||
|
locator.registerSingleton<GetLeaderboardUseCase>(
|
||||||
|
GetLeaderboardUseCase(locator()));
|
||||||
locator.registerSingleton<GetTiersUseCase>(GetTiersUseCase(locator()));
|
locator.registerSingleton<GetTiersUseCase>(GetTiersUseCase(locator()));
|
||||||
locator.registerSingleton<GetTablesInfoUseCase>(
|
locator.registerSingleton<GetTablesInfoUseCase>(
|
||||||
GetTablesInfoUseCase(locator()));
|
GetTablesInfoUseCase(locator()));
|
||||||
@@ -97,6 +106,7 @@ Future<void> setupLocator() async {
|
|||||||
ShopBloc(locator(), locator(), locator(), locator(), locator()));
|
ShopBloc(locator(), locator(), locator(), locator(), locator()));
|
||||||
locator.registerFactory<ProfileBloc>(
|
locator.registerFactory<ProfileBloc>(
|
||||||
() => ProfileBloc(locator(), locator()));
|
() => ProfileBloc(locator(), locator()));
|
||||||
|
locator.registerFactory<LeaderboardBloc>(() => LeaderboardBloc(locator()));
|
||||||
locator.registerFactory<GameBloc>(() => GameBloc(locator()));
|
locator.registerFactory<GameBloc>(() => GameBloc(locator()));
|
||||||
locator.registerFactory<TierBloc>(() => TierBloc(locator()));
|
locator.registerFactory<TierBloc>(() => TierBloc(locator()));
|
||||||
locator.registerFactory<PrivateInfoBloc>(() => PrivateInfoBloc(locator()));
|
locator.registerFactory<PrivateInfoBloc>(() => PrivateInfoBloc(locator()));
|
||||||
|
|||||||
@@ -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/<skin>/<code>.png` و `/cards/<skin>/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<String, Future<Sprite?>> _cache = {};
|
||||||
|
|
||||||
|
static String _url(String skin, String file) =>
|
||||||
|
'${AppConfig.baseUrl}/cards/$skin/$file';
|
||||||
|
|
||||||
|
/// اسپرایتِ رویِ یک کارت (مثل "AS"). در صورت خطا/۴۰۴ به اسکینِ پیشفرض و
|
||||||
|
/// سپس به `null` (کشیدنِ برداری در CardComponent) برمیگردد.
|
||||||
|
static Future<Sprite?> front(String skin, String code) =>
|
||||||
|
_load(skin, '$code.png');
|
||||||
|
|
||||||
|
/// اسپرایتِ پشتِ کارت برای اسکین دادهشده.
|
||||||
|
static Future<Sprite?> back(String skin) => _load(skin, 'back.jpg');
|
||||||
|
|
||||||
|
static Future<Sprite?> _load(String skin, String file) {
|
||||||
|
final key = '$skin/$file';
|
||||||
|
return _cache.putIfAbsent(key, () => _fetch(skin, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Sprite?> _fetch(String skin, String file) async {
|
||||||
|
final sprite = await _fetchFrom(skin, file);
|
||||||
|
if (sprite != null) return sprite;
|
||||||
|
if (skin != _fallbackSkin) return _fetchFrom(_fallbackSkin, file);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Sprite?> _fetchFrom(String skin, String file) async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.get<List<int>>(_url(skin, file));
|
||||||
|
final data = res.data;
|
||||||
|
if (data == null || data.isEmpty) return null;
|
||||||
|
final image = await _decode(Uint8List.fromList(data));
|
||||||
|
return Sprite(image);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('CardImages: $skin/$file failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ui.Image> _decode(Uint8List bytes) async {
|
||||||
|
final codec = await ui.instantiateImageCodec(bytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
return frame.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,9 @@ class _MobileScreenState extends State<MobileScreen> {
|
|||||||
if (s is LoginSuccess) {
|
if (s is LoginSuccess) {
|
||||||
context.push('/otp');
|
context.push('/otp');
|
||||||
} else if (s is LoginError) {
|
} else if (s is LoginError) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(
|
||||||
.showSnackBar(SnackBar(content: Text(s.message)));
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(s.message)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -58,33 +59,43 @@ class _MobileScreenState extends State<MobileScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text('برای ورود شماره موبایلت رو وارد کن',
|
const Text(
|
||||||
style: TextStyle(color: Colors.white70)),
|
'برای ورود شماره موبایلت رو وارد کن',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style:
|
textDirection: TextDirection.ltr,
|
||||||
const TextStyle(fontSize: 20, letterSpacing: 2),
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
letterSpacing: 2,
|
||||||
|
),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
LengthLimitingTextInputFormatter(11),
|
LengthLimitingTextInputFormatter(11),
|
||||||
],
|
],
|
||||||
decoration:
|
decoration: const InputDecoration(
|
||||||
const InputDecoration(hintText: '09xxxxxxxxx'),
|
hintText: '09xxxxxxxxx',
|
||||||
|
),
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => setState(() {}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
GameButton(
|
GameButton(
|
||||||
label: 'دریافت کد',
|
label: 'دریافت کد',
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
|
colors: const [
|
||||||
onTap: (!_valid || loading)
|
Color(0xFF3FA34D),
|
||||||
|
Color(0xFF1B5E20),
|
||||||
|
],
|
||||||
|
onTap:
|
||||||
|
(!_valid || loading)
|
||||||
? null
|
? null
|
||||||
: () => context
|
: () => context.read<AuthBloc>().add(
|
||||||
.read<AuthBloc>()
|
LoginOtpEvent(_controller.text.trim()),
|
||||||
.add(LoginOtpEvent(_controller.text.trim())),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ class GamePlayer {
|
|||||||
final String name;
|
final String name;
|
||||||
final bool bot;
|
final bool bot;
|
||||||
final bool connected;
|
final bool connected;
|
||||||
|
final int coins;
|
||||||
|
final String rankTier; // bronze..king (خالی برای بات)
|
||||||
|
|
||||||
GamePlayer.fromJson(Map<String, dynamic> j)
|
GamePlayer.fromJson(Map<String, dynamic> j)
|
||||||
: seat = (j['seat'] ?? 0) as int,
|
: seat = (j['seat'] ?? 0) as int,
|
||||||
name = (j['name'] ?? '') as String,
|
name = (j['name'] ?? '') as String,
|
||||||
bot = (j['bot'] ?? false) as bool,
|
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 {
|
class TrickCard {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../../core/service/app_sounds.dart';
|
|||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
||||||
|
import '../../../wallet/presentation/bloc/wallet_status.dart';
|
||||||
import '../../domain/entities/game_entities.dart';
|
import '../../domain/entities/game_entities.dart';
|
||||||
import '../bloc/game_bloc.dart';
|
import '../bloc/game_bloc.dart';
|
||||||
import '../bloc/game_state.dart';
|
import '../bloc/game_state.dart';
|
||||||
@@ -33,7 +34,12 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_game = HokmGame(context.read<GameBloc>());
|
final ws = context.read<WalletBloc>().state.walletStatus;
|
||||||
|
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
|
||||||
|
_game = HokmGame(
|
||||||
|
context.read<GameBloc>(),
|
||||||
|
skin: skin.isEmpty ? 'simple' : skin,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -3,25 +3,30 @@ import 'package:flame/effects.dart';
|
|||||||
import 'package:flame/events.dart';
|
import 'package:flame/events.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../../core/network/card_images.dart';
|
||||||
|
|
||||||
/// یک کارت روی میز؛ اگر تصویر `assets/images/cards/<code>.png` موجود باشد از آن
|
/// یک کارت روی میز؛ اگر تصویر `assets/images/cards/<code>.png` موجود باشد از آن
|
||||||
/// استفاده میکند، وگرنه نسخهی برداری میکشد. برای پشت کارت `back.jpg`.
|
/// استفاده میکند، وگرنه نسخهی برداری میکشد. برای پشت کارت `back.jpg`.
|
||||||
/// کارتهای قابلبازی فقط با **کشیدن (drag)** به سمت زمین بازی میشوند (نه tap).
|
/// کارتهای قابلبازی فقط با **کشیدن (drag)** به سمت زمین بازی میشوند (نه tap).
|
||||||
class CardComponent extends PositionComponent
|
class CardComponent extends PositionComponent
|
||||||
with DragCallbacks, HasGameReference {
|
with DragCallbacks, HasGameReference {
|
||||||
final String code; // مثل "AS"؛ برای پشت کارت خالی
|
final String code; // مثل "AS"؛ برای پشت کارت خالی
|
||||||
final bool faceUp;
|
final String skin; // اسکینِ کارت؛ تصاویر از backend بارگذاری میشوند
|
||||||
|
bool faceUp; // قابلِ تغییر: برای انیمیشنِ برگرداندنِ کارت هنگام پخش
|
||||||
VoidCallback? onPlay; // در صورت مجاز بودن، بازیِ این کارت
|
VoidCallback? onPlay; // در صورت مجاز بودن، بازیِ این کارت
|
||||||
bool dimmed; // کارت غیرمجاز/غیرفعال
|
bool dimmed; // کارت غیرمجاز/غیرفعال
|
||||||
Vector2? home; // موقعیت اصلی در دست (برای برگشت پس از کشیدنِ ناقص)
|
Vector2? home; // موقعیت اصلی در دست (برای برگشت پس از کشیدنِ ناقص)
|
||||||
int restPriority = 0; // ترتیب لایهی اصلی در دست (برای بازگردانی پس از کشیدن)
|
int restPriority = 0; // ترتیب لایهی اصلی در دست (برای بازگردانی پس از کشیدن)
|
||||||
Rect? dropZone; // ناحیهی وسط میز؛ رهاکردن کارت در آن یعنی بازی
|
Rect? dropZone; // ناحیهی وسط میز؛ رهاکردن کارت در آن یعنی بازی
|
||||||
Sprite? _sprite;
|
Sprite? _front;
|
||||||
|
Sprite? _back;
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
bool _overZone = false; // کارت روی ناحیهی انداختن است (برای هایلایت)
|
bool _overZone = false; // کارت روی ناحیهی انداختن است (برای هایلایت)
|
||||||
|
|
||||||
CardComponent({
|
CardComponent({
|
||||||
required this.code,
|
required this.code,
|
||||||
required this.faceUp,
|
required this.faceUp,
|
||||||
|
this.skin = 'simple',
|
||||||
this.onPlay,
|
this.onPlay,
|
||||||
this.dimmed = false,
|
this.dimmed = false,
|
||||||
super.position,
|
super.position,
|
||||||
@@ -35,11 +40,24 @@ class CardComponent extends PositionComponent
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onLoad() async {
|
Future<void> onLoad() async {
|
||||||
try {
|
if (code.isNotEmpty) {
|
||||||
_sprite = await game.loadSprite(faceUp ? 'cards/$code.png' : 'cards/back.jpg');
|
_front = await CardImages.front(skin, code);
|
||||||
} catch (_) {
|
|
||||||
_sprite = null; // fallback برداری
|
|
||||||
}
|
}
|
||||||
|
_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));
|
Paint()..color = Color(_dragging ? 0x66000000 : 0x44000000));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_sprite != null) {
|
final sprite = faceUp ? _front : _back;
|
||||||
|
if (sprite != null) {
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.clipRRect(rrect);
|
canvas.clipRRect(rrect);
|
||||||
_sprite!.render(canvas, size: size);
|
sprite.render(canvas, size: size);
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
} else if (faceUp) {
|
} else if (faceUp) {
|
||||||
_drawVectorFace(canvas, rrect);
|
_drawVectorFace(canvas, rrect);
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import 'turn_timer.dart';
|
|||||||
class HokmGame extends FlameGame {
|
class HokmGame extends FlameGame {
|
||||||
final GameBloc cubit;
|
final GameBloc cubit;
|
||||||
|
|
||||||
|
/// اسکینِ کارت (از فروشگاه/کیفپول انتخاب میشود)؛ تصاویر از backend میآیند.
|
||||||
|
final String skin;
|
||||||
|
|
||||||
// وضعیت بازی و اشتراکِ stream.
|
// وضعیت بازی و اشتراکِ stream.
|
||||||
GameState? _s;
|
GameState? _s;
|
||||||
StreamSubscription? _sub;
|
StreamSubscription? _sub;
|
||||||
@@ -35,6 +38,7 @@ class HokmGame extends FlameGame {
|
|||||||
final Map<String, CardComponent> _trick = {};
|
final Map<String, CardComponent> _trick = {};
|
||||||
final List<Component> _backs = [];
|
final List<Component> _backs = [];
|
||||||
final List<Component> _info = [];
|
final List<Component> _info = [];
|
||||||
|
final Map<String, Sprite> _badge = {}; // نشانهای رتبه (پیشبارگذاری)
|
||||||
|
|
||||||
// ابعادِ کارتهای روی میز (بر اساس عرض صفحه محاسبه میشود).
|
// ابعادِ کارتهای روی میز (بر اساس عرض صفحه محاسبه میشود).
|
||||||
double _cardW = 60;
|
double _cardW = 60;
|
||||||
@@ -44,6 +48,7 @@ class HokmGame extends FlameGame {
|
|||||||
double _shake = 0;
|
double _shake = 0;
|
||||||
double _t = 0;
|
double _t = 0;
|
||||||
List<String> _prevTrick = const [];
|
List<String> _prevTrick = const [];
|
||||||
|
bool _cutThisTrick = false; // افکتِ بریدن فقط یکبار در هر دست
|
||||||
int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن
|
int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن
|
||||||
bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود
|
bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود
|
||||||
bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف»
|
bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف»
|
||||||
@@ -64,7 +69,7 @@ class HokmGame extends FlameGame {
|
|||||||
bool _beatPlayed = false; // یکبار در هر نوبت، هنگام رسیدن به آستانه
|
bool _beatPlayed = false; // یکبار در هر نوبت، هنگام رسیدن به آستانه
|
||||||
bool _wasMyTurn = false;
|
bool _wasMyTurn = false;
|
||||||
|
|
||||||
HokmGame(this.cubit);
|
HokmGame(this.cubit, {this.skin = 'simple'});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Color backgroundColor() => const Color(0xFF2C0A10);
|
Color backgroundColor() => const Color(0xFF2C0A10);
|
||||||
@@ -72,6 +77,12 @@ class HokmGame extends FlameGame {
|
|||||||
@override
|
@override
|
||||||
Future<void> onLoad() async {
|
Future<void> onLoad() async {
|
||||||
add(Felt());
|
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) {
|
_sub = cubit.stream.listen((ui) {
|
||||||
if (ui.state == null) return;
|
if (ui.state == null) return;
|
||||||
final s = ui.state!;
|
final s = ui.state!;
|
||||||
@@ -132,6 +143,7 @@ class HokmGame extends FlameGame {
|
|||||||
}
|
}
|
||||||
final addedOne =
|
final addedOne =
|
||||||
now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now);
|
now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now);
|
||||||
|
if (now.length <= 1) _cutThisTrick = false; // دستِ جدید ⇒ ریستِ افکتِ بریدن
|
||||||
if (addedOne) {
|
if (addedOne) {
|
||||||
final card = s.trick.last;
|
final card = s.trick.last;
|
||||||
final isCut =
|
final isCut =
|
||||||
@@ -139,12 +151,14 @@ class HokmGame extends FlameGame {
|
|||||||
s.leadSuit != null &&
|
s.leadSuit != null &&
|
||||||
s.leadSuit != s.trump &&
|
s.leadSuit != s.trump &&
|
||||||
suitName(card.card) == s.trump;
|
suitName(card.card) == s.trump;
|
||||||
if (isCut) {
|
// افکتِ بریدن فقط برای اولین بریدنِ هر دست (بریدنهای بعدی فقط صدای گذاشتن).
|
||||||
|
if (isCut && !_cutThisTrick) {
|
||||||
|
_cutThisTrick = true;
|
||||||
_shake = 1.0;
|
_shake = 1.0;
|
||||||
add(Lightning());
|
add(Lightning());
|
||||||
AppSounds.cut(); // بریدن با حکم (شمشیر)
|
AppSounds.cut(); // بریدن با حکم (شمشیر)
|
||||||
} else {
|
} else {
|
||||||
AppSounds.placeCard(); // کارت روی میز نشست (همهی بازیکنان)
|
AppSounds.placeCard(); // کارت روی میز نشست
|
||||||
}
|
}
|
||||||
} else if (now.isEmpty && _prevTrick.isNotEmpty) {
|
} else if (now.isEmpty && _prevTrick.isNotEmpty) {
|
||||||
AppSounds.takeCard(); // دستِ کامل جمع شد
|
AppSounds.takeCard(); // دستِ کامل جمع شد
|
||||||
@@ -382,11 +396,13 @@ class HokmGame extends FlameGame {
|
|||||||
final target = Vector2(cx + t * stepX, baseY + norm * norm * dip);
|
final target = Vector2(cx + t * stepX, baseY + norm * norm * dip);
|
||||||
final legal = _legal(code);
|
final legal = _legal(code);
|
||||||
var c = _hand[code];
|
var c = _hand[code];
|
||||||
|
final isNew = c == null;
|
||||||
if (c == null) {
|
if (c == null) {
|
||||||
// کارت جدید ⇒ از مرکز میز پخش میشود.
|
// کارت جدید ⇒ پشترو از مرکز میز (دسته) پخش میشود و سپس رو میگردد.
|
||||||
c = CardComponent(
|
c = CardComponent(
|
||||||
code: code,
|
code: code,
|
||||||
faceUp: true,
|
faceUp: false,
|
||||||
|
skin: skin,
|
||||||
size: Vector2(hw, hh),
|
size: Vector2(hw, hh),
|
||||||
position: size / 2,
|
position: size / 2,
|
||||||
);
|
);
|
||||||
@@ -403,6 +419,7 @@ class HokmGame extends FlameGame {
|
|||||||
c.restPriority = 10 + i;
|
c.restPriority = 10 + i;
|
||||||
c.priority = 10 + i;
|
c.priority = 10 + i;
|
||||||
_moveTo(c, target);
|
_moveTo(c, target);
|
||||||
|
if (isNew) c.flipToFront(delay: 0.28 + i * 0.04); // پس از رسیدن، رو شود
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +468,7 @@ class HokmGame extends FlameGame {
|
|||||||
c = CardComponent(
|
c = CardComponent(
|
||||||
code: tc.card,
|
code: tc.card,
|
||||||
faceUp: true,
|
faceUp: true,
|
||||||
|
skin: skin,
|
||||||
size: Vector2(_cardW, _cardH),
|
size: Vector2(_cardW, _cardH),
|
||||||
position: _seatOrigin(_rel(tc.seat)),
|
position: _seatOrigin(_rel(tc.seat)),
|
||||||
);
|
);
|
||||||
@@ -618,6 +636,32 @@ class HokmGame extends FlameGame {
|
|||||||
color: isTurn ? const Color(0xFFE9B949) : Colors.white70,
|
color: isTurn ? const Color(0xFFE9B949) : Colors.white70,
|
||||||
bold: isTurn,
|
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 را داخلِ صفحه نگه میداریم؛ برای بازیکنِ بالا، تاج زیرِ نام مینشیند.
|
// y را داخلِ صفحه نگه میداریم؛ برای بازیکنِ بالا، تاج زیرِ نام مینشیند.
|
||||||
if (s.hakem == p.seat) {
|
if (s.hakem == p.seat) {
|
||||||
@@ -660,6 +704,7 @@ class HokmGame extends FlameGame {
|
|||||||
final c = CardComponent(
|
final c = CardComponent(
|
||||||
code: '',
|
code: '',
|
||||||
faceUp: false,
|
faceUp: false,
|
||||||
|
skin: skin,
|
||||||
size: sz,
|
size: sz,
|
||||||
position: pos,
|
position: pos,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class ProfileModel {
|
|||||||
xpInto: (wallet['xp_into_level'] ?? 0) as int,
|
xpInto: (wallet['xp_into_level'] ?? 0) as int,
|
||||||
xpNext: (wallet['xp_for_next'] ?? 1) as int,
|
xpNext: (wallet['xp_for_next'] ?? 1) as int,
|
||||||
vip: (stats['vip'] ?? false) as bool,
|
vip: (stats['vip'] ?? false) as bool,
|
||||||
|
rankPoints: (wallet['rank_points'] ?? 0) as int,
|
||||||
|
rankTier: (wallet['rank_tier'] ?? 'bronze') as String,
|
||||||
stats: s == null
|
stats: s == null
|
||||||
? null
|
? null
|
||||||
: ProfileStats(
|
: ProfileStats(
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class ProfileEntity {
|
|||||||
final int xpInto;
|
final int xpInto;
|
||||||
final int xpNext;
|
final int xpNext;
|
||||||
final bool vip;
|
final bool vip;
|
||||||
|
final int rankPoints;
|
||||||
|
final String rankTier; // bronze..king
|
||||||
final ProfileStats? stats; // null یعنی قفل (غیر VIP)
|
final ProfileStats? stats; // null یعنی قفل (غیر VIP)
|
||||||
|
|
||||||
const ProfileEntity({
|
const ProfileEntity({
|
||||||
@@ -39,6 +41,8 @@ class ProfileEntity {
|
|||||||
required this.xpInto,
|
required this.xpInto,
|
||||||
required this.xpNext,
|
required this.xpNext,
|
||||||
required this.vip,
|
required this.vip,
|
||||||
|
required this.rankPoints,
|
||||||
|
required this.rankTier,
|
||||||
required this.stats,
|
required this.stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:random_avatar/random_avatar.dart';
|
|||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/game_ui.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_bloc.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
||||||
import '../../domain/entities/profile_entity.dart';
|
import '../../domain/entities/profile_entity.dart';
|
||||||
@@ -146,6 +147,8 @@ class ProfileScreen extends StatelessWidget {
|
|||||||
if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()],
|
if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
RankBadge(tier: d.rankTier, points: d.rankPoints),
|
||||||
if (d.mobile.isNotEmpty)
|
if (d.mobile.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
d.mobile,
|
d.mobile,
|
||||||
|
|||||||
@@ -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<ApiProviderImp>();
|
||||||
|
|
||||||
|
Future<Response> getLeaderboard() => _api.get('/leaderboard');
|
||||||
|
}
|
||||||
@@ -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<DataState<Leaderboard>> getLeaderboard() async {
|
||||||
|
final Response res = await api.getLeaderboard();
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
return DataSuccess(
|
||||||
|
Leaderboard.fromJson(Map<String, dynamic>.from(res.data as Map)));
|
||||||
|
}
|
||||||
|
return DataError(errorConvertor(res.statusCode, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<RankEntry> entries;
|
||||||
|
const Leaderboard(this.season, this.entries);
|
||||||
|
|
||||||
|
factory Leaderboard.fromJson(Map<String, dynamic> j) => Leaderboard(
|
||||||
|
(j['season'] ?? 1) as int,
|
||||||
|
((j['entries'] as List?) ?? [])
|
||||||
|
.map((e) => RankEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import '../../../../core/resources/data_state.dart';
|
||||||
|
import '../entities/leaderboard.dart';
|
||||||
|
|
||||||
|
abstract class RankedRepository {
|
||||||
|
Future<DataState<Leaderboard>> getLeaderboard();
|
||||||
|
}
|
||||||
@@ -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<DataState<Leaderboard>, NoParams> {
|
||||||
|
final RankedRepository repository;
|
||||||
|
GetLeaderboardUseCase(this.repository);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DataState<Leaderboard>> call(NoParams params) =>
|
||||||
|
repository.getLeaderboard();
|
||||||
|
}
|
||||||
@@ -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<LeaderboardEvent, LeaderboardState> {
|
||||||
|
final GetLeaderboardUseCase getLeaderboardUseCase;
|
||||||
|
LeaderboardBloc(this.getLeaderboardUseCase) : super(LeaderboardInitial()) {
|
||||||
|
on<LoadLeaderboardEvent>((event, emit) async {
|
||||||
|
emit(LeaderboardLoading());
|
||||||
|
final res = await getLeaderboardUseCase(const NoParams());
|
||||||
|
if (res is DataSuccess) {
|
||||||
|
emit(LeaderboardLoaded(res.data!));
|
||||||
|
} else {
|
||||||
|
emit(LeaderboardError(res.error!));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LeaderboardBloc, LeaderboardState>(
|
||||||
|
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<LeaderboardBloc>()
|
||||||
|
.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),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../core/config.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/game_ui.dart';
|
import '../../../../core/widgets/game_ui.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
||||||
@@ -320,6 +321,7 @@ class _CardsTab extends StatelessWidget {
|
|||||||
title: c.title,
|
title: c.title,
|
||||||
glowColor: const Color(0xFF0E3C73),
|
glowColor: const Color(0xFF0E3C73),
|
||||||
icon: Icons.style,
|
icon: Icons.style,
|
||||||
|
art: _CardSkinArt(skin: c.id, selected: data.selectedCard == c.id),
|
||||||
subtitle: c.priceCoins > 0 ? '${c.priceCoins} سکه' : 'پیشفرض',
|
subtitle: c.priceCoins > 0 ? '${c.priceCoins} سکه' : 'پیشفرض',
|
||||||
action: _cardAction(context, c, data),
|
action: _cardAction(context, c, data),
|
||||||
),
|
),
|
||||||
@@ -381,6 +383,7 @@ class _ItemCard extends StatelessWidget {
|
|||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String? ribbon;
|
final String? ribbon;
|
||||||
final Widget action;
|
final Widget action;
|
||||||
|
final Widget? art; // هنرِ سفارشی (مثل پیشنمایشِ اسکینِ کارت) بهجای آیکن
|
||||||
const _ItemCard({
|
const _ItemCard({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -388,6 +391,7 @@ class _ItemCard extends StatelessWidget {
|
|||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
required this.action,
|
required this.action,
|
||||||
this.ribbon,
|
this.ribbon,
|
||||||
|
this.art,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -418,7 +422,7 @@ class _ItemCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Expanded(child: _GlowArt(color: glowColor, icon: icon)),
|
Expanded(child: art ?? _GlowArt(color: glowColor, icon: icon)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
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 {
|
class _PriceButton extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
GameButton(
|
||||||
label: 'فروشگاه',
|
label: 'فروشگاه',
|
||||||
icon: Icons.storefront,
|
icon: Icons.storefront,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/images/cards/
|
- assets/images/badge/
|
||||||
- assets/audio/
|
- assets/audio/
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
|
|||||||