diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index b329305..f9aa194 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -26,6 +26,8 @@ import '../widgets/confetti.dart'; import '../widgets/flame/hokm_game.dart'; import '../widgets/table_hud.dart'; +part 'game_screen.parts.dart'; + /// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت. class GameScreen extends StatefulWidget { final int prize; @@ -158,10 +160,11 @@ class _GameScreenState extends State { onExit: () => _confirmLeave(context), onChat: () => _openChat(context), ), - if (state.connection == WsStatus.disconnected) _connBanner(), + if (state.connection == WsStatus.disconnected) const _ConnBanner(), if (_tournament != null && !_showSearch(state)) - _tournamentBadge(), - if (_showSearch(state)) _searchPanel(state), + _TournamentBadge(title: _tournament!.title), + if (_showSearch(state)) + _SearchPanel(state: state, countdown: _countdown), // دیالوگِ انتخابِ حکم و بنرِ نوبت فقط پس از پایانِ انیمیشنِ // پخشِ کارت نمایش داده می‌شوند (نه وسطِ بُر زدن/پخش). if (!_showSearch(state)) @@ -171,15 +174,21 @@ class _GameScreenState extends State { if (animating) return const SizedBox.shrink(); return Stack( children: [ - if (_showTrumpPicker(state)) _trumpPicker(context), - if (_isMyPlayTurn(state)) _turnBanner(), + if (_showTrumpPicker(state)) const _TrumpPicker(), + if (_isMyPlayTurn(state)) const _TurnBanner(), ], ); }, ), if (state.handResult != null && state.gameOver == null) - _handResult(state), - if (state.gameOver != null) _gameOver(context, state), + _HandResult(state: state), + if (state.gameOver != null) + _GameOver( + state: state, + prize: widget.prize, + tournamentTitle: _tournament?.title, + onExit: () => _exitToLobby(context), + ), ], ); }, @@ -207,31 +216,6 @@ class _GameScreenState extends State { s.state!.isMyTurn && !s.state!.trickDone; - // بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد. - Widget _turnBanner() => Align( - alignment: const Alignment(0, 0.3), - child: IgnorePointer( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.gold, AppColors.goldDark], - ), - borderRadius: BorderRadius.circular(20), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)], - ), - child: const Text( - 'نوبت شماست', - style: TextStyle( - color: AppColors.bg, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - ), - ); - bool _showTrumpPicker(GameUiState s) => s.state != null && s.state!.phase == 'choose_trump' && @@ -276,540 +260,4 @@ class _GameScreenState extends State { context.go('/lobby'); } - Widget _connBanner() => Positioned( - top: 0, - left: 0, - right: 0, - child: Material( - color: Colors.orange.shade900, - child: const SafeArea( - bottom: false, - child: Padding( - padding: EdgeInsets.all(8), - child: Text( - 'ارتباط با سرور قطع شد، در حال تلاش برای اتصال مجدد…', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white), - ), - ), - ), - ), - ); - - Widget _searchPanel(GameUiState state) { - final players = state.state?.players ?? const []; - final mySeat = state.state?.yourSeat ?? -1; - final searching = state.state == null; - GamePlayer? at(int seat) { - for (final p in players) { - if (p.seat == seat) return p; - } - return null; - } - - return Container( - color: Colors.black.withValues(alpha: 0.78), - alignment: Alignment.center, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 20), - padding: const EdgeInsets.fromLTRB(16, 14, 16, 18), - decoration: BoxDecoration( - color: AppColors.panel, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.gold, width: 2.5), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'جستجوی حریف', - style: TextStyle( - color: AppColors.gold, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 18), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var seat = 0; seat < 4; seat++) - _searchSlot(at(seat), seat == mySeat, seat), - ], - ), - if (searching) ...[ - const SizedBox(height: 16), - const Text( - 'در حال یافتن حریفان…', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), - ] else if (_countdown > 0) ...[ - const SizedBox(height: 14), - const Text( - 'شروع بازی', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), - const SizedBox(height: 6), - TweenAnimationBuilder( - // هر عدد با یک «پاپ» ظاهر می‌شود تا واضح و جلب‌توجه باشد. - key: ValueKey(_countdown), - tween: Tween(begin: 1.6, end: 1.0), - duration: const Duration(milliseconds: 400), - curve: Curves.easeOut, - builder: (_, scale, child) => - Transform.scale(scale: scale, child: child), - child: Container( - width: 54, - height: 54, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [AppColors.gold, AppColors.goldDark], - ), - border: Border.all(color: Colors.white, width: 2), - ), - child: Text( - '$_countdown', - style: const TextStyle( - color: AppColors.bg, - fontSize: 26, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ], - ), - ), - ); - } - - Widget _searchSlot(GamePlayer? p, bool isYou, int seat) { - final found = p != null; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - found ? p.name : '...', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isYou ? AppColors.gold : Colors.white70, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Container( - width: 58, - height: 58, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: AppColors.bgDark, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isYou ? AppColors.gold : AppColors.goldDark, - width: 1.5, - ), - ), - child: - !found - ? _SearchingAvatar(slot: seat) // آواتارهای متغیر و متفاوت - : p.bot - ? const Icon( - Icons.smart_toy, - color: AppColors.gold, - size: 30, - ) - : ClipRRect( - borderRadius: BorderRadius.circular(8), - child: RandomAvatar(p.avatarSeed), - ), - ), - const SizedBox(height: 2), - Text( - found ? (p.bot ? 'ربات' : (isYou ? 'شما' : 'حریف')) : '', - style: const TextStyle(color: Colors.white38, fontSize: 10), - ), - ], - ), - ); - } - - Widget _trumpPicker(BuildContext context) { - const suits = [ - ('spades', '♠', 'پیک', Colors.white), - ('hearts', '♥', 'دل', AppColors.suitRed), - ('diamonds', '♦', 'خشت', AppColors.suitRed), - ('clubs', '♣', 'گشنیز', Colors.white), - ]; - return Container( - color: Colors.black54, - alignment: Alignment.center, - child: Container( - margin: const EdgeInsets.all(24), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppColors.panel, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.gold, width: 2), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'حکم را انتخاب کن', - style: TextStyle(color: AppColors.gold, fontSize: 20), - ), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 12, - alignment: WrapAlignment.center, - children: [ - for (final (id, sym, name, color) in suits) - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.bgDark, - minimumSize: const Size(120, 64), - ), - onPressed: () => context.read().chooseTrump(id), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(sym, style: TextStyle(fontSize: 26, color: color)), - const SizedBox(width: 8), - Text( - name, - style: const TextStyle(color: AppColors.text), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - const Divider(color: AppColors.goldFaint, height: 1), - const SizedBox(height: 12), - // بُرِ مجدد با مصرفِ یک بلیت (۵ کارتِ تازه، سپس باز هم انتخابِ حکم). - BlocBuilder( - builder: (context, ws) { - final tickets = ws.walletStatus is WalletLoaded - ? (ws.walletStatus as WalletLoaded).wallet.tickets - : 0; - final canReshuffle = tickets > 0; - return Column( - children: [ - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8E1B7A), - disabledBackgroundColor: const Color(0xFF3A2233), - minimumSize: const Size(220, 48), - ), - onPressed: canReshuffle - ? () { - AppSounds.button(); - context.read().reshuffle(); - // پس از مصرفِ بلیت، موجودی را تازه کن (بدون نگه‌داشتنِ context). - final wallet = context.read(); - Future.delayed(const Duration(milliseconds: 500), - () => wallet.add(LoadWalletEvent())); - } - : null, - icon: const Icon(Icons.casino, color: Colors.white), - label: const Text( - 'بُر مجدد (۱ بلیت)', - style: TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(height: 4), - Text( - canReshuffle - ? 'بلیت‌های شما: $tickets' - : 'بلیت کافی ندارید', - style: const TextStyle( - color: Colors.white60, fontSize: 12), - ), - ], - ); - }, - ), - ], - ), - ), - ); - } - - Widget _handResult(GameUiState s) { - final r = s.handResult!; - final mySeat = s.state?.yourSeat ?? 0; - final myTeam = mySeat % 2; - final won = r.winnerTeam == myTeam; - final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده - - return IgnorePointer( - child: Stack( - children: [ - if (kotWin) const Positioned.fill(child: Confetti(count: 40)), - Center( - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 28, vertical: 18), - decoration: BoxDecoration( - gradient: kotWin - ? const LinearGradient( - colors: [AppColors.lapisMid, AppColors.lapisInk]) - : null, - color: kotWin ? null : Colors.black87, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppColors.gold, width: kotWin ? 2 : 1), - boxShadow: kotWin - ? [ - BoxShadow( - color: AppColors.gold.withValues(alpha: 0.4), - blurRadius: 20), - ] - : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (kotWin) - Text( - r.hakemKot ? 'حاکم‌کُت! 🔥' : 'کُت! 🔥', - style: const TextStyle( - color: AppColors.gold, - fontSize: 30, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black, blurRadius: 8)], - ), - ), - Text( - won ? 'این دست را بردید!' : 'این دست را باختید', - style: TextStyle( - color: won ? AppColors.green : Colors.redAccent, - fontSize: kotWin ? 16 : 20, - fontWeight: FontWeight.bold, - ), - ), - if (r.kot && !kotWin) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - r.hakemKot - ? 'حاکم‌کُت! (${r.points} امتیاز)' - : 'کُت! (${r.points} امتیاز)', - style: const TextStyle(color: AppColors.gold), - ), - ), - if (kotWin) - Text('+${r.points} امتیاز', - style: const TextStyle(color: AppColors.gold)), - const SizedBox(height: 6), - Text( - 'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', - style: const TextStyle(color: Colors.white70), - ), - ], - ), - ), - ), - ], - ), - ); - } - - // نشانِ درون‌بازیِ تورنومنت: به کاربر می‌فهماند این بازیِ عمومی به تورنومنت - // امتیاز می‌دهد (بالای صحنه، وسط). - Widget _tournamentBadge() => Positioned( - top: 6, - left: 0, - right: 0, - child: IgnorePointer( - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF3A2A6E), AppColors.lapisInk], - ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.gold, width: 1.2), - boxShadow: [ - BoxShadow( - color: AppColors.gold.withValues(alpha: 0.3), - blurRadius: 8), - ], - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.emoji_events, color: AppColors.gold, size: 15), - const SizedBox(width: 5), - Text( - 'تورنومنت: ${_tournament!.title}', - style: const TextStyle( - color: AppColors.gold, - fontSize: 12, - fontWeight: FontWeight.bold), - ), - ]), - ), - ), - ), - ); - - Widget _gameOver(BuildContext context, GameUiState s) { - final g = s.gameOver!; - final mySeat = s.state?.yourSeat ?? 0; - final won = g.winnerTeam == mySeat % 2; - return Stack( - children: [ - const Positioned.fill(child: ColoredBox(color: Colors.black87)), - if (won) const Positioned.fill(child: Confetti()), - Center( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // مدالِ متحرک (تاجِ طلایی برای برد، نشانِ ملایم برای باخت). - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 650), - curve: Curves.easeOutBack, - builder: (_, v, __) => Transform.scale( - scale: v.clamp(0.0, 1.2), - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: won - ? const [AppColors.gold, AppColors.goldDark] - : [Colors.grey.shade700, Colors.grey.shade900], - ), - border: Border.all( - color: won ? Colors.white70 : Colors.white24, - width: 2), - boxShadow: won - ? [ - BoxShadow( - color: AppColors.gold - .withValues(alpha: 0.55), - blurRadius: 28), - ] - : null, - ), - child: Icon( - won - ? Icons.emoji_events - : Icons.sentiment_dissatisfied_outlined, - color: won ? AppColors.lapisInk : Colors.white54, - size: 54, - ), - ), - ), - ), - const SizedBox(height: 16), - Text( - won ? 'بردید! 🎉' : 'باختید', - style: TextStyle( - color: won ? AppColors.gold : Colors.redAccent, - fontSize: 34, - fontWeight: FontWeight.bold, - shadows: const [Shadow(color: Colors.black, blurRadius: 8)], - ), - ), - const SizedBox(height: 10), - Text( - 'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}', - style: const TextStyle(color: Colors.white70, fontSize: 18), - ), - // پاداشِ سکه‌ی برد (جایزه‌ی میز). - if (won && widget.prize > 0) ...[ - const SizedBox(height: 16), - _rewardChip( - Icons.monetization_on, '+${widget.prize} سکه'), - ], - // امتیازِ کسب‌شده‌ی تورنومنت (برد ۱۰۰، شرکت ۲۵). - if (_tournament != null) ...[ - const SizedBox(height: 12), - _rewardChip(Icons.emoji_events, - '+${won ? 100 : 25} امتیاز تورنومنت «${_tournament!.title}»'), - ], - const SizedBox(height: 30), - SizedBox( - width: 220, - child: ElevatedButton( - onPressed: () => _exitToLobby(context), - child: const Text('بازگشت به لابی'), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _rewardChip(IconData icon, String text) => Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: AppColors.gold.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.gold), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: AppColors.gold, size: 20), - const SizedBox(width: 8), - Text(text, - style: const TextStyle( - color: AppColors.gold, - fontSize: 14, - fontWeight: FontWeight.bold)), - ]), - ); -} - -/// آواتارِ در حالِ تغییر برای جایگاه‌های خالیِ پنل جستجو (حسِ «در حال یافتن»). -class _SearchingAvatar extends StatefulWidget { - final int slot; // هر جایگاه دامنه‌ی seedِ متفاوت دارد تا یکسان نباشند - const _SearchingAvatar({required this.slot}); - - @override - State<_SearchingAvatar> createState() => _SearchingAvatarState(); -} - -class _SearchingAvatarState extends State<_SearchingAvatar> { - Timer? _timer; - int _i = 0; - - @override - void initState() { - super.initState(); - _i = widget.slot * 13; // شروعِ متفاوت برای هر جایگاه - _timer = Timer.periodic(const Duration(milliseconds: 240), (_) { - if (mounted) setState(() => _i++); - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // پیشوندِ مخصوصِ جایگاه ⇒ آواتارِ هر جایگاه با بقیه فرق دارد. - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: RandomAvatar('s${widget.slot}-$_i'), - ); - } } diff --git a/lib/feature/game/presentation/screen/game_screen.parts.dart b/lib/feature/game/presentation/screen/game_screen.parts.dart new file mode 100644 index 0000000..a2be3d1 --- /dev/null +++ b/lib/feature/game/presentation/screen/game_screen.parts.dart @@ -0,0 +1,639 @@ +// اوورلی‌های خصوصیِ صفحه‌ی بازی (بنرها، پنلِ جستجو، انتخابِ حکم، نتیجه‌ی دست/بازی). +part of 'game_screen.dart'; + +/// بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد. +class _TurnBanner extends StatelessWidget { + const _TurnBanner(); + @override + Widget build(BuildContext context) => Align( + alignment: const Alignment(0, 0.3), + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.gold, AppColors.goldDark], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow(color: Colors.black54, blurRadius: 8) + ], + ), + child: const Text( + 'نوبت شماست', + style: TextStyle( + color: AppColors.bg, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ); +} + +/// بنرِ قطعِ ارتباط با سرور (بالای صفحه). +class _ConnBanner extends StatelessWidget { + const _ConnBanner(); + @override + Widget build(BuildContext context) => Positioned( + top: 0, + left: 0, + right: 0, + child: Material( + color: Colors.orange.shade900, + child: const SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.all(8), + child: Text( + 'ارتباط با سرور قطع شد، در حال تلاش برای اتصال مجدد…', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + ); +} + +/// نشانِ درون‌بازیِ تورنومنت: می‌فهماند این بازیِ عمومی به تورنومنت امتیاز می‌دهد. +class _TournamentBadge extends StatelessWidget { + final String title; + const _TournamentBadge({required this.title}); + @override + Widget build(BuildContext context) => Positioned( + top: 6, + left: 0, + right: 0, + child: IgnorePointer( + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF3A2A6E), AppColors.lapisInk], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.gold, width: 1.2), + boxShadow: [ + BoxShadow( + color: AppColors.gold.withValues(alpha: 0.3), + blurRadius: 8), + ], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.emoji_events, color: AppColors.gold, size: 15), + const SizedBox(width: 5), + Text( + 'تورنومنت: $title', + style: const TextStyle( + color: AppColors.gold, + fontSize: 12, + fontWeight: FontWeight.bold), + ), + ]), + ), + ), + ), + ); +} + +/// پنلِ «جستجوی حریف»: چهار جایگاه + وضعیتِ جستجو یا شمارشِ معکوسِ شروع. +class _SearchPanel extends StatelessWidget { + final GameUiState state; + final int countdown; + const _SearchPanel({required this.state, required this.countdown}); + + @override + Widget build(BuildContext context) { + final players = state.state?.players ?? const []; + final mySeat = state.state?.yourSeat ?? -1; + final searching = state.state == null; + GamePlayer? at(int seat) { + for (final p in players) { + if (p.seat == seat) return p; + } + return null; + } + + return Container( + color: Colors.black.withValues(alpha: 0.78), + alignment: Alignment.center, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 18), + decoration: BoxDecoration( + color: AppColors.panel, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.gold, width: 2.5), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'جستجوی حریف', + style: TextStyle( + color: AppColors.gold, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var seat = 0; seat < 4; seat++) + _SearchSlot( + player: at(seat), + isYou: seat == mySeat, + seat: seat, + ), + ], + ), + if (searching) ...[ + const SizedBox(height: 16), + const Text( + 'در حال یافتن حریفان…', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ] else if (countdown > 0) ...[ + const SizedBox(height: 14), + const Text( + 'شروع بازی', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + const SizedBox(height: 6), + TweenAnimationBuilder( + // هر عدد با یک «پاپ» ظاهر می‌شود تا واضح و جلب‌توجه باشد. + key: ValueKey(countdown), + tween: Tween(begin: 1.6, end: 1.0), + duration: const Duration(milliseconds: 400), + curve: Curves.easeOut, + builder: (_, scale, child) => + Transform.scale(scale: scale, child: child), + child: Container( + width: 54, + height: 54, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [AppColors.gold, AppColors.goldDark], + ), + border: Border.all(color: Colors.white, width: 2), + ), + child: Text( + '$countdown', + style: const TextStyle( + color: AppColors.bg, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +/// یک جایگاه در پنلِ جستجو (نام، آواتار/آیکن، برچسبِ نقش). +class _SearchSlot extends StatelessWidget { + final GamePlayer? player; + final bool isYou; + final int seat; + const _SearchSlot({ + required this.player, + required this.isYou, + required this.seat, + }); + + @override + Widget build(BuildContext context) { + final p = player; + final found = p != null; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + found ? p.name : '...', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isYou ? AppColors.gold : Colors.white70, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Container( + width: 58, + height: 58, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.bgDark, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isYou ? AppColors.gold : AppColors.goldDark, + width: 1.5, + ), + ), + child: !found + ? _SearchingAvatar(slot: seat) // آواتارهای متغیر و متفاوت + : p.bot + ? const Icon( + Icons.smart_toy, + color: AppColors.gold, + size: 30, + ) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RandomAvatar(p.avatarSeed), + ), + ), + const SizedBox(height: 2), + Text( + found ? (p.bot ? 'ربات' : (isYou ? 'شما' : 'حریف')) : '', + style: const TextStyle(color: Colors.white38, fontSize: 10), + ), + ], + ), + ); + } +} + +/// دیالوگِ انتخابِ حکم توسطِ حاکم (+ بُرِ مجدد با مصرفِ یک بلیت). +class _TrumpPicker extends StatelessWidget { + const _TrumpPicker(); + + @override + Widget build(BuildContext context) { + const suits = [ + ('spades', '♠', 'پیک', Colors.white), + ('hearts', '♥', 'دل', AppColors.suitRed), + ('diamonds', '♦', 'خشت', AppColors.suitRed), + ('clubs', '♣', 'گشنیز', Colors.white), + ]; + return Container( + color: Colors.black54, + alignment: Alignment.center, + child: Container( + margin: const EdgeInsets.all(24), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.panel, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.gold, width: 2), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'حکم را انتخاب کن', + style: TextStyle(color: AppColors.gold, fontSize: 20), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + for (final (id, sym, name, color) in suits) + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.bgDark, + minimumSize: const Size(120, 64), + ), + onPressed: () => context.read().chooseTrump(id), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(sym, style: TextStyle(fontSize: 26, color: color)), + const SizedBox(width: 8), + Text( + name, + style: const TextStyle(color: AppColors.text), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(color: AppColors.goldFaint, height: 1), + const SizedBox(height: 12), + // بُرِ مجدد با مصرفِ یک بلیت (۵ کارتِ تازه، سپس باز هم انتخابِ حکم). + BlocBuilder( + builder: (context, ws) { + final tickets = ws.walletStatus is WalletLoaded + ? (ws.walletStatus as WalletLoaded).wallet.tickets + : 0; + final canReshuffle = tickets > 0; + return Column( + children: [ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8E1B7A), + disabledBackgroundColor: const Color(0xFF3A2233), + minimumSize: const Size(220, 48), + ), + onPressed: canReshuffle + ? () { + AppSounds.button(); + context.read().reshuffle(); + // پس از مصرفِ بلیت، موجودی را تازه کن. + final wallet = context.read(); + Future.delayed(const Duration(milliseconds: 500), + () => wallet.add(LoadWalletEvent())); + } + : null, + icon: const Icon(Icons.casino, color: Colors.white), + label: const Text( + 'بُر مجدد (۱ بلیت)', + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 4), + Text( + canReshuffle ? 'بلیت‌های شما: $tickets' : 'بلیت کافی ندارید', + style: + const TextStyle(color: Colors.white60, fontSize: 12), + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +/// نتیجه‌ی یک دست (بُرد/باخت/کُت) با جشنِ کُتِ بُرده. +class _HandResult extends StatelessWidget { + final GameUiState state; + const _HandResult({required this.state}); + + @override + Widget build(BuildContext context) { + final r = state.handResult!; + final mySeat = state.state?.yourSeat ?? 0; + final myTeam = mySeat % 2; + final won = r.winnerTeam == myTeam; + final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده + + return IgnorePointer( + child: Stack( + children: [ + if (kotWin) const Positioned.fill(child: Confetti(count: 40)), + Center( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 28, vertical: 18), + decoration: BoxDecoration( + gradient: kotWin + ? const LinearGradient( + colors: [AppColors.lapisMid, AppColors.lapisInk]) + : null, + color: kotWin ? null : Colors.black87, + borderRadius: BorderRadius.circular(16), + border: + Border.all(color: AppColors.gold, width: kotWin ? 2 : 1), + boxShadow: kotWin + ? [ + BoxShadow( + color: AppColors.gold.withValues(alpha: 0.4), + blurRadius: 20), + ] + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (kotWin) + Text( + r.hakemKot ? 'حاکم‌کُت! 🔥' : 'کُت! 🔥', + style: const TextStyle( + color: AppColors.gold, + fontSize: 30, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 8)], + ), + ), + Text( + won ? 'این دست را بردید!' : 'این دست را باختید', + style: TextStyle( + color: won ? AppColors.green : Colors.redAccent, + fontSize: kotWin ? 16 : 20, + fontWeight: FontWeight.bold, + ), + ), + if (r.kot && !kotWin) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + r.hakemKot + ? 'حاکم‌کُت! (${r.points} امتیاز)' + : 'کُت! (${r.points} امتیاز)', + style: const TextStyle(color: AppColors.gold), + ), + ), + if (kotWin) + Text('+${r.points} امتیاز', + style: const TextStyle(color: AppColors.gold)), + const SizedBox(height: 6), + Text( + 'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// اوورلیِ پایانِ بازی (مدال، نتیجه، پاداش‌ها، بازگشت به لابی). +class _GameOver extends StatelessWidget { + final GameUiState state; + final int prize; + final String? tournamentTitle; + final VoidCallback onExit; + const _GameOver({ + required this.state, + required this.prize, + required this.tournamentTitle, + required this.onExit, + }); + + @override + Widget build(BuildContext context) { + final g = state.gameOver!; + final mySeat = state.state?.yourSeat ?? 0; + final won = g.winnerTeam == mySeat % 2; + return Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: Colors.black87)), + if (won) const Positioned.fill(child: Confetti()), + Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // مدالِ متحرک (تاجِ طلایی برای برد، نشانِ ملایم برای باخت). + TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 650), + curve: Curves.easeOutBack, + builder: (_, v, __) => Transform.scale( + scale: v.clamp(0.0, 1.2), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: won + ? const [AppColors.gold, AppColors.goldDark] + : [Colors.grey.shade700, Colors.grey.shade900], + ), + border: Border.all( + color: won ? Colors.white70 : Colors.white24, + width: 2), + boxShadow: won + ? [ + BoxShadow( + color: + AppColors.gold.withValues(alpha: 0.55), + blurRadius: 28), + ] + : null, + ), + child: Icon( + won + ? Icons.emoji_events + : Icons.sentiment_dissatisfied_outlined, + color: won ? AppColors.lapisInk : Colors.white54, + size: 54, + ), + ), + ), + ), + const SizedBox(height: 16), + Text( + won ? 'بردید! 🎉' : 'باختید', + style: TextStyle( + color: won ? AppColors.gold : Colors.redAccent, + fontSize: 34, + fontWeight: FontWeight.bold, + shadows: const [Shadow(color: Colors.black, blurRadius: 8)], + ), + ), + const SizedBox(height: 10), + Text( + 'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}', + style: const TextStyle(color: Colors.white70, fontSize: 18), + ), + // پاداشِ سکه‌ی برد (جایزه‌ی میز). + if (won && prize > 0) ...[ + const SizedBox(height: 16), + _RewardChip( + icon: Icons.monetization_on, text: '+$prize سکه'), + ], + // امتیازِ کسب‌شده‌ی تورنومنت (برد ۱۰۰، شرکت ۲۵). + if (tournamentTitle != null) ...[ + const SizedBox(height: 12), + _RewardChip( + icon: Icons.emoji_events, + text: + '+${won ? 100 : 25} امتیاز تورنومنت «$tournamentTitle»'), + ], + const SizedBox(height: 30), + SizedBox( + width: 220, + child: ElevatedButton( + onPressed: onExit, + child: const Text('بازگشت به لابی'), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// چیپِ پاداش (آیکن + متن) در اوورلیِ پایانِ بازی. +class _RewardChip extends StatelessWidget { + final IconData icon; + final String text; + const _RewardChip({required this.icon, required this.text}); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: AppColors.gold.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.gold), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, color: AppColors.gold, size: 20), + const SizedBox(width: 8), + Text(text, + style: const TextStyle( + color: AppColors.gold, + fontSize: 14, + fontWeight: FontWeight.bold)), + ]), + ); +} + +/// آواتارِ در حالِ تغییر برای جایگاه‌های خالیِ پنل جستجو (حسِ «در حال یافتن»). +class _SearchingAvatar extends StatefulWidget { + final int slot; // هر جایگاه دامنه‌ی seedِ متفاوت دارد تا یکسان نباشند + const _SearchingAvatar({required this.slot}); + + @override + State<_SearchingAvatar> createState() => _SearchingAvatarState(); +} + +class _SearchingAvatarState extends State<_SearchingAvatar> { + Timer? _timer; + int _i = 0; + + @override + void initState() { + super.initState(); + _i = widget.slot * 13; // شروعِ متفاوت برای هر جایگاه + _timer = Timer.periodic(const Duration(milliseconds: 240), (_) { + if (mounted) setState(() => _i++); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // پیشوندِ مخصوصِ جایگاه ⇒ آواتارِ هر جایگاه با بقیه فرق دارد. + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RandomAvatar('s${widget.slot}-$_i'), + ); + } +} diff --git a/lib/feature/game/presentation/screen/private_table_screen.dart b/lib/feature/game/presentation/screen/private_table_screen.dart index 227e603..6ad93f7 100644 --- a/lib/feature/game/presentation/screen/private_table_screen.dart +++ b/lib/feature/game/presentation/screen/private_table_screen.dart @@ -13,6 +13,8 @@ import '../bloc/game_bloc.dart'; import '../bloc/game_state.dart'; import 'game_screen.dart'; +part 'private_table_screen.parts.dart'; + /// میز خصوصی: اتاق انتظار (کد، بازیکنان، شروع) سپس صحنه‌ی بازی (روی همان اتصال). class PrivateTableScreen extends StatelessWidget { const PrivateTableScreen({super.key}); @@ -48,434 +50,3 @@ class PrivateTableScreen extends StatelessWidget { ); } } - -class _LobbyView extends StatefulWidget { - final GameUiState state; - const _LobbyView({required this.state}); - - @override - State<_LobbyView> createState() => _LobbyViewState(); -} - -class _LobbyViewState extends State<_LobbyView> { - int _hands = 3; // تعداد دستِ انتخابیِ میزبان - - void _leave() { - context.read().leaveTable(); - if (context.canPop()) context.pop(); - } - - @override - Widget build(BuildContext context) { - final state = widget.state; - final lobby = state.lobby; - final connecting = state.connection != WsStatus.connected || lobby == null; - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) _leave(); - }, - child: Scaffold( - body: GameBackground( - child: SafeArea( - child: Stack( - children: [ - Column( - children: [ - _topBar(context, lobby), - Expanded( - child: - connecting - ? const Center( - child: CircularProgressIndicator( - color: AppColors.gold, - ), - ) - : _content(context, lobby), - ), - ], - ), - if (state.countdown != null) - _CountdownOverlay(seconds: state.countdown!), - ], - ), - ), - ), - ), - ); - } - - // نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست). - Widget _topBar(BuildContext context, TableLobby? lobby) { - Widget btn(IconData icon, VoidCallback onTap) => GestureDetector( - onTap: onTap, - child: Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: AppColors.panel, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.gold, width: 1.5), - ), - child: Icon(icon, color: AppColors.gold), - ), - ); - return Padding( - padding: const EdgeInsets.all(10), - child: Directionality( - textDirection: TextDirection.ltr, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (lobby != null) - btn(Icons.share, () { - Clipboard.setData(ClipboardData(text: lobby.code)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('کد میز کپی شد؛ برای دوستانت بفرست'), - ), - ); - }), - btn(Icons.arrow_forward, _leave), - ], - ), - ), - ); - } - - Widget _content(BuildContext context, TableLobby lobby) { - final youSeat = lobby.youSeat; - // نگاشتِ جایگاهِ مطلق → بازیکن (خالی = بات هنگام شروع). - final bySeat = {for (final p in lobby.players) p.seat: p}; - // چیدمان از نگاهِ خودِ کاربر: «شما» پایین، هم‌تیمی (روبه‌رو) بالا، بقیه کنار. - LobbyPlayer? seatAt(int rel) => bySeat[(youSeat + rel) % 4]; - final me = seatAt(0); - final top = seatAt(2); // هم‌تیمی (روبه‌رو) - final left = seatAt(1); - final right = seatAt(3); - - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - const Text('شماره میز', style: TextStyle(color: Colors.white70)), - const SizedBox(height: 4), - GlowText(lobby.code, size: 36), - const SizedBox(height: 8), - // میز با چهار جایگاه. - SizedBox( - height: 340, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 28, - vertical: 36, - ), - child: Container( - decoration: BoxDecoration( - gradient: const RadialGradient( - colors: [AppColors.lapisHi, AppColors.lapisLo], - radius: 0.9, - ), - borderRadius: BorderRadius.circular(160), - border: Border.all( - color: const Color(0xFF8A5A12), - width: 6, - ), - ), - child: const Center(child: GlowText('سلطان حکم', size: 18)), - ), - ), - Align(alignment: Alignment.topCenter, child: _seat(top, false)), - Align( - alignment: Alignment.centerLeft, - child: _seat(left, false), - ), - Align( - alignment: Alignment.centerRight, - child: _seat(right, false), - ), - Align( - alignment: Alignment.bottomCenter, - child: _seat(me, true), - ), - // چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی. - if (lobby.isHost) ...[ - Align( - alignment: const Alignment(-1, -0.35), - child: _rotateBtn( - Icons.rotate_left, - () => context.read().rotateTable(-1), - ), - ), - Align( - alignment: const Alignment(1, -0.35), - child: _rotateBtn( - Icons.rotate_right, - () => context.read().rotateTable(1), - ), - ), - ], - ], - ), - ), - const SizedBox(height: 8), - const Text('تعداد دست', style: TextStyle(color: Colors.white70)), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final h in [3, 5, 7]) _handChip(h, lobby.isHost), - ], - ), - const SizedBox(height: 14), - const Text('ورودی (سکه) — برنده کل را می‌برد', - style: TextStyle(color: Colors.white70)), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - for (final v in const [0, 500, 1000, 5000, 20000]) - _stakeChip(v, lobby.isHost, lobby.stake), - ], - ), - const SizedBox(height: 20), - if (lobby.isHost) - GameButton( - label: lobby.stake > 0 - ? 'شروع بازی (${lobby.stake} سکه)' - : 'شروع بازی', - icon: Icons.play_arrow, - width: double.infinity, - colors: const [AppColors.success, AppColors.successDark], - onTap: () => - context.read().startTable(_hands, stake: lobby.stake), - ) - else - const Text( - 'در انتظار شروع توسط میزبان…', - style: TextStyle(color: AppColors.gold, fontSize: 15), - ), - const SizedBox(height: 6), - if (lobby.isHost) - const Text( - 'جای‌های خالی با ربات پر می‌شوند', - style: TextStyle(color: Colors.white54, fontSize: 12), - ), - const SizedBox(height: 20), - ], - ), - ); - } - - // یک جایگاهِ بازیکن دورِ میز (آواتار + ستاره + نام). - Widget _seat(LobbyPlayer? p, bool isYou) { - final empty = p == null; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 62, - height: 62, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: AppColors.bgDark, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isYou ? AppColors.gold : AppColors.goldDark, - width: 2, - ), - ), - child: - empty - ? const Icon( - Icons.person_add_alt_1, - color: Colors.white24, - ) - : ClipRRect( - borderRadius: BorderRadius.circular(9), - child: RandomAvatar(p.avatarSeed), - ), - ), - if (!empty) - const Positioned( - bottom: -6, - left: -4, - child: Icon(Icons.star, color: AppColors.gold, size: 22), - ), - ], - ), - const SizedBox(height: 6), - Text( - empty ? 'در انتظار' : (isYou ? 'شما' : p.name), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: empty ? Colors.white38 : Colors.white, - fontSize: 12, - fontWeight: empty ? FontWeight.normal : FontWeight.bold, - ), - ), - if (p?.host == true) - const Text( - 'میزبان', - style: TextStyle(color: AppColors.gold, fontSize: 10), - ), - ], - ); - } - - // دکمه‌ی چرخشِ جایگاه‌ها (کنارِ میز). - Widget _rotateBtn(IconData icon, VoidCallback onTap) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.lapisMid, AppColors.lapisLo], - ), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.gold, width: 1.5), - ), - child: Icon(icon, color: AppColors.gold, size: 26), - ), - ); - } - - Widget _handChip(int h, bool editable) { - const fa = {3: '۳', 5: '۵', 7: '۷'}; - final selected = _hands == h; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: GestureDetector( - onTap: editable ? () => setState(() => _hands = h) : null, - child: Container( - width: 52, - height: 52, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: - selected - ? const LinearGradient( - colors: [AppColors.gold, AppColors.goldDark], - ) - : null, - color: selected ? null : AppColors.panel, - border: Border.all( - color: selected ? AppColors.gold : AppColors.goldDark, - width: 2, - ), - ), - child: Text( - fa[h]!, - style: TextStyle( - color: selected ? AppColors.bg : Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ); - } - - // چیپِ شرطِ سکه؛ میزبان با انتخاب، setStake را زنده برای همه پخش می‌کند. - Widget _stakeChip(int value, bool editable, int current) { - final selected = current == value; - final label = value == 0 ? 'رایگان' : '$value'; - return GestureDetector( - onTap: editable ? () => context.read().setStake(value) : null, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: selected - ? const LinearGradient(colors: [AppColors.gold, AppColors.goldDark]) - : null, - color: selected ? null : AppColors.panel, - border: Border.all( - color: selected ? AppColors.gold : AppColors.goldDark, - width: 1.6, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (value > 0) - Icon(Icons.monetization_on, - size: 16, color: selected ? AppColors.bg : AppColors.gold), - if (value > 0) const SizedBox(width: 4), - Text( - label, - style: TextStyle( - color: selected ? AppColors.bg : Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ); - } -} - -/// اوورلی شمارش معکوس ۳، ۲، ۱ پیش از شروع بازی. -class _CountdownOverlay extends StatefulWidget { - final int seconds; - const _CountdownOverlay({required this.seconds}); - - @override - State<_CountdownOverlay> createState() => _CountdownOverlayState(); -} - -class _CountdownOverlayState extends State<_CountdownOverlay> { - late int _n; - Timer? _timer; - - @override - void initState() { - super.initState(); - _n = widget.seconds; - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) return; - setState(() => _n--); - if (_n <= 0) _timer?.cancel(); - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.black.withValues(alpha: 0.7), - alignment: Alignment.center, - child: TweenAnimationBuilder( - key: ValueKey(_n), - tween: Tween(begin: 0.4, end: 1.2), - duration: const Duration(milliseconds: 700), - curve: Curves.easeOut, - builder: - (context, scale, child) => - Transform.scale(scale: scale, child: child), - child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96), - ), - ); - } -} diff --git a/lib/feature/game/presentation/screen/private_table_screen.parts.dart b/lib/feature/game/presentation/screen/private_table_screen.parts.dart new file mode 100644 index 0000000..6839c70 --- /dev/null +++ b/lib/feature/game/presentation/screen/private_table_screen.parts.dart @@ -0,0 +1,433 @@ +// نمای لابیِ میزِ خصوصی و اوورلیِ شمارشِ معکوس. +part of 'private_table_screen.dart'; + +class _LobbyView extends StatefulWidget { + final GameUiState state; + const _LobbyView({required this.state}); + + @override + State<_LobbyView> createState() => _LobbyViewState(); +} + +class _LobbyViewState extends State<_LobbyView> { + int _hands = 3; // تعداد دستِ انتخابیِ میزبان + + void _leave() { + context.read().leaveTable(); + if (context.canPop()) context.pop(); + } + + @override + Widget build(BuildContext context) { + final state = widget.state; + final lobby = state.lobby; + final connecting = state.connection != WsStatus.connected || lobby == null; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _leave(); + }, + child: Scaffold( + body: GameBackground( + child: SafeArea( + child: Stack( + children: [ + Column( + children: [ + _topBar(context, lobby), + Expanded( + child: + connecting + ? const Center( + child: CircularProgressIndicator( + color: AppColors.gold, + ), + ) + : _content(context, lobby), + ), + ], + ), + if (state.countdown != null) + _CountdownOverlay(seconds: state.countdown!), + ], + ), + ), + ), + ), + ); + } + + // نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست). + Widget _topBar(BuildContext context, TableLobby? lobby) { + Widget btn(IconData icon, VoidCallback onTap) => GestureDetector( + onTap: onTap, + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: AppColors.panel, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.gold, width: 1.5), + ), + child: Icon(icon, color: AppColors.gold), + ), + ); + return Padding( + padding: const EdgeInsets.all(10), + child: Directionality( + textDirection: TextDirection.ltr, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (lobby != null) + btn(Icons.share, () { + Clipboard.setData(ClipboardData(text: lobby.code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('کد میز کپی شد؛ برای دوستانت بفرست'), + ), + ); + }), + btn(Icons.arrow_forward, _leave), + ], + ), + ), + ); + } + + Widget _content(BuildContext context, TableLobby lobby) { + final youSeat = lobby.youSeat; + // نگاشتِ جایگاهِ مطلق → بازیکن (خالی = بات هنگام شروع). + final bySeat = {for (final p in lobby.players) p.seat: p}; + // چیدمان از نگاهِ خودِ کاربر: «شما» پایین، هم‌تیمی (روبه‌رو) بالا، بقیه کنار. + LobbyPlayer? seatAt(int rel) => bySeat[(youSeat + rel) % 4]; + final me = seatAt(0); + final top = seatAt(2); // هم‌تیمی (روبه‌رو) + final left = seatAt(1); + final right = seatAt(3); + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const Text('شماره میز', style: TextStyle(color: Colors.white70)), + const SizedBox(height: 4), + GlowText(lobby.code, size: 36), + const SizedBox(height: 8), + // میز با چهار جایگاه. + SizedBox( + height: 340, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 36, + ), + child: Container( + decoration: BoxDecoration( + gradient: const RadialGradient( + colors: [AppColors.lapisHi, AppColors.lapisLo], + radius: 0.9, + ), + borderRadius: BorderRadius.circular(160), + border: Border.all( + color: const Color(0xFF8A5A12), + width: 6, + ), + ), + child: const Center(child: GlowText('سلطان حکم', size: 18)), + ), + ), + Align(alignment: Alignment.topCenter, child: _seat(top, false)), + Align( + alignment: Alignment.centerLeft, + child: _seat(left, false), + ), + Align( + alignment: Alignment.centerRight, + child: _seat(right, false), + ), + Align( + alignment: Alignment.bottomCenter, + child: _seat(me, true), + ), + // چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی. + if (lobby.isHost) ...[ + Align( + alignment: const Alignment(-1, -0.35), + child: _rotateBtn( + Icons.rotate_left, + () => context.read().rotateTable(-1), + ), + ), + Align( + alignment: const Alignment(1, -0.35), + child: _rotateBtn( + Icons.rotate_right, + () => context.read().rotateTable(1), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 8), + const Text('تعداد دست', style: TextStyle(color: Colors.white70)), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final h in [3, 5, 7]) _handChip(h, lobby.isHost), + ], + ), + const SizedBox(height: 14), + const Text('ورودی (سکه) — برنده کل را می‌برد', + style: TextStyle(color: Colors.white70)), + const SizedBox(height: 10), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + for (final v in const [0, 500, 1000, 5000, 20000]) + _stakeChip(v, lobby.isHost, lobby.stake), + ], + ), + const SizedBox(height: 20), + if (lobby.isHost) + GameButton( + label: lobby.stake > 0 + ? 'شروع بازی (${lobby.stake} سکه)' + : 'شروع بازی', + icon: Icons.play_arrow, + width: double.infinity, + colors: const [AppColors.success, AppColors.successDark], + onTap: () => + context.read().startTable(_hands, stake: lobby.stake), + ) + else + const Text( + 'در انتظار شروع توسط میزبان…', + style: TextStyle(color: AppColors.gold, fontSize: 15), + ), + const SizedBox(height: 6), + if (lobby.isHost) + const Text( + 'جای‌های خالی با ربات پر می‌شوند', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + const SizedBox(height: 20), + ], + ), + ); + } + + // یک جایگاهِ بازیکن دورِ میز (آواتار + ستاره + نام). + Widget _seat(LobbyPlayer? p, bool isYou) { + final empty = p == null; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 62, + height: 62, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.bgDark, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isYou ? AppColors.gold : AppColors.goldDark, + width: 2, + ), + ), + child: + empty + ? const Icon( + Icons.person_add_alt_1, + color: Colors.white24, + ) + : ClipRRect( + borderRadius: BorderRadius.circular(9), + child: RandomAvatar(p.avatarSeed), + ), + ), + if (!empty) + const Positioned( + bottom: -6, + left: -4, + child: Icon(Icons.star, color: AppColors.gold, size: 22), + ), + ], + ), + const SizedBox(height: 6), + Text( + empty ? 'در انتظار' : (isYou ? 'شما' : p.name), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: empty ? Colors.white38 : Colors.white, + fontSize: 12, + fontWeight: empty ? FontWeight.normal : FontWeight.bold, + ), + ), + if (p?.host == true) + const Text( + 'میزبان', + style: TextStyle(color: AppColors.gold, fontSize: 10), + ), + ], + ); + } + + // دکمه‌ی چرخشِ جایگاه‌ها (کنارِ میز). + Widget _rotateBtn(IconData icon, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.lapisMid, AppColors.lapisLo], + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.gold, width: 1.5), + ), + child: Icon(icon, color: AppColors.gold, size: 26), + ), + ); + } + + Widget _handChip(int h, bool editable) { + const fa = {3: '۳', 5: '۵', 7: '۷'}; + final selected = _hands == h; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: GestureDetector( + onTap: editable ? () => setState(() => _hands = h) : null, + child: Container( + width: 52, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: + selected + ? const LinearGradient( + colors: [AppColors.gold, AppColors.goldDark], + ) + : null, + color: selected ? null : AppColors.panel, + border: Border.all( + color: selected ? AppColors.gold : AppColors.goldDark, + width: 2, + ), + ), + child: Text( + fa[h]!, + style: TextStyle( + color: selected ? AppColors.bg : Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + + // چیپِ شرطِ سکه؛ میزبان با انتخاب، setStake را زنده برای همه پخش می‌کند. + Widget _stakeChip(int value, bool editable, int current) { + final selected = current == value; + final label = value == 0 ? 'رایگان' : '$value'; + return GestureDetector( + onTap: editable ? () => context.read().setStake(value) : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: selected + ? const LinearGradient(colors: [AppColors.gold, AppColors.goldDark]) + : null, + color: selected ? null : AppColors.panel, + border: Border.all( + color: selected ? AppColors.gold : AppColors.goldDark, + width: 1.6, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (value > 0) + Icon(Icons.monetization_on, + size: 16, color: selected ? AppColors.bg : AppColors.gold), + if (value > 0) const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: selected ? AppColors.bg : Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} + +/// اوورلی شمارش معکوس ۳، ۲، ۱ پیش از شروع بازی. +class _CountdownOverlay extends StatefulWidget { + final int seconds; + const _CountdownOverlay({required this.seconds}); + + @override + State<_CountdownOverlay> createState() => _CountdownOverlayState(); +} + +class _CountdownOverlayState extends State<_CountdownOverlay> { + late int _n; + Timer? _timer; + + @override + void initState() { + super.initState(); + _n = widget.seconds; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _n--); + if (_n <= 0) _timer?.cancel(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withValues(alpha: 0.7), + alignment: Alignment.center, + child: TweenAnimationBuilder( + key: ValueKey(_n), + tween: Tween(begin: 0.4, end: 1.2), + duration: const Duration(milliseconds: 700), + curve: Curves.easeOut, + builder: + (context, scale, child) => + Transform.scale(scale: scale, child: child), + child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96), + ), + ); + } +} diff --git a/lib/feature/game/presentation/widgets/flame/anim_gate.dart b/lib/feature/game/presentation/widgets/flame/anim_gate.dart new file mode 100644 index 0000000..05f6a81 --- /dev/null +++ b/lib/feature/game/presentation/widgets/flame/anim_gate.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; + +/// دروازه‌ی انیمیشنِ صحنه: نگه‌دارِ سه فازِ «مشغولِ انیمیشن» که در آن‌ها بازیِ کارت +/// قفل است و کارت‌ها/پشت‌کارت‌ها ممکن است پنهان شوند — اینترو («جستجوی حریف»)، +/// بُر زدن، و پخشِ بقیه‌ی کارت‌ها. یک [animating] هم برای HUD/دیالوگِ حکم می‌دهد. +class AnimGate { + bool _intro = true; + bool _pendingShuffle = false; // بُرِ دستِ اول که تا پایانِ اینترو معوق مانده + bool _shuffling = false; + bool _dealing = false; + + /// آیا انیمیشنی در جریان است (صفحه از این برای پنهان‌کردنِ دیالوگِ حکم استفاده می‌کند). + final ValueNotifier animating = ValueNotifier(true); + + bool get introActive => _intro; + bool get shuffling => _shuffling; + bool get dealing => _dealing; + + /// در جریانِ هر انیمیشنِ قفل‌کننده (بازیِ کارت ممنوع، صدا خاموش). + bool get busy => _intro || _shuffling || _dealing; + + /// در اینترو یا بُر زدن — کارت‌های دست/پشت‌کارت‌ها پنهان‌اند (انیمیشن خودش پخش می‌کند). + bool get cardsHidden => _intro || _shuffling; + + void _refresh() => animating.value = busy; + + void markPendingShuffle() => _pendingShuffle = true; + + /// پایانِ اینترو؛ true اگر بُرِ معوقِ دستِ اول باید اکنون اجرا شود. + bool endIntro() { + _intro = false; + final run = _pendingShuffle; + _pendingShuffle = false; + _refresh(); + return run; + } + + void beginShuffle() { + _shuffling = true; + _refresh(); + } + + void endShuffle() { + _shuffling = false; + _refresh(); + } + + void beginDeal() { + _dealing = true; + _refresh(); + } + + void endDeal() { + _dealing = false; + _refresh(); + } +} diff --git a/lib/feature/game/presentation/widgets/flame/cut_fx.dart b/lib/feature/game/presentation/widgets/flame/cut_fx.dart new file mode 100644 index 0000000..34798ad --- /dev/null +++ b/lib/feature/game/presentation/widgets/flame/cut_fx.dart @@ -0,0 +1,45 @@ +import '../../../domain/entities/game_entities.dart'; + +/// وضعیتِ افکتِ «بریدن با حکم»: بالاترین حکمِ دستِ جاری، کمبوِ روبُریدن‌های سریع، +/// و «مکثِ آتش» (فریزِ کوتاهِ صحنه پس از آتشِ کمبو). فقط وضعیت و تصمیم را نگه +/// می‌دارد؛ افزودنِ کامپوننت‌های افکت و بافرِ وضعیت بر عهده‌ی خودِ صحنه است. +class CutFx { + static const double comboWindowSec = 6.0; // پنجره‌ی شمارشِ بریدنِ پیاپی + static const double holdSec = 1.0; // مدتِ مکثِ آتش + + int _bestTrumpRank = 0; // بالاترین حکمِ بازی‌شده در دستِ جاری + int combo = 0; // شمارِ بریدنِ پیاپیِ سریع (برای شدتِ آتش) + double _lastCutAt = -999; + + bool holding = false; // آیا در «مکثِ آتش» هستیم + double _holdUntil = 0; + GameState? pending; // آخرین وضعیتِ بافرشده در حینِ مکث + + /// دستِ جدید (زمین خالی/تک‌کارت) ⇒ بالاترین حکم ریست می‌شود. + void resetTrick() => _bestTrumpRank = 0; + + /// ثبتِ یک بریدن؛ true اگر این حکم «دست را می‌بَرد» (اولین بریدن یا روبُریدنِ + /// بالاتر) — در این صورت کمبو هم به‌روزرسانی می‌شود. حکمِ بازنده false می‌دهد. + bool registerWinningCut(int rank, double now) { + if (rank <= _bestTrumpRank) return false; + _bestTrumpRank = rank; + combo = (now - _lastCutAt < comboWindowSec) ? combo + 1 : 1; + _lastCutAt = now; + return true; + } + + /// شروعِ مکثِ آتش (پس از آتشِ کمبو). + void startHold(double now) { + holding = true; + _holdUntil = now + holdSec; + } + + /// آیا مکث به پایان رسیده و باید وضعیتِ بافرشده اعمال شود. + bool holdExpired(double now) => holding && now >= _holdUntil; + + /// لغوِ مکث و پاک‌کردنِ بافر (هنگام خروج از میز). + void clearHold() { + holding = false; + pending = null; + } +} diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 9a54d21..d4ea9da 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -12,10 +12,13 @@ import '../../../../../core/settings/app_settings.dart'; import '../../../../../core/theme/app_theme.dart'; import '../../../domain/entities/game_entities.dart'; import '../../bloc/game_bloc.dart'; +import 'anim_gate.dart'; import 'card_codes.dart'; import 'card_component.dart'; +import 'cut_fx.dart'; import 'shuffle_animation.dart'; import 'table_pieces.dart'; +import 'turn_heartbeat.dart'; /// صحنه‌ی میز حکم با انیمیشن. شما همیشه پایین میز (rel=0) هستید. /// کامپوننت‌های دست و trick ماندگارند و با افکت حرکت جابه‌جا می‌شوند. @@ -49,37 +52,21 @@ class HokmGame extends FlameGame { double _t = 0; List _prevTrick = const []; bool _highlightedTrick = false; // آیا کارتِ برنده‌ی دستِ کامل هایلایت شده (یک‌بار) - int _bestTrumpRank = 0; // بالاترین رتبه‌ی حکمِ بازی‌شده در دستِ جاری (برای افکتِ بریدن) - int _cutCombo = 0; // شمارِ بریدنِ پیاپیِ سریع (روبُریدن‌ها) برای افکتِ آتش - double _lastCutAt = -999; // زمانِ آخرین بریدن (برای پنجره‌ی کمبو) - // «مکثِ آتش»: پس از افکتِ آتشِ کمبو، صحنه کوتاه فریز می‌شود تا لحظه دیده شود؛ - // وضعیت‌های ورودی بافر می‌شوند و پس از مکث اعمال می‌گردند (کاربر و بات فرقی ندارند). - bool _cutHold = false; - double _cutHoldUntil = 0; - GameState? _pendingState; - static const double _cutHoldSec = 1.0; + // افکتِ بریدن با حکم: کمبوِ روبُریدن + آتش + «مکثِ آتش» (منطق در CutFx جدا شده). + final CutFx _cut = CutFx(); int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن String _prevHandSig = ''; // امضای دست؛ برای تشخیصِ بُرِ مجدد (تغییرِ ۵ کارت در فازِ حکم) bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود - bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف» - bool _pendingShuffle = - false; // بُرِ دستِ اول که تا پایانِ اینترو به تعویق افتاده - bool _shuffling = false; // در حالِ بُر زدن/پخشِ ۵ کارت (بازیِ کارت ممنوع) - bool _dealing = false; // در حالِ پخشِ بقیه‌ی کارت‌ها پس از حکم (بازی ممنوع) String? _prevTrump; // برای تشخیصِ لحظه‌ی انتخابِ حکم - /// آیا انیمیشنی در حال اجراست (اینترو/بُر زدن/پخشِ کارت). صفحه از این برای - /// پنهان‌کردنِ دیالوگِ انتخابِ حکم تا پایانِ پخشِ کارت‌ها استفاده می‌کند. - final ValueNotifier animating = ValueNotifier(true); - void _refreshAnimating() => - animating.value = _introActive || _shuffling || _dealing; + // دروازه‌ی انیمیشن (اینترو/بُر/پخش) — منطق در AnimGate جدا شده است. + final AnimGate _anim = AnimGate(); - // مدیریتِ ضربانِ قلب (فقط نوبتِ خودِ کاربر؛ مستقل از بازساختِ صحنه). - double _turnElapsed = 0; - bool _beatPlayed = false; // اولین ضربان در پنجره‌ی پایانیِ نوبت پخش شد - double _hbAccum = 0; // انباشتِ زمان برای تکرارِ منظمِ ضربان تا پایانِ نوبت - bool _wasMyTurn = false; - String _turnSig = ''; // امضای نوبت (نوبت|تعدادِ کارتِ زمین|فاز) — هم‌گام با HUD + /// آیا انیمیشنی در حال اجراست؛ صفحه از این برای پنهان‌کردنِ دیالوگِ حکم استفاده می‌کند. + ValueNotifier get animating => _anim.animating; + + // ضربانِ قلبِ نوبتِ کاربر (منطق در TurnHeartbeat جدا شده است). + final TurnHeartbeat _heart = TurnHeartbeat(); /// فرشِ انتخابی؛ 'classic' یعنی سطحِ میزِ ترسیمی. فرشِ تصویری به‌عنوانِ سطحِ /// میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده می‌شود. @@ -100,8 +87,8 @@ class HokmGame extends FlameGame { if (ui.state == null) return; final s = ui.state!; // در حینِ «مکثِ آتش»، آخرین وضعیت را بافر کن و بعد از مکث اعمالش کن. - if (_cutHold) { - _pendingState = s; + if (_cut.holding) { + _cut.pending = s; return; } _applyState(s); @@ -124,8 +111,8 @@ class HokmGame extends FlameGame { // دستِ اول تا پایانِ نمایشِ «جستجوی حریف» به تعویق می‌افتد تا انیمیشن // زیرِ اوورلی پنهان نشود و صدا با تصویر هماهنگ باشد. if (_prevHandSize == 0 && s.yourHand.isNotEmpty && !_silent) { - if (_introActive) { - _pendingShuffle = true; + if (_anim.introActive) { + _anim.markPendingShuffle(); } else { _shuffleNow(); } @@ -133,7 +120,7 @@ class HokmGame extends FlameGame { // بُرِ مجدد: هنوز در فازِ حکم هستیم اما ۵ کارتِ حاکم عوض شده ⇒ انیمیشنِ بُر دوباره. final sig = (s.yourHand.toList()..sort()).join(','); if (!_silent && - !_introActive && + !_anim.introActive && s.phase == 'choose_trump' && s.trump == null && _prevHandSize > 0 && @@ -174,13 +161,13 @@ class HokmGame extends FlameGame { void _checkTrickSounds(GameState s) { final now = s.trick.map((t) => t.card).toList(); // در زمانِ اینترو/بُر زدن/پخش، کارتی روی میز نشان داده نمی‌شود ⇒ صدا هم نه. - if (_silent || _introActive || _shuffling || _dealing) { + if (_silent || _anim.busy) { _prevTrick = now; return; } final addedOne = now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now); - if (now.length <= 1) _bestTrumpRank = 0; // دستِ جدید ⇒ ریستِ بالاترین حکم + if (now.length <= 1) _cut.resetTrick(); // دستِ جدید ⇒ ریستِ بالاترین حکم if (addedOne) { final card = s.trick.last; // بریدن: خالِ راهبر حکم نیست و کارتِ بازی‌شده حکم است. @@ -189,25 +176,20 @@ class HokmGame extends FlameGame { s.leadSuit != s.trump && suitName(card.card) == s.trump; final rank = isTrump ? rankValue(card.card) : 0; - // افکتِ بریدن فقط وقتی حکمِ بازی‌شده «دست را می‌برد» (بالاترین حکم تا کنون): - // یعنی اولین بریدن یا روبریدنِ بالاتر. حکمِ پایین‌تر (بازنده) فقط صدای گذاشتن. - if (isTrump && rank > _bestTrumpRank) { - _bestTrumpRank = rank; + // افکتِ بریدن فقط وقتی حکمِ بازی‌شده «دست را می‌برد» (اولین بریدن یا روبُریدنِ + // بالاتر). حکمِ پایین‌تر (بازنده) فقط صدای گذاشتن. + if (isTrump && _cut.registerWinningCut(rank, _t)) { _shake = 1.0; add(Lightning()); - // کمبوِ بریدن: روبُریدن‌های سریعِ پیاپی (پنجره‌ی ~۶ ثانیه) را می‌شمارد. - _cutCombo = (_t - _lastCutAt < 6.0) ? _cutCombo + 1 : 1; - _lastCutAt = _t; if (!AppSettings.I.lowGraphics.value) { add(CutBurst()); // فلاش + شوک‌ویو + جرقه (کم‌مصرف: خاموش) // سه بریدنِ پیاپی یا بیشتر ⇒ افکتِ آتش (هرچه کمبو بیشتر، شعله بلندتر). - if (_cutCombo >= 3) { + if (_cut.combo >= 3) { _shake = 1.4; // تکانِ قوی‌تر برای اوجِ کمبو - add(FireBurst(intensity: _cutCombo)); + add(FireBurst(intensity: _cut.combo)); // مکثِ کوتاه پس از آتش تا لحظه دیده شود؛ کارتِ بعدی (کاربر یا بات) // تا پایانِ مکث اعمال نمی‌شود. - _cutHold = true; - _cutHoldUntil = _t + _cutHoldSec; + _cut.startHold(_t); } } AppSounds.cut(); // بریدن با حکم (شمشیر) @@ -224,20 +206,14 @@ class HokmGame extends FlameGame { /// قطعِ صداهای بازی هنگام خروج از میز (جلوگیری از پخشِ صدای کارت پس از ترک). void silence() { _silent = true; - _cutHold = false; // مکثِ آتش را لغو کن تا وضعیتِ بافرشده اعمال/رها نشود - _pendingState = null; + _cut.clearHold(); // مکثِ آتش را لغو کن تا وضعیتِ بافرشده اعمال/رها نشود AppSounds.stopHeartBeat(); } /// پایانِ نمایشِ «جستجوی حریف»؛ اگر بُرِ دستِ اول به تعویق افتاده بود، اکنون /// (که اوورلی کنار رفته) با صدا و تصویرِ هماهنگ اجرا می‌شود. void endIntro() { - _introActive = false; - if (_pendingShuffle && !_silent) { - _pendingShuffle = false; - _shuffleNow(); - } - _refreshAnimating(); + if (_anim.endIntro() && !_silent) _shuffleNow(); } /// اجرای هم‌زمانِ انیمیشن و صدای بُر زدن (صدا با پایانِ انیمیشن قطع می‌شود). @@ -246,11 +222,9 @@ class HokmGame extends FlameGame { if (_silent) return; AppSounds.shuffle(cutoffMs: ShuffleAnimation.totalMs); add(ShuffleAnimation(size)); - _shuffling = true; - _refreshAnimating(); + _anim.beginShuffle(); Future.delayed(const Duration(milliseconds: ShuffleAnimation.totalMs), () { - _shuffling = false; - _refreshAnimating(); + _anim.endShuffle(); if (_s != null && !_silent) _relayout(); // فعال‌سازیِ دوباره‌ی کارت‌ها }); } @@ -260,11 +234,9 @@ class HokmGame extends FlameGame { if (_silent) return; AppSounds.shuffle(cutoffMs: ShuffleAnimation.dealMs); add(ShuffleAnimation(size, dealOnly: true)); - _dealing = true; - _refreshAnimating(); + _anim.beginDeal(); Future.delayed(const Duration(milliseconds: ShuffleAnimation.dealMs), () { - _dealing = false; - _refreshAnimating(); + _anim.endDeal(); if (_s != null && !_silent) _relayout(); }); } @@ -310,10 +282,10 @@ class HokmGame extends FlameGame { super.update(dt); _t += dt; // پایانِ «مکثِ آتش»: آخرین وضعیتِ بافرشده را اعمال کن (یا صحنه را تازه کن). - if (_cutHold && _t >= _cutHoldUntil) { - _cutHold = false; - final p = _pendingState; - _pendingState = null; + if (_cut.holdExpired(_t)) { + _cut.holding = false; + final p = _cut.pending; + _cut.pending = null; if (!_silent) { if (p != null) { _applyState(p); @@ -323,70 +295,8 @@ class HokmGame extends FlameGame { } } if (_shake > 0) _shake = math.max(0, _shake - dt * 2.2); - _updateHeartBeat(dt); - } - - // ضربانِ قلب فقط در ثانیه‌های پایانیِ نوبتِ خودِ کاربر؛ هنگامِ پایانِ نوبت - // (بازی‌شدنِ کارت توسط کاربر یا سیستم) بلافاصله قطع می‌شود. - // - // زمان‌سنجِ نوبت باید دقیقاً هم‌زمان با «حلقه‌ی زمانِ نوبت» در HUD شروع شود؛ - // آن حلقه با تغییرِ امضای نوبت (نوبت|تعدادِ کارتِ زمین|فاز) ریست می‌شود و به - // انیمیشنِ بُر/پخش کاری ندارد. پس اینجا هم شمارش را به همان امضا می‌بندیم و - // فقط «پخشِ صدا» را در حینِ انیمیشن/اینترو خاموش می‌کنیم تا صدا و تصویر یکی شوند. - void _updateHeartBeat(double dt) { - final s = _s; - final isMyTurn = s != null && - ((s.phase == 'playing' && s.turn == s.yourSeat) || - (s.phase == 'choose_trump' && s.hakem == s.yourSeat)); - - if (!isMyTurn || _silent) { - if (_wasMyTurn) { - AppSounds.stopHeartBeat(); // نوبت تمام شد ⇒ توقفِ فوریِ صدا - _wasMyTurn = false; - } - _turnSig = s == null ? '' : '${s.turn}|${s.trick.length}|${s.phase}'; - return; - } - - // امضای نوبت — همانی که حلقه‌ی HUD استفاده می‌کند. - final sig = '${s.turn}|${s.trick.length}|${s.phase}'; - if (!_wasMyTurn || sig != _turnSig) { - // شروعِ نوبتِ من (یا نوبتِ جدید) ⇒ شمارش از صفر، هم‌زمان با حلقه‌ی HUD. - _wasMyTurn = true; - _turnSig = sig; - _turnElapsed = 0; - _beatPlayed = false; - _hbAccum = 0; - // صدای «نوبتِ شما» فقط هنگامِ بازیِ کارت و بیرون از انیمیشن‌ها. - if (s.phase == 'playing' && !_introActive && !_shuffling && !_dealing) { - AppSounds.turn(); - } - } - _turnElapsed += dt; - - // در حینِ اینترو/بُر/پخش صدایی پخش نمی‌کنیم (اما شمارش ادامه دارد تا با - // حلقه‌ی HUD هم‌گام بماند). این انیمیشن‌ها در ابتدای نوبت‌اند و با پنجره‌ی - // پایانیِ ضربان هم‌پوشانی ندارند. - if (_introActive || _shuffling || _dealing) return; - - final dur = (s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000.0 : 30.0); - final remaining = dur - _turnElapsed; - final warn = math.min(7.0, dur * 0.4); - // در پنجره‌ی پایانیِ نوبت، ضربان را با فاصله‌ی منظم تکرار کن تا تا لحظه‌ی - // آخر شنیده شود (نه فقط یک ضربانِ زودهنگام که در سکوتِ پایانی گم می‌شود). - if (remaining > 0 && remaining <= warn) { - if (!_beatPlayed) { - _beatPlayed = true; - _hbAccum = 0; - AppSounds.heartBeat(); - } else { - _hbAccum += dt; - if (_hbAccum >= 0.95) { - _hbAccum = 0; - AppSounds.heartBeat(); - } - } - } + _heart.update(dt, _s, + silent: _silent, busy: _anim.busy); } @override @@ -423,7 +333,7 @@ class HokmGame extends FlameGame { bool _legal(String code) { final s = _s!; // هنگامِ اینترو/بُر زدن/پخشِ کارت‌ها یا مکثِ آتش، بازیِ کارت مجاز نیست. - if (_introActive || _shuffling || _dealing || _cutHold) return false; + if (_anim.busy || _cut.holding) return false; if (s.phase != 'playing' || s.trickDone || !s.isMyTurn) return false; final lead = s.leadSuit; if (lead == null || lead.isEmpty) return true; @@ -458,7 +368,7 @@ class HokmGame extends FlameGame { void _layoutHand(GameState s) { // در زمانِ اینترو/بُر زدنِ ابتدای هَند، کارت‌های دست پنهان‌اند و پس از پایانِ // بُر زدن «پخش» می‌شوند (می‌آیند). در زمانِ پخشِ بقیه‌ی کارت‌ها مخفی نمی‌شوند. - if (_introActive || _shuffling) { + if (_anim.cardsHidden) { for (final c in _hand.values.toList()) { c.removeFromParent(); } @@ -532,7 +442,7 @@ class HokmGame extends FlameGame { void _layoutTrick(GameState s) { // در زمانِ اینترو/بُر زدن/پخش، کارت‌های روی میز پنهان می‌مانند تا با انیمیشن // تداخل نکنند (پس از پایانِ انیمیشن دوباره چیده می‌شوند). - if (_introActive || _shuffling || _dealing) { + if (_anim.busy) { for (final c in _trick.values.toList()) { c.removeFromParent(); } @@ -681,7 +591,7 @@ class HokmGame extends FlameGame { // پشتِ کارتِ حریفان در زمانِ بُر زدنِ ابتدای هَند پنهان است (انیمیشنِ بُر // خودش کارت‌ها را پخش می‌کند)؛ پس از آن نمایش داده می‌شود. - final hideBacks = _introActive || _shuffling; + final hideBacks = _anim.cardsHidden; for (var seat = 0; seat < 4 && !hideBacks; seat++) { if (seat == s.yourSeat) continue; final count = seat < s.handCounts.length ? s.handCounts[seat] : 0; diff --git a/lib/feature/game/presentation/widgets/flame/turn_heartbeat.dart b/lib/feature/game/presentation/widgets/flame/turn_heartbeat.dart new file mode 100644 index 0000000..415eee9 --- /dev/null +++ b/lib/feature/game/presentation/widgets/flame/turn_heartbeat.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +import '../../../../../core/service/app_sounds.dart'; +import '../../../domain/entities/game_entities.dart'; + +/// ضربانِ قلبِ نوبتِ کاربر: در ثانیه‌های پایانیِ نوبتِ خودِ کاربر صدای ضربان پخش +/// می‌کند و با پایانِ نوبت بلافاصله قطع می‌شود. مستقل از چیدمانِ صحنه است. +/// +/// زمان‌سنجِ نوبت دقیقاً هم‌زمان با «حلقه‌ی زمانِ نوبت» در HUD شروع می‌شود؛ آن حلقه +/// با تغییرِ امضای نوبت (نوبت|تعدادِ کارتِ زمین|فاز) ریست می‌شود و به انیمیشنِ بُر/ +/// پخش کاری ندارد. اینجا هم شمارش به همان امضا بسته می‌شود و فقط «پخشِ صدا» در حینِ +/// انیمیشن/اینترو خاموش است تا صدا و تصویر یکی شوند. +class TurnHeartbeat { + double _elapsed = 0; + bool _beatPlayed = false; + double _accum = 0; + bool _wasMyTurn = false; + String _sig = ''; + + /// هر فریم صدا زده می‌شود. [busy] یعنی اینترو/بُر زدن/پخشِ کارت در جریان است + /// (شمارش ادامه دارد اما صدایی پخش نمی‌شود). + void update( + double dt, + GameState? s, { + required bool silent, + required bool busy, + }) { + final isMyTurn = s != null && + ((s.phase == 'playing' && s.turn == s.yourSeat) || + (s.phase == 'choose_trump' && s.hakem == s.yourSeat)); + + if (!isMyTurn || silent) { + if (_wasMyTurn) { + AppSounds.stopHeartBeat(); // نوبت تمام شد ⇒ توقفِ فوریِ صدا + _wasMyTurn = false; + } + _sig = s == null ? '' : '${s.turn}|${s.trick.length}|${s.phase}'; + return; + } + + final sig = '${s.turn}|${s.trick.length}|${s.phase}'; + if (!_wasMyTurn || sig != _sig) { + // شروعِ نوبتِ من (یا نوبتِ جدید) ⇒ شمارش از صفر، هم‌زمان با حلقه‌ی HUD. + _wasMyTurn = true; + _sig = sig; + _elapsed = 0; + _beatPlayed = false; + _accum = 0; + if (s.phase == 'playing' && !busy) AppSounds.turn(); + } + _elapsed += dt; + + if (busy) return; + + final dur = (s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000.0 : 30.0); + final remaining = dur - _elapsed; + final warn = math.min(7.0, dur * 0.4); + // در پنجره‌ی پایانیِ نوبت، ضربان با فاصله‌ی منظم تکرار می‌شود تا تا لحظه‌ی آخر + // شنیده شود (نه فقط یک ضربانِ زودهنگام که در سکوتِ پایانی گم می‌شود). + if (remaining > 0 && remaining <= warn) { + if (!_beatPlayed) { + _beatPlayed = true; + _accum = 0; + AppSounds.heartBeat(); + } else { + _accum += dt; + if (_accum >= 0.95) { + _accum = 0; + AppSounds.heartBeat(); + } + } + } + } + + void stop() => AppSounds.stopHeartBeat(); +} diff --git a/lib/feature/game/presentation/widgets/table_hud.dart b/lib/feature/game/presentation/widgets/table_hud.dart index 9f42bb1..6b4f3a8 100644 --- a/lib/feature/game/presentation/widgets/table_hud.dart +++ b/lib/feature/game/presentation/widgets/table_hud.dart @@ -8,6 +8,8 @@ import '../../../../core/theme/ranks.dart'; import '../../domain/entities/game_entities.dart'; import '../bloc/game_state.dart'; +part 'table_hud.parts.dart'; + /// اوورلیِ روی صحنه‌ی Flame: آواتارِ بازیکنان با قابِ طلایی، نام، نشانِ رتبه، /// شمارنده‌ی نوبت، تاجِ حاکم و تعدادِ کارت — به‌علاوه‌ی پنلِ امتیاز/حکم (بالا-چپ)، /// نشانگرِ اتصال و دکمه‌ی خروج (بالا-راست) و دکمه‌ی گفتگو (پایین-راست). @@ -217,425 +219,3 @@ class TableHud extends StatelessWidget { /// رتبه (رنگ از سرور). بات‌ها رتبه ندارند ⇒ رنگِ پیش‌فرض. List _seatFrame(GamePlayer p) => frameGradient(p.frame) ?? rankGradient(p.bot ? '' : p.rankTier); - -class _PlayerSeat extends StatelessWidget { - final GamePlayer player; - final double diameter; - final bool isTurn; - final bool isHakem; - final bool nameAbove; - final int cardCount; - final int - teamTricks; // دست‌های برده‌ی تیمِ این بازیکن در این هَند (tricks_won) - final String turnToken; - final double turnSeconds; - final bool showRing; - final ChatBubble? bubble; // پیام/شکلکِ فعالِ این بازیکن (یا null) - const _PlayerSeat({ - required this.player, - required this.diameter, - required this.isTurn, - required this.isHakem, - required this.nameAbove, - required this.cardCount, - required this.teamTricks, - required this.turnToken, - required this.turnSeconds, - required this.showRing, - required this.bubble, - }); - - @override - Widget build(BuildContext context) { - final ringW = diameter * 0.08; - final name = Container( - constraints: BoxConstraints(maxWidth: diameter * 1.7), - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), - decoration: BoxDecoration( - color: const Color(0xCC14100B), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isTurn ? AppColors.gold : const Color(0x55B8860B), - ), - ), - child: Text( - player.bot ? '${player.name} (ربات)' : player.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: isTurn ? AppColors.gold : Colors.white, - fontSize: diameter * 0.18, - fontWeight: FontWeight.bold, - ), - ), - ); - - final avatar = SizedBox( - width: diameter, - height: diameter, - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - // قابِ آواتار: قابِ اختصاصیِ خریداری‌شده (اگر باشد)، وگرنه قابِ رتبه. - Container( - width: diameter, - height: diameter, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: _seatFrame(player), - ), - boxShadow: [ - const BoxShadow(color: Colors.black54, blurRadius: 6), - if (isTurn) - BoxShadow( - color: _seatFrame(player)[0].withValues(alpha: 0.6), - blurRadius: 12), - ], - ), - padding: EdgeInsets.all(ringW), - child: ClipOval( - child: Container( - color: const Color(0xFF1A2740), - child: - player.bot - ? Icon( - Icons.smart_toy, - color: Colors.white70, - size: diameter * 0.5, - ) - : RandomAvatar( - player.avatarSeed.isEmpty ? '?' : player.avatarSeed), - ), - ), - ), - // حلقه‌ی شمارشِ نوبت (روی قاب). - if (isTurn && showRing) - Positioned.fill( - child: _TurnRing( - token: turnToken, - seconds: turnSeconds, - stroke: ringW * 1.1, - ), - ), - // تاجِ حاکم بالای آواتار. - if (isHakem) - Positioned( - top: -diameter * 0.40, - child: Text('👑', style: TextStyle(fontSize: diameter * 0.4)), - ), - // دست‌های برده‌ی تیمِ این بازیکن در این هَند (بالا-چپ، با نشانِ تک‌خال). - if (teamTricks > 0) - Positioned( - left: -diameter * 0.06, - top: -diameter * 0.06, - child: _TricksBadge(count: teamTricks, diameter: diameter), - ), - - // حبابِ تعدادِ کارت (پایین-راست). - ], - ), - ); - - // نشانِ رتبه‌ی فشرده (تصویر) کنارِ آواتار. - final badge = - (!player.bot && player.rankTier.isNotEmpty) - ? Image.asset( - 'assets/images/badge/${player.rankTier}.png', - width: diameter * 0.34, - height: diameter * 0.34, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ) - : const SizedBox.shrink(); - - final nameRow = Row( - mainAxisSize: MainAxisSize.min, - children: [ - badge, - if (!player.bot && player.rankTier.isNotEmpty) const SizedBox(width: 4), - Flexible(child: name), - if (!player.bot && player.coins > 0) ...[ - const SizedBox(width: 4), - Text( - '${player.coins}', - style: TextStyle( - color: AppColors.gold, - fontSize: diameter * 0.16, - ), - ), - ], - ], - ); - - final content = Column( - mainAxisSize: MainAxisSize.min, - children: - nameAbove - ? [nameRow, SizedBox(height: diameter * 0.06), avatar] - : [avatar, SizedBox(height: diameter * 0.06), nameRow], - ); - - if (bubble == null) return content; - // حبابِ پیام بالای آواتار (برای بازیکنِ بالا، زیرِ آواتار تا از صفحه بیرون نزند). - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - content, - Positioned( - top: nameAbove ? null : -diameter * 0.62, - bottom: nameAbove ? -diameter * 0.62 : null, - child: _ChatBubbleView(bubble: bubble!, diameter: diameter), - ), - ], - ); - } -} - -/// نمایشِ حبابِ پیام/شکلک با یک «پاپ»ِ ورود. شکلک بزرگ و متن در یک پیلِ روشن. -class _ChatBubbleView extends StatelessWidget { - final ChatBubble bubble; - final double diameter; - const _ChatBubbleView({required this.bubble, required this.diameter}); - - @override - Widget build(BuildContext context) { - final isEmoji = (bubble.emoji ?? '').isNotEmpty; - final child = isEmoji - ? Text(bubble.emoji!, style: TextStyle(fontSize: diameter * 0.7)) - : Container( - constraints: BoxConstraints(maxWidth: diameter * 2.4), - padding: EdgeInsets.symmetric( - horizontal: diameter * 0.16, vertical: diameter * 0.08), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(diameter * 0.3), - border: Border.all(color: AppColors.goldDark, width: 1.4), - boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 5) - ], - ), - child: Text( - bubble.text ?? '', - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: AppColors.bgDark, - fontSize: diameter * 0.2, - fontWeight: FontWeight.bold, - ), - ), - ); - return TweenAnimationBuilder( - key: ValueKey(bubble.id), - tween: Tween(begin: 0.5, end: 1.0), - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutBack, - builder: (_, v, c) => Transform.scale(scale: v, child: c), - child: child, - ); - } -} - -/// حلقه‌ی شمارشِ معکوسِ نوبت: از پر به خالی طیِ `seconds`؛ نزدیکِ پایان قرمز -/// می‌شود. با تغییرِ `token` (نوبتِ جدید) از نو شروع می‌شود. -class _TurnRing extends StatefulWidget { - final String token; - final double seconds; - final double stroke; - const _TurnRing({ - required this.token, - required this.seconds, - required this.stroke, - }); - - @override - State<_TurnRing> createState() => _TurnRingState(); -} - -class _TurnRingState extends State<_TurnRing> - with SingleTickerProviderStateMixin { - late final AnimationController _c; - - @override - void initState() { - super.initState(); - _c = AnimationController( - vsync: this, - duration: Duration(milliseconds: (widget.seconds * 1000).round()), - )..forward(); - } - - @override - void didUpdateWidget(covariant _TurnRing old) { - super.didUpdateWidget(old); - if (old.token != widget.token) { - _c.duration = Duration(milliseconds: (widget.seconds * 1000).round()); - _c - ..reset() - ..forward(); - } - } - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _c, - builder: (_, __) { - final left = (1 - _c.value).clamp(0.0, 1.0); - return CustomPaint( - painter: _RingPainter(left: left, stroke: widget.stroke), - ); - }, - ); - } -} - -class _RingPainter extends CustomPainter { - final double left; // ۱..۰ - final double stroke; - _RingPainter({required this.left, required this.stroke}); - - @override - void paint(Canvas canvas, Size size) { - final rect = Offset.zero & size; - final center = rect.center; - final radius = (size.shortestSide - stroke) / 2; - const start = -1.5708; // بالا - final sweep = 6.28318 * left; - final color = - left < 0.3 ? AppColors.suitRed : AppColors.online; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - start, - sweep, - false, - Paint() - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeWidth = stroke - ..color = color, - ); - } - - @override - bool shouldRepaint(covariant _RingPainter old) => - old.left != left || old.stroke != stroke; -} - -/// نشانِ دست‌های برده‌ی تیم؛ هنگامِ نمایش و هر بار که عدد زیاد می‌شود یک «پاپ» -/// (بزرگ‌نماییِ کوتاه) می‌زند تا کاربر بفهمد دست‌های برده اینجا نشان داده می‌شوند. -class _TricksBadge extends StatefulWidget { - final int count; - final double diameter; - const _TricksBadge({required this.count, required this.diameter}); - - @override - State<_TricksBadge> createState() => _TricksBadgeState(); -} - -class _TricksBadgeState extends State<_TricksBadge> - with SingleTickerProviderStateMixin { - late final AnimationController _c; - late final Animation _scale; - - @override - void initState() { - super.initState(); - _c = AnimationController( - vsync: this, duration: const Duration(milliseconds: 420)); - _scale = TweenSequence([ - TweenSequenceItem(tween: Tween(begin: 0.4, end: 1.35), weight: 50), - TweenSequenceItem(tween: Tween(begin: 1.35, end: 1.0), weight: 50), - ]).animate(CurvedAnimation(parent: _c, curve: Curves.easeOut)); - _c.forward(); // پاپِ اولیه هنگامِ ظاهرشدن - } - - @override - void didUpdateWidget(covariant _TricksBadge old) { - super.didUpdateWidget(old); - if (old.count != widget.count) { - _c - ..reset() - ..forward(); // پاپ هر بار که دستِ برده اضافه می‌شود - } - } - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final d = widget.diameter; - return ScaleTransition( - scale: _scale, - child: Container( - padding: - EdgeInsets.symmetric(horizontal: d * 0.08, vertical: d * 0.03), - decoration: BoxDecoration( - color: const Color(0xFF7B0E14), - borderRadius: BorderRadius.circular(d), - border: Border.all(color: AppColors.gold, width: 1.4), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.layers, color: Colors.white, size: d * 0.18), - SizedBox(width: d * 0.03), - Text('${widget.count}', - style: TextStyle( - color: Colors.white, - fontSize: d * 0.2, - fontWeight: FontWeight.bold)), - ], - ), - ), - ); - } -} - -class _RoundBtn extends StatelessWidget { - final IconData icon; - final Color color; - final VoidCallback onTap; - final double size; - const _RoundBtn({ - required this.icon, - required this.color, - required this.onTap, - this.size = 46, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: AppColors.gold, width: 1.6), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)], - ), - child: Icon(icon, color: Colors.white, size: size * 0.5), - ), - ); - } -} diff --git a/lib/feature/game/presentation/widgets/table_hud.parts.dart b/lib/feature/game/presentation/widgets/table_hud.parts.dart new file mode 100644 index 0000000..3e78878 --- /dev/null +++ b/lib/feature/game/presentation/widgets/table_hud.parts.dart @@ -0,0 +1,424 @@ +// ویجت‌های خصوصیِ HUDِ میز (نشستِ بازیکن، حلقه‌ی نوبت، نشانِ دست‌ها، دکمه‌ها). +part of 'table_hud.dart'; + +class _PlayerSeat extends StatelessWidget { + final GamePlayer player; + final double diameter; + final bool isTurn; + final bool isHakem; + final bool nameAbove; + final int cardCount; + final int + teamTricks; // دست‌های برده‌ی تیمِ این بازیکن در این هَند (tricks_won) + final String turnToken; + final double turnSeconds; + final bool showRing; + final ChatBubble? bubble; // پیام/شکلکِ فعالِ این بازیکن (یا null) + const _PlayerSeat({ + required this.player, + required this.diameter, + required this.isTurn, + required this.isHakem, + required this.nameAbove, + required this.cardCount, + required this.teamTricks, + required this.turnToken, + required this.turnSeconds, + required this.showRing, + required this.bubble, + }); + + @override + Widget build(BuildContext context) { + final ringW = diameter * 0.08; + final name = Container( + constraints: BoxConstraints(maxWidth: diameter * 1.7), + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xCC14100B), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isTurn ? AppColors.gold : const Color(0x55B8860B), + ), + ), + child: Text( + player.bot ? '${player.name} (ربات)' : player.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: isTurn ? AppColors.gold : Colors.white, + fontSize: diameter * 0.18, + fontWeight: FontWeight.bold, + ), + ), + ); + + final avatar = SizedBox( + width: diameter, + height: diameter, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // قابِ آواتار: قابِ اختصاصیِ خریداری‌شده (اگر باشد)، وگرنه قابِ رتبه. + Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: _seatFrame(player), + ), + boxShadow: [ + const BoxShadow(color: Colors.black54, blurRadius: 6), + if (isTurn) + BoxShadow( + color: _seatFrame(player)[0].withValues(alpha: 0.6), + blurRadius: 12), + ], + ), + padding: EdgeInsets.all(ringW), + child: ClipOval( + child: Container( + color: const Color(0xFF1A2740), + child: + player.bot + ? Icon( + Icons.smart_toy, + color: Colors.white70, + size: diameter * 0.5, + ) + : RandomAvatar( + player.avatarSeed.isEmpty ? '?' : player.avatarSeed), + ), + ), + ), + // حلقه‌ی شمارشِ نوبت (روی قاب). + if (isTurn && showRing) + Positioned.fill( + child: _TurnRing( + token: turnToken, + seconds: turnSeconds, + stroke: ringW * 1.1, + ), + ), + // تاجِ حاکم بالای آواتار. + if (isHakem) + Positioned( + top: -diameter * 0.40, + child: Text('👑', style: TextStyle(fontSize: diameter * 0.4)), + ), + // دست‌های برده‌ی تیمِ این بازیکن در این هَند (بالا-چپ، با نشانِ تک‌خال). + if (teamTricks > 0) + Positioned( + left: -diameter * 0.06, + top: -diameter * 0.06, + child: _TricksBadge(count: teamTricks, diameter: diameter), + ), + + // حبابِ تعدادِ کارت (پایین-راست). + ], + ), + ); + + // نشانِ رتبه‌ی فشرده (تصویر) کنارِ آواتار. + final badge = + (!player.bot && player.rankTier.isNotEmpty) + ? Image.asset( + 'assets/images/badge/${player.rankTier}.png', + width: diameter * 0.34, + height: diameter * 0.34, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ) + : const SizedBox.shrink(); + + final nameRow = Row( + mainAxisSize: MainAxisSize.min, + children: [ + badge, + if (!player.bot && player.rankTier.isNotEmpty) const SizedBox(width: 4), + Flexible(child: name), + if (!player.bot && player.coins > 0) ...[ + const SizedBox(width: 4), + Text( + '${player.coins}', + style: TextStyle( + color: AppColors.gold, + fontSize: diameter * 0.16, + ), + ), + ], + ], + ); + + final content = Column( + mainAxisSize: MainAxisSize.min, + children: + nameAbove + ? [nameRow, SizedBox(height: diameter * 0.06), avatar] + : [avatar, SizedBox(height: diameter * 0.06), nameRow], + ); + + if (bubble == null) return content; + // حبابِ پیام بالای آواتار (برای بازیکنِ بالا، زیرِ آواتار تا از صفحه بیرون نزند). + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + content, + Positioned( + top: nameAbove ? null : -diameter * 0.62, + bottom: nameAbove ? -diameter * 0.62 : null, + child: _ChatBubbleView(bubble: bubble!, diameter: diameter), + ), + ], + ); + } +} + +/// نمایشِ حبابِ پیام/شکلک با یک «پاپ»ِ ورود. شکلک بزرگ و متن در یک پیلِ روشن. +class _ChatBubbleView extends StatelessWidget { + final ChatBubble bubble; + final double diameter; + const _ChatBubbleView({required this.bubble, required this.diameter}); + + @override + Widget build(BuildContext context) { + final isEmoji = (bubble.emoji ?? '').isNotEmpty; + final child = isEmoji + ? Text(bubble.emoji!, style: TextStyle(fontSize: diameter * 0.7)) + : Container( + constraints: BoxConstraints(maxWidth: diameter * 2.4), + padding: EdgeInsets.symmetric( + horizontal: diameter * 0.16, vertical: diameter * 0.08), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(diameter * 0.3), + border: Border.all(color: AppColors.goldDark, width: 1.4), + boxShadow: const [ + BoxShadow(color: Colors.black54, blurRadius: 5) + ], + ), + child: Text( + bubble.text ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.bgDark, + fontSize: diameter * 0.2, + fontWeight: FontWeight.bold, + ), + ), + ); + return TweenAnimationBuilder( + key: ValueKey(bubble.id), + tween: Tween(begin: 0.5, end: 1.0), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + builder: (_, v, c) => Transform.scale(scale: v, child: c), + child: child, + ); + } +} + +/// حلقه‌ی شمارشِ معکوسِ نوبت: از پر به خالی طیِ `seconds`؛ نزدیکِ پایان قرمز +/// می‌شود. با تغییرِ `token` (نوبتِ جدید) از نو شروع می‌شود. +class _TurnRing extends StatefulWidget { + final String token; + final double seconds; + final double stroke; + const _TurnRing({ + required this.token, + required this.seconds, + required this.stroke, + }); + + @override + State<_TurnRing> createState() => _TurnRingState(); +} + +class _TurnRingState extends State<_TurnRing> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: Duration(milliseconds: (widget.seconds * 1000).round()), + )..forward(); + } + + @override + void didUpdateWidget(covariant _TurnRing old) { + super.didUpdateWidget(old); + if (old.token != widget.token) { + _c.duration = Duration(milliseconds: (widget.seconds * 1000).round()); + _c + ..reset() + ..forward(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _c, + builder: (_, __) { + final left = (1 - _c.value).clamp(0.0, 1.0); + return CustomPaint( + painter: _RingPainter(left: left, stroke: widget.stroke), + ); + }, + ); + } +} + +class _RingPainter extends CustomPainter { + final double left; // ۱..۰ + final double stroke; + _RingPainter({required this.left, required this.stroke}); + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final center = rect.center; + final radius = (size.shortestSide - stroke) / 2; + const start = -1.5708; // بالا + final sweep = 6.28318 * left; + final color = + left < 0.3 ? AppColors.suitRed : AppColors.online; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + start, + sweep, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = stroke + ..color = color, + ); + } + + @override + bool shouldRepaint(covariant _RingPainter old) => + old.left != left || old.stroke != stroke; +} + +/// نشانِ دست‌های برده‌ی تیم؛ هنگامِ نمایش و هر بار که عدد زیاد می‌شود یک «پاپ» +/// (بزرگ‌نماییِ کوتاه) می‌زند تا کاربر بفهمد دست‌های برده اینجا نشان داده می‌شوند. +class _TricksBadge extends StatefulWidget { + final int count; + final double diameter; + const _TricksBadge({required this.count, required this.diameter}); + + @override + State<_TricksBadge> createState() => _TricksBadgeState(); +} + +class _TricksBadgeState extends State<_TricksBadge> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _scale; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, duration: const Duration(milliseconds: 420)); + _scale = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.4, end: 1.35), weight: 50), + TweenSequenceItem(tween: Tween(begin: 1.35, end: 1.0), weight: 50), + ]).animate(CurvedAnimation(parent: _c, curve: Curves.easeOut)); + _c.forward(); // پاپِ اولیه هنگامِ ظاهرشدن + } + + @override + void didUpdateWidget(covariant _TricksBadge old) { + super.didUpdateWidget(old); + if (old.count != widget.count) { + _c + ..reset() + ..forward(); // پاپ هر بار که دستِ برده اضافه می‌شود + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final d = widget.diameter; + return ScaleTransition( + scale: _scale, + child: Container( + padding: + EdgeInsets.symmetric(horizontal: d * 0.08, vertical: d * 0.03), + decoration: BoxDecoration( + color: const Color(0xFF7B0E14), + borderRadius: BorderRadius.circular(d), + border: Border.all(color: AppColors.gold, width: 1.4), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.layers, color: Colors.white, size: d * 0.18), + SizedBox(width: d * 0.03), + Text('${widget.count}', + style: TextStyle( + color: Colors.white, + fontSize: d * 0.2, + fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } +} + +class _RoundBtn extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback onTap; + final double size; + const _RoundBtn({ + required this.icon, + required this.color, + required this.onTap, + this.size = 46, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 1.6), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)], + ), + child: Icon(icon, color: Colors.white, size: size * 0.5), + ), + ); + } +} diff --git a/lib/feature/profile/presentation/screen/profile_screen.dart b/lib/feature/profile/presentation/screen/profile_screen.dart index 90840af..9c92919 100644 --- a/lib/feature/profile/presentation/screen/profile_screen.dart +++ b/lib/feature/profile/presentation/screen/profile_screen.dart @@ -17,6 +17,8 @@ import '../bloc/profile_event.dart'; import '../bloc/profile_state.dart'; import '../bloc/profile_status.dart'; +part 'profile_screen.parts.dart'; + /// صفحه‌ی پروفایل: نام، آواتار (قابل ویرایش)، سطح، جام و آمارِ بازی (ویژه‌ی VIP). class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @@ -195,508 +197,3 @@ class ProfileScreen extends StatelessWidget { ); } } - -/// کارتِ سرآمدِ پروفایل: آواتار با حلقه‌ی رتبه، نام، رتبه و نوارِ سطح/XP. -class _HeroCard extends StatelessWidget { - final ProfileEntity profile; - final VoidCallback onEdit; - const _HeroCard({required this.profile, required this.onEdit}); - - @override - Widget build(BuildContext context) { - final d = profile; - final grad = rankGradient(d.rankTier); - final pct = d.xpNext == 0 ? 0.0 : (d.xpInto / d.xpNext).clamp(0.0, 1.0); - return GamePanel( - child: Column( - children: [ - Stack( - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: grad, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: grad.first.withValues(alpha: 0.5), - blurRadius: 14, - spreadRadius: 1, - ), - ], - ), - child: Container( - padding: const EdgeInsets.all(3), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: AppColors.bgDark, - ), - child: RandomAvatar(d.avatar, height: 90, width: 90), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: GestureDetector( - onTap: onEdit, - child: Container( - padding: const EdgeInsets.all(7), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [Color(0xFFFFD54F), AppColors.goldDark], - ), - border: Border.all(color: AppColors.bgDark, width: 2), - ), - child: const Icon( - Icons.edit, - color: AppColors.bg, - size: 16, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible(child: GlowText(d.name, size: 22)), - if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()], - ], - ), - const SizedBox(height: 8), - RankBadge(tier: d.rankTier, points: d.rankPoints), - if (d.mobile.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - d.mobile, - style: const TextStyle(color: Colors.white38, fontSize: 12), - ), - ], - const SizedBox(height: 16), - Row( - children: [ - Text( - 'سطح ${d.level}', - style: const TextStyle( - color: AppColors.gold, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - Text( - '${d.xpInto} / ${d.xpNext} XP', - style: const TextStyle(color: Colors.white38, fontSize: 11), - ), - ], - ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: pct.toDouble(), - minHeight: 9, - backgroundColor: Colors.white10, - valueColor: const AlwaysStoppedAnimation(AppColors.gold), - ), - ), - ], - ), - ); - } -} - -/// نوارِ سه‌گانه‌ی برجسته: سطح، جام، امتیازِ رتبه. -class _HighlightStrip extends StatelessWidget { - final ProfileEntity profile; - const _HighlightStrip({required this.profile}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _Highlight( - icon: Icons.star, - value: '${profile.level}', - label: 'سطح', - ), - ), - const SizedBox(width: 10), - Expanded( - child: _Highlight( - icon: Icons.emoji_events, - value: '${profile.trophies}', - label: 'جام', - ), - ), - const SizedBox(width: 10), - Expanded( - child: _Highlight( - icon: Icons.military_tech, - value: '${profile.rankPoints}', - label: 'امتیاز', - ), - ), - ], - ); - } -} - -class _Highlight extends StatelessWidget { - final IconData icon; - final String value; - final String label; - const _Highlight({ - required this.icon, - required this.value, - required this.label, - }); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: AppColors.panel.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.goldFaint), - ), - child: Column( - children: [ - Icon(icon, color: AppColors.gold, size: 22), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: const TextStyle(color: Colors.white54, fontSize: 11), - ), - ], - ), - ); - } -} - -/// عنوانِ بخش با آیکون (راست‌چین). -class _SectionHeader extends StatelessWidget { - final IconData icon; - final String title; - const _SectionHeader({required this.icon, required this.title}); - @override - Widget build(BuildContext context) { - return Row( - children: [ - Icon(icon, color: AppColors.gold, size: 18), - const SizedBox(width: 8), - GlowText(title, size: 18), - ], - ); - } -} - -/// کاشیِ آماری (آیکون + مقدار + برچسب). accent ⇒ برجسته با قابِ طلایی. -class _StatTile extends StatelessWidget { - final String label; - final String value; - final IconData icon; - final bool accent; - const _StatTile(this.label, this.value, this.icon, {this.accent = false}); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: - accent - ? AppColors.gold.withValues(alpha: 0.14) - : AppColors.panel.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: accent ? AppColors.gold : AppColors.goldFaint, - width: accent ? 1.4 : 1, - ), - ), - child: Row( - children: [ - Icon(icon, color: AppColors.gold, size: 22), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 17, - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: const TextStyle( - color: Colors.white54, - fontSize: 11, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// ردیفِ تنظیمات (آیکون + برچسب + فلش) با ظاهرِ هماهنگ با سوئیچِ گرافیک. -class _SettingsTile extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - const _SettingsTile({ - required this.icon, - required this.label, - required this.onTap, - }); - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - color: AppColors.panel.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.goldFaint), - ), - child: Row( - children: [ - Icon(icon, color: AppColors.gold, size: 20), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - style: const TextStyle(color: Colors.white, fontSize: 14), - ), - ), - const Icon( - Icons.chevron_left, - color: Colors.white38, - size: 20, - ), - ], - ), - ), - ), - ); - } -} - -class _VipBadge extends StatelessWidget { - const _VipBadge(); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFFFD54F), AppColors.goldDark], - ), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'VIP', - style: TextStyle( - color: AppColors.bg, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ); - } -} - -/// شیتِ ویرایش نام و آواتار (با تأیید، مقدار جدید را برمی‌گرداند). -class _EditProfileSheet extends StatefulWidget { - final String name; - final String avatar; - const _EditProfileSheet({required this.name, required this.avatar}); - - @override - State<_EditProfileSheet> createState() => _EditProfileSheetState(); -} - -class _EditProfileSheetState extends State<_EditProfileSheet> { - late final TextEditingController _name; - late final List _seeds; - late String _selected; - - @override - void initState() { - super.initState(); - _name = TextEditingController(text: widget.name); - _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); - if (!_seeds.contains(widget.avatar)) _seeds.insert(0, widget.avatar); - _selected = widget.avatar; - } - - @override - void dispose() { - _name.dispose(); - super.dispose(); - } - - bool get _valid => _name.text.trim().length >= 2; - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - decoration: const BoxDecoration( - color: AppColors.bgDark, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - border: Border(top: BorderSide(color: AppColors.gold, width: 2)), - ), - padding: const EdgeInsets.all(18), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const GlowText('ویرایش پروفایل', size: 20), - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.gold, width: 2), - ), - child: RandomAvatar(_selected, height: 72, width: 72), - ), - const SizedBox(height: 12), - TextField( - controller: _name, - textAlign: TextAlign.center, - maxLength: 20, - inputFormatters: [LengthLimitingTextInputFormatter(20)], - decoration: const InputDecoration( - hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', - counterText: '', - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 10), - const Align( - alignment: Alignment.centerRight, - child: Text( - 'انتخاب آواتار', - style: TextStyle(color: AppColors.gold), - ), - ), - const SizedBox(height: 8), - GridView.count( - crossAxisCount: 4, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 10, - crossAxisSpacing: 10, - children: [ - for (final s in _seeds) - GestureDetector( - onTap: () => setState(() => _selected = s), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.panel, - border: Border.all( - color: - _selected == s - ? AppColors.gold - : Colors.transparent, - width: 2.5, - ), - ), - child: RandomAvatar(s), - ), - ), - ], - ), - const SizedBox(height: 16), - GameButton( - label: 'تأیید', - width: double.infinity, - colors: const [AppColors.success, AppColors.successDark], - onTap: - _valid - ? () => Navigator.pop(context, { - 'name': _name.text.trim(), - 'avatar': _selected, - }) - : null, - ), - ], - ), - ), - ), - ), - ); - } -} - -/// سوئیچِ «گرافیک کم‌مصرف»: افکت‌های اتمسفری را برای گوشی‌های ضعیف خاموش می‌کند. -class _GraphicsToggle extends StatelessWidget { - const _GraphicsToggle(); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: AppSettings.I.lowGraphics, - builder: (context, low, _) => Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: AppColors.panel.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.goldFaint), - ), - child: Row( - children: [ - const Icon(Icons.bolt, color: AppColors.gold, size: 20), - const SizedBox(width: 10), - const Expanded( - child: Text( - 'گرافیک کم‌مصرف', - style: TextStyle(color: Colors.white, fontSize: 14), - ), - ), - Switch( - value: low, - activeThumbColor: AppColors.gold, - onChanged: (v) => AppSettings.I.setLowGraphics(v), - ), - ], - ), - ), - ); - } -} diff --git a/lib/feature/profile/presentation/screen/profile_screen.parts.dart b/lib/feature/profile/presentation/screen/profile_screen.parts.dart new file mode 100644 index 0000000..e24728b --- /dev/null +++ b/lib/feature/profile/presentation/screen/profile_screen.parts.dart @@ -0,0 +1,507 @@ +// ویجت‌های خصوصیِ صفحه‌ی پروفایل (کارتِ سرآمد، نوارِ برجسته، کاشی‌ها، شیتِ ویرایش). +part of 'profile_screen.dart'; + +/// کارتِ سرآمدِ پروفایل: آواتار با حلقه‌ی رتبه، نام، رتبه و نوارِ سطح/XP. +class _HeroCard extends StatelessWidget { + final ProfileEntity profile; + final VoidCallback onEdit; + const _HeroCard({required this.profile, required this.onEdit}); + + @override + Widget build(BuildContext context) { + final d = profile; + final grad = rankGradient(d.rankTier); + final pct = d.xpNext == 0 ? 0.0 : (d.xpInto / d.xpNext).clamp(0.0, 1.0); + return GamePanel( + child: Column( + children: [ + Stack( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: grad, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: grad.first.withValues(alpha: 0.5), + blurRadius: 14, + spreadRadius: 1, + ), + ], + ), + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.bgDark, + ), + child: RandomAvatar(d.avatar, height: 90, width: 90), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: onEdit, + child: Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFFFFD54F), AppColors.goldDark], + ), + border: Border.all(color: AppColors.bgDark, width: 2), + ), + child: const Icon( + Icons.edit, + color: AppColors.bg, + size: 16, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: GlowText(d.name, size: 22)), + if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()], + ], + ), + const SizedBox(height: 8), + RankBadge(tier: d.rankTier, points: d.rankPoints), + if (d.mobile.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + d.mobile, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Text( + 'سطح ${d.level}', + style: const TextStyle( + color: AppColors.gold, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + '${d.xpInto} / ${d.xpNext} XP', + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: pct.toDouble(), + minHeight: 9, + backgroundColor: Colors.white10, + valueColor: const AlwaysStoppedAnimation(AppColors.gold), + ), + ), + ], + ), + ); + } +} + +/// نوارِ سه‌گانه‌ی برجسته: سطح، جام، امتیازِ رتبه. +class _HighlightStrip extends StatelessWidget { + final ProfileEntity profile; + const _HighlightStrip({required this.profile}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _Highlight( + icon: Icons.star, + value: '${profile.level}', + label: 'سطح', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _Highlight( + icon: Icons.emoji_events, + value: '${profile.trophies}', + label: 'جام', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _Highlight( + icon: Icons.military_tech, + value: '${profile.rankPoints}', + label: 'امتیاز', + ), + ), + ], + ); + } +} + +class _Highlight extends StatelessWidget { + final IconData icon; + final String value; + final String label; + const _Highlight({ + required this.icon, + required this.value, + required this.label, + }); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: AppColors.panel.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.goldFaint), + ), + child: Column( + children: [ + Icon(icon, color: AppColors.gold, size: 22), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ); + } +} + +/// عنوانِ بخش با آیکون (راست‌چین). +class _SectionHeader extends StatelessWidget { + final IconData icon; + final String title; + const _SectionHeader({required this.icon, required this.title}); + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: AppColors.gold, size: 18), + const SizedBox(width: 8), + GlowText(title, size: 18), + ], + ); + } +} + +/// کاشیِ آماری (آیکون + مقدار + برچسب). accent ⇒ برجسته با قابِ طلایی. +class _StatTile extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final bool accent; + const _StatTile(this.label, this.value, this.icon, {this.accent = false}); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: + accent + ? AppColors.gold.withValues(alpha: 0.14) + : AppColors.panel.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: accent ? AppColors.gold : AppColors.goldFaint, + width: accent ? 1.4 : 1, + ), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.gold, size: 22), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: const TextStyle( + color: Colors.white54, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// ردیفِ تنظیمات (آیکون + برچسب + فلش) با ظاهرِ هماهنگ با سوئیچِ گرافیک. +class _SettingsTile extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _SettingsTile({ + required this.icon, + required this.label, + required this.onTap, + }); + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: AppColors.panel.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.goldFaint), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.gold, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ), + const Icon( + Icons.chevron_left, + color: Colors.white38, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +class _VipBadge extends StatelessWidget { + const _VipBadge(); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFFD54F), AppColors.goldDark], + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'VIP', + style: TextStyle( + color: AppColors.bg, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } +} + +/// شیتِ ویرایش نام و آواتار (با تأیید، مقدار جدید را برمی‌گرداند). +class _EditProfileSheet extends StatefulWidget { + final String name; + final String avatar; + const _EditProfileSheet({required this.name, required this.avatar}); + + @override + State<_EditProfileSheet> createState() => _EditProfileSheetState(); +} + +class _EditProfileSheetState extends State<_EditProfileSheet> { + late final TextEditingController _name; + late final List _seeds; + late String _selected; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.name); + _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); + if (!_seeds.contains(widget.avatar)) _seeds.insert(0, widget.avatar); + _selected = widget.avatar; + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + bool get _valid => _name.text.trim().length >= 2; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + decoration: const BoxDecoration( + color: AppColors.bgDark, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + border: Border(top: BorderSide(color: AppColors.gold, width: 2)), + ), + padding: const EdgeInsets.all(18), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GlowText('ویرایش پروفایل', size: 20), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 2), + ), + child: RandomAvatar(_selected, height: 72, width: 72), + ), + const SizedBox(height: 12), + TextField( + controller: _name, + textAlign: TextAlign.center, + maxLength: 20, + inputFormatters: [LengthLimitingTextInputFormatter(20)], + decoration: const InputDecoration( + hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', + counterText: '', + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 10), + const Align( + alignment: Alignment.centerRight, + child: Text( + 'انتخاب آواتار', + style: TextStyle(color: AppColors.gold), + ), + ), + const SizedBox(height: 8), + GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (final s in _seeds) + GestureDetector( + onTap: () => setState(() => _selected = s), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.panel, + border: Border.all( + color: + _selected == s + ? AppColors.gold + : Colors.transparent, + width: 2.5, + ), + ), + child: RandomAvatar(s), + ), + ), + ], + ), + const SizedBox(height: 16), + GameButton( + label: 'تأیید', + width: double.infinity, + colors: const [AppColors.success, AppColors.successDark], + onTap: + _valid + ? () => Navigator.pop(context, { + 'name': _name.text.trim(), + 'avatar': _selected, + }) + : null, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// سوئیچِ «گرافیک کم‌مصرف»: افکت‌های اتمسفری را برای گوشی‌های ضعیف خاموش می‌کند. +class _GraphicsToggle extends StatelessWidget { + const _GraphicsToggle(); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: AppSettings.I.lowGraphics, + builder: (context, low, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: AppColors.panel.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.goldFaint), + ), + child: Row( + children: [ + const Icon(Icons.bolt, color: AppColors.gold, size: 20), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'گرافیک کم‌مصرف', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ), + Switch( + value: low, + activeThumbColor: AppColors.gold, + onChanged: (v) => AppSettings.I.setLowGraphics(v), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/shop/presentation/screen/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart index d37eebb..e5498de 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -21,6 +21,8 @@ import '../bloc/shop_event.dart'; import '../bloc/shop_state.dart'; import '../bloc/shop_status.dart'; +part 'shop_screen.parts.dart'; + /// فروشگاه با تب‌های سکه/بلیط/کارت/تجهیزات/VIP. class ShopScreen extends StatelessWidget { const ShopScreen({super.key}); @@ -150,846 +152,3 @@ class ShopScreen extends StatelessWidget { ); } } - -class _ShopTabs extends StatelessWidget { - const _ShopTabs(); - @override - Widget build(BuildContext context) { - return const TabBar( - indicator: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.lapis, AppColors.suitDark], - ), - borderRadius: BorderRadius.all(Radius.circular(12)), - ), - indicatorSize: TabBarIndicatorSize.tab, - labelColor: AppColors.gold, - unselectedLabelColor: Colors.white60, - labelStyle: TextStyle(fontWeight: FontWeight.bold), - dividerColor: Colors.transparent, - isScrollable: true, - tabAlignment: TabAlignment.center, - tabs: [ - Tab(text: 'سکه'), - Tab(text: 'بلیط'), - Tab(text: 'کارت'), - Tab(text: 'فرش'), - Tab(text: 'قاب'), - Tab(text: 'تجهیزات'), - Tab(text: 'VIP'), - ], - ); - } -} - -// ===== تب‌ها ===== - -class _CoinsTab extends StatelessWidget { - final List packages; - const _CoinsTab({required this.packages}); - @override - Widget build(BuildContext context) { - return _grid( - note: 'با داشتن اشتراک VIP در هر خرید ۱۰٪ سکه اضافه هدیه می‌گیرید.', - children: [ - // «سکه رایگان» (تبلیغِ ویدیویی-جایزه‌ای) فقط وقتی App ID تپسل تنظیم شده باشد. - if (AppConfig.adsEnabled) - _ItemCard( - title: 'سکه رایگان', - glowColor: AppColors.successDark, - icon: Icons.card_giftcard, - subtitle: 'با دیدن تبلیغ', - action: _PriceButton( - label: 'رایگان', - green: true, - onTap: () => context.read().add(AdRewardEvent()), - ), - ), - for (final p in packages) - _ItemCard( - title: p.title, - glowColor: AppColors.successDark, - icon: Icons.savings, - ribbon: p.bonusPct > 0 ? '+${p.bonusPct}٪' : null, - subtitle: - '${p.coins} سکه${p.vipDays > 0 ? ' + ${p.vipDays} روز VIP' : ''}', - action: _PriceButton( - label: '${p.priceToman} تومان', - onTap: - () => context.read().add( - PurchaseEvent('coin', p.id, sku: p.sku), - ), - ), - ), - ], - ); - } -} - -class _TicketsTab extends StatelessWidget { - final List packages; - const _TicketsTab({required this.packages}); - @override - Widget build(BuildContext context) { - return _grid( - note: 'با خرید بلیط می‌توانید درخواست بر زدن مجدد در بازی‌ها انجام دهید.', - children: [ - for (final p in packages) - _ItemCard( - title: p.title, - glowColor: const Color(0xFF8E1B7A), - icon: Icons.confirmation_number, - subtitle: '${p.tickets} بلیط', - action: _PriceButton( - label: '${p.priceToman} تومان', - onTap: - () => context.read().add( - PurchaseEvent('ticket', p.id, sku: p.sku), - ), - ), - ), - ], - ); - } -} - -class _BoostersTab extends StatelessWidget { - final List boosters; - const _BoostersTab({required this.boosters}); - @override - Widget build(BuildContext context) { - return _grid( - note: 'با بسته‌های تجربه، چند برابر XP بگیرید و سریع‌تر بالا بروید.', - children: [ - for (final b in boosters) - _ItemCard( - title: b.title, - glowColor: const Color(0xFF1E3A8A), - icon: Icons.bolt, - subtitle: 'تجربه ×${b.multiplier} — ${b.hours} ساعت', - action: _PriceButton( - label: '${b.priceToman} تومان', - onTap: - () => context.read().add( - PurchaseEvent('booster', b.id, sku: b.sku), - ), - ), - ), - ], - ); - } -} - -class _VipTab extends StatelessWidget { - final List packages; - const _VipTab({required this.packages}); - @override - Widget build(BuildContext context) { - return _grid( - note: 'با VIP: میز خصوصی نامحدود، آمار کامل و ۱۰٪ سکه‌ی هدیه.', - children: [ - for (final p in packages) - _ItemCard( - title: p.title, - glowColor: const Color(0xFF8A6D00), - icon: Icons.workspace_premium, - ribbon: p.months >= 6 ? 'بهترین' : null, - subtitle: '${p.months} ماه اشتراک', - action: _PriceButton( - label: '${p.priceToman} تومان', - onTap: - () => context.read().add( - PurchaseEvent('vip', p.id, sku: p.sku), - ), - ), - ), - ], - ); - } -} - -class _CardsTab extends StatelessWidget { - final ShopData data; - const _CardsTab({required this.data}); - @override - Widget build(BuildContext context) { - return _grid( - note: 'با اسکین کارت‌های متنوع حال‌وهوای بازی را عوض کن.', - children: [ - for (final c in data.cardSkins) - _ItemCard( - 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), - ), - ], - ); - } - - Widget _cardAction(BuildContext context, CardSkin c, ShopData d) { - if (d.selectedCard == c.id) { - return const _PriceButton(label: 'انتخاب شده', disabled: true); - } - if (d.owns(c.id)) { - return _PriceButton( - label: 'انتخاب', - green: true, - onTap: () => context.read().add(SelectCardEvent(c.id)), - ); - } - return _PriceButton( - label: '${c.priceCoins} سکه', - green: true, - coin: true, - onTap: () => context.read().add(BuyCardEvent(c.id)), - ); - } -} - -/// تبِ فرش‌ها: پیش‌نمایشِ تصویرِ هر فرش از backend با خرید (سکه/VIP) و انتخاب. -/// داده‌ها مستقل از ShopBloc از /api/carpets گرفته می‌شوند. -class _CarpetsTab extends StatefulWidget { - const _CarpetsTab(); - @override - State<_CarpetsTab> createState() => _CarpetsTabState(); -} - -class _CarpetsTabState extends State<_CarpetsTab> { - final _api = locator(); - late Future _future; - String? _busy; - - @override - void initState() { - super.initState(); - _future = _api.getCarpets(); - } - - void _reload() => setState(() { - _future = _api.getCarpets(); - }); - - Future _act(Carpet c, {required bool buy}) async { - setState(() => _busy = c.id); - final err = - buy ? await _api.buyCarpet(c.id) : await _api.selectCarpet(c.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!; - return _grid( - note: 'فرشِ زیرِ میز را عوض کن؛ بعضی فرش‌ها ویژه‌ی VIP هستند.', - children: [ - for (final c in cat.carpets) - _CarpetCard( - carpet: c, - selected: cat.selected == c.id, - busy: _busy == c.id, - onSelect: () => _act(c, buy: false), - onBuy: () => _act(c, buy: true), - ), - ], - ); - }, - ); - } -} - -class _CarpetCard extends StatelessWidget { - final Carpet carpet; - final bool selected; - final bool busy; - final VoidCallback onSelect; - final VoidCallback onBuy; - const _CarpetCard({ - required this.carpet, - required this.selected, - required this.busy, - required this.onSelect, - required this.onBuy, - }); - - @override - Widget build(BuildContext context) { - final c = carpet; - 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( - c.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Expanded(child: _preview(c)), - const SizedBox(height: 6), - SizedBox(width: double.infinity, child: _action(c)), - ], - ), - ); - } - - Widget _preview(Carpet c) { - final frame = ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - c.isClassic - ? Container( - color: const Color(0xFF0E3614), - child: const Center( - child: Icon(Icons.casino, color: AppColors.gold, size: 36), - ), - ) - : Image.network( - c.imageUrl, - fit: BoxFit.cover, - errorBuilder: - (_, __, ___) => Container( - color: AppColors.lapisLo, - child: const Icon( - Icons.image_not_supported, - color: AppColors.goldDark, - ), - ), - ), - ); - return Stack( - fit: StackFit.expand, - children: [ - frame, - if (c.vip && !c.owned) - const Positioned( - top: 4, - left: 4, - child: Chip( - label: Text( - 'VIP', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - backgroundColor: AppColors.goldDark, - padding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ); - } - - Widget _action(Carpet c) { - if (busy) { - return const _PriceButton(label: '...', disabled: true); - } - if (selected) { - return const _PriceButton(label: 'انتخاب شده', disabled: true); - } - if (c.owned) { - return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect); - } - // قفل: VIP رایگان برای VIP (که owned می‌شود)، وگرنه خرید با سکه. - return _PriceButton( - label: '${c.priceCoins} سکه', - green: true, - coin: true, - onTap: onBuy, - ); - } -} - -// ===== تبِ قاب‌های آواتار ===== - -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: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), - child: Text( - note, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.gold, fontSize: 13), - ), - ), - Expanded( - child: GridView.count( - crossAxisCount: 2, - padding: const EdgeInsets.all(12), - childAspectRatio: 0.74, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - children: children, - ), - ), - ], - ); -} - -// ===== ویجت‌های کارت آیتم ===== - -class _ItemCard extends StatelessWidget { - final String title; - final IconData icon; - final Color glowColor; - final String subtitle; - final String? ribbon; - final Widget action; - final Widget? art; // هنرِ سفارشی (مثل پیش‌نمایشِ اسکینِ کارت) به‌جای آیکن - const _ItemCard({ - required this.title, - required this.icon, - required this.glowColor, - required this.subtitle, - required this.action, - this.ribbon, - this.art, - }); - - @override - Widget build(BuildContext context) { - final card = 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: AppColors.gold, width: 1.6), - boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 8, offset: Offset(0, 4)), - ], - ), - child: Column( - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Expanded(child: art ?? _GlowArt(color: glowColor, icon: icon)), - const SizedBox(height: 4), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white70, fontSize: 11), - ), - const SizedBox(height: 6), - SizedBox(width: double.infinity, child: action), - ], - ), - ); - if (ribbon == null) return card; - return Stack( - clipBehavior: Clip.none, - children: [ - card, - Positioned( - top: 8, - left: -22, - child: Transform.rotate( - angle: -0.7, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 3), - color: AppColors.green, - child: Text( - ribbon!, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ); - } -} - -class _GlowArt extends StatelessWidget { - final Color color; - final IconData icon; - const _GlowArt({required this.color, required this.icon}); - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - gradient: RadialGradient( - colors: [color, Colors.black.withValues(alpha: 0.85)], - radius: 0.9, - ), - border: Border.all(color: Colors.black26), - ), - child: Center(child: Icon(icon, size: 44, color: AppColors.gold)), - ); - } -} - -/// پیش‌نمایشِ اسکینِ کارت در فروشگاه: دو کارت فن‌شده (پشت + تک‌خال پیک) -/// که مستقیماً از 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: [AppColors.lapis, 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; - final bool green; - final bool coin; - final bool disabled; - const _PriceButton({ - required this.label, - this.onTap, - this.green = false, - this.coin = false, - this.disabled = false, - }); - - @override - Widget build(BuildContext context) { - final busy = context.select((ShopBloc c) => c.state.busy); - final colors = - disabled - ? const [Color(0xFF555555), Color(0xFF333333)] - : green - ? const [AppColors.success, AppColors.successDark] - : const [ - AppColors.accent, - Color(0xFF7A1019), - ]; // شنگرفِ ایرانی (هم‌رنگِ تم) - return Opacity( - opacity: disabled ? 0.85 : 1, - child: GestureDetector( - onTap: (disabled || busy) ? null : onTap, - child: Container( - height: 36, - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: colors, - ), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.gold, width: 1.2), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (coin) ...[ - const Icon( - Icons.monetization_on, - color: AppColors.gold, - size: 16, - ), - const SizedBox(width: 4), - ], - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/feature/shop/presentation/screen/shop_screen.parts.dart b/lib/feature/shop/presentation/screen/shop_screen.parts.dart new file mode 100644 index 0000000..0edb0c3 --- /dev/null +++ b/lib/feature/shop/presentation/screen/shop_screen.parts.dart @@ -0,0 +1,845 @@ +// ویجت‌های خصوصیِ فروشگاه (تب‌ها و کارت‌های آیتم). +part of 'shop_screen.dart'; + +class _ShopTabs extends StatelessWidget { + const _ShopTabs(); + @override + Widget build(BuildContext context) { + return const TabBar( + indicator: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.lapis, AppColors.suitDark], + ), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + indicatorSize: TabBarIndicatorSize.tab, + labelColor: AppColors.gold, + unselectedLabelColor: Colors.white60, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + dividerColor: Colors.transparent, + isScrollable: true, + tabAlignment: TabAlignment.center, + tabs: [ + Tab(text: 'سکه'), + Tab(text: 'بلیط'), + Tab(text: 'کارت'), + Tab(text: 'فرش'), + Tab(text: 'قاب'), + Tab(text: 'تجهیزات'), + Tab(text: 'VIP'), + ], + ); + } +} + +// ===== تب‌ها ===== + +class _CoinsTab extends StatelessWidget { + final List packages; + const _CoinsTab({required this.packages}); + @override + Widget build(BuildContext context) { + return _grid( + note: 'با داشتن اشتراک VIP در هر خرید ۱۰٪ سکه اضافه هدیه می‌گیرید.', + children: [ + // «سکه رایگان» (تبلیغِ ویدیویی-جایزه‌ای) فقط وقتی App ID تپسل تنظیم شده باشد. + if (AppConfig.adsEnabled) + _ItemCard( + title: 'سکه رایگان', + glowColor: AppColors.successDark, + icon: Icons.card_giftcard, + subtitle: 'با دیدن تبلیغ', + action: _PriceButton( + label: 'رایگان', + green: true, + onTap: () => context.read().add(AdRewardEvent()), + ), + ), + for (final p in packages) + _ItemCard( + title: p.title, + glowColor: AppColors.successDark, + icon: Icons.savings, + ribbon: p.bonusPct > 0 ? '+${p.bonusPct}٪' : null, + subtitle: + '${p.coins} سکه${p.vipDays > 0 ? ' + ${p.vipDays} روز VIP' : ''}', + action: _PriceButton( + label: '${p.priceToman} تومان', + onTap: + () => context.read().add( + PurchaseEvent('coin', p.id, sku: p.sku), + ), + ), + ), + ], + ); + } +} + +class _TicketsTab extends StatelessWidget { + final List packages; + const _TicketsTab({required this.packages}); + @override + Widget build(BuildContext context) { + return _grid( + note: 'با خرید بلیط می‌توانید درخواست بر زدن مجدد در بازی‌ها انجام دهید.', + children: [ + for (final p in packages) + _ItemCard( + title: p.title, + glowColor: const Color(0xFF8E1B7A), + icon: Icons.confirmation_number, + subtitle: '${p.tickets} بلیط', + action: _PriceButton( + label: '${p.priceToman} تومان', + onTap: + () => context.read().add( + PurchaseEvent('ticket', p.id, sku: p.sku), + ), + ), + ), + ], + ); + } +} + +class _BoostersTab extends StatelessWidget { + final List boosters; + const _BoostersTab({required this.boosters}); + @override + Widget build(BuildContext context) { + return _grid( + note: 'با بسته‌های تجربه، چند برابر XP بگیرید و سریع‌تر بالا بروید.', + children: [ + for (final b in boosters) + _ItemCard( + title: b.title, + glowColor: const Color(0xFF1E3A8A), + icon: Icons.bolt, + subtitle: 'تجربه ×${b.multiplier} — ${b.hours} ساعت', + action: _PriceButton( + label: '${b.priceToman} تومان', + onTap: + () => context.read().add( + PurchaseEvent('booster', b.id, sku: b.sku), + ), + ), + ), + ], + ); + } +} + +class _VipTab extends StatelessWidget { + final List packages; + const _VipTab({required this.packages}); + @override + Widget build(BuildContext context) { + return _grid( + note: 'با VIP: میز خصوصی نامحدود، آمار کامل و ۱۰٪ سکه‌ی هدیه.', + children: [ + for (final p in packages) + _ItemCard( + title: p.title, + glowColor: const Color(0xFF8A6D00), + icon: Icons.workspace_premium, + ribbon: p.months >= 6 ? 'بهترین' : null, + subtitle: '${p.months} ماه اشتراک', + action: _PriceButton( + label: '${p.priceToman} تومان', + onTap: + () => context.read().add( + PurchaseEvent('vip', p.id, sku: p.sku), + ), + ), + ), + ], + ); + } +} + +class _CardsTab extends StatelessWidget { + final ShopData data; + const _CardsTab({required this.data}); + @override + Widget build(BuildContext context) { + return _grid( + note: 'با اسکین کارت‌های متنوع حال‌وهوای بازی را عوض کن.', + children: [ + for (final c in data.cardSkins) + _ItemCard( + 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), + ), + ], + ); + } + + Widget _cardAction(BuildContext context, CardSkin c, ShopData d) { + if (d.selectedCard == c.id) { + return const _PriceButton(label: 'انتخاب شده', disabled: true); + } + if (d.owns(c.id)) { + return _PriceButton( + label: 'انتخاب', + green: true, + onTap: () => context.read().add(SelectCardEvent(c.id)), + ); + } + return _PriceButton( + label: '${c.priceCoins} سکه', + green: true, + coin: true, + onTap: () => context.read().add(BuyCardEvent(c.id)), + ); + } +} + +/// تبِ فرش‌ها: پیش‌نمایشِ تصویرِ هر فرش از backend با خرید (سکه/VIP) و انتخاب. +/// داده‌ها مستقل از ShopBloc از /api/carpets گرفته می‌شوند. +class _CarpetsTab extends StatefulWidget { + const _CarpetsTab(); + @override + State<_CarpetsTab> createState() => _CarpetsTabState(); +} + +class _CarpetsTabState extends State<_CarpetsTab> { + final _api = locator(); + late Future _future; + String? _busy; + + @override + void initState() { + super.initState(); + _future = _api.getCarpets(); + } + + void _reload() => setState(() { + _future = _api.getCarpets(); + }); + + Future _act(Carpet c, {required bool buy}) async { + setState(() => _busy = c.id); + final err = + buy ? await _api.buyCarpet(c.id) : await _api.selectCarpet(c.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!; + return _grid( + note: 'فرشِ زیرِ میز را عوض کن؛ بعضی فرش‌ها ویژه‌ی VIP هستند.', + children: [ + for (final c in cat.carpets) + _CarpetCard( + carpet: c, + selected: cat.selected == c.id, + busy: _busy == c.id, + onSelect: () => _act(c, buy: false), + onBuy: () => _act(c, buy: true), + ), + ], + ); + }, + ); + } +} + +class _CarpetCard extends StatelessWidget { + final Carpet carpet; + final bool selected; + final bool busy; + final VoidCallback onSelect; + final VoidCallback onBuy; + const _CarpetCard({ + required this.carpet, + required this.selected, + required this.busy, + required this.onSelect, + required this.onBuy, + }); + + @override + Widget build(BuildContext context) { + final c = carpet; + 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( + c.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Expanded(child: _preview(c)), + const SizedBox(height: 6), + SizedBox(width: double.infinity, child: _action(c)), + ], + ), + ); + } + + Widget _preview(Carpet c) { + final frame = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + c.isClassic + ? Container( + color: const Color(0xFF0E3614), + child: const Center( + child: Icon(Icons.casino, color: AppColors.gold, size: 36), + ), + ) + : Image.network( + c.imageUrl, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => Container( + color: AppColors.lapisLo, + child: const Icon( + Icons.image_not_supported, + color: AppColors.goldDark, + ), + ), + ), + ); + return Stack( + fit: StackFit.expand, + children: [ + frame, + if (c.vip && !c.owned) + const Positioned( + top: 4, + left: 4, + child: Chip( + label: Text( + 'VIP', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppColors.goldDark, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ); + } + + Widget _action(Carpet c) { + if (busy) { + return const _PriceButton(label: '...', disabled: true); + } + if (selected) { + return const _PriceButton(label: 'انتخاب شده', disabled: true); + } + if (c.owned) { + return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect); + } + // قفل: VIP رایگان برای VIP (که owned می‌شود)، وگرنه خرید با سکه. + return _PriceButton( + label: '${c.priceCoins} سکه', + green: true, + coin: true, + onTap: onBuy, + ); + } +} + +// ===== تبِ قاب‌های آواتار ===== + +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: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), + child: Text( + note, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.gold, fontSize: 13), + ), + ), + Expanded( + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + childAspectRatio: 0.74, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: children, + ), + ), + ], + ); +} + +// ===== ویجت‌های کارت آیتم ===== + +class _ItemCard extends StatelessWidget { + final String title; + final IconData icon; + final Color glowColor; + final String subtitle; + final String? ribbon; + final Widget action; + final Widget? art; // هنرِ سفارشی (مثل پیش‌نمایشِ اسکینِ کارت) به‌جای آیکن + const _ItemCard({ + required this.title, + required this.icon, + required this.glowColor, + required this.subtitle, + required this.action, + this.ribbon, + this.art, + }); + + @override + Widget build(BuildContext context) { + final card = 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: AppColors.gold, width: 1.6), + boxShadow: const [ + BoxShadow(color: Colors.black54, blurRadius: 8, offset: Offset(0, 4)), + ], + ), + child: Column( + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Expanded(child: art ?? _GlowArt(color: glowColor, icon: icon)), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + const SizedBox(height: 6), + SizedBox(width: double.infinity, child: action), + ], + ), + ); + if (ribbon == null) return card; + return Stack( + clipBehavior: Clip.none, + children: [ + card, + Positioned( + top: 8, + left: -22, + child: Transform.rotate( + angle: -0.7, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 3), + color: AppColors.green, + child: Text( + ribbon!, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ); + } +} + +class _GlowArt extends StatelessWidget { + final Color color; + final IconData icon; + const _GlowArt({required this.color, required this.icon}); + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: RadialGradient( + colors: [color, Colors.black.withValues(alpha: 0.85)], + radius: 0.9, + ), + border: Border.all(color: Colors.black26), + ), + child: Center(child: Icon(icon, size: 44, color: AppColors.gold)), + ); + } +} + +/// پیش‌نمایشِ اسکینِ کارت در فروشگاه: دو کارت فن‌شده (پشت + تک‌خال پیک) +/// که مستقیماً از 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: [AppColors.lapis, 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; + final bool green; + final bool coin; + final bool disabled; + const _PriceButton({ + required this.label, + this.onTap, + this.green = false, + this.coin = false, + this.disabled = false, + }); + + @override + Widget build(BuildContext context) { + final busy = context.select((ShopBloc c) => c.state.busy); + final colors = + disabled + ? const [Color(0xFF555555), Color(0xFF333333)] + : green + ? const [AppColors.success, AppColors.successDark] + : const [ + AppColors.accent, + Color(0xFF7A1019), + ]; // شنگرفِ ایرانی (هم‌رنگِ تم) + return Opacity( + opacity: disabled ? 0.85 : 1, + child: GestureDetector( + onTap: (disabled || busy) ? null : onTap, + child: Container( + height: 36, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: colors, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.gold, width: 1.2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (coin) ...[ + const Icon( + Icons.monetization_on, + color: AppColors.gold, + size: 16, + ), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.dart b/lib/feature/wallet/presentation/screen/lobby_screen.dart index 9b1820c..e9234dd 100644 --- a/lib/feature/wallet/presentation/screen/lobby_screen.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.dart @@ -16,6 +16,8 @@ import '../bloc/wallet_status.dart'; import '../widgets/coin_fly.dart'; import '../widgets/streak_dialog.dart'; +part 'lobby_screen.parts.dart'; + /// لابی اصلی: کیف‌پول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه. class LobbyScreen extends StatefulWidget { const LobbyScreen({super.key}); @@ -191,454 +193,3 @@ class _LobbyScreenState extends State { if (context.mounted) _reload(); } } - -/// عنوانِ لابی: تاجِ طلایی + نامِ بازی + زیرعنوان. -class _LobbyHeader extends StatelessWidget { - const _LobbyHeader(); - @override - Widget build(BuildContext context) { - return Column( - children: const [ - Icon(Icons.workspace_premium, color: AppColors.gold, size: 34), - SizedBox(height: 4), - GlowText('حاکم ایرانی', size: 36), - SizedBox(height: 2), - Text( - 'بازیِ آنلاینِ حکم', - style: TextStyle(color: Colors.white54, fontSize: 13), - ), - ], - ); - } -} - -/// اکشنِ اصلی: بازیِ سریع (میزِ عمومی) — کارتِ بزرگِ برجسته. -class _PrimaryPlay extends StatelessWidget { - final VoidCallback onTap; - const _PrimaryPlay({required this.onTap}); - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.lapis, AppColors.lapisHi, AppColors.lapisLo], - ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.gold, width: 2), - boxShadow: [ - BoxShadow( - color: AppColors.gold.withValues(alpha: 0.28), - blurRadius: 16, - ), - const BoxShadow( - color: Colors.black54, - blurRadius: 10, - offset: Offset(0, 5), - ), - ], - ), - child: Row( - children: [ - Container( - width: 54, - height: 54, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppColors.gold, AppColors.goldDark], - ), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.lapisInk, - size: 36, - ), - ), - const SizedBox(width: 14), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'بازی سریع', - style: TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - SizedBox(height: 2), - Text( - 'ورود به میزِ عمومی', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), - ], - ), - const Spacer(), - const Icon( - Icons.arrow_forward_ios, - color: AppColors.gold, - size: 18, - ), - ], - ), - ), - ); - } -} - -/// کاشیِ اکشنِ ثانویه در گرید (آیکن + عنوان + زیرعنوان). -class _ActionTile extends StatelessWidget { - final IconData icon; - final String label; - final String sub; - final Color accent; - final VoidCallback onTap; - const _ActionTile({ - required this.icon, - required this.label, - required this.sub, - required this.accent, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(18), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: onTap, - child: Ink( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.lapisHi, AppColors.lapisLo], - ), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.goldDark, width: 1.2), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.18), - shape: BoxShape.circle, - border: Border.all(color: accent.withValues(alpha: 0.6)), - ), - child: Icon(icon, color: accent, size: 22), - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - Text( - sub, - style: const TextStyle(color: Colors.white54, fontSize: 11), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -/// کارتِ هدیه‌ی روزانه با استریک (مثلِ دولینگو): شعله + تعدادِ روزهای پیاپی و -/// وضعیتِ قابلِ‌دریافت/دریافت‌شده. -class _DailyCard extends StatelessWidget { - final WalletEntity? wallet; - final VoidCallback onTap; - const _DailyCard({super.key, required this.wallet, required this.onTap}); - - @override - Widget build(BuildContext context) { - final w = wallet; - final streak = w?.dailyStreak ?? 0; - final ready = w?.dailyReady ?? true; - final next = w?.nextDaily ?? 200; - - final colors = - ready - ? const [AppColors.success, AppColors.successDark] - : const [AppColors.lapisHi, AppColors.lapisLo]; - - return Opacity( - opacity: ready ? 1 : 0.75, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: onTap, // همیشه دیالوگِ استریک را باز می‌کند - child: Ink( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), - decoration: BoxDecoration( - gradient: LinearGradient(colors: colors), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.gold, width: 1.4), - ), - child: Row( - children: [ - // شعله‌ی استریک با عددِ روز. - Stack( - alignment: Alignment.center, - children: [ - Icon( - Icons.local_fire_department, - color: - streak > 0 ? const Color(0xFFFF7A1A) : Colors.white54, - size: 40, - ), - if (streak > 0) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - '$streak', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'هدیه‌ی روزانه', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - streak > 0 - ? '$streak روز پیاپی 🔥' - : 'زنجیره‌ات رو شروع کن!', - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - ), - ), - ], - ), - const Spacer(), - if (ready) - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.monetization_on, - color: AppColors.gold, - size: 16, - ), - const SizedBox(width: 3), - Text( - '+$next', - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Text( - 'دریافت', - style: TextStyle(color: Colors.white70, fontSize: 11), - ), - ], - ) - else - const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle, color: AppColors.gold, size: 18), - SizedBox(width: 4), - Text( - 'امروز گرفتی', - style: TextStyle(color: Colors.white70, fontSize: 12), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - final WalletEntity? wallet; - final VoidCallback onCoinTap; - final Key? coinKey; - const _TopBar({required this.wallet, required this.onCoinTap, this.coinKey}); - - @override - Widget build(BuildContext context) { - final w = wallet; - return Container( - margin: const EdgeInsets.all(8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.bg, AppColors.bgDark], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.goldDark), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)], - ), - child: Row( - children: [ - GestureDetector( - onTap: () async { - await context.push('/profile'); - if (context.mounted) { - context.read().add(LoadWalletEvent()); - } - }, - child: Container( - width: 48, - height: 48, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.panel, - border: Border.all(color: AppColors.gold, width: 2), - ), - child: - (w == null || w.avatar.isEmpty) - ? const Icon(Icons.person, color: AppColors.gold) - : ClipOval(child: RandomAvatar(w.avatar)), - ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - if (w != null && w.name.isNotEmpty) ...[ - Text( - w.name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 6), - ], - if (w?.vip == true) const _VipTag(), - ], - ), - const SizedBox(height: 2), - Row( - children: [ - const Icon(Icons.star, color: AppColors.gold, size: 16), - const SizedBox(width: 4), - Text( - 'سطح ${w?.level ?? '-'}', - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 4), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - width: 96, - height: 7, - child: LinearProgressIndicator( - value: - (w == null || w.xpForNext == 0) - ? 0 - : w.xpIntoLevel / w.xpForNext, - backgroundColor: Colors.white10, - valueColor: const AlwaysStoppedAnimation(AppColors.gold), - ), - ), - ), - ], - ), - const Spacer(), - GestureDetector( - onTap: onCoinTap, - child: StatChip( - icon: Icons.confirmation_number, - value: '${w?.tickets ?? 0}', - ), - ), - const SizedBox(width: 8), - GestureDetector( - key: coinKey, - onTap: onCoinTap, - child: StatChip( - icon: Icons.monetization_on, - value: '${w?.coins ?? 0}', - ), - ), - ], - ), - ); - } -} - -class _VipTag extends StatelessWidget { - const _VipTag(); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFFFD54F), AppColors.goldDark], - ), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'VIP', - style: TextStyle( - color: AppColors.bg, - fontWeight: FontWeight.bold, - fontSize: 10, - ), - ), - ); - } -} diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart b/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart new file mode 100644 index 0000000..a02dbff --- /dev/null +++ b/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart @@ -0,0 +1,453 @@ +// ویجت‌های خصوصیِ لابی (عنوان، دکمه‌ی بازی، کاشی‌ها، کارتِ روزانه، نوارِ بالا). +part of 'lobby_screen.dart'; + +/// عنوانِ لابی: تاجِ طلایی + نامِ بازی + زیرعنوان. +class _LobbyHeader extends StatelessWidget { + const _LobbyHeader(); + @override + Widget build(BuildContext context) { + return Column( + children: const [ + Icon(Icons.workspace_premium, color: AppColors.gold, size: 34), + SizedBox(height: 4), + GlowText('حاکم ایرانی', size: 36), + SizedBox(height: 2), + Text( + 'بازیِ آنلاینِ حکم', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + ], + ); + } +} + +/// اکشنِ اصلی: بازیِ سریع (میزِ عمومی) — کارتِ بزرگِ برجسته. +class _PrimaryPlay extends StatelessWidget { + final VoidCallback onTap; + const _PrimaryPlay({required this.onTap}); + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.lapis, AppColors.lapisHi, AppColors.lapisLo], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.gold, width: 2), + boxShadow: [ + BoxShadow( + color: AppColors.gold.withValues(alpha: 0.28), + blurRadius: 16, + ), + const BoxShadow( + color: Colors.black54, + blurRadius: 10, + offset: Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Container( + width: 54, + height: 54, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.gold, AppColors.goldDark], + ), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.lapisInk, + size: 36, + ), + ), + const SizedBox(width: 14), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + 'بازی سریع', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 2), + Text( + 'ورود به میزِ عمومی', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ), + const Spacer(), + const Icon( + Icons.arrow_forward_ios, + color: AppColors.gold, + size: 18, + ), + ], + ), + ), + ); + } +} + +/// کاشیِ اکشنِ ثانویه در گرید (آیکن + عنوان + زیرعنوان). +class _ActionTile extends StatelessWidget { + final IconData icon; + final String label; + final String sub; + final Color accent; + final VoidCallback onTap; + const _ActionTile({ + required this.icon, + required this.label, + required this.sub, + required this.accent, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: Ink( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.lapisHi, AppColors.lapisLo], + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: AppColors.goldDark, width: 1.2), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.18), + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.6)), + ), + child: Icon(icon, color: accent, size: 22), + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + sub, + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// کارتِ هدیه‌ی روزانه با استریک (مثلِ دولینگو): شعله + تعدادِ روزهای پیاپی و +/// وضعیتِ قابلِ‌دریافت/دریافت‌شده. +class _DailyCard extends StatelessWidget { + final WalletEntity? wallet; + final VoidCallback onTap; + const _DailyCard({super.key, required this.wallet, required this.onTap}); + + @override + Widget build(BuildContext context) { + final w = wallet; + final streak = w?.dailyStreak ?? 0; + final ready = w?.dailyReady ?? true; + final next = w?.nextDaily ?? 200; + + final colors = + ready + ? const [AppColors.success, AppColors.successDark] + : const [AppColors.lapisHi, AppColors.lapisLo]; + + return Opacity( + opacity: ready ? 1 : 0.75, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, // همیشه دیالوگِ استریک را باز می‌کند + child: Ink( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.gold, width: 1.4), + ), + child: Row( + children: [ + // شعله‌ی استریک با عددِ روز. + Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.local_fire_department, + color: + streak > 0 ? const Color(0xFFFF7A1A) : Colors.white54, + size: 40, + ), + if (streak > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '$streak', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'هدیه‌ی روزانه', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + streak > 0 + ? '$streak روز پیاپی 🔥' + : 'زنجیره‌ات رو شروع کن!', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ), + const Spacer(), + if (ready) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.monetization_on, + color: AppColors.gold, + size: 16, + ), + const SizedBox(width: 3), + Text( + '+$next', + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Text( + 'دریافت', + style: TextStyle(color: Colors.white70, fontSize: 11), + ), + ], + ) + else + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: AppColors.gold, size: 18), + SizedBox(width: 4), + Text( + 'امروز گرفتی', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TopBar extends StatelessWidget { + final WalletEntity? wallet; + final VoidCallback onCoinTap; + final Key? coinKey; + const _TopBar({required this.wallet, required this.onCoinTap, this.coinKey}); + + @override + Widget build(BuildContext context) { + final w = wallet; + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.bg, AppColors.bgDark], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.goldDark), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)], + ), + child: Row( + children: [ + GestureDetector( + onTap: () async { + await context.push('/profile'); + if (context.mounted) { + context.read().add(LoadWalletEvent()); + } + }, + child: Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.panel, + border: Border.all(color: AppColors.gold, width: 2), + ), + child: + (w == null || w.avatar.isEmpty) + ? const Icon(Icons.person, color: AppColors.gold) + : ClipOval(child: RandomAvatar(w.avatar)), + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (w != null && w.name.isNotEmpty) ...[ + Text( + w.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + ], + if (w?.vip == true) const _VipTag(), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + const Icon(Icons.star, color: AppColors.gold, size: 16), + const SizedBox(width: 4), + Text( + 'سطح ${w?.level ?? '-'}', + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: 96, + height: 7, + child: LinearProgressIndicator( + value: + (w == null || w.xpForNext == 0) + ? 0 + : w.xpIntoLevel / w.xpForNext, + backgroundColor: Colors.white10, + valueColor: const AlwaysStoppedAnimation(AppColors.gold), + ), + ), + ), + ], + ), + const Spacer(), + GestureDetector( + onTap: onCoinTap, + child: StatChip( + icon: Icons.confirmation_number, + value: '${w?.tickets ?? 0}', + ), + ), + const SizedBox(width: 8), + GestureDetector( + key: coinKey, + onTap: onCoinTap, + child: StatChip( + icon: Icons.monetization_on, + value: '${w?.coins ?? 0}', + ), + ), + ], + ), + ); + } +} + +class _VipTag extends StatelessWidget { + const _VipTag(); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFFD54F), AppColors.goldDark], + ), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'VIP', + style: TextStyle( + color: AppColors.bg, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ); + } +}