Add tic tac toe DM game

This commit is contained in:
Janez T
2026-03-05 09:05:18 +01:00
parent 972a9ba944
commit 55a85659d3
4 changed files with 496 additions and 0 deletions

View File

@@ -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<MessagesTab> {
}
}
Future<void> _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<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final contactsProvider = context.read<ContactsProvider>();
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<void> _pickAndSendImage({
@@ -1077,6 +1126,15 @@ class _MessagesTabState extends State<MessagesTab> {
_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();
},
),
],
),
);

View File

@@ -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<String?> 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<TicTacToeEvent> events,
}) {
final board = List<String?>.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<String?> board) {
const lines = <List<int>>[
[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;
}

View File

@@ -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<MessageBubble> {
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),

View File

@@ -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<ConnectionProvider>();
final contacts = context.read<ContactsProvider>();
final messages = context.watch<MessagesProvider>().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>[];
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<void> _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<MessagesProvider>();
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<String?> board;
final bool enabled;
final ValueChanged<int> 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,
),
),
),
);
},
),
);
}
}