From b9a89a4437d8c34795df8d8ddaf67e4ad97e6a54 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sat, 13 Jun 2026 00:42:14 +0700 Subject: [PATCH] =?UTF-8?q?feat(calls):=20=D0=AF=20=D0=95=D0=91=D0=9B?= =?UTF-8?q?=D0=90=D0=9D=20=D0=A5=D0=90=D0=A5=D0=90=D0=A5=D0=A5=D0=90=20?= =?UTF-8?q?=D0=AF=20=D0=A8=D0=90=D0=A8=D0=9A=D0=98=20=D0=A7=D0=95=D0=A0?= =?UTF-8?q?=D0=95=D0=97=20=D0=97=D0=92=D0=9E=D0=9D=D0=9A=D0=98=20=D0=A1?= =?UTF-8?q?=D0=94=D0=95=D0=9B=D0=90=D0=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/calls/call_session.dart | 72 +++ lib/core/games/checkers.dart | 209 +++++++ lib/core/storage/app_database.dart | 30 +- lib/core/storage/app_instance.dart | 9 + lib/core/utils/media_cache.dart | 3 +- lib/frontend/screens/calls/call_screen.dart | 53 +- lib/frontend/screens/calls/komet_hub.dart | 631 ++++++++++++++++++++ lib/main.dart | 4 + 8 files changed, 999 insertions(+), 12 deletions(-) create mode 100644 lib/core/games/checkers.dart create mode 100644 lib/core/storage/app_instance.dart create mode 100644 lib/frontend/screens/calls/komet_hub.dart diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index 9909b4b..ca5da90 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; @@ -35,6 +36,14 @@ class CallParticipant { }); } +class CallChatMessage { + final String text; + final bool mine; + final DateTime time; + + CallChatMessage({required this.text, required this.mine, required this.time}); +} + class CallSession { final Ws2Config ws2Config; @@ -90,6 +99,17 @@ class CallSession { static const String _probeQuestion = 'AreYouKomet?'; static const String _probeAnswer = 'YesImKomet😎'; + final List _chat = []; + final _chatController = StreamController.broadcast(); + final _gameController = StreamController>.broadcast(); + + List get chatLog => List.unmodifiable(_chat); + Stream get chatMessages => _chatController.stream; + Stream> get gameMessages => _gameController.stream; + + int get selfUserId => ws2Config.userId; + int? get peerUserId => _peerId; + bool get localVideo => _localVideo; bool get localScreen => _localScreen; MediaStream? get localVideoStream => _localVideoStream; @@ -160,6 +180,7 @@ class CallSession { Future _sampleLevels() async { final pc = _pc; if (pc == null || _ended) return; + if (!_mediaConnected || _current != CallSessionState.active) return; var local = 0.0; var remote = 0.0; @@ -571,6 +592,21 @@ class CallSession { void _onProbeMessage(RTCDataChannel channel, RTCDataChannelMessage message) { if (message.isBinary) return; final text = message.text; + + final frame = _decodeFrame(text); + if (frame != null && frame['t'] == 'chat') { + final body = frame['text']; + if (body is String && body.isNotEmpty) { + _addChat(CallChatMessage(text: body, mine: false, time: DateTime.now())); + } + return; + } + if (frame != null && frame['t'] == 'game') { + final data = Map.of(frame)..remove('t'); + if (!_gameController.isClosed) _gameController.add(data); + return; + } + if (text == _probeQuestion) { _sendProbe(channel, _probeAnswer); } else if (text == _probeAnswer) { @@ -578,12 +614,46 @@ class CallSession { } } + Map? _decodeFrame(String text) { + try { + final v = jsonDecode(text); + return v is Map ? v : null; + } catch (_) { + return null; + } + } + void _sendProbe(RTCDataChannel channel, String text) { try { channel.send(RTCDataChannelMessage(text)); } catch (_) {} } + void sendChatMessage(String text) { + final body = text.trim(); + final channel = _probeChannel; + if (body.isEmpty || channel == null) return; + try { + channel.send(RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body}))); + } catch (_) { + return; + } + _addChat(CallChatMessage(text: body, mine: true, time: DateTime.now())); + } + + void sendGame(Map data) { + final channel = _probeChannel; + if (channel == null) return; + try { + channel.send(RTCDataChannelMessage(jsonEncode({'t': 'game', ...data}))); + } catch (_) {} + } + + void _addChat(CallChatMessage message) { + _chat.add(message); + if (!_chatController.isClosed) _chatController.add(message); + } + void _markPeerKomet() { if (_peerIsKomet) return; _peerIsKomet = true; @@ -1097,6 +1167,8 @@ class CallSession { if (!_remoteStream.isClosed) await _remoteStream.close(); if (!_info.isClosed) await _info.close(); if (!_kometDetected.isClosed) await _kometDetected.close(); + if (!_chatController.isClosed) await _chatController.close(); + if (!_gameController.isClosed) await _gameController.close(); } void _applyConnectionInfo(Map msg, List iceServers) { diff --git a/lib/core/games/checkers.dart b/lib/core/games/checkers.dart new file mode 100644 index 0000000..798b8d5 --- /dev/null +++ b/lib/core/games/checkers.dart @@ -0,0 +1,209 @@ +enum CheckersSide { white, black } + +class Checkers { + static const int size = 8; + static const int empty = 0; + static const int whiteMan = 1; + static const int whiteKing = 2; + static const int blackMan = 3; + static const int blackKing = 4; + + static const List> _dirs = [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], + ]; + + static List initial() { + final board = List.filled(size * size, empty); + for (var r = 0; r < size; r++) { + for (var c = 0; c < size; c++) { + if ((r + c) % 2 == 0) continue; + final i = r * size + c; + if (r <= 2) board[i] = blackMan; + if (r >= 5) board[i] = whiteMan; + } + } + return board; + } + + static CheckersSide? sideOf(int piece) { + if (piece == whiteMan || piece == whiteKing) return CheckersSide.white; + if (piece == blackMan || piece == blackKing) return CheckersSide.black; + return null; + } + + static bool isKing(int piece) => piece == whiteKing || piece == blackKing; + + static CheckersSide opponent(CheckersSide side) => + side == CheckersSide.white ? CheckersSide.black : CheckersSide.white; + + static int _row(int i) => i ~/ size; + static int _col(int i) => i % size; + static bool _inB(int r, int c) => r >= 0 && r < size && c >= 0 && c < size; + static int _idx(int r, int c) => r * size + c; + static int _lastRow(CheckersSide side) => + side == CheckersSide.white ? 0 : size - 1; + static int _kingOf(CheckersSide side) => + side == CheckersSide.white ? whiteKing : blackKing; + + static List> legalMoves(List board, CheckersSide side) { + final captures = >[]; + for (var i = 0; i < board.length; i++) { + if (sideOf(board[i]) != side) continue; + _collectCaptures(List.of(board), i, side, [i], {}, captures); + } + if (captures.isNotEmpty) return captures; + + final quiet = >[]; + for (var i = 0; i < board.length; i++) { + if (sideOf(board[i]) != side) continue; + _collectQuiet(board, i, side, quiet); + } + return quiet; + } + + static void _collectCaptures(List work, int at, CheckersSide side, + List path, Set captured, List> out) { + final steps = _captureSteps(work, at, captured); + if (steps.isEmpty) { + if (path.length > 1) out.add(List.of(path)); + return; + } + final piece = work[at]; + for (final step in steps) { + final landing = step[0]; + final victim = step[1]; + final promote = !isKing(piece) && _row(landing) == _lastRow(side); + final moved = promote ? _kingOf(side) : piece; + + work[at] = empty; + work[landing] = moved; + captured.add(victim); + path.add(landing); + + _collectCaptures(work, landing, side, path, captured, out); + + path.removeLast(); + captured.remove(victim); + work[landing] = empty; + work[at] = piece; + } + } + + static List> _captureSteps( + List work, int at, Set captured) { + final piece = work[at]; + final side = sideOf(piece); + if (side == null) return const []; + final king = isKing(piece); + final r0 = _row(at); + final c0 = _col(at); + final result = >[]; + + for (final d in _dirs) { + var r = r0 + d[0]; + var c = c0 + d[1]; + if (king) { + while (_inB(r, c) && work[_idx(r, c)] == empty) { + r += d[0]; + c += d[1]; + } + if (!_inB(r, c)) continue; + final vi = _idx(r, c); + if (sideOf(work[vi]) == side || captured.contains(vi)) continue; + var lr = r + d[0]; + var lc = c + d[1]; + while (_inB(lr, lc) && work[_idx(lr, lc)] == empty) { + result.add([_idx(lr, lc), vi]); + lr += d[0]; + lc += d[1]; + } + } else { + if (!_inB(r, c)) continue; + final vi = _idx(r, c); + if (work[vi] == empty || + sideOf(work[vi]) == side || + captured.contains(vi)) { + continue; + } + final lr = r + d[0]; + final lc = c + d[1]; + if (_inB(lr, lc) && work[_idx(lr, lc)] == empty) { + result.add([_idx(lr, lc), vi]); + } + } + } + return result; + } + + static void _collectQuiet( + List board, int at, CheckersSide side, List> out) { + final piece = board[at]; + final r0 = _row(at); + final c0 = _col(at); + if (isKing(piece)) { + for (final d in _dirs) { + var r = r0 + d[0]; + var c = c0 + d[1]; + while (_inB(r, c) && board[_idx(r, c)] == empty) { + out.add([at, _idx(r, c)]); + r += d[0]; + c += d[1]; + } + } + } else { + final forward = side == CheckersSide.white ? -1 : 1; + for (final dc in const [-1, 1]) { + final r = r0 + forward; + final c = c0 + dc; + if (_inB(r, c) && board[_idx(r, c)] == empty) { + out.add([at, _idx(r, c)]); + } + } + } + } + + static List applyMove(List board, List path) { + final next = List.of(board); + if (path.length < 2) return next; + final from = path.first; + final side = sideOf(board[from]); + if (side == null) return next; + final piece = board[from]; + next[from] = empty; + var promoted = isKing(piece); + + for (var k = 0; k < path.length - 1; k++) { + final a = path[k]; + final b = path[k + 1]; + final dr = (_row(b) - _row(a)).sign; + final dc = (_col(b) - _col(a)).sign; + var r = _row(a) + dr; + var c = _col(a) + dc; + while (r != _row(b) || c != _col(b)) { + final vi = _idx(r, c); + if (next[vi] != empty && sideOf(next[vi]) != side) { + next[vi] = empty; + } + r += dr; + c += dc; + } + if (_row(b) == _lastRow(side)) promoted = true; + } + + next[path.last] = promoted ? _kingOf(side) : piece; + return next; + } + + static bool _hasPieces(List board, CheckersSide side) => + board.any((p) => sideOf(p) == side); + + static CheckersSide? winner(List board, CheckersSide toMove) { + if (!_hasPieces(board, toMove) || legalMoves(board, toMove).isEmpty) { + return opponent(toMove); + } + return null; + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 93af2bb..8ea71f2 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'package:komet/core/storage/app_instance.dart'; import 'package:komet/core/utils/logger.dart'; import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; class ProfileData { @@ -155,10 +157,34 @@ class AppDatabase { return _db!; } + static Future _databasesDir() async { + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + final dir = await getApplicationSupportDirectory(); + return dir.path; + } + return getDatabasesPath(); + } + + static Future _migrateLegacyDb(String target) async { + if (AppInstance.isNamed) return; + if (!(Platform.isLinux || Platform.isWindows || Platform.isMacOS)) return; + try { + if (await File(target).exists()) return; + final legacy = File(join(await getDatabasesPath(), 'komet.db')); + if (legacy.path == target) return; + if (await legacy.exists()) { + await legacy.copy(target); + logger.i('[db] перенёс komet.db -> $target'); + } + } catch (_) {} + } + static Future _open() async { - final dbPath = await getDatabasesPath(); + final dbPath = await _databasesDir(); + final target = join(dbPath, 'komet${AppInstance.suffix}.db'); + await _migrateLegacyDb(target); return openDatabase( - join(dbPath, 'komet.db'), + target, version: 11, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), diff --git a/lib/core/storage/app_instance.dart b/lib/core/storage/app_instance.dart new file mode 100644 index 0000000..a396841 --- /dev/null +++ b/lib/core/storage/app_instance.dart @@ -0,0 +1,9 @@ +class AppInstance { + AppInstance._(); + + static const String id = String.fromEnvironment('KOMET_INSTANCE'); + + static bool get isNamed => id.isNotEmpty; + + static String get suffix => isNamed ? '_$id' : ''; +} diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index cc274ac..f165f7b 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../config/app_media_cache.dart'; +import '../storage/app_instance.dart'; /// Постоянный дисковый кэш скачанных медиа (файлы, видео). /// @@ -21,7 +22,7 @@ class MediaCache { final cached = _dir; if (cached != null) return cached; final base = await getApplicationSupportDirectory(); - final dir = Directory(p.join(base.path, 'media_cache')); + final dir = Directory(p.join(base.path, 'media_cache${AppInstance.suffix}')); if (!await dir.exists()) { await dir.create(recursive: true); } diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 7e4ef5f..c0aed0f 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -22,6 +22,7 @@ import '../../../core/calls/call_session.dart'; import '../../../core/utils/format.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; +import 'komet_hub.dart'; const Color _kEndRed = Color(0xFFE5484D); const Color _kAcceptGreen = Color(0xFF2EC36B); @@ -52,7 +53,9 @@ class _CallScreenState extends State StreamSubscription? _stateSub; StreamSubscription? _infoSub; StreamSubscription? _kometSub; + StreamSubscription? _chatSub; StreamSubscription? _remoteStreamSub; + bool _chatOpen = false; CallSessionState _state = CallSessionState.connecting; bool _incomingPending = false; @@ -215,6 +218,7 @@ class _CallScreenState extends State }); _remoteStreamSub = session.remoteStreamStream.listen(_attachStream); _kometSub = session.peerKometDetected.listen((_) => _showKometBadge()); + _chatSub = session.chatMessages.listen(_onChatMessage); if (session.peerIsKomet) { WidgetsBinding.instance.addPostFrameCallback((_) => _showKometBadge()); } @@ -229,6 +233,19 @@ class _CallScreenState extends State showCustomNotification(context, 'Этот человек использует Komet! :3'); } + void _onChatMessage(CallChatMessage message) { + if (!mounted || message.mine || _chatOpen) return; + showCustomNotification(context, message.text); + } + + Future _openKometHub() async { + final session = _session; + if (session == null) return; + setState(() => _chatOpen = true); + await showKometHub(context, session: session, scheme: _darkScheme(context)); + if (mounted) setState(() => _chatOpen = false); + } + void _resolveParticipants() { final session = _session; if (session == null) return; @@ -349,6 +366,7 @@ class _CallScreenState extends State _stateSub?.cancel(); _infoSub?.cancel(); _kometSub?.cancel(); + _chatSub?.cancel(); _remoteStreamSub?.cancel(); _dotsController.dispose(); _videoController.dispose(); @@ -825,15 +843,32 @@ class _CallScreenState extends State if (_session != null) Align( alignment: Alignment.centerRight, - child: IconButton( - onPressed: _showInfoSheet, - tooltip: 'О звонке', - icon: Icon( - Symbols.info, - color: cs.onSurface, - weight: 500, - size: 26, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_session?.peerIsKomet == true) + IconButton( + onPressed: _openKometHub, + tooltip: 'Komet', + //TODO: Бля иконку кометы в код дайтtе' мориарти 00. ал.о + icon: Icon( + Symbols.auto_awesome, + color: cs.primary, + weight: 500, + size: 26, + ), + ), + IconButton( + onPressed: _showInfoSheet, + tooltip: 'О звонке', + icon: Icon( + Symbols.info, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), + ], ), ), if (showTimer) diff --git a/lib/frontend/screens/calls/komet_hub.dart b/lib/frontend/screens/calls/komet_hub.dart new file mode 100644 index 0000000..c76d021 --- /dev/null +++ b/lib/frontend/screens/calls/komet_hub.dart @@ -0,0 +1,631 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/calls/call_session.dart'; +import '../../../core/games/checkers.dart'; + +Future showKometHub( + BuildContext context, { + required CallSession session, + required ColorScheme scheme, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: scheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: scheme), + child: _KometHub(session: session), + ), + ); +} + +enum _HubPage { menu, chat, games, checkers } + +class _KometHub extends StatefulWidget { + final CallSession session; + + const _KometHub({required this.session}); + + @override + State<_KometHub> createState() => _KometHubState(); +} + +class _KometHubState extends State<_KometHub> { + _HubPage _page = _HubPage.menu; + + void _go(_HubPage page) => setState(() => _page = page); + + void _back() { + switch (_page) { + case _HubPage.menu: + Navigator.of(context).maybePop(); + break; + case _HubPage.checkers: + _go(_HubPage.games); + break; + case _HubPage.chat: + case _HubPage.games: + _go(_HubPage.menu); + break; + } + } + + String get _title { + switch (_page) { + case _HubPage.menu: + return 'Komet'; + case _HubPage.chat: + return 'Анонимный чат'; + case _HubPage.games: + return 'Игры'; + case _HubPage.checkers: + return 'Шашки'; + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.78, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header(cs), + Flexible(child: _body(cs)), + ], + ), + ), + ), + ); + } + + Widget _header(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), + child: Row( + children: [ + IconButton( + onPressed: _back, + icon: Icon( + _page == _HubPage.menu ? Symbols.close : Symbols.arrow_back, + color: cs.onSurface, + ), + ), + const SizedBox(width: 4), + Text( + _title, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _body(ColorScheme cs) { + switch (_page) { + case _HubPage.menu: + return _menu(cs); + case _HubPage.games: + return _games(cs); + case _HubPage.chat: + return _KometChatView(session: widget.session); + case _HubPage.checkers: + return _CheckersView(session: widget.session); + } + } + + Widget _menu(ColorScheme cs) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _tile(cs, Symbols.forum, 'Чат', 'Анонимные сообщения', + () => _go(_HubPage.chat)), + _tile(cs, Symbols.stadia_controller, 'Игры', 'Сыграть с собеседником', + () => _go(_HubPage.games)), + const SizedBox(height: 12), + ], + ); + } + + Widget _games(ColorScheme cs) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _tile(cs, Symbols.grid_on, 'Шашки', 'Русские шашки', + () => _go(_HubPage.checkers)), + _tile(cs, Symbols.more_horiz, 'Скоро ещё…', 'В разработке', null), + const SizedBox(height: 12), + ], + ); + } + + Widget _tile(ColorScheme cs, IconData icon, String title, String subtitle, + VoidCallback? onTap) { + final enabled = onTap != null; + return ListTile( + onTap: onTap, + enabled: enabled, + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon(icon, color: enabled ? cs.primary : cs.onSurfaceVariant), + ), + title: Text( + title, + style: TextStyle( + color: enabled ? cs.onSurface : cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + trailing: enabled + ? Icon(Symbols.chevron_right, color: cs.onSurfaceVariant) + : null, + ); + } +} + +class _KometChatView extends StatefulWidget { + final CallSession session; + + const _KometChatView({required this.session}); + + @override + State<_KometChatView> createState() => _KometChatViewState(); +} + +class _KometChatViewState extends State<_KometChatView> { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scroll = ScrollController(); + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _sub = widget.session.chatMessages.listen((_) { + if (mounted) setState(() {}); + _scrollToBottom(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scroll.hasClients) return; + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ); + }); + } + + void _send() { + final text = _controller.text.trim(); + if (text.isEmpty) return; + widget.session.sendChatMessage(text); + _controller.clear(); + } + + @override + void dispose() { + _sub?.cancel(); + _controller.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final messages = widget.session.chatLog; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 6), + child: Row( + children: [ + Icon(Symbols.lock, size: 16, color: cs.primary, fill: 1), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Напрямую через звонок, нигде не сохраняется', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + ], + ), + ), + Flexible( + child: messages.isEmpty + ? _empty(cs) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + itemCount: messages.length, + itemBuilder: (_, i) => _bubble(cs, messages[i]), + ), + ), + _input(cs), + ], + ); + } + + Widget _empty(ColorScheme cs) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'Сообщений пока нет', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ), + ); + } + + Widget _bubble(ColorScheme cs, CallChatMessage message) { + return Align( + alignment: message.mine ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 3, horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.72, + ), + decoration: BoxDecoration( + color: message.mine ? cs.primary : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Text( + message.text, + style: TextStyle( + color: message.mine ? cs.onPrimary : cs.onSurface, + fontSize: 15, + height: 1.25, + ), + ), + ), + ); + } + + Widget _input(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _controller, + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + hintText: 'Сообщение…', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + filled: true, + fillColor: cs.surfaceContainerHighest, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _send, + icon: const Icon(Symbols.send, fill: 1), + ), + ], + ), + ); + } +} + +class _CheckersView extends StatefulWidget { + final CallSession session; + + const _CheckersView({required this.session}); + + @override + State<_CheckersView> createState() => _CheckersViewState(); +} + +class _CheckersViewState extends State<_CheckersView> { + List _board = Checkers.initial(); + CheckersSide _turn = CheckersSide.white; + List _path = const []; + List> _legal = const []; + CheckersSide? _result; + late final CheckersSide _me; + StreamSubscription>? _sub; + + @override + void initState() { + super.initState(); + final self = widget.session.selfUserId; + final peer = widget.session.peerUserId ?? (self + 1); + _me = self <= peer ? CheckersSide.white : CheckersSide.black; + _recompute(); + _sub = widget.session.gameMessages.listen(_onGame); + } + + void _recompute() { + _legal = Checkers.legalMoves(_board, _turn); + _result = _legal.isEmpty ? Checkers.opponent(_turn) : null; + } + + void _restart() { + _board = Checkers.initial(); + _turn = CheckersSide.white; + _path = const []; + _recompute(); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + void _onGame(Map data) { + if (data['g'] != 'checkers') return; + if (data['a'] == 'reset') { + setState(_restart); + return; + } + if (data['a'] == 'move') { + final raw = data['path']; + if (raw is! List) return; + _applyMove(raw.map((e) => e as int).toList(), fromRemote: true); + } + } + + void _applyMove(List path, {required bool fromRemote}) { + if (!_legal.any((p) => _listEq(p, path))) return; + setState(() { + _board = Checkers.applyMove(_board, path); + _turn = Checkers.opponent(_turn); + _path = const []; + _recompute(); + }); + if (!fromRemote) { + widget.session.sendGame({'g': 'checkers', 'a': 'move', 'path': path}); + } + } + + void _reset() { + setState(_restart); + widget.session.sendGame({'g': 'checkers', 'a': 'reset'}); + } + + void _onTap(int square) { + if (_turn != _me || _result != null) return; + + if (_path.isEmpty) { + if (_legal.any((p) => p.first == square)) { + setState(() => _path = [square]); + } + return; + } + + final prefix = [..._path, square]; + final matching = _legal.where((p) => _startsWith(p, prefix)).toList(); + if (matching.isEmpty) { + setState(() => + _path = _legal.any((p) => p.first == square) ? [square] : const []); + return; + } + if (matching.any((p) => p.length == prefix.length)) { + _applyMove(prefix, fromRemote: false); + } else { + setState(() => _path = prefix); + } + } + + Set _options() { + if (_turn != _me || _result != null) return const {}; + if (_path.isEmpty) return {for (final p in _legal) p.first}; + final next = {}; + for (final p in _legal) { + if (_startsWith(p, _path) && p.length > _path.length) { + next.add(p[_path.length]); + } + } + return next; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + _status(), + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + TextButton.icon( + onPressed: _reset, + icon: const Icon(Symbols.refresh, size: 20), + label: const Text('Заново'), + ), + ], + ), + const SizedBox(height: 8), + _boardWidget(cs), + const SizedBox(height: 10), + Text( + _me == CheckersSide.white + ? 'Вы играете белыми' + : 'Вы играете чёрными', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ); + } + + String _status() { + final w = _result; + if (w != null) return w == _me ? 'Вы выиграли 🎉' : 'Вы проиграли'; + return _turn == _me ? 'Ваш ход' : 'Ход соперника…'; + } + + Widget _boardWidget(ColorScheme cs) { + final flip = _me == CheckersSide.black; + final options = _options(); + final selected = _path.isNotEmpty ? _path.last : -1; + + return AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: Checkers.size, + ), + itemCount: Checkers.size * Checkers.size, + itemBuilder: (_, i) { + final square = flip ? (Checkers.size * Checkers.size - 1 - i) : i; + final rb = square ~/ Checkers.size; + final cb = square % Checkers.size; + final dark = (rb + cb) % 2 == 1; + return GestureDetector( + onTap: dark ? () => _onTap(square) : null, + child: _cell( + cs, + dark: dark, + piece: _board[square], + option: options.contains(square), + selected: square == selected, + ), + ); + }, + ), + ), + ); + } + + Widget _cell( + ColorScheme cs, { + required bool dark, + required int piece, + required bool option, + required bool selected, + }) { + final base = dark ? cs.surfaceContainerHighest : cs.surfaceContainerLow; + return Container( + color: selected ? cs.primary.withValues(alpha: 0.45) : base, + child: Stack( + alignment: Alignment.center, + children: [ + if (option && piece == Checkers.empty) + FractionallySizedBox( + widthFactor: 0.34, + heightFactor: 0.34, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary.withValues(alpha: 0.55), + ), + ), + ), + if (piece != Checkers.empty) _piece(cs, piece, option), + ], + ), + ); + } + + Widget _piece(ColorScheme cs, int piece, bool option) { + final white = Checkers.sideOf(piece) == CheckersSide.white; + final king = Checkers.isKing(piece); + return FractionallySizedBox( + widthFactor: 0.76, + heightFactor: 0.76, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: white ? const Color(0xFFEDEDED) : const Color(0xFF262626), + border: Border.all( + color: option + ? cs.primary + : (white ? const Color(0xFFB8B8B8) : const Color(0xFF050505)), + width: option ? 2.5 : 1.5, + ), + boxShadow: const [ + BoxShadow(color: Colors.black38, blurRadius: 3, offset: Offset(0, 1)), + ], + ), + child: king + ? Icon( + Symbols.star, + fill: 1, + size: 16, + color: white ? const Color(0xFF8A6D00) : const Color(0xFFE7C200), + ) + : null, + ), + ); + } + + bool _startsWith(List path, List prefix) { + if (path.length < prefix.length) return false; + for (var i = 0; i < prefix.length; i++) { + if (path[i] != prefix[i]) return false; + } + return true; + } + + bool _listEq(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/main.dart b/lib/main.dart index 343f7e5..08dcddf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'core/cache/info_cache.dart'; +import 'core/storage/app_instance.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; @@ -78,6 +79,9 @@ Future _loadInitialLocale() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); + if (AppInstance.isNamed) { + SharedPreferences.setPrefix('flutter.${AppInstance.id}.'); + } await AppDatabase.init(); final activeAccountId = await TokenStorage.getActiveAccountId(); if (activeAccountId != null) {