From fa45ef0737e4583ee743b6fa16340751e5877176 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Thu, 25 Jun 2026 01:11:34 +0330 Subject: [PATCH] feat: add message feature --- .../game/presentation/bloc/game_bloc.dart | 28 ++++ .../game/presentation/bloc/game_event.dart | 14 ++ .../game/presentation/bloc/game_state.dart | 16 +++ .../game/presentation/screen/game_screen.dart | 19 ++- .../game/presentation/widgets/chat_sheet.dart | 136 ++++++++++++++++++ .../game/presentation/widgets/table_hud.dart | 69 ++++++++- 6 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 lib/feature/game/presentation/widgets/chat_sheet.dart diff --git a/lib/feature/game/presentation/bloc/game_bloc.dart b/lib/feature/game/presentation/bloc/game_bloc.dart index 2d5f0b6..7138203 100644 --- a/lib/feature/game/presentation/bloc/game_bloc.dart +++ b/lib/feature/game/presentation/bloc/game_bloc.dart @@ -17,6 +17,7 @@ class GameBloc extends Bloc { Map _joinAction = const {}; bool _joined = false; + int _bubbleSeq = 0; // شناسه‌ی یکتا برای هر حبابِ پیام (انقضای دقیق) GameBloc(this.repository) : super(const GameUiState()) { _msgSub = @@ -45,6 +46,18 @@ class GameBloc extends Bloc { on( (event, emit) => repository.send({'type': 'play_card', 'card': event.card})); on((event, emit) => repository.send({'type': 'reshuffle'})); + on((event, emit) => repository.send({ + 'type': 'chat', + if (event.text != null) 'text': event.text, + if (event.emoji != null) 'emoji': event.emoji, + })); + on((event, emit) { + final cur = state.chatBubbles[event.seat]; + if (cur == null || cur.id != event.id) return; // حبابِ جدیدتر است ⇒ نگه‌دار + final next = Map.from(state.chatBubbles) + ..remove(event.seat); + emit(state.copyWith(chatBubbles: next)); + }); on((event, emit) => repository.send({'type': 'leave'})); on((event, emit) => repository.send({'type': 'start_table', 'hands': event.hands})); @@ -75,6 +88,19 @@ class GameBloc extends Bloc { emit(state.copyWith(notice: 'یک بازیکن قطع شد')); case 'player_reconnected': emit(state.copyWith(notice: 'بازیکن بازگشت')); + case 'chat': + final seat = (msg['seat'] ?? 0) as int; + final id = ++_bubbleSeq; + final bubble = ChatBubble( + text: msg['text'] as String?, + emoji: msg['emoji'] as String?, + id: id, + ); + final next = Map.from(state.chatBubbles) + ..[seat] = bubble; + emit(state.copyWith(chatBubbles: next)); + // انقضای خودکار پس از ۴ ثانیه (مگر حبابِ جدیدتری روی همان جایگاه بنشیند). + Timer(const Duration(seconds: 4), () => add(ExpireChatEvent(seat, id))); case 'player_left': emit(state.copyWith(notice: 'یک بازیکن میز را ترک کرد')); case 'error': @@ -85,6 +111,8 @@ class GameBloc extends Bloc { // --- متدهای کمکی برای موتور Flame و صفحه‌ها --- void chooseTrump(String suit) => add(ChooseTrumpEvent(suit)); void reshuffle() => add(ReshuffleEvent()); + void sendChatText(String text) => add(SendChatEvent(text: text)); + void sendChatEmoji(String emoji) => add(SendChatEvent(emoji: emoji)); void playCard(String card) => add(PlayCardEvent(card)); void leave() => add(LeaveGameEvent()); void startTable(int hands) => add(StartTableEvent(hands)); diff --git a/lib/feature/game/presentation/bloc/game_event.dart b/lib/feature/game/presentation/bloc/game_event.dart index 4787c1d..b4a9b38 100644 --- a/lib/feature/game/presentation/bloc/game_event.dart +++ b/lib/feature/game/presentation/bloc/game_event.dart @@ -32,6 +32,20 @@ class PlayCardEvent extends GameEvent { class ReshuffleEvent extends GameEvent {} // بُرِ مجدد با مصرفِ یک بلیت +/// ارسالِ پیام/شکلک (یکی از text یا emoji پر است). +class SendChatEvent extends GameEvent { + final String? text; + final String? emoji; + SendChatEvent({this.text, this.emoji}); +} + +/// پاک‌کردنِ حبابِ پیامِ یک جایگاه پس از انقضا (id برای جلوگیری از پاکِ حبابِ جدیدتر). +class ExpireChatEvent extends GameEvent { + final int seat; + final int id; + ExpireChatEvent(this.seat, this.id); +} + class LeaveGameEvent extends GameEvent {} class StartTableEvent extends GameEvent { diff --git a/lib/feature/game/presentation/bloc/game_state.dart b/lib/feature/game/presentation/bloc/game_state.dart index 1c4de6c..75890fd 100644 --- a/lib/feature/game/presentation/bloc/game_state.dart +++ b/lib/feature/game/presentation/bloc/game_state.dart @@ -47,6 +47,15 @@ class TableLobby { '${players.map((p) => '${p.name}${p.host ? '*' : ''}@${p.seat}').join(',')}'; } +/// حبابِ پیام/شکلکِ یک بازیکن که کنارِ آواتارش نشان داده می‌شود. +/// id برای انقضای دقیق (پاک‌کردنِ همین حباب و نه حبابِ جدیدتر) به‌کار می‌رود. +class ChatBubble { + final String? text; + final String? emoji; + final int id; + const ChatBubble({this.text, this.emoji, required this.id}); +} + class GameUiState extends Equatable { final WsStatus connection; final GameState? state; @@ -56,6 +65,7 @@ class GameUiState extends Equatable { final TableLobby? lobby; final int? countdown; final bool tableClosed; + final Map chatBubbles; // جایگاه ⇒ حبابِ پیامِ فعال const GameUiState({ this.connection = WsStatus.connecting, @@ -66,6 +76,7 @@ class GameUiState extends Equatable { this.lobby, this.countdown, this.tableClosed = false, + this.chatBubbles = const {}, }); GameUiState copyWith({ @@ -77,6 +88,7 @@ class GameUiState extends Equatable { TableLobby? lobby, int? countdown, bool? tableClosed, + Map? chatBubbles, bool clearHandResult = false, bool clearNotice = false, }) => @@ -89,6 +101,7 @@ class GameUiState extends Equatable { lobby: lobby ?? this.lobby, countdown: countdown ?? this.countdown, tableClosed: tableClosed ?? this.tableClosed, + chatBubbles: chatBubbles ?? this.chatBubbles, ); @override @@ -101,5 +114,8 @@ class GameUiState extends Equatable { lobby?.sig, countdown, tableClosed, + chatBubbles.entries + .map((e) => '${e.key}:${e.value.id}') + .join(','), ]; } diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 1fad905..805f706 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -16,6 +16,7 @@ import '../../../wallet/presentation/bloc/wallet_status.dart'; import '../../domain/entities/game_entities.dart'; import '../bloc/game_bloc.dart'; import '../bloc/game_state.dart'; +import '../widgets/chat_sheet.dart'; import '../widgets/flame/hokm_game.dart'; import '../widgets/table_hud.dart'; @@ -118,14 +119,9 @@ class _GameScreenState extends State { TableHud( s: state.state!, connection: state.connection, + chatBubbles: state.chatBubbles, onExit: () => _confirmLeave(context), - onChat: - () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('گفتگو به‌زودی'), - duration: Duration(seconds: 1), - ), - ), + onChat: () => _openChat(context), ), if (state.connection == WsStatus.disconnected) _connBanner(), if (_showSearch(state)) _searchPanel(state), @@ -156,6 +152,15 @@ class _GameScreenState extends State { ); } + void _openChat(BuildContext context) { + final bloc = context.read(); + ChatSheet.show( + context, + onText: bloc.sendChatText, + onEmoji: bloc.sendChatEmoji, + ); + } + bool _isMyPlayTurn(GameUiState s) => s.state != null && s.gameOver == null && diff --git a/lib/feature/game/presentation/widgets/chat_sheet.dart b/lib/feature/game/presentation/widgets/chat_sheet.dart new file mode 100644 index 0000000..94c24e7 --- /dev/null +++ b/lib/feature/game/presentation/widgets/chat_sheet.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/app_theme.dart'; + +/// شیتِ «بسته چت»: شکلک‌ها و پیام‌های آماده. با انتخابِ هر مورد، بسته می‌شود و +/// همان لحظه برای همه‌ی میز پخش می‌شود (نمایش کنارِ آواتارِ فرستنده). +class ChatSheet extends StatelessWidget { + final void Function(String text) onText; + final void Function(String emoji) onEmoji; + const ChatSheet({super.key, required this.onText, required this.onEmoji}); + + static const _emojis = [ + '😀', '😂', '🤣', '😎', '😍', '😡', '😭', '👏', + '👍', '🔥', '💪', '🎉', '❤️', '🤔', '🙏', '😴', + ]; + + static const _phrases = [ + 'سلام', 'خسته نباشید', 'آفرین!', 'چه حرکتی!', + 'کارت تمومه', 'حواست کجاست؟', 'عجب شانسی!', 'دمت گرم', + 'بریم بعدی', 'ساکت!', 'خیلی عقبید', 'برادرمی', + 'کُت نشین!', 'حاکم فقط خودم', 'یاد گرفتی؟', 'رحم کن', + ]; + + static Future show( + BuildContext context, { + required void Function(String text) onText, + required void Function(String emoji) onEmoji, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => ChatSheet(onText: onText, onEmoji: onEmoji), + ); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF6A0D1A), AppColors.bgDark], + ), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + border: Border(top: BorderSide(color: AppColors.gold, width: 2)), + ), + padding: const EdgeInsets.only(bottom: 12), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + const Text('بسته چت', + style: TextStyle( + color: AppColors.gold, + fontSize: 18, + fontWeight: FontWeight.bold)), + const TabBar( + indicatorColor: AppColors.gold, + labelColor: AppColors.gold, + unselectedLabelColor: Colors.white54, + tabs: [Tab(text: 'شکلک'), Tab(text: 'پیام‌ها')], + ), + SizedBox( + height: 240, + child: TabBarView( + children: [ + _emojiGrid(context), + _phraseList(context), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _emojiGrid(BuildContext context) => GridView.count( + crossAxisCount: 5, + padding: const EdgeInsets.all(12), + children: [ + for (final e in _emojis) + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + onEmoji(e); + Navigator.pop(context); + }, + child: Center(child: Text(e, style: const TextStyle(fontSize: 34))), + ), + ], + ); + + Widget _phraseList(BuildContext context) => GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + childAspectRatio: 3.2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: [ + for (final p in _phrases) + GestureDetector( + onTap: () { + onText(p); + Navigator.pop(context); + }, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF8E1620), Color(0xFF5A0E14)], + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.goldDark, width: 1.2), + ), + child: Text( + p, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ); +} diff --git a/lib/feature/game/presentation/widgets/table_hud.dart b/lib/feature/game/presentation/widgets/table_hud.dart index d534bdc..c80f40f 100644 --- a/lib/feature/game/presentation/widgets/table_hud.dart +++ b/lib/feature/game/presentation/widgets/table_hud.dart @@ -4,6 +4,7 @@ import 'package:random_avatar/random_avatar.dart'; import '../../../../core/network/ws_client.dart'; import '../../../../core/theme/app_theme.dart'; import '../../domain/entities/game_entities.dart'; +import '../bloc/game_state.dart'; /// اوورلیِ روی صحنه‌ی Flame: آواتارِ بازیکنان با قابِ طلایی، نام، نشانِ رتبه، /// شمارنده‌ی نوبت، تاجِ حاکم و تعدادِ کارت — به‌علاوه‌ی پنلِ امتیاز/حکم (بالا-چپ)، @@ -12,12 +13,14 @@ import '../../domain/entities/game_entities.dart'; class TableHud extends StatelessWidget { final GameState s; final WsStatus connection; + final Map chatBubbles; final VoidCallback onExit; final VoidCallback onChat; const TableHud({ super.key, required this.s, required this.connection, + required this.chatBubbles, required this.onExit, required this.onChat, }); @@ -64,6 +67,7 @@ class TableHud extends StatelessWidget { turnSeconds: s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000 : 30, showRing: s.phase == 'playing' || s.phase == 'choose_trump', + bubble: chatBubbles[p.seat], ), ), ), @@ -219,6 +223,7 @@ class _PlayerSeat extends StatelessWidget { final String turnToken; final double turnSeconds; final bool showRing; + final ChatBubble? bubble; // پیام/شکلکِ فعالِ این بازیکن (یا null) const _PlayerSeat({ required this.player, required this.diameter, @@ -230,6 +235,7 @@ class _PlayerSeat extends StatelessWidget { required this.turnToken, required this.turnSeconds, required this.showRing, + required this.bubble, }); @override @@ -353,13 +359,74 @@ class _PlayerSeat extends StatelessWidget { ], ); - return Column( + 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, + ); } }