feat: add characters
This commit is contained in:
@@ -31,6 +31,7 @@ import '../../feature/ranked/domain/repository/ranked_repository.dart';
|
||||
import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart';
|
||||
import '../../feature/ranked/presentation/bloc/leaderboard_bloc.dart';
|
||||
import '../../feature/shop/data/data_source/remote/shop_api_provider.dart';
|
||||
import '../../feature/shop/data/data_source/remote/avatar_api_provider.dart';
|
||||
import '../../feature/shop/data/data_source/remote/carpet_api_provider.dart';
|
||||
import '../../feature/shop/data/data_source/remote/frame_api_provider.dart';
|
||||
import '../../feature/tournament/data/data_source/remote/tournament_api_provider.dart';
|
||||
@@ -71,6 +72,7 @@ Future<void> setupLocator() async {
|
||||
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
|
||||
locator.registerSingleton<CarpetApiProvider>(CarpetApiProvider());
|
||||
locator.registerSingleton<FrameApiProvider>(FrameApiProvider());
|
||||
locator.registerSingleton<AvatarApiProvider>(AvatarApiProvider());
|
||||
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
|
||||
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
|
||||
locator.registerSingleton<ChatApiProvider>(ChatApiProvider());
|
||||
|
||||
@@ -57,17 +57,17 @@ class CardImages {
|
||||
}
|
||||
}
|
||||
|
||||
/// تصویرِ فرش (برای سطحِ میز) از backend: `/carpets/<id>.jpg`. کششده.
|
||||
static Future<Sprite?> carpet(String id) {
|
||||
return _cache.putIfAbsent('carpet/$id', () async {
|
||||
/// تصویرِ فرش (برای سطحِ میز) از URL کاملِ backend. کششده با کلیدِ URL.
|
||||
static Future<Sprite?> carpetByUrl(String url) {
|
||||
if (url.isEmpty) return Future.value(null);
|
||||
return _cache.putIfAbsent('carpet/$url', () async {
|
||||
try {
|
||||
final res = await _dio
|
||||
.get<List<int>>('${AppConfig.baseUrl}/carpets/$id.jpg');
|
||||
final res = await _dio.get<List<int>>(url);
|
||||
final data = res.data;
|
||||
if (data == null || data.isEmpty) return null;
|
||||
return Sprite(await _decode(Uint8List.fromList(data)));
|
||||
} catch (e) {
|
||||
debugPrint('CardImages: carpet/$id failed: $e');
|
||||
debugPrint('CardImages: carpet $url failed: $e');
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:random_avatar/random_avatar.dart';
|
||||
|
||||
/// آواتارِ یک بازیکن در کلِ بازی: اگر شخصیتِ آواتاری خریده و انتخاب کرده باشد
|
||||
/// ([imageUrl] یک URL کاملِ svg/png از سرور) همان را نشان میدهد؛ وگرنه به آواتارِ
|
||||
/// تصادفیِ پیشفرض بر اساسِ [seed] برمیگردد.
|
||||
///
|
||||
/// URLِ تصویر همیشه از backend میآید (URL-safe)؛ کلاینت آن را نمیسازد. یک منبعِ
|
||||
/// واحد برای رندرِ آواتار در سرِ میز، پروفایل، لابی و فروشگاه (svg با flutter_svg،
|
||||
/// png/jpg با Image.network).
|
||||
class PlayerAvatar extends StatelessWidget {
|
||||
final String seed; // seed آواتارِ تصادفی (fallback)
|
||||
final String imageUrl; // URL کاملِ تصویرِ شخصیت (از سرور) — خالی ⇒ تصادفی
|
||||
final double? size; // اگر null، فضای موجود را پر میکند
|
||||
|
||||
const PlayerAvatar({
|
||||
super.key,
|
||||
required this.seed,
|
||||
this.imageUrl = '',
|
||||
this.size,
|
||||
});
|
||||
|
||||
bool get _hasCharacter => imageUrl.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget child;
|
||||
if (_hasCharacter) {
|
||||
child =
|
||||
imageUrl.toLowerCase().endsWith('.svg')
|
||||
? SvgPicture.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: (_) => _fallback(),
|
||||
)
|
||||
: Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _fallback(),
|
||||
);
|
||||
} else {
|
||||
child = _fallback();
|
||||
}
|
||||
final clipped = ClipOval(child: SizedBox.expand(child: child));
|
||||
if (size == null) return clipped;
|
||||
return SizedBox(width: size, height: size, child: clipped);
|
||||
}
|
||||
|
||||
Widget _fallback() => RandomAvatar(seed.isEmpty ? 'hakem-1' : seed);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class GamePlayer {
|
||||
final String rankTier; // bronze..king (خالی برای بات)
|
||||
final String avatar; // seed آواتارِ انتخابیِ کاربر (خالی برای بات)
|
||||
final String frame; // قابِ آواتارِ انتخابی (کازمتیک؛ خالی ⇒ قابِ رتبه)
|
||||
final String avatarImg; // شخصیتِ آواتارِ انتخابی (<id>.<ext>؛ خالی ⇒ تصادفی)
|
||||
|
||||
GamePlayer.fromJson(Map<String, dynamic> j)
|
||||
: seat = (j['seat'] ?? 0) as int,
|
||||
@@ -18,7 +19,8 @@ class GamePlayer {
|
||||
coins = (j['coins'] ?? 0) as int,
|
||||
rankTier = (j['rank_tier'] ?? '') as String,
|
||||
avatar = (j['avatar'] ?? '') as String,
|
||||
frame = (j['frame'] ?? '') as String;
|
||||
frame = (j['frame'] ?? '') as String,
|
||||
avatarImg = (j['avatar_img'] ?? '') as String;
|
||||
|
||||
/// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام (fallback برای بات).
|
||||
String get avatarSeed => avatar.isNotEmpty ? avatar : name;
|
||||
|
||||
@@ -9,7 +9,9 @@ class LobbyPlayer {
|
||||
final String avatar; // seed آواتارِ انتخابیِ کاربر
|
||||
final bool host;
|
||||
final int seat; // جایگاهِ مطلق (۰..۳)
|
||||
const LobbyPlayer(this.name, this.avatar, this.host, this.seat);
|
||||
final String avatarImg; // شخصیتِ آواتارِ انتخابی (<id>.<ext>؛ خالی ⇒ تصادفی)
|
||||
const LobbyPlayer(this.name, this.avatar, this.host, this.seat,
|
||||
{this.avatarImg = ''});
|
||||
|
||||
/// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام.
|
||||
String get avatarSeed => avatar.isNotEmpty ? avatar : name;
|
||||
@@ -42,6 +44,7 @@ class TableLobby {
|
||||
(e['avatar'] ?? '') as String,
|
||||
(e['host'] ?? false) as bool,
|
||||
(e['seat'] ?? 0) as int,
|
||||
avatarImg: (e['avatar_img'] ?? '') as String,
|
||||
))
|
||||
.toList(),
|
||||
isHost: (j['host'] ?? false) as bool,
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../../core/locator/locator.dart';
|
||||
import '../../../../core/network/ws_client.dart';
|
||||
import '../../../../core/service/app_sounds.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/player_avatar.dart';
|
||||
import '../../../shop/data/data_source/remote/frame_api_provider.dart';
|
||||
import '../../../tournament/data/data_source/remote/tournament_api_provider.dart';
|
||||
import '../../../tournament/domain/entities/tournament.dart';
|
||||
@@ -39,7 +40,6 @@ class GameScreen extends StatefulWidget {
|
||||
|
||||
class _GameScreenState extends State<GameScreen> {
|
||||
late final HokmGame _game;
|
||||
String _carpet = 'classic'; // فرشِ انتخابی (برای پسزمینهی پشتِ صحنه)
|
||||
Timer? _introTimer;
|
||||
bool _introHidden = false;
|
||||
int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱)
|
||||
@@ -50,11 +50,12 @@ class _GameScreenState extends State<GameScreen> {
|
||||
super.initState();
|
||||
final ws = context.read<WalletBloc>().state.walletStatus;
|
||||
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
|
||||
_carpet = ws is WalletLoaded ? ws.wallet.selectedCarpet : 'classic';
|
||||
// URL کاملِ فرش از سرور میآید (خالی ⇒ میزِ کلاسیک).
|
||||
final carpetUrl = ws is WalletLoaded ? ws.wallet.selectedCarpetImg : '';
|
||||
_game = HokmGame(
|
||||
context.read<GameBloc>(),
|
||||
skin: skin.isEmpty ? 'simple' : skin,
|
||||
carpet: _carpet.isEmpty ? 'classic' : _carpet,
|
||||
carpetUrl: carpetUrl,
|
||||
);
|
||||
_loadTournament();
|
||||
locator<FrameApiProvider>().warmCache(); // رنگِ قابها برای HUD (best-effort)
|
||||
|
||||
@@ -253,7 +253,10 @@ class _SearchSlot extends StatelessWidget {
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: RandomAvatar(p.avatarSeed),
|
||||
child: PlayerAvatar(
|
||||
seed: p.avatarSeed,
|
||||
imageUrl: p.avatarImg,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:random_avatar/random_avatar.dart';
|
||||
|
||||
import '../../../../core/network/ws_client.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/game_ui.dart';
|
||||
import '../../../../core/widgets/player_avatar.dart';
|
||||
import '../bloc/game_bloc.dart';
|
||||
import '../bloc/game_state.dart';
|
||||
import 'game_screen.dart';
|
||||
|
||||
@@ -253,7 +253,10 @@ class _LobbyViewState extends State<_LobbyView> {
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: RandomAvatar(p.avatarSeed),
|
||||
child: PlayerAvatar(
|
||||
seed: p.avatarSeed,
|
||||
imageUrl: p.avatarImg,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!empty)
|
||||
|
||||
@@ -68,18 +68,18 @@ class HokmGame extends FlameGame {
|
||||
// ضربانِ قلبِ نوبتِ کاربر (منطق در TurnHeartbeat جدا شده است).
|
||||
final TurnHeartbeat _heart = TurnHeartbeat();
|
||||
|
||||
/// فرشِ انتخابی؛ 'classic' یعنی سطحِ میزِ ترسیمی. فرشِ تصویری بهعنوانِ سطحِ
|
||||
/// میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده میشود.
|
||||
final String carpet;
|
||||
/// URL کاملِ تصویرِ فرش (از سرور)؛ خالی یعنی میزِ ترسیمیِ کلاسیک. فرشِ تصویری
|
||||
/// بهعنوانِ سطحِ میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده میشود.
|
||||
final String carpetUrl;
|
||||
|
||||
HokmGame(this.cubit, {this.skin = 'simple', this.carpet = 'classic'});
|
||||
HokmGame(this.cubit, {this.skin = 'simple', this.carpetUrl = ''});
|
||||
|
||||
@override
|
||||
Color backgroundColor() => AppColors.lapisNight;
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
add(Felt(carpet: carpet));
|
||||
add(Felt(carpetUrl: carpetUrl));
|
||||
if (!AppSettings.I.lowGraphics.value) {
|
||||
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کممصرف خاموش)
|
||||
}
|
||||
|
||||
@@ -16,19 +16,19 @@ const _goldDark = AppColors.goldDark;
|
||||
/// اگر فرشی انتخاب شده باشد، سطحِ میز تصویرِ فرش است (بریدهشده در شکلِ میز)؛
|
||||
/// وگرنه سطحِ سرمهایِ ترسیمی. کارتها داخلِ همین ناحیه انداخته میشوند.
|
||||
class Felt extends PositionComponent with HasGameReference {
|
||||
/// شناسهی فرشِ انتخابی؛ 'classic' یعنی سطحِ ترسیمی (بدون تصویر).
|
||||
final String carpet;
|
||||
Felt({this.carpet = 'classic'});
|
||||
/// URL کاملِ تصویرِ فرش (از سرور)؛ خالی یعنی سطحِ ترسیمیِ کلاسیک (بدون تصویر).
|
||||
final String carpetUrl;
|
||||
Felt({this.carpetUrl = ''});
|
||||
|
||||
Sprite? _carpetSprite;
|
||||
Sprite? _bgSprite; // پسزمینهی نقاشیشده (اختیاری، قابلِ تعویض)
|
||||
|
||||
bool get _hasCarpet => carpet != 'classic';
|
||||
bool get _hasCarpet => carpetUrl.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
if (_hasCarpet) {
|
||||
_carpetSprite = await CardImages.carpet(carpet);
|
||||
_carpetSprite = await CardImages.carpetByUrl(carpetUrl);
|
||||
}
|
||||
// پسزمینهی سفارشی (تالار/کاخ): فایلِ assets/images/game_bg.jpg را بگذارید.
|
||||
// اگر نبود یا حالتِ کممصرف باشد، به گرادیانِ لاجوردیِ پیشفرض برمیگردد.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:random_avatar/random_avatar.dart';
|
||||
|
||||
import '../../../../core/network/ws_client.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/frames.dart';
|
||||
import '../../../../core/theme/ranks.dart';
|
||||
import '../../../../core/widgets/player_avatar.dart';
|
||||
import '../../domain/entities/game_entities.dart';
|
||||
import '../bloc/game_state.dart';
|
||||
|
||||
|
||||
@@ -91,8 +91,12 @@ class _PlayerSeat extends StatelessWidget {
|
||||
color: Colors.white70,
|
||||
size: diameter * 0.5,
|
||||
)
|
||||
: RandomAvatar(
|
||||
player.avatarSeed.isEmpty ? '?' : player.avatarSeed),
|
||||
: PlayerAvatar(
|
||||
seed: player.avatarSeed.isEmpty
|
||||
? '?'
|
||||
: player.avatarSeed,
|
||||
imageUrl: player.avatarImg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProfileModel {
|
||||
return ProfileEntity(
|
||||
name: (name == null || name.isEmpty) ? 'بازیکن' : name,
|
||||
avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar,
|
||||
avatarImg: (wallet['selected_avatar_img'] ?? '') as String,
|
||||
mobile: (user['mobile'] as String?) ?? '',
|
||||
level: (wallet['level'] ?? 1) as int,
|
||||
trophies: (wallet['trophies'] ?? 0) as int,
|
||||
|
||||
@@ -22,6 +22,7 @@ class ProfileStats {
|
||||
class ProfileEntity {
|
||||
final String name;
|
||||
final String avatar;
|
||||
final String avatarImg; // شخصیتِ آواتارِ انتخابی (<id>.<ext>؛ خالی ⇒ تصادفی)
|
||||
final String mobile;
|
||||
final int level;
|
||||
final int trophies;
|
||||
@@ -35,6 +36,7 @@ class ProfileEntity {
|
||||
const ProfileEntity({
|
||||
required this.name,
|
||||
required this.avatar,
|
||||
this.avatarImg = '',
|
||||
required this.mobile,
|
||||
required this.level,
|
||||
required this.trophies,
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../../core/settings/app_settings.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/ranks.dart';
|
||||
import '../../../../core/widgets/game_ui.dart';
|
||||
import '../../../../core/widgets/player_avatar.dart';
|
||||
import '../../../../core/widgets/rank_badge.dart';
|
||||
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
||||
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
||||
|
||||
@@ -40,7 +40,11 @@ class _HeroCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.bgDark,
|
||||
),
|
||||
child: RandomAvatar(d.avatar, height: 90, width: 90),
|
||||
child: PlayerAvatar(
|
||||
seed: d.avatar,
|
||||
imageUrl: d.avatarImg,
|
||||
size: 90,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import '../../../../../core/locator/locator.dart';
|
||||
import '../../../../../core/network/api_provider_imp.dart';
|
||||
import '../../../domain/entities/avatar_character.dart';
|
||||
|
||||
/// دادهٔ شخصیتهای آواتار از backend (دریافت، خرید، انتخاب).
|
||||
class AvatarApiProvider {
|
||||
ApiProviderImp get _api => locator<ApiProviderImp>();
|
||||
|
||||
Future<AvatarCatalog> getAvatars() async {
|
||||
final res = await _api.get('/avatars');
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('avatars: ${res.statusCode}');
|
||||
}
|
||||
final list = (res.data['avatars'] as List?) ?? const [];
|
||||
final avatars = list
|
||||
.map((e) => AvatarCharacter.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
return AvatarCatalog(
|
||||
avatars: avatars,
|
||||
selected: (res.data['selected'] ?? '') as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// خرید؛ null یعنی موفق، وگرنه پیامِ خطای فارسی.
|
||||
Future<String?> buyAvatar(String id) => _post('/shop/buy-avatar', id);
|
||||
|
||||
/// انتخاب؛ id خالی ⇒ بازگشت به آواتارِ تصادفی. null یعنی موفق.
|
||||
Future<String?> selectAvatar(String id) => _post('/shop/select-avatar', id);
|
||||
|
||||
Future<String?> _post(String path, String id) async {
|
||||
final res = await _api.post(path, body: {'avatar_id': id});
|
||||
if (res.statusCode == 200) return null;
|
||||
switch (res.statusCode) {
|
||||
case 402:
|
||||
return 'سکهٔ کافی ندارید';
|
||||
case 403:
|
||||
return 'این شخصیت را ندارید';
|
||||
case 409:
|
||||
return 'این شخصیت را دارید';
|
||||
default:
|
||||
return 'عملیات ناموفق بود';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// یک شخصیتِ آواتار (کازمتیک تصویری). owned یعنی قابلِ انتخاب.
|
||||
class AvatarCharacter {
|
||||
final String id;
|
||||
final String title;
|
||||
final int priceCoins;
|
||||
final bool vip;
|
||||
final String img; // نامِ فایل: <id>.<ext> (برای ساختِ URL و انتخابِ رندرر)
|
||||
final bool owned;
|
||||
|
||||
const AvatarCharacter({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.priceCoins,
|
||||
required this.vip,
|
||||
required this.img,
|
||||
required this.owned,
|
||||
});
|
||||
|
||||
factory AvatarCharacter.fromJson(Map<String, dynamic> j) => AvatarCharacter(
|
||||
id: (j['id'] ?? '') as String,
|
||||
title: (j['title'] ?? '') as String,
|
||||
priceCoins: (j['price_coins'] ?? 0) as int,
|
||||
vip: (j['vip'] ?? false) as bool,
|
||||
img: (j['img'] ?? '') as String,
|
||||
owned: (j['owned'] ?? false) as bool,
|
||||
);
|
||||
}
|
||||
|
||||
/// کاتالوگِ شخصیتها + شناسهی شخصیتِ انتخابیِ کاربر (خالی ⇒ آواتارِ تصادفی).
|
||||
class AvatarCatalog {
|
||||
final List<AvatarCharacter> avatars;
|
||||
final String selected;
|
||||
const AvatarCatalog({required this.avatars, required this.selected});
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import '../../../../core/config.dart';
|
||||
|
||||
/// یک فرشِ ایرانی که بهجای میزِ بازی استفاده میشود. تصویر از backend میآید.
|
||||
/// یک فرشِ ایرانی که بهجای میزِ بازی استفاده میشود. تصویر (URL کامل) از backend میآید.
|
||||
class Carpet {
|
||||
final String id;
|
||||
final String title;
|
||||
final int priceCoins;
|
||||
final bool vip; // برای VIP رایگان
|
||||
final String img; // URL کاملِ تصویر (از سرور)؛ خالی برای classic
|
||||
final bool owned;
|
||||
|
||||
const Carpet({
|
||||
@@ -13,19 +12,19 @@ class Carpet {
|
||||
required this.title,
|
||||
required this.priceCoins,
|
||||
required this.vip,
|
||||
required this.img,
|
||||
required this.owned,
|
||||
});
|
||||
|
||||
/// 'classic' یعنی میزِ ترسیمیِ پیشفرض (بدون تصویر).
|
||||
bool get isClassic => id == 'classic';
|
||||
|
||||
String get imageUrl => '${AppConfig.baseUrl}/carpets/$id.jpg';
|
||||
|
||||
factory Carpet.fromJson(Map<String, dynamic> j) => Carpet(
|
||||
id: (j['id'] ?? '') as String,
|
||||
title: (j['title'] ?? '') as String,
|
||||
priceCoins: (j['price_coins'] ?? 0) as int,
|
||||
vip: (j['vip'] ?? false) as bool,
|
||||
img: (j['img'] ?? '') as String,
|
||||
owned: (j['owned'] ?? false) as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import '../../../../core/locator/locator.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/frames.dart';
|
||||
import '../../../../core/widgets/game_ui.dart';
|
||||
import '../../../../core/widgets/player_avatar.dart';
|
||||
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
||||
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
||||
import '../../../wallet/presentation/bloc/wallet_state.dart';
|
||||
import '../../../wallet/presentation/bloc/wallet_status.dart';
|
||||
import '../../data/data_source/remote/avatar_api_provider.dart';
|
||||
import '../../data/data_source/remote/carpet_api_provider.dart';
|
||||
import '../../data/data_source/remote/frame_api_provider.dart';
|
||||
import '../../domain/entities/avatar_character.dart';
|
||||
import '../../domain/entities/carpet.dart';
|
||||
import '../../domain/entities/frame.dart';
|
||||
import '../../domain/entities/shop_entities.dart';
|
||||
@@ -30,7 +33,7 @@ class ShopScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 7,
|
||||
length: 8,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: GameBackground(
|
||||
@@ -95,6 +98,7 @@ class ShopScreen extends StatelessWidget {
|
||||
_CardsTab(data: d),
|
||||
const _CarpetsTab(),
|
||||
const _FramesTab(),
|
||||
const _AvatarsTab(),
|
||||
_BoostersTab(boosters: d.boosters),
|
||||
_VipTab(packages: d.vipPackages),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ class _ShopTabs extends StatelessWidget {
|
||||
(Icons.style, 'کارت'),
|
||||
(Icons.casino, 'فرش'),
|
||||
(Icons.filter_center_focus, 'قاب'),
|
||||
(Icons.face, 'شخصیت'),
|
||||
(Icons.bolt, 'تجهیزات'),
|
||||
(Icons.workspace_premium, 'VIP'),
|
||||
];
|
||||
@@ -352,7 +353,7 @@ class _CarpetCard extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
c.imageUrl,
|
||||
c.img,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => Container(
|
||||
@@ -453,12 +454,15 @@ class _FramesTabState extends State<_FramesTab> {
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.gold));
|
||||
child: CircularProgressIndicator(color: AppColors.gold),
|
||||
);
|
||||
}
|
||||
if (!snap.hasData) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
onPressed: _reload, child: const Text('تلاش مجدد')),
|
||||
onPressed: _reload,
|
||||
child: const Text('تلاش مجدد'),
|
||||
),
|
||||
);
|
||||
}
|
||||
final cat = snap.data!;
|
||||
@@ -471,7 +475,8 @@ class _FramesTabState extends State<_FramesTab> {
|
||||
_FrameCard(
|
||||
frame: f,
|
||||
avatarSeed: seed.isEmpty ? 'hakem-1' : seed,
|
||||
selected: cat.selected == f.id ||
|
||||
selected:
|
||||
cat.selected == f.id ||
|
||||
(cat.selected.isEmpty && f.id == 'none'),
|
||||
busy: _busy == f.id,
|
||||
onSelect: () => _act(f, buy: false),
|
||||
@@ -503,19 +508,23 @@ class _FrameCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final f = frame;
|
||||
final grad = frameGradient(f.id) ?? const [Color(0xFFF3D27A), AppColors.goldDark];
|
||||
final grad =
|
||||
frameGradient(f.id) ?? const [Color(0xFFF3D27A), AppColors.goldDark];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: _shopCardDeco(highlight: selected),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(f.title,
|
||||
Text(
|
||||
f.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.gold,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14)),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// پیشنمایش: آواتار درونِ قاب با گرادیانِ همان قاب.
|
||||
Expanded(
|
||||
@@ -536,9 +545,7 @@ class _FrameCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: SizedBox.expand(
|
||||
child: RandomAvatar(avatarSeed),
|
||||
),
|
||||
child: SizedBox.expand(child: RandomAvatar(avatarSeed)),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -549,8 +556,10 @@ class _FrameCard extends StatelessWidget {
|
||||
if (f.vip && !f.owned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 4),
|
||||
child: Text('ویژهی VIP',
|
||||
style: TextStyle(color: AppColors.gold, fontSize: 11)),
|
||||
child: Text(
|
||||
'ویژهی VIP',
|
||||
style: TextStyle(color: AppColors.gold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
SizedBox(width: double.infinity, child: _action(f)),
|
||||
],
|
||||
@@ -560,8 +569,12 @@ class _FrameCard extends StatelessWidget {
|
||||
|
||||
Widget _action(AvatarFrame f) {
|
||||
if (busy) return const _PriceButton(label: '...', disabled: true);
|
||||
if (selected) return const _PriceButton(label: 'انتخاب شده', disabled: true);
|
||||
if (f.owned) return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect);
|
||||
if (selected) {
|
||||
return const _PriceButton(label: 'انتخاب شده', disabled: true);
|
||||
}
|
||||
if (f.owned) {
|
||||
return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect);
|
||||
}
|
||||
return _PriceButton(
|
||||
label: '${f.priceCoins} سکه',
|
||||
green: true,
|
||||
@@ -571,6 +584,213 @@ class _FrameCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== تبِ شخصیتهای آواتار =====
|
||||
|
||||
class _AvatarsTab extends StatefulWidget {
|
||||
const _AvatarsTab();
|
||||
@override
|
||||
State<_AvatarsTab> createState() => _AvatarsTabState();
|
||||
}
|
||||
|
||||
class _AvatarsTabState extends State<_AvatarsTab> {
|
||||
final _api = locator<AvatarApiProvider>();
|
||||
late Future<AvatarCatalog> _future;
|
||||
String? _busy;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _api.getAvatars();
|
||||
}
|
||||
|
||||
void _reload() => setState(() {
|
||||
_future = _api.getAvatars();
|
||||
});
|
||||
|
||||
Future<void> _act(AvatarCharacter a, {required bool buy}) async {
|
||||
setState(() => _busy = a.id);
|
||||
final err =
|
||||
buy ? await _api.buyAvatar(a.id) : await _api.selectAvatar(a.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = null);
|
||||
if (err == null) {
|
||||
context.read<WalletBloc>().add(LoadWalletEvent());
|
||||
_reload();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
setState(() => _busy = '__none__');
|
||||
await _api.selectAvatar(''); // بازگشت به آواتارِ تصادفی
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = null);
|
||||
context.read<WalletBloc>().add(LoadWalletEvent());
|
||||
_reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<AvatarCatalog>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.gold),
|
||||
);
|
||||
}
|
||||
if (!snap.hasData) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
onPressed: _reload,
|
||||
child: const Text('تلاش مجدد'),
|
||||
),
|
||||
);
|
||||
}
|
||||
final cat = snap.data!;
|
||||
final ws = context.watch<WalletBloc>().state.walletStatus;
|
||||
final seed = ws is WalletLoaded ? ws.wallet.avatar : 'hakem-1';
|
||||
if (cat.avatars.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'بهزودی شخصیتهای تازه اضافه میشوند.',
|
||||
style: TextStyle(color: AppColors.gold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _grid(
|
||||
note:
|
||||
'شخصیتِ آواتارت را انتخاب کن؛ در کلِ بازی (سرِ میز، پروفایل و لابی) دیده میشود.',
|
||||
children: [
|
||||
// «پیشفرض»: بازگشت به آواتارِ تصادفی.
|
||||
_AvatarCard.defaultRandom(
|
||||
avatarSeed: seed.isEmpty ? 'hakem-1' : seed,
|
||||
selected: cat.selected.isEmpty,
|
||||
busy: _busy == '__none__',
|
||||
onSelect: _clear,
|
||||
),
|
||||
for (final a in cat.avatars)
|
||||
_AvatarCard(
|
||||
avatar: a,
|
||||
avatarSeed: seed.isEmpty ? 'hakem-1' : seed,
|
||||
selected: cat.selected == a.id,
|
||||
busy: _busy == a.id,
|
||||
onSelect: () => _act(a, buy: false),
|
||||
onBuy: () => _act(a, buy: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarCard extends StatelessWidget {
|
||||
final AvatarCharacter? avatar; // null ⇒ کارتِ «پیشفرض» (آواتارِ تصادفی)
|
||||
final String avatarSeed;
|
||||
final bool selected;
|
||||
final bool busy;
|
||||
final VoidCallback onSelect;
|
||||
final VoidCallback? onBuy;
|
||||
const _AvatarCard({
|
||||
required this.avatar,
|
||||
required this.avatarSeed,
|
||||
required this.selected,
|
||||
required this.busy,
|
||||
required this.onSelect,
|
||||
required this.onBuy,
|
||||
});
|
||||
|
||||
const _AvatarCard.defaultRandom({
|
||||
required this.avatarSeed,
|
||||
required this.selected,
|
||||
required this.busy,
|
||||
required this.onSelect,
|
||||
}) : avatar = null,
|
||||
onBuy = null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final a = avatar;
|
||||
final title = a?.title ?? 'پیشفرض';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: _shopCardDeco(highlight: selected),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.gold,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (_, box) {
|
||||
final d = box.maxHeight.clamp(40.0, 96.0);
|
||||
return Container(
|
||||
width: d,
|
||||
height: d,
|
||||
padding: EdgeInsets.all(d * 0.06),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.bgDark,
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.gold : AppColors.goldDark,
|
||||
width: selected ? 2 : 1.4,
|
||||
),
|
||||
),
|
||||
child: PlayerAvatar(
|
||||
seed: avatarSeed,
|
||||
imageUrl: a?.img ?? '',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (a != null && a.vip && !a.owned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'ویژهی VIP',
|
||||
style: TextStyle(color: AppColors.gold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
SizedBox(width: double.infinity, child: _action(a)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action(AvatarCharacter? a) {
|
||||
if (busy) return const _PriceButton(label: '...', disabled: true);
|
||||
if (selected) {
|
||||
return const _PriceButton(label: 'انتخاب شده', disabled: true);
|
||||
}
|
||||
if (a == null || a.owned) {
|
||||
return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect);
|
||||
}
|
||||
return _PriceButton(
|
||||
label: '${a.priceCoins} سکه',
|
||||
green: true,
|
||||
coin: true,
|
||||
onTap: onBuy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _grid({required String note, required List<Widget> children}) {
|
||||
return Column(
|
||||
children: [
|
||||
@@ -630,10 +850,12 @@ BoxDecoration _shopCardDeco({bool highlight = false}) => BoxDecoration(
|
||||
),
|
||||
boxShadow: [
|
||||
const BoxShadow(
|
||||
color: Colors.black54, blurRadius: 10, offset: Offset(0, 5)),
|
||||
color: Colors.black54,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
if (highlight)
|
||||
BoxShadow(
|
||||
color: AppColors.gold.withValues(alpha: 0.3), blurRadius: 14),
|
||||
BoxShadow(color: AppColors.gold.withValues(alpha: 0.3), blurRadius: 14),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -761,7 +983,8 @@ class _ItemCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: isBonus
|
||||
colors:
|
||||
isBonus
|
||||
? const [AppColors.success, AppColors.successDark]
|
||||
: const [Color(0xFFFFD54F), AppColors.goldDark],
|
||||
),
|
||||
@@ -915,7 +1138,8 @@ class _PriceButtonState extends State<_PriceButton> {
|
||||
Widget build(BuildContext context) {
|
||||
final busy = context.select((ShopBloc c) => c.state.busy);
|
||||
final enabled = !(widget.disabled || busy) && widget.onTap != null;
|
||||
final colors = widget.disabled
|
||||
final colors =
|
||||
widget.disabled
|
||||
? const [Color(0xFF555555), Color(0xFF333333)]
|
||||
: widget.green
|
||||
? const [AppColors.success, AppColors.successDark]
|
||||
@@ -945,7 +1169,8 @@ class _PriceButtonState extends State<_PriceButton> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: AppColors.gold, width: 1.2),
|
||||
boxShadow: enabled
|
||||
boxShadow:
|
||||
enabled
|
||||
? [
|
||||
BoxShadow(
|
||||
color: colors.last.withValues(alpha: 0.55),
|
||||
|
||||
@@ -13,8 +13,10 @@ class WalletModel extends WalletEntity {
|
||||
required super.vip,
|
||||
required super.selectedCard,
|
||||
required super.selectedCarpet,
|
||||
super.selectedCarpetImg,
|
||||
required super.name,
|
||||
required super.avatar,
|
||||
super.avatarImg,
|
||||
super.dailyStreak,
|
||||
super.bestStreak,
|
||||
super.dailyReady,
|
||||
@@ -38,8 +40,10 @@ class WalletModel extends WalletEntity {
|
||||
vip: (wallet['vip'] ?? false) as bool,
|
||||
selectedCard: (wallet['selected_card'] ?? 'simple') as String,
|
||||
selectedCarpet: (wallet['selected_carpet'] ?? 'classic') as String,
|
||||
selectedCarpetImg: (wallet['selected_carpet_img'] ?? '') as String,
|
||||
name: (name == null || name.isEmpty) ? 'بازیکن' : name,
|
||||
avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar,
|
||||
avatarImg: (wallet['selected_avatar_img'] ?? '') as String,
|
||||
dailyStreak: (wallet['daily_streak'] ?? 0) as int,
|
||||
bestStreak: (wallet['best_streak'] ?? 0) as int,
|
||||
dailyReady: (wallet['daily_ready'] ?? true) as bool,
|
||||
|
||||
@@ -10,8 +10,10 @@ class WalletEntity {
|
||||
final bool vip;
|
||||
final String selectedCard;
|
||||
final String selectedCarpet;
|
||||
final String selectedCarpetImg; // URL کاملِ تصویرِ فرش (خالی ⇒ میزِ کلاسیک)
|
||||
final String name;
|
||||
final String avatar;
|
||||
final String avatarImg; // شخصیتِ آواتارِ انتخابی (<id>.<ext>؛ خالی ⇒ تصادفی)
|
||||
final int dailyStreak; // روزهای پیاپیِ دریافتِ سکهی روزانه
|
||||
final int bestStreak;
|
||||
final bool dailyReady; // آیا امروز سکهی روزانه قابلِ دریافت است
|
||||
@@ -28,8 +30,10 @@ class WalletEntity {
|
||||
required this.vip,
|
||||
required this.selectedCard,
|
||||
required this.selectedCarpet,
|
||||
this.selectedCarpetImg = '',
|
||||
required this.name,
|
||||
required this.avatar,
|
||||
this.avatarImg = '',
|
||||
this.dailyStreak = 0,
|
||||
this.bestStreak = 0,
|
||||
this.dailyReady = true,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:random_avatar/random_avatar.dart';
|
||||
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/game_ui.dart';
|
||||
import '../../../../core/widgets/player_avatar.dart';
|
||||
import '../../../auth/presentation/bloc/auth_bloc.dart';
|
||||
import '../../../auth/presentation/bloc/auth_event.dart';
|
||||
import '../../domain/entities/wallet_entity.dart';
|
||||
|
||||
@@ -350,7 +350,7 @@ class _TopBar extends StatelessWidget {
|
||||
child:
|
||||
(w == null || w.avatar.isEmpty)
|
||||
? const Icon(Icons.person, color: AppColors.gold)
|
||||
: ClipOval(child: RandomAvatar(w.avatar)),
|
||||
: PlayerAvatar(seed: w.avatar, imageUrl: w.avatarImg),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
+1
-1
@@ -279,7 +279,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||
|
||||
@@ -45,6 +45,7 @@ dependencies:
|
||||
flame: ^1.30.1
|
||||
flame_audio: ^2.11.14
|
||||
random_avatar: ^0.0.8
|
||||
flutter_svg: ^2.3.0 # نمایشِ شخصیتِ آواتارِ svg (و png با Image.network)
|
||||
# خرید درونبرنامهای: هر flavor یکی را استفاده میکند (مایکت/کافهبازار).
|
||||
myket_iap: ^1.1.11
|
||||
flutter_poolakey: ^2.2.0
|
||||
|
||||
Reference in New Issue
Block a user