feat:add frames
This commit is contained in:
@@ -32,6 +32,7 @@ import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart';
|
|||||||
import '../../feature/ranked/presentation/bloc/leaderboard_bloc.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/data_source/remote/carpet_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';
|
import '../../feature/tournament/data/data_source/remote/tournament_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';
|
||||||
@@ -69,6 +70,7 @@ Future<void> setupLocator() async {
|
|||||||
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
|
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
|
||||||
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
|
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
|
||||||
locator.registerSingleton<CarpetApiProvider>(CarpetApiProvider());
|
locator.registerSingleton<CarpetApiProvider>(CarpetApiProvider());
|
||||||
|
locator.registerSingleton<FrameApiProvider>(FrameApiProvider());
|
||||||
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
|
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
|
||||||
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
|
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
|
||||||
locator.registerSingleton<ChatApiProvider>(ChatApiProvider());
|
locator.registerSingleton<ChatApiProvider>(ChatApiProvider());
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_theme.dart';
|
||||||
|
|
||||||
|
/// گرادیانِ هر قابِ آواتار (کازمتیک). شناسهها با کاتالوگِ سرور جور است.
|
||||||
|
/// null یعنی «بدون قابِ اختصاصی» ⇒ از قابِ رتبه استفاده شود.
|
||||||
|
List<Color>? frameGradient(String id) {
|
||||||
|
switch (id) {
|
||||||
|
case 'gold':
|
||||||
|
return const [Color(0xFFF3D27A), AppColors.goldDark];
|
||||||
|
case 'fire':
|
||||||
|
return const [Color(0xFFFF9A3C), Color(0xFFB81D2A)];
|
||||||
|
case 'emerald':
|
||||||
|
return const [Color(0xFF7BEAA5), Color(0xFF1B7A4A)];
|
||||||
|
case 'ocean':
|
||||||
|
return const [Color(0xFF8FD3F0), Color(0xFF1E5FA8)];
|
||||||
|
case 'rose':
|
||||||
|
return const [Color(0xFFF7A8C4), Color(0xFFA83060)];
|
||||||
|
case 'royal':
|
||||||
|
return const [Color(0xFFCE9BEA), Color(0xFF6A2FA0)];
|
||||||
|
default: // none / '' / ناشناخته
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ class GamePlayer {
|
|||||||
final int coins;
|
final int coins;
|
||||||
final String rankTier; // bronze..king (خالی برای بات)
|
final String rankTier; // bronze..king (خالی برای بات)
|
||||||
final String avatar; // seed آواتارِ انتخابیِ کاربر (خالی برای بات)
|
final String avatar; // seed آواتارِ انتخابیِ کاربر (خالی برای بات)
|
||||||
|
final String frame; // قابِ آواتارِ انتخابی (کازمتیک؛ خالی ⇒ قابِ رتبه)
|
||||||
|
|
||||||
GamePlayer.fromJson(Map<String, dynamic> j)
|
GamePlayer.fromJson(Map<String, dynamic> j)
|
||||||
: seat = (j['seat'] ?? 0) as int,
|
: seat = (j['seat'] ?? 0) as int,
|
||||||
@@ -16,7 +17,8 @@ class GamePlayer {
|
|||||||
connected = (j['connected'] ?? false) as bool,
|
connected = (j['connected'] ?? false) as bool,
|
||||||
coins = (j['coins'] ?? 0) as int,
|
coins = (j['coins'] ?? 0) as int,
|
||||||
rankTier = (j['rank_tier'] ?? '') as String,
|
rankTier = (j['rank_tier'] ?? '') as String,
|
||||||
avatar = (j['avatar'] ?? '') as String;
|
avatar = (j['avatar'] ?? '') as String,
|
||||||
|
frame = (j['frame'] ?? '') as String;
|
||||||
|
|
||||||
/// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام (fallback برای بات).
|
/// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام (fallback برای بات).
|
||||||
String get avatarSeed => avatar.isNotEmpty ? avatar : name;
|
String get avatarSeed => avatar.isNotEmpty ? avatar : name;
|
||||||
|
|||||||
@@ -431,22 +431,28 @@ class HokmGame extends FlameGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// چیدمانِ بادبزنیِ منحنی: کارتها چرخش و قوسِ ملایم دارند (مثل دستِ واقعی).
|
// چیدمانِ بادبزنیِ واقعی: کارتها روی قوسِ یک دایره چیده و همراستا با شعاعِ آن
|
||||||
// اندازهی دست کمی بزرگتر از کارتهای روی میز است (خوانا ولی نه نامتوازن).
|
// چرخانده میشوند (کارتِ وسط بالا و صاف، کناریها پایینتر و چرخیده) — مثلِ دستِ
|
||||||
|
// واقعی. مرکزِ قوس پایینِ دست است. اندازهی دست کمی بزرگتر از کارتهای میز.
|
||||||
final hw = size.x * 0.24, hh = hw * kCardRatio;
|
final hw = size.x * 0.24, hh = hw * kCardRatio;
|
||||||
final handW = size.x * 0.90;
|
|
||||||
final stepX = n > 1 ? ((handW - hw) / (n - 1)).clamp(0.0, hw * 0.62) : 0.0;
|
|
||||||
final cx = size.x / 2;
|
final cx = size.x / 2;
|
||||||
final baseY = size.y * 0.83;
|
final baseY = size.y * 0.86;
|
||||||
final tMax = (n - 1) / 2;
|
final tMax = (n - 1) / 2;
|
||||||
const edgeAngle = 0.34; // چرخشِ کارتهای کناری (رادیان)
|
// زاویهی بینِ کارتها؛ با تعدادِ زیاد جمعتر میشود تا بادبزن بیشازحد باز نشود.
|
||||||
final dip = hh * 0.16; // افتِ عمودیِ کارتهای کناری برای قوس
|
final perCard = n > 1 ? (0.9 / (n - 1)).clamp(0.0, 0.11) : 0.0;
|
||||||
|
final maxA = perCard * tMax;
|
||||||
|
// شعاعِ قوس از عرضِ گسترشِ دست مشتق میشود تا فاصلهها متوازن بماند.
|
||||||
|
final spreadX = size.x * 0.86 - hw;
|
||||||
|
final radius = maxA > 0.01 ? (spreadX / 2) / math.sin(maxA) : size.y;
|
||||||
|
final pivotY = baseY + radius;
|
||||||
|
|
||||||
for (var i = 0; i < n; i++) {
|
for (var i = 0; i < n; i++) {
|
||||||
final code = codes[i];
|
final code = codes[i];
|
||||||
final t = i - tMax;
|
final a = (i - tMax) * perCard; // زاویهی این کارت روی قوس
|
||||||
final norm = tMax > 0 ? t / tMax : 0.0;
|
final target = Vector2(
|
||||||
final target = Vector2(cx + t * stepX, baseY + norm * norm * dip);
|
cx + radius * math.sin(a),
|
||||||
|
pivotY - radius * math.cos(a),
|
||||||
|
);
|
||||||
final legal = _legal(code);
|
final legal = _legal(code);
|
||||||
var c = _hand[code];
|
var c = _hand[code];
|
||||||
final isNew = c == null;
|
final isNew = c == null;
|
||||||
@@ -464,7 +470,7 @@ class HokmGame extends FlameGame {
|
|||||||
} else {
|
} else {
|
||||||
c.size = Vector2(hw, hh);
|
c.size = Vector2(hw, hh);
|
||||||
}
|
}
|
||||||
c.angle = norm * edgeAngle; // چرخشِ بادبزنی
|
c.angle = a; // چرخشِ کارت همراستا با شعاعِ قوس
|
||||||
c.onPlay = legal ? () => cubit.playCard(code) : null;
|
c.onPlay = legal ? () => cubit.playCard(code) : null;
|
||||||
c.dimmed = s.phase == 'playing' && s.isMyTurn && !legal;
|
c.dimmed = s.phase == 'playing' && s.isMyTurn && !legal;
|
||||||
c.home = target;
|
c.home = target;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:random_avatar/random_avatar.dart';
|
|||||||
|
|
||||||
import '../../../../core/network/ws_client.dart';
|
import '../../../../core/network/ws_client.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/theme/frames.dart';
|
||||||
import '../../domain/entities/game_entities.dart';
|
import '../../domain/entities/game_entities.dart';
|
||||||
import '../bloc/game_state.dart';
|
import '../bloc/game_state.dart';
|
||||||
|
|
||||||
@@ -227,6 +228,10 @@ List<Color> _rankFrame(String tier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// قابِ نمایشِ آواتارِ یک بازیکن: قابِ اختصاصیِ خریداریشده (اگر باشد)، وگرنه قابِ رتبه.
|
||||||
|
List<Color> _seatFrame(GamePlayer p) =>
|
||||||
|
frameGradient(p.frame) ?? _rankFrame(p.bot ? '' : p.rankTier);
|
||||||
|
|
||||||
class _PlayerSeat extends StatelessWidget {
|
class _PlayerSeat extends StatelessWidget {
|
||||||
final GamePlayer player;
|
final GamePlayer player;
|
||||||
final double diameter;
|
final double diameter;
|
||||||
@@ -287,7 +292,7 @@ class _PlayerSeat extends StatelessWidget {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
// قابِ رتبهای + آواتار (رنگِ قاب بر اساسِ نشانِ رتبهی بازیکن).
|
// قابِ آواتار: قابِ اختصاصیِ خریداریشده (اگر باشد)، وگرنه قابِ رتبه.
|
||||||
Container(
|
Container(
|
||||||
width: diameter,
|
width: diameter,
|
||||||
height: diameter,
|
height: diameter,
|
||||||
@@ -296,14 +301,13 @@ class _PlayerSeat extends StatelessWidget {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: _rankFrame(player.bot ? '' : player.rankTier),
|
colors: _seatFrame(player),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
const BoxShadow(color: Colors.black54, blurRadius: 6),
|
const BoxShadow(color: Colors.black54, blurRadius: 6),
|
||||||
if (isTurn)
|
if (isTurn)
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _rankFrame(player.rankTier)[0]
|
color: _seatFrame(player)[0].withValues(alpha: 0.6),
|
||||||
.withValues(alpha: 0.6),
|
|
||||||
blurRadius: 12),
|
blurRadius: 12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,56 +19,70 @@ class LeaderboardScreen extends StatelessWidget {
|
|||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(
|
||||||
const AppBackButton(),
|
children: [
|
||||||
const Spacer(),
|
Padding(padding: EdgeInsets.all(10), child: AppBackButton()),
|
||||||
const GlowText('رتبهبندی فصل', size: 22),
|
const Spacer(),
|
||||||
const Spacer(),
|
const GlowText('رتبهبندی فصل', size: 22),
|
||||||
const SizedBox(width: 48),
|
const Spacer(),
|
||||||
]),
|
const SizedBox(width: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<LeaderboardBloc, LeaderboardState>(
|
child: BlocBuilder<LeaderboardBloc, LeaderboardState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is LeaderboardError) {
|
if (state is LeaderboardError) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
Text(state.message,
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(color: Colors.white70)),
|
children: [
|
||||||
TextButton(
|
Text(
|
||||||
onPressed: () => context
|
state.message,
|
||||||
.read<LeaderboardBloc>()
|
style: const TextStyle(color: Colors.white70),
|
||||||
.add(LoadLeaderboardEvent()),
|
),
|
||||||
child: const Text('تلاش مجدد'),
|
TextButton(
|
||||||
),
|
onPressed:
|
||||||
]),
|
() => context.read<LeaderboardBloc>().add(
|
||||||
|
LoadLeaderboardEvent(),
|
||||||
|
),
|
||||||
|
child: const Text('تلاش مجدد'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is! LeaderboardLoaded) {
|
if (state is! LeaderboardLoaded) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child:
|
child: CircularProgressIndicator(color: AppColors.gold),
|
||||||
CircularProgressIndicator(color: AppColors.gold));
|
);
|
||||||
}
|
}
|
||||||
final lb = state.data;
|
final lb = state.data;
|
||||||
if (lb.entries.isEmpty) {
|
if (lb.entries.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('هنوز کسی در این فصل امتیاز نگرفته است',
|
child: Text(
|
||||||
style: TextStyle(color: Colors.white54)),
|
'هنوز کسی در این فصل امتیاز نگرفته است',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Text('فصل ${lb.season}',
|
child: Text(
|
||||||
style: const TextStyle(
|
'فصل ${lb.season}',
|
||||||
color: AppColors.gold, fontSize: 14)),
|
style: const TextStyle(
|
||||||
|
color: AppColors.gold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 20),
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 20),
|
||||||
itemCount: lb.entries.length,
|
itemCount: lb.entries.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder:
|
||||||
const SizedBox(height: 8),
|
(_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (_, i) => _row(lb.entries[i]),
|
itemBuilder: (_, i) => _row(lb.entries[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -89,40 +103,47 @@ class LeaderboardScreen extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: LinearGradient(colors: [AppColors.lapis, AppColors.lapisHi]),
|
||||||
colors: [Color(0xFF4A0C16), Color(0xFF2A0710)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: medal ? AppColors.gold : AppColors.goldDark,
|
color: medal ? AppColors.gold : AppColors.goldDark,
|
||||||
width: medal ? 1.6 : 1),
|
width: medal ? 1.6 : 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
SizedBox(
|
children: [
|
||||||
width: 28,
|
SizedBox(
|
||||||
child: Text('${e.rank}',
|
width: 28,
|
||||||
|
child: Text(
|
||||||
|
'${e.rank}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: medal ? AppColors.gold : Colors.white70,
|
color: medal ? AppColors.gold : Colors.white70,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16)),
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
SizedBox(
|
),
|
||||||
width: 40,
|
const SizedBox(width: 8),
|
||||||
height: 40,
|
SizedBox(
|
||||||
child: ClipOval(
|
width: 40,
|
||||||
child: RandomAvatar(e.avatar.isEmpty ? e.name : e.avatar)),
|
height: 40,
|
||||||
),
|
child: ClipOval(
|
||||||
const SizedBox(width: 10),
|
child: RandomAvatar(e.avatar.isEmpty ? e.name : e.avatar),
|
||||||
Expanded(
|
),
|
||||||
child: Text(e.name,
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
e.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 15)),
|
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||||
),
|
),
|
||||||
RankBadge(tier: e.tier, points: e.rankPoints, size: 13),
|
),
|
||||||
]),
|
RankBadge(tier: e.tier, points: e.rankPoints, size: 13),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import '../../../../../core/locator/locator.dart';
|
||||||
|
import '../../../../../core/network/api_provider_imp.dart';
|
||||||
|
import '../../../domain/entities/frame.dart';
|
||||||
|
|
||||||
|
/// دادهٔ قابهای آواتار از backend (دریافت، خرید، انتخاب).
|
||||||
|
class FrameApiProvider {
|
||||||
|
ApiProviderImp get _api => locator<ApiProviderImp>();
|
||||||
|
|
||||||
|
Future<FrameCatalog> getFrames() async {
|
||||||
|
final res = await _api.get('/frames');
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('frames: ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final list = (res.data['frames'] as List?) ?? const [];
|
||||||
|
return FrameCatalog(
|
||||||
|
frames: list
|
||||||
|
.map((e) => AvatarFrame.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList(),
|
||||||
|
selected: (res.data['selected'] ?? '') as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// خرید؛ null یعنی موفق، وگرنه پیامِ خطای فارسی.
|
||||||
|
Future<String?> buyFrame(String id) => _post('/shop/buy-frame', id);
|
||||||
|
|
||||||
|
/// انتخاب؛ null یعنی موفق، وگرنه پیامِ خطای فارسی.
|
||||||
|
Future<String?> selectFrame(String id) => _post('/shop/select-frame', id);
|
||||||
|
|
||||||
|
Future<String?> _post(String path, String id) async {
|
||||||
|
final res = await _api.post(path, body: {'frame_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,31 @@
|
|||||||
|
/// یک قابِ آواتار (کازمتیک). owned یعنی قابلِ انتخاب.
|
||||||
|
class AvatarFrame {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final int priceCoins;
|
||||||
|
final bool vip;
|
||||||
|
final bool owned;
|
||||||
|
|
||||||
|
const AvatarFrame({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.priceCoins,
|
||||||
|
required this.vip,
|
||||||
|
required this.owned,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AvatarFrame.fromJson(Map<String, dynamic> j) => AvatarFrame(
|
||||||
|
id: (j['id'] ?? '') as String,
|
||||||
|
title: (j['title'] ?? '') as String,
|
||||||
|
priceCoins: (j['price_coins'] ?? 0) as int,
|
||||||
|
vip: (j['vip'] ?? false) as bool,
|
||||||
|
owned: (j['owned'] ?? false) as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// کاتالوگِ قابها + قابِ انتخابیِ کاربر.
|
||||||
|
class FrameCatalog {
|
||||||
|
final List<AvatarFrame> frames;
|
||||||
|
final String selected;
|
||||||
|
const FrameCatalog({required this.frames, required this.selected});
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:random_avatar/random_avatar.dart';
|
||||||
|
|
||||||
import '../../../../core/config.dart';
|
import '../../../../core/config.dart';
|
||||||
import '../../../../core/locator/locator.dart';
|
import '../../../../core/locator/locator.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/theme/frames.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';
|
||||||
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_state.dart';
|
import '../../../wallet/presentation/bloc/wallet_state.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_status.dart';
|
import '../../../wallet/presentation/bloc/wallet_status.dart';
|
||||||
import '../../data/data_source/remote/carpet_api_provider.dart';
|
import '../../data/data_source/remote/carpet_api_provider.dart';
|
||||||
|
import '../../data/data_source/remote/frame_api_provider.dart';
|
||||||
import '../../domain/entities/carpet.dart';
|
import '../../domain/entities/carpet.dart';
|
||||||
|
import '../../domain/entities/frame.dart';
|
||||||
import '../../domain/entities/shop_entities.dart';
|
import '../../domain/entities/shop_entities.dart';
|
||||||
import '../bloc/shop_bloc.dart';
|
import '../bloc/shop_bloc.dart';
|
||||||
import '../bloc/shop_event.dart';
|
import '../bloc/shop_event.dart';
|
||||||
@@ -24,7 +28,7 @@ class ShopScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 6,
|
length: 7,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: GameBackground(
|
body: GameBackground(
|
||||||
@@ -88,6 +92,7 @@ class ShopScreen extends StatelessWidget {
|
|||||||
_TicketsTab(packages: d.ticketPackages),
|
_TicketsTab(packages: d.ticketPackages),
|
||||||
_CardsTab(data: d),
|
_CardsTab(data: d),
|
||||||
const _CarpetsTab(),
|
const _CarpetsTab(),
|
||||||
|
const _FramesTab(),
|
||||||
_BoostersTab(boosters: d.boosters),
|
_BoostersTab(boosters: d.boosters),
|
||||||
_VipTab(packages: d.vipPackages),
|
_VipTab(packages: d.vipPackages),
|
||||||
],
|
],
|
||||||
@@ -171,6 +176,7 @@ class _ShopTabs extends StatelessWidget {
|
|||||||
Tab(text: 'بلیط'),
|
Tab(text: 'بلیط'),
|
||||||
Tab(text: 'کارت'),
|
Tab(text: 'کارت'),
|
||||||
Tab(text: 'فرش'),
|
Tab(text: 'فرش'),
|
||||||
|
Tab(text: 'قاب'),
|
||||||
Tab(text: 'تجهیزات'),
|
Tab(text: 'تجهیزات'),
|
||||||
Tab(text: 'VIP'),
|
Tab(text: 'VIP'),
|
||||||
],
|
],
|
||||||
@@ -540,6 +546,176 @@ class _CarpetCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== تبِ قابهای آواتار =====
|
||||||
|
|
||||||
|
class _FramesTab extends StatefulWidget {
|
||||||
|
const _FramesTab();
|
||||||
|
@override
|
||||||
|
State<_FramesTab> createState() => _FramesTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FramesTabState extends State<_FramesTab> {
|
||||||
|
final _api = locator<FrameApiProvider>();
|
||||||
|
late Future<FrameCatalog> _future;
|
||||||
|
String? _busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = _api.getFrames();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reload() => setState(() {
|
||||||
|
_future = _api.getFrames();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _act(AvatarFrame f, {required bool buy}) async {
|
||||||
|
setState(() => _busy = f.id);
|
||||||
|
final err = buy ? await _api.buyFrame(f.id) : await _api.selectFrame(f.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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<FrameCatalog>(
|
||||||
|
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';
|
||||||
|
return _grid(
|
||||||
|
note: 'قابِ آواتارت را عوض کن؛ بازیکنهای دیگر هم آن را میبینند.',
|
||||||
|
children: [
|
||||||
|
for (final f in cat.frames)
|
||||||
|
_FrameCard(
|
||||||
|
frame: f,
|
||||||
|
avatarSeed: seed.isEmpty ? 'hakem-1' : seed,
|
||||||
|
selected: cat.selected == f.id ||
|
||||||
|
(cat.selected.isEmpty && f.id == 'none'),
|
||||||
|
busy: _busy == f.id,
|
||||||
|
onSelect: () => _act(f, buy: false),
|
||||||
|
onBuy: () => _act(f, buy: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FrameCard extends StatelessWidget {
|
||||||
|
final AvatarFrame frame;
|
||||||
|
final String avatarSeed;
|
||||||
|
final bool selected;
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback onSelect;
|
||||||
|
final VoidCallback onBuy;
|
||||||
|
const _FrameCard({
|
||||||
|
required this.frame,
|
||||||
|
required this.avatarSeed,
|
||||||
|
required this.selected,
|
||||||
|
required this.busy,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onBuy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final f = frame;
|
||||||
|
final grad = frameGradient(f.id) ?? const [Color(0xFFF3D27A), AppColors.goldDark];
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [AppColors.lapisHi, AppColors.lapisLo],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.gold : AppColors.goldDark,
|
||||||
|
width: selected ? 2 : 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(f.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.gold, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// پیشنمایش: آواتار درونِ قاب با گرادیانِ همان قاب.
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (_, box) {
|
||||||
|
final d = box.maxHeight.clamp(40.0, 92.0);
|
||||||
|
return Container(
|
||||||
|
width: d,
|
||||||
|
height: d,
|
||||||
|
padding: EdgeInsets.all(d * 0.08),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: grad,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: RandomAvatar(avatarSeed),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
if (f.vip && !f.owned)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text('ویژهی VIP',
|
||||||
|
style: TextStyle(color: AppColors.gold, fontSize: 11)),
|
||||||
|
),
|
||||||
|
SizedBox(width: double.infinity, child: _action(f)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return _PriceButton(
|
||||||
|
label: '${f.priceCoins} سکه',
|
||||||
|
green: true,
|
||||||
|
coin: true,
|
||||||
|
onTap: onBuy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _grid({required String note, required List<Widget> children}) {
|
Widget _grid({required String note, required List<Widget> children}) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
Reference in New Issue
Block a user