feat: get cards from backend
This commit is contained in:
@@ -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<LeaderboardBloc>()..add(LoadLeaderboardEvent()),
|
||||
child: const LeaderboardScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/shop',
|
||||
builder: (_, __) => BlocProvider(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> setupLocator() async {
|
||||
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
|
||||
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
|
||||
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
|
||||
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
|
||||
locator.registerSingleton<GameApiProvider>(GameApiProvider());
|
||||
locator.registerSingleton<GameWsProvider>(GameWsProvider(locator()));
|
||||
|
||||
@@ -67,6 +73,7 @@ Future<void> setupLocator() async {
|
||||
locator.registerSingleton<ShopRepository>(ShopRepositoryImpl(locator()));
|
||||
locator.registerSingleton<ProfileRepository>(
|
||||
ProfileRepositoryImpl(locator()));
|
||||
locator.registerSingleton<RankedRepository>(RankedRepositoryImpl(locator()));
|
||||
locator.registerSingleton<GameRepository>(
|
||||
GameRepositoryImpl(locator(), locator()));
|
||||
|
||||
@@ -85,6 +92,8 @@ Future<void> setupLocator() async {
|
||||
locator.registerSingleton<AdRewardUseCase>(AdRewardUseCase(locator()));
|
||||
locator.registerSingleton<GetProfileUseCase>(GetProfileUseCase(locator()));
|
||||
locator.registerSingleton<SaveProfileUseCase>(SaveProfileUseCase(locator()));
|
||||
locator.registerSingleton<GetLeaderboardUseCase>(
|
||||
GetLeaderboardUseCase(locator()));
|
||||
locator.registerSingleton<GetTiersUseCase>(GetTiersUseCase(locator()));
|
||||
locator.registerSingleton<GetTablesInfoUseCase>(
|
||||
GetTablesInfoUseCase(locator()));
|
||||
@@ -97,6 +106,7 @@ Future<void> setupLocator() async {
|
||||
ShopBloc(locator(), locator(), locator(), locator(), locator()));
|
||||
locator.registerFactory<ProfileBloc>(
|
||||
() => ProfileBloc(locator(), locator()));
|
||||
locator.registerFactory<LeaderboardBloc>(() => LeaderboardBloc(locator()));
|
||||
locator.registerFactory<GameBloc>(() => GameBloc(locator()));
|
||||
locator.registerFactory<TierBloc>(() => TierBloc(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) {
|
||||
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<MobileScreen> {
|
||||
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<AuthBloc>()
|
||||
.add(LoginOtpEvent(_controller.text.trim())),
|
||||
colors: const [
|
||||
Color(0xFF3FA34D),
|
||||
Color(0xFF1B5E20),
|
||||
],
|
||||
onTap:
|
||||
(!_valid || loading)
|
||||
? null
|
||||
: () => context.read<AuthBloc>().add(
|
||||
LoginOtpEvent(_controller.text.trim()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<String, dynamic> 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 {
|
||||
|
||||
@@ -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<GameScreen> {
|
||||
@override
|
||||
void 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
|
||||
|
||||
@@ -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/<code>.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<void> 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);
|
||||
|
||||
@@ -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<String, CardComponent> _trick = {};
|
||||
final List<Component> _backs = [];
|
||||
final List<Component> _info = [];
|
||||
final Map<String, Sprite> _badge = {}; // نشانهای رتبه (پیشبارگذاری)
|
||||
|
||||
// ابعادِ کارتهای روی میز (بر اساس عرض صفحه محاسبه میشود).
|
||||
double _cardW = 60;
|
||||
@@ -44,6 +48,7 @@ class HokmGame extends FlameGame {
|
||||
double _shake = 0;
|
||||
double _t = 0;
|
||||
List<String> _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<void> 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: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;
|
||||
|
||||
@@ -84,6 +84,14 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
||||
},
|
||||
),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user