From df2ae2a215b1f357fcca62edfcf504b951099bd3 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Wed, 8 Jul 2026 12:15:06 +0330 Subject: [PATCH] feat:add frames --- lib/core/locator/locator.dart | 2 + lib/core/theme/frames.dart | 24 +++ .../game/domain/entities/game_entities.dart | 4 +- .../presentation/widgets/flame/hokm_game.dart | 28 +-- .../game/presentation/widgets/table_hud.dart | 12 +- .../screen/leaderboard_screen.dart | 127 +++++++------ .../remote/frame_api_provider.dart | 43 +++++ lib/feature/shop/domain/entities/frame.dart | 31 +++ .../shop/presentation/screen/shop_screen.dart | 178 +++++++++++++++++- 9 files changed, 379 insertions(+), 70 deletions(-) create mode 100644 lib/core/theme/frames.dart create mode 100644 lib/feature/shop/data/data_source/remote/frame_api_provider.dart create mode 100644 lib/feature/shop/domain/entities/frame.dart diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart index 997defd..3d5ed7b 100644 --- a/lib/core/locator/locator.dart +++ b/lib/core/locator/locator.dart @@ -32,6 +32,7 @@ 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/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/shop/data/repository/shop_repository_impl.dart'; import '../../feature/shop/domain/repository/shop_repository.dart'; @@ -69,6 +70,7 @@ Future setupLocator() async { locator.registerSingleton(WalletApiProvider()); locator.registerSingleton(ShopApiProvider()); locator.registerSingleton(CarpetApiProvider()); + locator.registerSingleton(FrameApiProvider()); locator.registerSingleton(ProfileApiProvider()); locator.registerSingleton(RankedApiProvider()); locator.registerSingleton(ChatApiProvider()); diff --git a/lib/core/theme/frames.dart b/lib/core/theme/frames.dart new file mode 100644 index 0000000..f203d39 --- /dev/null +++ b/lib/core/theme/frames.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import 'app_theme.dart'; + +/// گرادیانِ هر قابِ آواتار (کازمتیک). شناسه‌ها با کاتالوگِ سرور جور است. +/// null یعنی «بدون قابِ اختصاصی» ⇒ از قابِ رتبه استفاده شود. +List? 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; + } +} diff --git a/lib/feature/game/domain/entities/game_entities.dart b/lib/feature/game/domain/entities/game_entities.dart index 9d815f1..eb37cbb 100644 --- a/lib/feature/game/domain/entities/game_entities.dart +++ b/lib/feature/game/domain/entities/game_entities.dart @@ -8,6 +8,7 @@ class GamePlayer { final int coins; final String rankTier; // bronze..king (خالی برای بات) final String avatar; // seed آواتارِ انتخابیِ کاربر (خالی برای بات) + final String frame; // قابِ آواتارِ انتخابی (کازمتیک؛ خالی ⇒ قابِ رتبه) GamePlayer.fromJson(Map j) : seat = (j['seat'] ?? 0) as int, @@ -16,7 +17,8 @@ class GamePlayer { connected = (j['connected'] ?? false) as bool, coins = (j['coins'] ?? 0) as int, rankTier = (j['rank_tier'] ?? '') as String, - avatar = (j['avatar'] ?? '') as String; + avatar = (j['avatar'] ?? '') as String, + frame = (j['frame'] ?? '') as String; /// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام (fallback برای بات). String get avatarSeed => avatar.isNotEmpty ? avatar : name; diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index c719f64..38cd311 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -431,22 +431,28 @@ class HokmGame extends FlameGame { } } - // چیدمانِ بادبزنیِ منحنی: کارت‌ها چرخش و قوسِ ملایم دارند (مثل دستِ واقعی). - // اندازه‌ی دست کمی بزرگ‌تر از کارت‌های روی میز است (خوانا ولی نه نامتوازن). + // چیدمانِ بادبزنیِ واقعی: کارت‌ها روی قوسِ یک دایره چیده و هم‌راستا با شعاعِ آن + // چرخانده می‌شوند (کارتِ وسط بالا و صاف، کناری‌ها پایین‌تر و چرخیده) — مثلِ دستِ + // واقعی. مرکزِ قوس پایینِ دست است. اندازه‌ی دست کمی بزرگ‌تر از کارت‌های میز. 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 baseY = size.y * 0.83; + final baseY = size.y * 0.86; 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++) { final code = codes[i]; - final t = i - tMax; - final norm = tMax > 0 ? t / tMax : 0.0; - final target = Vector2(cx + t * stepX, baseY + norm * norm * dip); + final a = (i - tMax) * perCard; // زاویه‌ی این کارت روی قوس + final target = Vector2( + cx + radius * math.sin(a), + pivotY - radius * math.cos(a), + ); final legal = _legal(code); var c = _hand[code]; final isNew = c == null; @@ -464,7 +470,7 @@ class HokmGame extends FlameGame { } else { c.size = Vector2(hw, hh); } - c.angle = norm * edgeAngle; // چرخشِ بادبزنی + c.angle = a; // چرخشِ کارت هم‌راستا با شعاعِ قوس c.onPlay = legal ? () => cubit.playCard(code) : null; c.dimmed = s.phase == 'playing' && s.isMyTurn && !legal; c.home = target; diff --git a/lib/feature/game/presentation/widgets/table_hud.dart b/lib/feature/game/presentation/widgets/table_hud.dart index df38ec3..621041e 100644 --- a/lib/feature/game/presentation/widgets/table_hud.dart +++ b/lib/feature/game/presentation/widgets/table_hud.dart @@ -3,6 +3,7 @@ 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 '../../domain/entities/game_entities.dart'; import '../bloc/game_state.dart'; @@ -227,6 +228,10 @@ List _rankFrame(String tier) { } } +/// قابِ نمایشِ آواتارِ یک بازیکن: قابِ اختصاصیِ خریداری‌شده (اگر باشد)، وگرنه قابِ رتبه. +List _seatFrame(GamePlayer p) => + frameGradient(p.frame) ?? _rankFrame(p.bot ? '' : p.rankTier); + class _PlayerSeat extends StatelessWidget { final GamePlayer player; final double diameter; @@ -287,7 +292,7 @@ class _PlayerSeat extends StatelessWidget { clipBehavior: Clip.none, alignment: Alignment.center, children: [ - // قابِ رتبه‌ای + آواتار (رنگِ قاب بر اساسِ نشانِ رتبه‌ی بازیکن). + // قابِ آواتار: قابِ اختصاصیِ خریداری‌شده (اگر باشد)، وگرنه قابِ رتبه. Container( width: diameter, height: diameter, @@ -296,14 +301,13 @@ class _PlayerSeat extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: _rankFrame(player.bot ? '' : player.rankTier), + colors: _seatFrame(player), ), boxShadow: [ const BoxShadow(color: Colors.black54, blurRadius: 6), if (isTurn) BoxShadow( - color: _rankFrame(player.rankTier)[0] - .withValues(alpha: 0.6), + color: _seatFrame(player)[0].withValues(alpha: 0.6), blurRadius: 12), ], ), diff --git a/lib/feature/ranked/presentation/screen/leaderboard_screen.dart b/lib/feature/ranked/presentation/screen/leaderboard_screen.dart index d64d64c..20812b4 100644 --- a/lib/feature/ranked/presentation/screen/leaderboard_screen.dart +++ b/lib/feature/ranked/presentation/screen/leaderboard_screen.dart @@ -19,56 +19,70 @@ class LeaderboardScreen extends StatelessWidget { child: SafeArea( child: Column( children: [ - Row(children: [ - const AppBackButton(), - const Spacer(), - const GlowText('رتبه‌بندی فصل', size: 22), - const Spacer(), - const SizedBox(width: 48), - ]), + Row( + children: [ + Padding(padding: EdgeInsets.all(10), child: AppBackButton()), + const Spacer(), + const GlowText('رتبه‌بندی فصل', size: 22), + const Spacer(), + const SizedBox(width: 48), + ], + ), Expanded( child: BlocBuilder( builder: (context, state) { if (state is LeaderboardError) { return Center( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(state.message, - style: const TextStyle(color: Colors.white70)), - TextButton( - onPressed: () => context - .read() - .add(LoadLeaderboardEvent()), - child: const Text('تلاش مجدد'), - ), - ]), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + state.message, + style: const TextStyle(color: Colors.white70), + ), + TextButton( + onPressed: + () => context.read().add( + LoadLeaderboardEvent(), + ), + child: const Text('تلاش مجدد'), + ), + ], + ), ); } if (state is! LeaderboardLoaded) { return const Center( - child: - CircularProgressIndicator(color: AppColors.gold)); + child: CircularProgressIndicator(color: AppColors.gold), + ); } final lb = state.data; if (lb.entries.isEmpty) { return const Center( - child: Text('هنوز کسی در این فصل امتیاز نگرفته است', - style: TextStyle(color: Colors.white54)), + 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)), + 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), + separatorBuilder: + (_, __) => const SizedBox(height: 8), itemBuilder: (_, i) => _row(lb.entries[i]), ), ), @@ -89,40 +103,47 @@ class LeaderboardScreen extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF4A0C16), Color(0xFF2A0710)], - ), + gradient: LinearGradient(colors: [AppColors.lapis, AppColors.lapisHi]), borderRadius: BorderRadius.circular(12), border: Border.all( - color: medal ? AppColors.gold : AppColors.goldDark, - width: medal ? 1.6 : 1), + color: medal ? AppColors.gold : AppColors.goldDark, + width: medal ? 1.6 : 1, + ), ), - child: Row(children: [ - SizedBox( - width: 28, - child: Text('${e.rank}', + 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, + 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), - ]), + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + ), + RankBadge(tier: e.tier, points: e.rankPoints, size: 13), + ], + ), ); } } diff --git a/lib/feature/shop/data/data_source/remote/frame_api_provider.dart b/lib/feature/shop/data/data_source/remote/frame_api_provider.dart new file mode 100644 index 0000000..5a2a5fc --- /dev/null +++ b/lib/feature/shop/data/data_source/remote/frame_api_provider.dart @@ -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(); + + Future 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.from(e as Map))) + .toList(), + selected: (res.data['selected'] ?? '') as String, + ); + } + + /// خرید؛ null یعنی موفق، وگرنه پیامِ خطای فارسی. + Future buyFrame(String id) => _post('/shop/buy-frame', id); + + /// انتخاب؛ null یعنی موفق، وگرنه پیامِ خطای فارسی. + Future selectFrame(String id) => _post('/shop/select-frame', id); + + Future _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 'عملیات ناموفق بود'; + } + } +} diff --git a/lib/feature/shop/domain/entities/frame.dart b/lib/feature/shop/domain/entities/frame.dart new file mode 100644 index 0000000..57bd2bd --- /dev/null +++ b/lib/feature/shop/domain/entities/frame.dart @@ -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 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 frames; + final String selected; + const FrameCatalog({required this.frames, required this.selected}); +} diff --git a/lib/feature/shop/presentation/screen/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart index 80670be..d37eebb 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -1,16 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:random_avatar/random_avatar.dart'; import '../../../../core/config.dart'; import '../../../../core/locator/locator.dart'; import '../../../../core/theme/app_theme.dart'; +import '../../../../core/theme/frames.dart'; import '../../../../core/widgets/game_ui.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/carpet_api_provider.dart'; +import '../../data/data_source/remote/frame_api_provider.dart'; import '../../domain/entities/carpet.dart'; +import '../../domain/entities/frame.dart'; import '../../domain/entities/shop_entities.dart'; import '../bloc/shop_bloc.dart'; import '../bloc/shop_event.dart'; @@ -24,7 +28,7 @@ class ShopScreen extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 6, + length: 7, child: Scaffold( backgroundColor: Colors.transparent, body: GameBackground( @@ -88,6 +92,7 @@ class ShopScreen extends StatelessWidget { _TicketsTab(packages: d.ticketPackages), _CardsTab(data: d), const _CarpetsTab(), + const _FramesTab(), _BoostersTab(boosters: d.boosters), _VipTab(packages: d.vipPackages), ], @@ -171,6 +176,7 @@ class _ShopTabs extends StatelessWidget { Tab(text: 'بلیط'), Tab(text: 'کارت'), Tab(text: 'فرش'), + Tab(text: 'قاب'), Tab(text: 'تجهیزات'), 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(); + late Future _future; + String? _busy; + + @override + void initState() { + super.initState(); + _future = _api.getFrames(); + } + + void _reload() => setState(() { + _future = _api.getFrames(); + }); + + Future _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().add(LoadWalletEvent()); + _reload(); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err))); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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().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 children}) { return Column( children: [