feat(calls): Я ЕБЛАН ХАХАХХА Я ШАШКИ ЧЕРЕЗ ЗВОНКИ СДЕЛАЛ
This commit is contained in:
@@ -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<CallChatMessage> _chat = [];
|
||||
final _chatController = StreamController<CallChatMessage>.broadcast();
|
||||
final _gameController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
List<CallChatMessage> get chatLog => List.unmodifiable(_chat);
|
||||
Stream<CallChatMessage> get chatMessages => _chatController.stream;
|
||||
Stream<Map<String, dynamic>> 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<void> _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<String, dynamic>.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<String, dynamic>? _decodeFrame(String text) {
|
||||
try {
|
||||
final v = jsonDecode(text);
|
||||
return v is Map<String, dynamic> ? 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<String, dynamic> 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<String, dynamic> msg, List iceServers) {
|
||||
|
||||
@@ -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<List<int>> _dirs = [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
];
|
||||
|
||||
static List<int> initial() {
|
||||
final board = List<int>.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<List<int>> legalMoves(List<int> board, CheckersSide side) {
|
||||
final captures = <List<int>>[];
|
||||
for (var i = 0; i < board.length; i++) {
|
||||
if (sideOf(board[i]) != side) continue;
|
||||
_collectCaptures(List<int>.of(board), i, side, [i], <int>{}, captures);
|
||||
}
|
||||
if (captures.isNotEmpty) return captures;
|
||||
|
||||
final quiet = <List<int>>[];
|
||||
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<int> work, int at, CheckersSide side,
|
||||
List<int> path, Set<int> captured, List<List<int>> out) {
|
||||
final steps = _captureSteps(work, at, captured);
|
||||
if (steps.isEmpty) {
|
||||
if (path.length > 1) out.add(List<int>.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<List<int>> _captureSteps(
|
||||
List<int> work, int at, Set<int> 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 = <List<int>>[];
|
||||
|
||||
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<int> board, int at, CheckersSide side, List<List<int>> 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<int> applyMove(List<int> board, List<int> path) {
|
||||
final next = List<int>.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<int> board, CheckersSide side) =>
|
||||
board.any((p) => sideOf(p) == side);
|
||||
|
||||
static CheckersSide? winner(List<int> board, CheckersSide toMove) {
|
||||
if (!_hasPieces(board, toMove) || legalMoves(board, toMove).isEmpty) {
|
||||
return opponent(toMove);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<String> _databasesDir() async {
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return dir.path;
|
||||
}
|
||||
return getDatabasesPath();
|
||||
}
|
||||
|
||||
static Future<void> _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<Database> _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),
|
||||
|
||||
@@ -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' : '';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<CallScreen>
|
||||
StreamSubscription<CallSessionState>? _stateSub;
|
||||
StreamSubscription<void>? _infoSub;
|
||||
StreamSubscription<void>? _kometSub;
|
||||
StreamSubscription<CallChatMessage>? _chatSub;
|
||||
StreamSubscription<MediaStream>? _remoteStreamSub;
|
||||
bool _chatOpen = false;
|
||||
CallSessionState _state = CallSessionState.connecting;
|
||||
bool _incomingPending = false;
|
||||
|
||||
@@ -215,6 +218,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
});
|
||||
_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<CallScreen>
|
||||
showCustomNotification(context, 'Этот человек использует Komet! :3');
|
||||
}
|
||||
|
||||
void _onChatMessage(CallChatMessage message) {
|
||||
if (!mounted || message.mine || _chatOpen) return;
|
||||
showCustomNotification(context, message.text);
|
||||
}
|
||||
|
||||
Future<void> _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<CallScreen>
|
||||
_stateSub?.cancel();
|
||||
_infoSub?.cancel();
|
||||
_kometSub?.cancel();
|
||||
_chatSub?.cancel();
|
||||
_remoteStreamSub?.cancel();
|
||||
_dotsController.dispose();
|
||||
_videoController.dispose();
|
||||
@@ -825,15 +843,32 @@ class _CallScreenState extends State<CallScreen>
|
||||
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)
|
||||
|
||||
@@ -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<void> showKometHub(
|
||||
BuildContext context, {
|
||||
required CallSession session,
|
||||
required ColorScheme scheme,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
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<CallChatMessage>? _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<int> _board = Checkers.initial();
|
||||
CheckersSide _turn = CheckersSide.white;
|
||||
List<int> _path = const [];
|
||||
List<List<int>> _legal = const [];
|
||||
CheckersSide? _result;
|
||||
late final CheckersSide _me;
|
||||
StreamSubscription<Map<String, dynamic>>? _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<String, dynamic> 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<int> 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<int> _options() {
|
||||
if (_turn != _me || _result != null) return const {};
|
||||
if (_path.isEmpty) return {for (final p in _legal) p.first};
|
||||
final next = <int>{};
|
||||
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<int> path, List<int> 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<int> a, List<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Locale> _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) {
|
||||
|
||||
Reference in New Issue
Block a user