From 55a85659d385059d897c680cf8d0faa509f2bbe9 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 09:05:18 +0100 Subject: [PATCH] Add tic tac toe DM game --- lib/screens/messages_tab.dart | 58 ++++ lib/utils/tictactoe_message_parser.dart | 163 +++++++++++ lib/widgets/messages/message_bubble.dart | 7 + .../messages/tictactoe_message_bubble.dart | 268 ++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 lib/utils/tictactoe_message_parser.dart create mode 100644 lib/widgets/messages/tictactoe_message_bubble.dart diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index e901ea5..947e939 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -27,6 +27,7 @@ import '../utils/toast_logger.dart'; import '../utils/key_comparison.dart'; import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; +import '../utils/tictactoe_message_parser.dart'; import '../providers/image_provider.dart' as ip; import '../services/image_codec_service.dart'; import '../services/image_preferences.dart'; @@ -428,6 +429,54 @@ class _MessagesTabState extends State { } } + Future _startTicTacToeGame() async { + if (!mounted) return; + if (_destinationType != + MessageDestinationPreferences.destinationTypeContact || + _selectedRecipient == null) { + ToastLogger.warning( + context, + 'Tic-Tac-Toe works only in direct messages. Choose a contact first.', + ); + return; + } + + final connectionProvider = context.read(); + final messagesProvider = context.read(); + final contactsProvider = context.read(); + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error(context, 'Not connected to device'); + return; + } + + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + if (devicePublicKey == null || devicePublicKey.length < 6) { + ToastLogger.error(context, 'Device key unavailable'); + return; + } + + final gameId = List.generate( + 8, + (_) => math.Random.secure().nextInt(16).toRadixString(16), + ).join(); + final starterKey6 = devicePublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final startMessage = TicTacToeMessageParser.encodeStart( + gameId: gameId, + starterKey6: starterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + await _sendToRecipient( + startMessage, + connectionProvider, + messagesProvider, + contactsProvider, + ); + } + // ── Image sending ─────────────────────────────────────────────────────────── Future _pickAndSendImage({ @@ -1077,6 +1126,15 @@ class _MessagesTabState extends State { _pickAndSendImage(source: ImageSource.camera); }, ), + ListTile( + leading: const Icon(Icons.grid_3x3), + title: const Text('Start Tic-Tac-Toe'), + subtitle: const Text('DM only'), + onTap: () { + Navigator.pop(sheetContext); + _startTicTacToeGame(); + }, + ), ], ), ); diff --git a/lib/utils/tictactoe_message_parser.dart b/lib/utils/tictactoe_message_parser.dart new file mode 100644 index 0000000..9156033 --- /dev/null +++ b/lib/utils/tictactoe_message_parser.dart @@ -0,0 +1,163 @@ +enum TicTacToeEventType { start, move } + +class TicTacToeEvent { + final TicTacToeEventType type; + final String gameId; + final String playerKey6; + final int? cell; + final int timestampSec; + + const TicTacToeEvent({ + required this.type, + required this.gameId, + required this.playerKey6, + this.cell, + required this.timestampSec, + }); +} + +class TicTacToeMessageParser { + static const String _prefix = 'TTT1:'; + + static bool isTicTacToe(String text) => text.startsWith(_prefix); + + static TicTacToeEvent? tryParse(String text) { + if (!isTicTacToe(text)) return null; + final body = text.substring(_prefix.length); + final parts = body.split(':'); + if (parts.length < 4) return null; + + final action = parts[0]; + final gameId = parts[1].toLowerCase(); + if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null; + + if (action == 'S' && parts.length == 4) { + final starterKey6 = parts[2].toLowerCase(); + final ts = int.tryParse(parts[3]); + if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null; + if (ts == null || ts <= 0) return null; + return TicTacToeEvent( + type: TicTacToeEventType.start, + gameId: gameId, + playerKey6: starterKey6, + timestampSec: ts, + ); + } + + if (action == 'M' && parts.length == 5) { + final cell = int.tryParse(parts[2]); + final playerKey6 = parts[3].toLowerCase(); + final ts = int.tryParse(parts[4]); + if (cell == null || cell < 0 || cell > 8) return null; + if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null; + if (ts == null || ts <= 0) return null; + return TicTacToeEvent( + type: TicTacToeEventType.move, + gameId: gameId, + playerKey6: playerKey6, + cell: cell, + timestampSec: ts, + ); + } + + return null; + } + + static String encodeStart({ + required String gameId, + required String starterKey6, + required int timestampSec, + }) { + return '$_prefix' + 'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec'; + } + + static String encodeMove({ + required String gameId, + required int cell, + required String playerKey6, + required int timestampSec, + }) { + return '$_prefix' + 'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec'; + } +} + +class TicTacToeGameState { + final String gameId; + final String xPlayerKey6; + final String oPlayerKey6; + final List board; // 'X' / 'O' / null + final String nextSymbol; + final String? winnerSymbol; + + const TicTacToeGameState({ + required this.gameId, + required this.xPlayerKey6, + required this.oPlayerKey6, + required this.board, + required this.nextSymbol, + this.winnerSymbol, + }); + + bool get isDraw => + winnerSymbol == null && board.every((cell) => cell != null); + bool get isFinished => winnerSymbol != null || isDraw; +} + +TicTacToeGameState buildTicTacToeState({ + required String gameId, + required String xPlayerKey6, + required String oPlayerKey6, + required List events, +}) { + final board = List.filled(9, null); + var next = 'X'; + String? winner; + + final sorted = [...events] + ..sort((a, b) => a.timestampSec.compareTo(b.timestampSec)); + + for (final event in sorted) { + if (event.type != TicTacToeEventType.move) continue; + if (winner != null) break; + + final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6; + final cell = event.cell; + if (cell == null) continue; + if (event.playerKey6 != expectedKey) continue; + if (board[cell] != null) continue; + + board[cell] = next; + winner = _computeWinner(board); + next = next == 'X' ? 'O' : 'X'; + } + + return TicTacToeGameState( + gameId: gameId, + xPlayerKey6: xPlayerKey6, + oPlayerKey6: oPlayerKey6, + board: board, + nextSymbol: next, + winnerSymbol: winner, + ); +} + +String? _computeWinner(List board) { + const lines = >[ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (final line in lines) { + final a = board[line[0]]; + if (a == null) continue; + if (a == board[line[1]] && a == board[line[2]]) return a; + } + return null; +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 386ad1c..b349a17 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -22,10 +22,12 @@ import '../../utils/sar_message_parser.dart'; import '../../utils/key_comparison.dart'; import '../../utils/voice_message_parser.dart'; import '../../utils/image_message_parser.dart'; +import '../../utils/tictactoe_message_parser.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; import 'voice_message_bubble.dart'; import 'image_message_bubble.dart'; +import 'tictactoe_message_bubble.dart'; import 'message_trace_sheet.dart'; /// Reusable message bubble widget that displays messages with various types: @@ -2180,6 +2182,11 @@ class _MessageBubbleState extends State { else if (ImageEnvelope.isEnvelope(message.text) && !widget.isCompact) ImageMessageBubble(message: message, isSentByMe: isOwnMessage) + // Tic-Tac-Toe control message content + else if (message.isContactMessage && + TicTacToeMessageParser.isTicTacToe(message.text) && + !widget.isCompact) + TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage) // Regular message content else if (!message.isDrawing || widget.isCompact) Text(message.text, style: Theme.of(context).textTheme.bodyMedium), diff --git a/lib/widgets/messages/tictactoe_message_bubble.dart b/lib/widgets/messages/tictactoe_message_bubble.dart new file mode 100644 index 0000000..6124732 --- /dev/null +++ b/lib/widgets/messages/tictactoe_message_bubble.dart @@ -0,0 +1,268 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../utils/tictactoe_message_parser.dart'; +import '../../utils/toast_logger.dart'; + +class TicTacToeMessageBubble extends StatelessWidget { + final Message message; + final bool isSentByMe; + + const TicTacToeMessageBubble({ + super.key, + required this.message, + required this.isSentByMe, + }); + + @override + Widget build(BuildContext context) { + final event = TicTacToeMessageParser.tryParse(message.text); + if (event == null) { + return const SizedBox.shrink(); + } + + final conn = context.watch(); + final contacts = context.read(); + final messages = context.watch().messages; + final selfKey = conn.deviceInfo.publicKey; + if (selfKey == null || selfKey.length < 6) { + return const Text('Tic-Tac-Toe unavailable'); + } + + final selfKey6 = _key6Hex(selfKey); + final opponent = _resolveOpponentContact( + message: message, + contactsProvider: contacts, + isSentByMe: isSentByMe, + ); + if (opponent == null) { + return const Text('Tic-Tac-Toe: opponent unknown'); + } + final opponentKey6 = _key6Hex(opponent.publicKey); + + final gameEvents = []; + TicTacToeEvent? start; + for (final m in messages) { + if (!m.isContactMessage) continue; + final parsed = TicTacToeMessageParser.tryParse(m.text); + if (parsed == null || parsed.gameId != event.gameId) continue; + if (!_isSameDmThread( + message: m, + selfKey: selfKey, + opponentKey6: opponentKey6, + )) { + continue; + } + if (parsed.type == TicTacToeEventType.start) { + start ??= parsed; + } else { + gameEvents.add(parsed); + } + } + + start ??= event.type == TicTacToeEventType.start ? event : null; + if (start == null) { + return const Text('Tic-Tac-Toe: waiting for start'); + } + + final xPlayer = start.playerKey6; + final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6; + final state = buildTicTacToeState( + gameId: event.gameId, + xPlayerKey6: xPlayer, + oPlayerKey6: oPlayer, + events: gameEvents, + ); + + final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O'; + final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 230), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tic-Tac-Toe · Game ${state.gameId}', + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + _BoardGrid( + board: state.board, + enabled: isMyTurn, + onTapCell: (idx) => _onCellTap( + context: context, + idx: idx, + state: state, + selfKey6: selfKey6, + opponent: opponent, + connectionProvider: conn, + ), + ), + const SizedBox(height: 8), + Text( + _statusText(state: state, mySymbol: mySymbol), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ); + } + + Future _onCellTap({ + required BuildContext context, + required int idx, + required TicTacToeGameState state, + required String selfKey6, + required Contact opponent, + required ConnectionProvider connectionProvider, + }) async { + if (idx < 0 || idx > 8 || state.board[idx] != null) return; + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error(context, 'Not connected to device'); + return; + } + + final messagesProvider = context.read(); + final text = TicTacToeMessageParser.encodeMove( + gameId: state.gameId, + cell: idx, + playerKey6: selfKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move'; + final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey! + .sublist(0, 6); + + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: opponent.publicKey, + ); + messagesProvider.addSentMessage(sentMessage); + + final sent = await connectionProvider.sendTextMessage( + contactPublicKey: opponent.publicKey, + text: text, + messageId: messageId, + contact: opponent, + ); + if (!sent) { + messagesProvider.markMessageFailed(messageId); + if (!context.mounted) return; + ToastLogger.error(context, 'Failed to send Tic-Tac-Toe move'); + } + } + + static String _statusText({ + required TicTacToeGameState state, + required String mySymbol, + }) { + if (state.winnerSymbol != null) { + return state.winnerSymbol == mySymbol ? 'You won' : 'Opponent won'; + } + if (state.isDraw) return 'Draw'; + return state.nextSymbol == mySymbol ? 'Your turn' : 'Opponent turn'; + } + + static String _key6Hex(Uint8List key) => key + .sublist(0, math.min(6, key.length)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toLowerCase(); + + static Contact? _resolveOpponentContact({ + required Message message, + required ContactsProvider contactsProvider, + required bool isSentByMe, + }) { + if (isSentByMe && message.recipientPublicKey != null) { + return contactsProvider.findContactByKey(message.recipientPublicKey!); + } + final sender = message.senderPublicKeyPrefix; + if (sender == null || sender.length < 6) return null; + return contactsProvider.findContactByPrefix( + Uint8List.fromList(sender.sublist(0, 6)), + ); + } + + static bool _isSameDmThread({ + required Message message, + required Uint8List selfKey, + required String opponentKey6, + }) { + final isOwn = message.isSentMessage || message.isFromSelf(selfKey); + if (isOwn) { + final recipient = message.recipientPublicKey; + if (recipient == null || recipient.length < 6) return false; + return _key6Hex(recipient) == opponentKey6; + } + final sender = message.senderPublicKeyPrefix; + if (sender == null || sender.length < 6) return false; + return _key6Hex(sender) == opponentKey6; + } +} + +class _BoardGrid extends StatelessWidget { + final List board; + final bool enabled; + final ValueChanged onTapCell; + + const _BoardGrid({ + required this.board, + required this.enabled, + required this.onTapCell, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 180, + height: 180, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: 9, + itemBuilder: (context, idx) { + final value = board[idx]; + return InkWell( + onTap: enabled && value == null ? () => onTapCell(idx) : null, + borderRadius: BorderRadius.circular(8), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Text( + value ?? '', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + ); + }, + ), + ); + } +}