diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 35db697..7e115ef 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -57,6 +57,7 @@ class Api { _sessionExpiredController.stream; Stream get handshakeSuccessStream => _handshakeSuccessController.stream; + Stream get errorStream => _dispatcher.errorStream; SessionState get state => _sessionState; StreamSubscription? _dataSubscription; diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index f604cbf..5d3efa7 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -190,8 +190,8 @@ class AppDatabase { onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { - await db.execute( - 'ALTER TABLE profile ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0', + await _addColumnIfMissing( + db, 'profile', 'is_active', 'INTEGER NOT NULL DEFAULT 0', ); await db.execute('DROP TABLE IF EXISTS sync_state'); await db.execute(_syncStateSchema); @@ -210,47 +210,33 @@ class AppDatabase { await db.execute(_messagesSchema); } if (oldVersion < 7) { - await db.execute( - 'ALTER TABLE profile ADD COLUMN profile_options TEXT', - ); + await _addColumnIfMissing(db, 'profile', 'profile_options', 'TEXT'); } if (oldVersion < 8) { - await db.execute( - 'ALTER TABLE chats_cache ADD COLUMN participants TEXT', - ); + await _addColumnIfMissing(db, 'chats_cache', 'participants', 'TEXT'); } if (oldVersion < 9) { - await db.execute( - 'ALTER TABLE contacts ADD COLUMN options TEXT', - ); - await db.execute( - 'ALTER TABLE chats_cache ADD COLUMN options TEXT', - ); + await _addColumnIfMissing(db, 'contacts', 'options', 'TEXT'); + await _addColumnIfMissing(db, 'chats_cache', 'options', 'TEXT'); } if (oldVersion < 10) { - await db.execute( - 'ALTER TABLE chats_cache ADD COLUMN owner INTEGER', - ); - await db.execute( - 'ALTER TABLE chats_cache ADD COLUMN admins TEXT', - ); + await _addColumnIfMissing(db, 'chats_cache', 'owner', 'INTEGER'); + await _addColumnIfMissing(db, 'chats_cache', 'admins', 'TEXT'); } if (oldVersion < 11) { await _createIndexes(db); } if (oldVersion < 12) { - await db.execute( - 'ALTER TABLE chats_cache ADD COLUMN last_msg_status TEXT', - ); + await _addColumnIfMissing(db, 'chats_cache', 'last_msg_status', 'TEXT'); } if (oldVersion < 13) { - await db.execute( - 'ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0', + await _addColumnIfMissing( + db, 'messages', 'deleted', 'INTEGER NOT NULL DEFAULT 0', ); } if (oldVersion < 14) { - await db.execute( - 'ALTER TABLE chats_cache ADD COLUMN in_list INTEGER NOT NULL DEFAULT 1', + await _addColumnIfMissing( + db, 'chats_cache', 'in_list', 'INTEGER NOT NULL DEFAULT 1', ); } }, @@ -281,6 +267,18 @@ class AppDatabase { await _createIndexes(db); } + static Future _addColumnIfMissing( + Database db, + String table, + String column, + String definition, + ) async { + final info = await db.rawQuery('PRAGMA table_info($table)'); + final exists = info.any((row) => row['name'] == column); + if (exists) return; + await db.execute('ALTER TABLE $table ADD COLUMN $column $definition'); + } + static Future _createIndexes(Database db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(account_id, chat_id, time DESC)', diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index 8562283..0a5f268 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -17,10 +17,22 @@ class PacketDispatcher { final Map _pushHandlers = {}; final _pushController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); /// Стрим всех входящих пушей (cmd == 1) Stream get pushStream => _pushController.stream; + Stream get errorStream => _errorController.stream; + + static String? _serverErrorText(dynamic payload) { + if (payload is! Map) return null; + for (final key in ['localizedMessage', 'title']) { + final v = payload[key]; + if (v is String && v.trim().isNotEmpty) return v.trim(); + } + return null; + } + Timer? _cleanupTimer; PacketDispatcher() { @@ -60,10 +72,22 @@ class PacketDispatcher { if (packet.cmd == CmdType.ok || packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { + final payloadLog = packet.opcode == Opcode.login + ? '<скрыто: ответ login>' + : payloadForLog(packet.payload); logger.i( - '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $payloadLog}', ); + if (packet.isError) { + final isSessionExpired = packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN'; + final serverText = _serverErrorText(packet.payload); + if (serverText != null && !isSessionExpired) { + _errorController.add(serverText); + } + } + final completer = _pendingRequests.remove(packet.seq); _requestTimestamps.remove(packet.seq); @@ -130,5 +154,6 @@ class PacketDispatcher { _cleanupTimer?.cancel(); clearPending(); _pushController.close(); + _errorController.close(); } } diff --git a/lib/frontend/commands/anim_command.dart b/lib/frontend/commands/anim_command.dart new file mode 100644 index 0000000..321f3f3 --- /dev/null +++ b/lib/frontend/commands/anim_command.dart @@ -0,0 +1,88 @@ +import 'dart:math'; + +import 'slash_command.dart'; + +const double _defaultCooldownSec = 0.15; +const int _minLength = 3; +const List _fillChars = ['#', '@', '%', '&', '*']; + +final Random _rng = Random(); + +String _fill() => _fillChars[_rng.nextInt(_fillChars.length)]; + +Future runAnim1(CommandContext ctx) async { + var cooldownSec = _defaultCooldownSec; + var text = ctx.args; + + if (text.startsWith('{')) { + final match = RegExp(r'^\{\s*([0-9]*\.?[0-9]+)\s*\}\s*').firstMatch(text); + final parsed = match != null ? double.tryParse(match.group(1)!) : null; + if (match == null || parsed == null || parsed < 0) { + ctx.notify('НЕВЕРНЫЙ СИНТАКСИС🚨🚨🚨'); + return; + } + cooldownSec = parsed; + text = text.substring(match.end); + } + + final chars = text.runes.map(String.fromCharCode).toList(); + if (chars.length < _minLength) { + ctx.notify('Минимум $_minLength символа для анимации'); + return; + } + if (!ctx.isOnline()) { + ctx.notify('Нет соединения'); + return; + } + + final cooldown = Duration(milliseconds: (cooldownSec * 1000).round()); + + final frames = _buildFrames(chars); + if (frames.isEmpty) return; + final id = await ctx.postMessage(frames.first); + if (id.isEmpty) return; + + await playFrames(ctx, id, frames, cooldown); +} + +List _buildFrames(List chars) { + final n = chars.length; + final noise = List.generate(n, (_) => _fill()); + final frames = [chars.join()]; + + for (var i = 0; i <= n + 1; i++) { + final sb = StringBuffer(); + for (var p = 0; p < n; p++) { + if (p < i - 1) { + sb.write(noise[p]); + } else if (p == i - 1) { + sb.write('\$'); + } else if (p == i) { + sb.write(chars[p].toUpperCase()); + } else { + sb.write(chars[p]); + } + } + frames.add(sb.toString()); + } + + for (var w = n; w >= 1; w -= 2) { + if (w == 1) { + frames.add('%'); + } else { + final sb = StringBuffer('>'); + for (var k = 0; k < w - 2; k++) { + sb.write(noise[k]); + } + sb.write('<'); + frames.add(sb.toString()); + } + } + + for (var w = n.isEven ? 2 : 1; w <= n; w += 2) { + final start = (n - w) ~/ 2; + frames.add(chars.sublist(start, start + w).join()); + } + + return frames.where((f) => f.trim().isNotEmpty).toList(); +} diff --git a/lib/frontend/commands/command_registry.dart b/lib/frontend/commands/command_registry.dart index 2d16898..ca54a7d 100644 --- a/lib/frontend/commands/command_registry.dart +++ b/lib/frontend/commands/command_registry.dart @@ -1,10 +1,20 @@ +import 'anim_command.dart'; import 'crush_command.dart'; +import 'epsh_files_command.dart'; import 'info_command.dart'; import 'slash_command.dart'; +import 'watching_command.dart'; const List kSlashCommands = [ SlashCommand('/test', '12345 test отображение'), SlashCommand('/info', 'сводка данных о человеке', run: runInfo), + SlashCommand('/anim1', 'анимация текста', run: runAnim1), + SlashCommand('/IAlwaysWatchingYou', '👁️', run: runWatching), + SlashCommand( + '/epshFiles', + 'цензура слов чёрными квадратами {шанс 1-100}', + run: runEpshFiles, + ), SlashCommand( '/crush', 'Тест устойчивости веб клиента макса', @@ -14,8 +24,16 @@ const List kSlashCommands = [ ]; SlashCommand? findSlashCommand(String text) { + final name = text.trimLeft().split(RegExp(r'\s')).first.toLowerCase(); for (final c in kSlashCommands) { - if (c.name == text) return c; + if (c.name.toLowerCase() == name) return c; } return null; } + +String commandArgs(String text) { + final trimmed = text.trimLeft(); + final idx = trimmed.indexOf(RegExp(r'\s')); + if (idx == -1) return ''; + return trimmed.substring(idx + 1).trim(); +} diff --git a/lib/frontend/commands/epsh_files_command.dart b/lib/frontend/commands/epsh_files_command.dart new file mode 100644 index 0000000..d8e7fa5 --- /dev/null +++ b/lib/frontend/commands/epsh_files_command.dart @@ -0,0 +1,37 @@ +import 'dart:math'; + +import 'slash_command.dart'; + +const int _defaultChance = 65; +const String _square = '⬛'; + +final Random _rng = Random(); + +Future runEpshFiles(CommandContext ctx) async { + var chance = _defaultChance; + var text = ctx.args; + + if (text.startsWith('{')) { + final match = RegExp(r'^\{\s*(\d+)\s*\}\s*').firstMatch(text); + final parsed = match != null ? int.tryParse(match.group(1)!) : null; + if (match == null || parsed == null || parsed < 1 || parsed > 100) { + ctx.notify('НЕВЕРНЫЙ СИНТАКСИС🚨🚨🚨'); + return; + } + chance = parsed; + text = text.substring(match.end); + } + + text = text.trim(); + if (text.isEmpty) { + ctx.notify('Нет текста'); + return; + } + + final censored = text.replaceAllMapped(RegExp(r'\S+'), (m) { + final word = m.group(0)!; + return _rng.nextInt(100) < chance ? _square * word.runes.length : word; + }); + + await ctx.postMessage(censored); +} diff --git a/lib/frontend/commands/slash_command.dart b/lib/frontend/commands/slash_command.dart index 5c86436..d6b7201 100644 --- a/lib/frontend/commands/slash_command.dart +++ b/lib/frontend/commands/slash_command.dart @@ -1,13 +1,18 @@ import '../../backend/modules/messages.dart'; +const String kAntiFloodNotification = + 'Упс! МАХ сбросил соединение, кажется, тебе стоит немного помедлить с командами.'; +const Duration _antiFloodNotificationDuration = Duration(seconds: 3); + class CommandContext { final int accountId; final int chatId; final int? otherUserId; + final String args; final MessagesModule messages; final bool Function() isOnline; final bool Function() isActive; - final void Function(String message) notify; + final void Function(String message, {Duration? duration}) notify; final Future Function(String text) postMessage; final Future Function(String id, String text) updateMessage; @@ -15,6 +20,7 @@ class CommandContext { required this.accountId, required this.chatId, required this.otherUserId, + required this.args, required this.messages, required this.isOnline, required this.isActive, @@ -22,6 +28,33 @@ class CommandContext { required this.postMessage, required this.updateMessage, }); + + void notifyAntiFlood() => + notify(kAntiFloodNotification, duration: _antiFloodNotificationDuration); +} + +Future playFrames( + CommandContext ctx, + String id, + List frames, + Duration delay, { + int from = 1, +}) async { + for (var i = from; i < frames.length; i++) { + await Future.delayed(delay); + if (!ctx.isActive()) return false; + if (!ctx.isOnline()) { + ctx.notifyAntiFlood(); + return false; + } + try { + await ctx.updateMessage(id, frames[i]); + } catch (_) { + if (ctx.isActive() && !ctx.isOnline()) ctx.notifyAntiFlood(); + return false; + } + } + return true; } typedef CommandRunner = Future Function(CommandContext ctx); diff --git a/lib/frontend/commands/watching_command.dart b/lib/frontend/commands/watching_command.dart new file mode 100644 index 0000000..433c225 --- /dev/null +++ b/lib/frontend/commands/watching_command.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'slash_command.dart'; + +const Duration _frameDelay = Duration(milliseconds: 100); +const Duration _holdBeforeText = Duration(seconds: 1); +const String _endText = 'I Always Watching You'; + +const String _art = r''' +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$@bd$$$$$$$$$$M&B$$$$$$$$$$$$$$ +$$$$$$$%kdOW$$$$;$t$$$$$kqda$$$$$$$$$$$$ +$$$$$$WwmYX@$$$$$$$$$$$$kCCO*%$$$$$$$$$$ +$$$$$WwJQYXd$$$$$$$$$$$$q0LOqaM&@$@$$$$$ +$$$$BhamCXUUZ$$$$$$$$$$w0QQOqa%W8&&$$$$$ +$$$$$@#dpOUYzX0&$$$$BdO00waoM8@$$$$$$$$$ +$$$$$$$@W*bmwwmmZpqbwpb*&B$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$8&%$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$W$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$%B%$$$$$$$$$$$$$$$ +$$$$$$$$$WaQq$$$$$$$$$@aoMB$$$$$$$$$$$$$ +$$$$$$$kq0Uo$$$$'$l$$B$$OLQh8@$$$$$$$$$$ +$$$$$BkQUuc@$$$$$$$$$$$$wCJmaM$$$$$$$$$$ +$$$$&o0XYzc*$$$$$$$$$$$$OOQOpaW&8$8$$$$$ +$$$$%hhmCYJXq$$$$$$$$$@m0QQOq*B*8#M@$$$$ +$$$$$B*pqQYUzXZ8$$$$&dO00Zboo&B$$$$$$$$$ +$$$$$$$@W*pwwmZZOpqbubkoW8@$$$$$$$$$$$$$ +$$$$$$$$$$$$$$%&%$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$&*oa@$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$u$$$$&ZczLm*%$$$$$$$$$$$$$ +$$$$$$$o$$$$$$$$$$$&OuruXJOk&$$$$$$$$$$$ +$$$$$$h0q$$$$$$$$$$pCunnvzJwbhW$$$$$$$$$ +$$$$$8wJz0@$$$$$$&Ocrxxxuz0wdk&8%&$$$$$$ +$$$$$#dZJzuzwoawJznfjjxvU0mdo&B8WW@$$$$$ +$$$$$$BommYccvurrjrnvzQmpkM8B$$$$$$$$$$$ +$$$$$$$$$@W*abppZwbaWW&B$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$@$X$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$hCJXx0wW$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$%$$$Bc{[[1fuOhB$$$$$$$$$$$$ +$$$$$$$@$$$$$$$$$$x|(/nnzJJzOo$$$$$$$$$$ +$$$$$$dp$$$$$$$$$q(1|txucJ0QOqW8$$$$$$$$ +$$$$@8qJM$$$$$$@zj/|(|jnz0Zwbo#8$&$$$$$$ +$$$$$WdmUvqWWpYutt(|rvvvJmph8%@$(8@$$$$$ +$$$$$$BomwJvcrrfrnxxvvLOdaM%@$$$$$$$$$$$ +$$$$$$$$$@WahpqpwpwaMW&B$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$B@Q0$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$wLXvvqpB$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$b/}[[(jnZMB$$$$$$$$$$$$ +$$$$$$8$$$$$$$$$$hf(1fnvJYUzq8$$$$$$$$$$ +$$$$$@ZM$$$$$$$$$J|{/rrnXQCQOhh$$$$$$$$$ +$$$$88OC@$$$$$$@zjtt(tuvL0ZqboM@8M$$$$$$ +$$$$$#pZYJ#%&pYnf||jnvuzCwd*8@@8UB@$$$$$ +$$$$$$%aqOYzvrjrjrvuzX0qbaWB@$$$$$$$$$$$ +$$$$$$$$$B&Wopppqwd#MWB@$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$hZYnrJZkB$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$@$$oj{-_?(xYh#$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$@8f{[1|nvzzzJhB$$$$$$$$$$ +$$$$$$d$$$$$$$$$mjj1fxruUJJCmh*$$$$$$$$$ +$$$$8&wd$$$$$$$krt|)(jvXQQwqkWM$Bq$$$$$$ +$$$$$#pZQa$$$wUnt|trnuuU0pkM%@@Bk%$$$$$$ +$$$$$$BopOYUcnrxxuvXXYQwh#&B$$$$$$$$$$$$ +$$$$$$$$$B&&*hpddp0MW&%@$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$Moo&k@B$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$dYrj|Xwh%$$$$$$$$$$$$$$$ +$$$$$$$$$$$$%$$@0l'~~[}nQd#@$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$w/11(ruzzXvCb@$$$$$$$$$$ +$$$$$&k$$$$$$$$$U/|1jfuXJCQ0w&M$$$$$$$$$ +$$$$&WZ*$$$$$$BYjjf/fxcQ0Owp##%$Qp$$$$$$ +$$$$$MpOCdB&oJujtfrxnucCwdo&@$$%&@$$$$$$ +$$$$$$B*qqJzzuuvuXXJQZdbo8%$$$$$$$$$$$$$ +$$$$$$$$$$B8&*#M&M8%B@$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$##MkkB%$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$hJXv(rZq&$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$*@Wof<>_?{/zw*8$$$$$$$$$$$$ +$$$$$$@$$$$$$$$$8x{1)juczXcJq&$$$$$$$$$$ +$$$$$@p$$$$$$$$$Zt||tnnzUUCQq*h$$$$$$$$$ +$$$$%MqZ$$$$$$$wxjf1|fcU0Omq*#W$hx$$$$$$ +$$$$$WbwJqB@#OvrftfxuucU0daW@@$8&@$$$$$$ +$$$$$$@#ppLYcuvcvzXUCOpkoW8@$$$$$$$$$$$$ +$$$$$$$$$$@8W#**#%W88B$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$8**Qao8$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$WYrn|uYQo$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$t0r|)1ruXUYLa$$$$$$$$$$$$ +$$$$$$%$$$$$$$$$n{[1{|jzYXJLpW$$$$$$$$$$ +$$$$$@O8$$$$$$8u{[{[}/nUCCZZbh#$$$$$$$$$ +$$$$8oZUZ8$B*v/[}]{1f/nvLmw#BBB$ut$$$$$$ +$$$$$&hwYvunf((()ttjnxvzUmbM%@&W$$$$$$$$ +$$$$$$$WbwOXzXvvuvzJQmqk*&%$$$$$$$$$$$$$ +$$$$$$$$$$@$8MW&%&%B@$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$*kkwqk@$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$wx|)(nuJh%$$$$$$$$$$$$$$ +$$$$$$$$$$$$$@B(^(1(jrvYXYQ*$$$$$$$$$$$$ +$$$$$W$$$$$$$$$Ott[}(|rzJUQZob$$$$$$$$$$ +$$$$$OC$$$$$$Mv]]{}}{fuCQmqdhM$$$$$$$$$$ +$$$BWpCvJkaLx([[[[}1jzUJ0mpM%%$th8$$$$$$ +$$$$B*dJvunt){11)t//xnucCwa8@$&MB$$$$$$$ +$$$$$$%awqYvJUvvvvULmmh*&%@$$$$$$$$$$$$$ +$$$$$$$$$$B8&W&8&BWB$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$MW@$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$BhZwpW$$$$$$$$$$$$$$$ +$$$$$$$%M@$$$$$$$$$$aLcvcUw&$$$$$$$$$$$$ +$$$$$$dCp$$$$$$$$$$$*0vzYwOpb#$$$$$$$$$$ +$$$$$WJXOB$$$$$$$$$$pLYXXJOmpk*&$$$$$$$$ +$$$$#kQzJ0&$$$$$$$%QvuvvcU0wda#@$%$$$$$$ +$$$$BobOUccYOo*o0UuxnnvcY0qk#%$$$X8$$$$$$ +$$$$$@8hpQUzczcvnuuvzU0wk#%B$$$$$$$$$$$$ +$$$$$$$$$B&M*hdqwqa#M8B$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$hm@$$$$$$$$$$MB$$$$$$$$$$$$$$$ +$$$$$$$%pYX@$$$$$$$$$$$@wmwo$$$$$$$$$$$$ +$$$$$$pcxrc$$$$$$$$$$$$$ZCLda%$$$$$$$$$$ +$$$$$*Jvuuna$$$$$$$$$$$kqOJZd*8B$$$$$$$$ +$$$@opZCJcYuC@$$$$$$$B0JLLJLwo8W&$$$$$$$ +$$$$BabUcYvvnncCkkd0CvvzzCLq#%BB$8$$$$$$ +$$$$$$8aw0UzcczYccvvzU0qk#%B$$$$$$$$$$$$ +$$$$$$$$$%&#obdqqZboW&@$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$%$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$#ZqqB$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$#mcv(na$$$$$$$$$$$$MB$$$$$$$$$$$$ +$$$$$@Zzf}1{u&$$$B$$$$$$$$Wko@$$$$$$$$$$ +$$$$@wXnrjjfnB$$$$$$$$$$$$ohoWB$$$$$$$$$ +$$$@WZQQJcuxuUM$$$$$$$$$@kqpaM8W]$$$$$$$ +$$$$%haQXuvUvuncw@$$$$8bmZZb#%8%b#$$$$$$ +$$$$$@WdpOYYzczzUUJLLZmphW%@$$$$$$$$$$$$ +$$$$$$$$$8&*abpqZbk*#8@$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$@M&pwkmpdB$$$$$$$$$$$$$$$$$$ +$$$$$$$$8dZqLJnY{uq$$$$$$$$$$$$$$$$$$$$$ +$$$$$$8pX//[]?++]p`$@$$$|$$$$$$$$$$$$$$$ +$$$$$%hJYf(}__-]U$$$$$$$$$$$$$$$$$$$$$$$ +$$$$hZYXcnnrrrjjC$$$$$$$$$$$$$$$$@$@$$$$ +$$$$8hbmZmOYXcuvXp@$$$$$$$$$$$$$$m8$$$$$ +$$$$$$WkbmJccLJUUUQo$$$$$$$&8@$$$$$$$$$$ +$$$$$$$$%MoqbkbrOwdqdkoMWB$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$@B$$$$$$$$$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$Bq*YqmCqb&$$$$$$$$$$$$$$$$ +$$$$$$$$$%baQXJuOvzJJpY$$$@$$$$$$$$$$$$$ +$$$$$$$88kZXY/t)|j/nh$$$$$$$$$$$$$$$$$$$ +$$$$$@@kZwCvuj/1)|/C$$$$$$$$$$$$$$$$$$$$ +$$$$$@&#dZqLUYYXzvcZ$$$$$$$$$$$$$$$B@$$$$ +$$$$$$@WhMkqq0QCLCQZbB$$$$$$$$$$$$$$$$$$ +$$$$$$$$$%MkwppqZwwqpko$$$@%8B@$$$$$$$$$$ +$$$$$$$$$$$$$$B%8W8&&%B@$$$hmhoZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$<&Wp8$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$akqCZmbY0d%$$$$$$$$$$$$$$$$$$$ +$$$$$$$$*oqYJjxfxYp$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$hwwCzvj/jfq$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$8&dpOLLUzuXk$$$$$$$$$$$$$$$$$B$$$$ +$$$$$$$&oopww0LJCmW$$$$$$$$$$$%@$$$$$$$$ +$$$$$$$$$BMaqpdwmpqa$$$$$$W&B@$$$$$$$$$$ +$$$$$$$$$$$$$$$%8a&&M&8%@$$hmhaZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$%kqO."0CucOZkW$$$$$$$$$$$$$$ +$$$$$$$$BabJL(zjC({Xx/8@$$$$$$$$$$$$$$$$ +$$$$$$$**qCfr1{{}?[{C$$$$$$$$$$$$$$$$$$$ +$$$$$@$bJ0vnxf[][}}O$$$$$$$$$$$$$$$@$$$$ +$$$$$@M*pZwJUvzuvnn&$$$$$$$$$$$$$$$@$$$$ +$$$$$$@WaooqkOQQCUUZ@$$$$$$$$$$@$$$$$$$$ +$$$$$$$$$BWawbqqqwmwp#$$$$@&B$$$$$$$$$$$ +$$$$$$$$$$$$$$@88#8B&W&@$$$hmhaZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$%Wb$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$@#wQtnW$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$p0r}({tB$$$$$$$$$$$$kdB$$$$$$$$$ +$$$$$$$hXn(()||Z$$$$$$$$$$$%kdh8$$$$$$$$ +$$$$$@oLJXcujfrrQ$$$$$$$$@dOOwqo&&$$$$$$ +$$$$$&#qO0nrfrjj(jxQoWWb0UzcULda*k8#B$$$ +$$$$$$@*bqYvxvjrrrrvnnrnvzJwhM88@@@$$$$$ +$$$$$$$$@8#bdbqqO0Lpdkh*8B$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$@$$$@$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$WwB@$$$$$$$$$$$$$$$$$ +$$$$$$$$$$&*oqwZbwZ00M$$$$$$$$$$$$$$$$$$ +$$$$$$$%ao*dqQcXYOvd$$$$$$$$$$$$$$$$$$$$ +$$$$$$%$Mhow0CUYzUC$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$%###kdbwm0QL@$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$@&kqkbqpwdbB$$$$$$BB@$$$$$$$$$$ +$$$$$$$$$$$$$@B%###&MW&M8B@hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$8*&WaB$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$BQnv/vZZ&@$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$@Bpl`+]}/z0h&$$$$$$$$$$$ +$$$$$$$B$$$$$$$$$$kx11(fuzzYCq&$$$$$$$$$ +$$$$$$$wk$$$$$$$$%buftfuuzJJ0OaoB$$$$$$$ +$$$$$@aq0$$$$$$$$dzrf/fncCQ0wpo&@#O@$$$$ +$$$$$$&ddLp$$$$oCujfrnuuvJZqoM%$&Q&@$$$$ +$$$$$$$@#dqJUJzvunnvcXCOdh#WB@$$$$$$$$$$ +$$$$$$$$$$@%W#okkbdk*#8@$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$@pUcuQwa@$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$8r/{{xUmkM$$$$$$$$$$$$ +$$$$$$$@B$$$$$$$$$$Ct|1/rcJ0ma@$$$$$$$$$ +$$$$$$$bm$$$$$$$$$$Lr/jnuXJCQZa88$$$$$$$ +$$$$$@&bLB$$$$$$$$#znrjnXYUQZph*8$a8$$$$ +$$$$$$8bdCa$$$$$$qvuucnncJ0dh*@$@YMB$$$$ +$$$$$$$@MdwLOOQLzuuuvcJ0ma*W8B$@$$$$$$$$ +$$$$$$$$$$@&W#akdddh#W&B%$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$Bo0awdd@$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$hvztzQd*%$$$$$$$$$$$$$ +$$$$$$$BB$$$$$b$@$$J/t{/uzZoW@$$$$$$$$$$ +$$$$$$$ma$$$$$$$$$$dxrruJCLQ0qM$$$$$$$$$ +$$$$$@@Ow$$$$$$$$$$wcvuXYJ0ZZpo*B$@$$$$$ +$$$$$$#amZ$$$$$$$$wJYXzzULZkko$$$J#B$$$$ +$$$$$$$8kpOp%$$8qOUXzUJ0wh#W&B@@$$$$$$$$ +$$$$$$$$$$Wohqpdw0ZdkboW8@$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$@*YpJhwa%$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$*rz//zJW8$$$$$$$$$$$$$ +$$$$$$$M@$$$$$$$$$$0f/|xuuqba8$$$$$$$$$$ +$$$$$$$Q#$$$$$$$$$$knrrcYJ00ZwM@$$$$$$$$ +$$$$$88OO$$$$$$$$$@QvvczUCQZwd#*B$%$$$$$ +$$$$$$#bw0$$$$$$$@OXzXvcYLmha#$$$q%$$$$$ +$$$$$$$8od00o#Wk0JzXXXCmk*MW8B$$$$$$$$$$ +$$$$$$$$$$8#*kqpwmdbka#88@$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$@%pohqbo&$$$$$$$$$$$$$$$$ +$$$$$$$$$$$apz0Q$$$$$$@wwpo$$$$$$$$$$$$$ +$$$$$$$$Mpzrtj$$$$$$$$$$@QQ0q&$$$$$$$$$$ +$$$$$$$WXu|//$$$$$@$$$$@$BCULOh@$$$$$$$$ +$$$$$$BOcxnju$$$$$$$$$$$$@0JLwa#%B$$$$$$ +$$$$$&bp0QXnz#$$$$$$$$$$$dZZ0wbk*&%@$$$$ +$$$$$$8kp0YUYz0$$$$$$$$@bZOmwq*&%@@@$$$$ +$$$$$$$$WamqQUXzCw*WWhqqqkoM&%$$$$$$$$$$ +$$$$$$$$$$$B8W#oobdo#M&%@$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$pkwaC0mXzZwk@$$$$$$$$$$$$$$ +$$$$$$$$$$oLL/un%$$$$$8UzOw#@$$$$$$$$$$$ +$$$$$$$$wOf{1(@$$$$$$$$$@JJcLd$$$$$$$$$$ +$$$$$$$bn/1)($$$$$$$$$$@$@UzU0mW@$$$$$$$ +$$$$$B%Qcnufx$$$$$$$$$$$$@0JCZbM%&$$$$$$ +$$$$$%hpOQYvva$$$$$$$$$$$km0Qmdko&%&@$$$ +$$$$$$@apZYUYzL@$$$$$$$@bOOmZwo&%@@@$$$$ +$$$$$$$$8omp0JUXJwM8&apqphoW&%@$$$$$$$$$ +$$$$$$$$$$$@%&###kk#MW8B$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$hdwaLmmXUQqp%$$$$$$$$$$$$$$ +$$$$$$$$$$#Q0tut#$$$$$@Jc0Z*B$$$$$$$$$$$ +$$$$$$$$pOt{{}a$$$$$$$$$$UUzYZB$$$$$$$$$ +$$$$$$$#c/111B$$$$$%$$$$$$QzUQZ*@$$$$$$$ +$$$$$@&Qzunfr$$$$$$$$$$$$$wJC0bM&W$$$$$$ +$$$$$BadZ0zvup$$$$$$$$$$$8mO0mbh*MW%W$$$ +$$$$$$@*qmYJUzU@$$$$$$$$*mOOZwo8%@@%$$$$ +$$$$$$$$8ompLCUXUmW%%#dqqka*&%@$$$$$$$$$ +$$$$$$$$$$@@%&##*kko*W88@$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$&h*qqh0qpW*$$$$$$$$$$$$$$$ +$$$$$$$$$$@aZvntL%$$$$#Jc0pW$$$$$$$$$$$$ +$$$$$$$$@wz1(}z$$$$$$$$$@UJYUb@$$$$$$$$$ +$$$$$$$$Un{11O$$$$+$k$$$$@0vzCp#@$$$$$$$ +$$$$$$&pcnrfj8$$$$$$$$$$$$dLLCba&%@$$$$$ +$$$$$@#bOCUzvZ$$$$$$$$$$$$p0QZd**WW$%$$$ +$$$$$$@*pmCUQXC@$$$$$$$$@qZOZwkW%B@BB$$$ +$$$$$$$$%amdLCYzJd$$$$#qwwdo*&B$$$$$$$$$ +$$$$$$$$$$$@&MokqOzh*o#&B@$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$qbad#M@$$$$$$$$$$$$$$$$ +$$$$$$$$$$$kOnUrqa@$@#CJwq#@$$$$$$$$$$$$ +$$$$$$$$@pvjx[O$$$$$$$$$kUCUqW$$$$$$$$$$ +$$$$$$$@Ux({1B$$$B`$%$$Z@MYucJo8$$$$$$$$ +$$$$$$%wvrf|j$$$$$$$$$$$$$0JJZboWB@$$$$$ +$$$$$BodLzYzvB$$$$$$$$$$$%Z00Zqko#1$B$$$ +$$$$$$Bohw0YCYo$$$$$$$$$@m0QLZpM%%B8@$$$ +$$$$$$$$WkpZJJXYq@$$$$WbwZmh*M%$$$$$$$$$ +$$$$$$$$$$$8Wodpq1vbhZ*#WBB$$$$$$$$$$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$hmhaZ$kmC$$$$ + +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +$$$$$$$$$$@dq$$$$$$$$$$M8@$$$$$$$$$$$$$$ +$$$$$$$8ddOW$$$$;$/$$@@$dqdM$$$$$$$$$$$$ +$$$$$$&qOJcB$$$$$$$$$$$$kLCO*%$$$$$$$$$$ +$$$$@#wJLXzq$$$$$$$$$$$$qOQZwkW&@@$$$$$$ +$$$$@ohwQXCJO$$$$$$$$$$qO0OOq*BWB8&$$$$$ +$$$$$@Wpd0XUzzZM$$$@@dO00qko#%@$$$$$$$$$ +$$$$$$$$%*pwppZqQpqbQpko&B@$$$$$$$$$$$$$ +$$$$$$$$$$$$$@BB$$@$$$$$$$$hmhaZ$kmC$$$$ +$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ +'''; + +List _buildFrames() { + final all = const LineSplitter().convert(_art); + var start = 0; + while (start < all.length && all[start].trim().isEmpty) { + start++; + } + final lines = all.sublist(start); + + var width = 0; + for (final line in lines) { + if (line.length > width) width = line.length; + } + final padLine = ''.padRight(width, '\$'); + + final frames = []; + final buffer = []; + for (final line in lines) { + buffer.add(line.isEmpty ? padLine : line.padRight(width, '\$')); + if (line.contains(r'Z$kmC')) { + frames.add(buffer.join('\n')); + buffer.clear(); + } + } + return frames; +} + +final List _frames = _buildFrames(); + +Future runWatching(CommandContext ctx) async { + if (!ctx.isOnline()) { + ctx.notify('Нет соединения'); + return; + } + if (_frames.isEmpty) return; + + final id = await ctx.postMessage(_frames.first); + if (id.isEmpty) return; + + final completed = await playFrames(ctx, id, _frames, _frameDelay); + if (!completed) return; + + await Future.delayed(_holdBeforeText); + if (!ctx.isActive()) return; + try { + await ctx.updateMessage(id, _endText); + } catch (_) { + if (ctx.isActive() && !ctx.isOnline()) ctx.notifyAntiFlood(); + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index eed1f9f..7cab827 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -18,6 +18,7 @@ import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/chat_menu_overlay.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -167,6 +168,8 @@ class _ChatScreenState extends State late final AnimationController _attachAnim; late final AnimationController _commandAnim; bool _commandPanelVisible = false; + final ValueNotifier> _commandMatches = + ValueNotifier(const []); String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; @@ -192,6 +195,11 @@ class _ChatScreenState extends State String? _lastMarkedId; final ValueNotifier _otherUnread = ValueNotifier(0); + final ValueNotifier> _selectedIds = ValueNotifier(const {}); + late final AnimationController _selectionAnim; + + bool get _selectionMode => _selectedIds.value.isNotEmpty; + @override void initState() { super.initState(); @@ -214,6 +222,11 @@ class _ChatScreenState extends State vsync: this, duration: const Duration(milliseconds: 200), ); + _selectionAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + reverseDuration: const Duration(milliseconds: 200), + ); AppCommands.current.addListener(_updateCommandPanel); _pushSub = api.pushStream .where( @@ -597,6 +610,9 @@ class _ChatScreenState extends State _attachAnim.dispose(); AppCommands.current.removeListener(_updateCommandPanel); _commandAnim.dispose(); + _selectionAnim.dispose(); + _selectedIds.dispose(); + _commandMatches.dispose(); _messageController.dispose(); _messageFocusNode.dispose(); _scrollController.dispose(); @@ -615,9 +631,26 @@ class _ChatScreenState extends State _updateCommandPanel(); } + List _matchingCommands(String raw) { + if (!AppCommands.current.value) return const []; + final text = raw.trimLeft(); + if (!text.startsWith('/')) return const []; + if (text.contains(RegExp(r'\s'))) return const []; + final query = text.toLowerCase(); + for (final c in kSlashCommands) { + if (!c.hidden && c.name.toLowerCase() == query) return const []; + } + return kSlashCommands + .where((c) => !c.hidden && c.name.toLowerCase().startsWith(query)) + .toList(growable: false); + } + void _updateCommandPanel() { - final show = - AppCommands.current.value && _messageController.text.startsWith('/'); + final matches = _matchingCommands(_messageController.text); + final show = matches.isNotEmpty; + if (show && !listEquals(_commandMatches.value, matches)) { + _commandMatches.value = matches; + } if (show == _commandPanelVisible) return; _commandPanelVisible = show; if (show) { @@ -663,7 +696,14 @@ class _ChatScreenState extends State Widget _buildCommandPanel() { return AnimatedBuilder( animation: _commandAnim, - builder: (context, _) { + child: ValueListenableBuilder>( + valueListenable: _commandMatches, + builder: (context, matches, _) => CommandSuggestionsPanel( + commands: matches, + onSelected: _onCommandSelected, + ), + ), + builder: (context, child) { final t = _commandAnim.value; if (t == 0) return const SizedBox.shrink(); return Padding( @@ -672,7 +712,7 @@ class _ChatScreenState extends State ignoring: t < 1, child: Opacity( opacity: t, - child: CommandSuggestionsPanel(onSelected: _onCommandSelected), + child: child, ), ), ); @@ -846,6 +886,279 @@ class _ChatScreenState extends State _messagesRev.value++; } + void _enterSelection(CachedMessage message) { + if (message.isControl) return; + Haptics.medium(); + if (_selectedIds.value.contains(message.id)) return; + _selectedIds.value = {..._selectedIds.value, message.id}; + _syncSelectionAnim(); + } + + void _toggleSelection(CachedMessage message) { + if (message.isControl) return; + final next = Set.from(_selectedIds.value); + if (!next.remove(message.id)) next.add(message.id); + Haptics.selection(); + _selectedIds.value = next; + _syncSelectionAnim(); + } + + void _clearSelection() { + if (_selectedIds.value.isEmpty) return; + _selectedIds.value = const {}; + _syncSelectionAnim(); + } + + void _syncSelectionAnim() { + if (_selectedIds.value.isEmpty) { + _selectionAnim.reverse(); + } else if (_selectionAnim.status != AnimationStatus.forward && + _selectionAnim.value < 1) { + _selectionAnim.forward(); + } + } + + List _selectedMessages(Set ids) => + _messages.where((m) => ids.contains(m.id)).toList(); + + CachedMessage? _singleCopyableText(Set ids) { + CachedMessage? found; + var textCount = 0; + for (final m in _messages) { + if (!ids.contains(m.id)) continue; + if ((m.text ?? '').isEmpty) continue; + if (++textCount > 1) return null; + found = m; + } + return found; + } + + CachedMessage? _singleEditable(Set ids) { + if (ids.length != 1) return null; + final list = _selectedMessages(ids); + if (list.isEmpty) return null; + return _canEditMessage(list.first) ? list.first : null; + } + + void _copySelected(CachedMessage message) { + final text = message.text; + if (text == null || text.isEmpty) return; + Clipboard.setData(ClipboardData(text: text)); + Haptics.tap(); + showCustomNotification(context, 'Скопировано'); + _clearSelection(); + } + + void _editSelected(CachedMessage message) { + _clearSelection(); + _startEditMessage(message); + } + + Future _deleteSelected() async { + final msgs = _selectedMessages(_selectedIds.value); + if (msgs.isEmpty) return; + + final serverMsgs = + msgs.where((m) => !m.id.startsWith('temp_')).toList(); + if (serverMsgs.isEmpty) { + for (final m in msgs) { + _startDeleteAnimation(m.id); + } + _clearSelection(); + return; + } + + final canForEveryone = serverMsgs.every((m) => m.senderId == _myId); + final forEveryone = await _showDeleteMessageDialog(canForEveryone); + if (forEveryone == null || !mounted) return; + + final ok = await messagesModule.deleteMessages( + widget.chatId, + serverMsgs.map((m) => m.id).toList(), + forEveryone: forEveryone, + ); + if (!mounted) return; + if (!ok) { + Haptics.error(); + showCustomNotification(context, 'Не удалось удалить сообщения'); + return; + } + for (final m in msgs) { + _startDeleteAnimation(m.id); + } + _clearSelection(); + } + + void _replySelected() { + showCustomNotification(context, 'Ответ — пока в разработке'); + } + + void _forwardSelected() { + showCustomNotification(context, 'Пересылка — пока в разработке'); + } + + Widget _buildComposerArea(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _selectionAnim, + builder: (context, child) { + final t = Curves.easeOut.transform( + _selectionAnim.value.clamp(0.0, 1.0), + ); + if (t == 0) return child!; + if (t == 1) return const SizedBox.shrink(); + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: 1 - t, + child: Transform.translate( + offset: Offset(0, 48 * t), + child: Opacity(opacity: 1 - t, child: child), + ), + ), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _attachAnim, + builder: (context, _) { + if (_attachAnim.value == 0) { + return const SizedBox.shrink(); + } + final curve = _attachAnim.status == AnimationStatus.reverse + ? Curves.easeIn + : Curves.easeOut; + final t = curve.transform(_attachAnim.value); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: t, + child: Opacity( + opacity: t, + child: AttachmentPanel( + onClose: () => _showAttachmentPanel.value = false, + onPickFile: _pickAndUploadFile, + onSendById: _sendFileById, + ), + ), + ), + ), + ); + }, + ), + _buildInputArea(context), + ], + ), + ), + AnimatedBuilder( + animation: _selectionAnim, + builder: (context, child) { + final t = Curves.easeOut.transform( + _selectionAnim.value.clamp(0.0, 1.0), + ); + if (t == 0) return const SizedBox.shrink(); + return ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: t, + child: Opacity(opacity: t, child: child), + ), + ); + }, + child: ValueListenableBuilder>( + valueListenable: _selectedIds, + builder: (context, selected, _) => + _buildSelectionBottomBar(cs, selected), + ), + ), + ], + ); + } + + Widget _buildSelectionBottomBar(ColorScheme cs, Set selected) { + final single = selected.length == 1; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + if (single) ...[ + Expanded( + child: _selectionActionPill( + cs, + icon: Symbols.reply, + label: 'Ответить', + iconLeading: false, + onTap: _replySelected, + ), + ), + const SizedBox(width: 12), + ] else + const Spacer(), + Expanded( + child: _selectionActionPill( + cs, + icon: Symbols.forward, + label: 'Переслать', + iconLeading: true, + onTap: _forwardSelected, + ), + ), + ], + ), + ), + ); + } + + Widget _selectionActionPill( + ColorScheme cs, { + required IconData icon, + required String label, + required bool iconLeading, + required VoidCallback onTap, + }) { + final textWidget = Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ); + final iconWidget = Icon(icon, color: cs.onSurface, size: 22, weight: 500); + return GlossyPill( + onTap: onTap, + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + borderRadius: BorderRadius.circular(28), + depth: 8, + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + child: SizedBox( + height: 54, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: iconLeading + ? [iconWidget, const SizedBox(width: 8), textWidget] + : [textWidget, const SizedBox(width: 8), iconWidget], + ), + ), + ); + } + bool _canEditMessage(CachedMessage message) { if (message.senderId != _myId) return false; if (message.id.startsWith('temp_')) return false; @@ -1211,145 +1524,531 @@ class _ChatScreenState extends State ); } - PreferredSizeWidget _materialAppBar(ColorScheme cs) { - return PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, + PreferredSizeWidget _buildAppBar(ColorScheme cs) { + final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final height = glossy ? 76.0 : kToolbarHeight; + return AppBar( + backgroundColor: glossy ? Colors.transparent : cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), + elevation: 0, + toolbarHeight: height, + automaticallyImplyLeading: false, + titleSpacing: 0, + centerTitle: false, + title: SizedBox( + height: height, + child: AnimatedBuilder( + animation: _selectionAnim, + builder: (context, _) { + final t = Curves.easeOut.transform( + _selectionAnim.value.clamp(0.0, 1.0), + ); + return ValueListenableBuilder>( + valueListenable: _selectedIds, + builder: (context, selected, _) => Stack( + fit: StackFit.expand, + children: [ + if (t < 1) + IgnorePointer( + ignoring: t > 0.5, + child: Opacity( + opacity: 1 - t, + child: Transform.translate( + offset: Offset(0, -height * 0.4 * t), + child: glossy + ? _glossyHeaderRow(cs) + : _materialHeaderRow(cs), + ), + ), + ), + if (t > 0) + IgnorePointer( + ignoring: t < 0.5, + child: Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, height * 0.4 * (1 - t)), + child: _selectionTopBar(cs, selected, glossy), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _glossyHeaderRow(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: Row( + children: [ + _backWithBadge( + cs, + SizedBox( + width: 56, + height: 56, + child: GlossyPill( + onTap: () { + if (widget.embedded) { + widget.onClose?.call(); + } else { + Navigator.pop(context); + } + }, + child: Center( + child: Icon( + widget.embedded ? Symbols.close : Symbols.arrow_back, + color: cs.onSurface, + weight: 500, + size: 24, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GlossyPill( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + ), + ), + ), + padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), + child: Row( + children: [ + _withOnlineDot( + cs, + widget.imageUrl.isNotEmpty + ? CircleAvatar( + radius: 22, + backgroundImage: CachedNetworkImageProvider( + widget.imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + : CircleAvatar( + radius: 22, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (chat?.isOfficial ?? false) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], + ), + ValueListenableBuilder( + valueListenable: _headerStatusNotifier, + builder: (context, status, _) => Text( + status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 56, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: _scheduledCount, + builder: (_, count, _) => count > 0 + ? IconButton( + icon: Icon( + Symbols.schedule, + weight: 500, + color: cs.onSurface, + ), + onPressed: _openScheduledMessages, + ) + : const SizedBox.shrink(), + ), + IconButton( + icon: Icon(Symbols.call, weight: 500, color: cs.onSurface), + onPressed: _startCall, + ), + Builder( + builder: (btnContext) => IconButton( + icon: Icon( + Symbols.more_vert, + weight: 500, + color: cs.onSurface, + ), + onPressed: () => _openChatMenu(btnContext), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _materialHeaderRow(ColorScheme cs) { + return Row( + children: [ + _backWithBadge( + cs, + IconButton( + icon: Icon( + widget.embedded ? Symbols.close : Symbols.arrow_back, + weight: 400, + color: cs.onSurface, + ), + onPressed: () { + if (widget.embedded) { + widget.onClose?.call(); + } else { + Navigator.pop(context); + } + }, + ), + ), + Expanded( + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + ), + ), + ), + child: Row( + children: [ + _withOnlineDot( + cs, + widget.imageUrl.isNotEmpty + ? CircleAvatar( + radius: 18, + backgroundImage: CachedNetworkImageProvider( + widget.imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + : CircleAvatar( + radius: 18, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, + ), + ), + ), + dotSize: 11, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (chat?.isOfficial ?? false) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], + ), + ValueListenableBuilder( + valueListenable: _headerStatusNotifier, + builder: (context, status, _) => Text( + status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ), + ), + ], ), ), ), - child: AppBar( - backgroundColor: cs.surfaceContainerHigh, - foregroundColor: cs.onSurface, - elevation: 0, - surfaceTintColor: Colors.transparent, - iconTheme: IconThemeData(color: cs.onSurface), - leading: _backWithBadge( - cs, - IconButton( - icon: Icon( - widget.embedded ? Symbols.close : Symbols.arrow_back, - weight: 400, - ), - onPressed: () { - if (widget.embedded) { - widget.onClose?.call(); - } else { - Navigator.pop(context); - } - }, - ), + ValueListenableBuilder( + valueListenable: _scheduledCount, + builder: (_, count, _) => count > 0 + ? IconButton( + icon: Icon(Symbols.schedule, weight: 400, color: cs.onSurface), + onPressed: _openScheduledMessages, + ) + : const SizedBox.shrink(), + ), + IconButton( + icon: Icon(Symbols.call, weight: 400, color: cs.onSurface), + onPressed: _startCall, + ), + Builder( + builder: (btnContext) => IconButton( + icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface), + onPressed: () => _openChatMenu(btnContext), ), - titleSpacing: 0, - title: Row( - children: [ - _withOnlineDot( - cs, - widget.imageUrl.isNotEmpty - ? CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider( - widget.imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - : CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, - ), - ), - ), - dotSize: 11, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (chat?.isOfficial ?? false) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, - ), - ], - ], - ), - ValueListenableBuilder( - valueListenable: _headerStatusNotifier, - builder: (context, status, _) => Text( - status, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ), - ], + ), + ], + ); + } + + Widget _selectionTopBar(ColorScheme cs, Set selected, bool glossy) { + final count = selected.length; + final copyMsg = _singleCopyableText(selected); + final editMsg = _singleEditable(selected); + final label = 'Выбрано $count'; + + if (!glossy) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.close, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', ), ), - ], - ), - actions: [ - ValueListenableBuilder( - valueListenable: _scheduledCount, - builder: (_, count, _) => count > 0 - ? IconButton( - icon: const Icon(Symbols.schedule, weight: 400), - onPressed: _openScheduledMessages, - ) - : const SizedBox.shrink(), ), + if (copyMsg != null) + IconButton( + icon: Icon(Symbols.content_copy, color: cs.onSurface), + onPressed: () => _copySelected(copyMsg), + ), + if (editMsg != null) + IconButton( + icon: Icon(Symbols.edit, color: cs.onSurface), + onPressed: () => _editSelected(editMsg), + ), IconButton( - icon: const Icon(Symbols.call, weight: 400), - onPressed: _startCall, - ), - IconButton( - icon: const Icon(Symbols.more_vert, weight: 400), - onPressed: () {}, + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _deleteSelected, ), ], ), + ); + } + + Widget actionBtn(IconData icon, VoidCallback onTap) => IconButton( + icon: Icon(icon, weight: 500, color: cs.onSurface), + onPressed: onTap, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: Row( + children: [ + SizedBox( + width: 56, + height: 56, + child: GlossyPill( + onTap: _clearSelection, + child: Center( + child: Icon( + Symbols.close, + color: cs.onSurface, + weight: 500, + size: 24, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SizedBox( + height: 56, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 56, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (copyMsg != null) + actionBtn(Symbols.content_copy, () => _copySelected(copyMsg)), + if (editMsg != null) + actionBtn(Symbols.edit, () => _editSelected(editMsg)), + actionBtn(Symbols.delete, _deleteSelected), + ], + ), + ), + ), + ], ), ); } + void _openChatMenu(BuildContext btnContext) { + final box = btnContext.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final anchorRect = box.localToGlobal(Offset.zero) & box.size; + showChatMenu( + context: context, + anchorRect: anchorRect, + items: [ + ChatMenuItem( + icon: Symbols.volume_up, + label: 'Уведомления', + showChevron: true, + dividerAfter: true, + onTap: () {}, + ), + ChatMenuItem( + icon: Symbols.videocam, + label: 'Видеозвонок', + onTap: () {}, + ), + ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: () {}), + ChatMenuItem( + icon: Symbols.wallpaper, + label: 'Изменить обои', + onTap: () {}, + ), + ChatMenuItem( + icon: Symbols.mop, + label: 'Очистить историю', + onTap: () {}, + ), + ChatMenuItem( + icon: Symbols.delete, + label: 'Удалить чат', + onTap: () {}, + ), + ], + ); + } + Future _startCall() async { if (widget.chatType != 'DIALOG') { showCustomNotification(context, 'Звонки доступны только в диалогах'); @@ -1502,12 +2201,19 @@ class _ChatScreenState extends State final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; - if (AppCommands.current.value) { + if (AppCommands.current.value && text.startsWith('/')) { final command = findSlashCommand(text); - if (command?.run != null) { + if (command == null) { _messageController.clear(); _hasText.value = false; - unawaited(command!.run!(_commandContext())); + showCustomNotification(context, 'ТАКОЙ КОМАНДЫ НЕТУ🚨🚨🚨'); + return; + } + if (command.run != null) { + final args = commandArgs(text); + _messageController.clear(); + _hasText.value = false; + unawaited(command.run!(_commandContext(args))); return; } } @@ -1735,15 +2441,16 @@ class _ChatScreenState extends State } } - CommandContext _commandContext() => CommandContext( + CommandContext _commandContext(String args) => CommandContext( accountId: _myId, chatId: widget.chatId, otherUserId: _resolveOtherId(), + args: args, messages: messagesModule, isOnline: () => api.state == SessionState.online, isActive: () => mounted, - notify: (message) { - if (mounted) showCustomNotification(context, message); + notify: (message, {duration}) { + if (mounted) showCustomNotification(context, message, duration: duration); }, postMessage: _postCommandMessage, updateMessage: _updateCommandMessage, @@ -2102,7 +2809,16 @@ class _ChatScreenState extends State final bottomInset = _keyboardReserve > 0 ? math.max(mq.viewInsets.bottom, _keyboardReserve) : mq.viewInsets.bottom; - return MediaQuery( + return ValueListenableBuilder>( + valueListenable: _selectedIds, + builder: (context, selected, child) => PopScope( + canPop: selected.isEmpty, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _clearSelection(); + }, + child: child!, + ), + child: MediaQuery( data: mq.copyWith( viewInsets: mq.viewInsets.copyWith(bottom: bottomInset), ), @@ -2119,189 +2835,7 @@ class _ChatScreenState extends State ), child: Scaffold( backgroundColor: cs.surface, - appBar: AppVisualStyle.current.value == VisualStyle.glossy - ? AppBar( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - elevation: 0, - toolbarHeight: 76, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), - child: Row( - children: [ - _backWithBadge( - cs, - SizedBox( - width: 56, - height: 56, - child: GlossyPill( - onTap: () { - if (widget.embedded) { - widget.onClose?.call(); - } else { - Navigator.pop(context); - } - }, - child: Center( - child: Icon( - widget.embedded - ? Symbols.close - : Symbols.arrow_back, - color: cs.onSurface, - weight: 500, - size: 24, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: GlossyPill( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, - ), - ), - ), - padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), - child: Row( - children: [ - _withOnlineDot( - cs, - widget.imageUrl.isNotEmpty - ? CircleAvatar( - radius: 22, - backgroundImage: - CachedNetworkImageProvider( - widget.imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - : CircleAvatar( - radius: 22, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (chat?.isOfficial ?? false) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, - ), - ], - ], - ), - ValueListenableBuilder( - valueListenable: _headerStatusNotifier, - builder: (context, status, _) => Text( - status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - GlossyPill( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: SizedBox( - height: 56, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: _scheduledCount, - builder: (_, count, _) => count > 0 - ? IconButton( - icon: Icon( - Symbols.schedule, - weight: 500, - color: cs.onSurface, - ), - onPressed: _openScheduledMessages, - ) - : const SizedBox.shrink(), - ), - IconButton( - icon: Icon( - Symbols.call, - weight: 500, - color: cs.onSurface, - ), - onPressed: _startCall, - ), - IconButton( - icon: Icon( - Symbols.more_vert, - weight: 500, - color: cs.onSurface, - ), - onPressed: () {}, - ), - ], - ), - ), - ), - ], - ), - ), - ) - : _materialAppBar(cs), + appBar: _buildAppBar(cs), body: Column( children: [ Expanded( @@ -2321,43 +2855,14 @@ class _ChatScreenState extends State ], ), ), - AnimatedBuilder( - animation: _attachAnim, - builder: (context, _) { - if (_attachAnim.value == 0) - return const SizedBox.shrink(); - final curve = - _attachAnim.status == AnimationStatus.reverse - ? Curves.easeIn - : Curves.easeOut; - final t = curve.transform(_attachAnim.value); - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: ClipRect( - child: Align( - alignment: Alignment.bottomCenter, - heightFactor: t, - child: Opacity( - opacity: t, - child: AttachmentPanel( - onClose: () => - _showAttachmentPanel.value = false, - onPickFile: _pickAndUploadFile, - onSendById: _sendFileById, - ), - ), - ), - ), - ); - }, - ), - _buildInputArea(context), + _buildComposerArea(context), ], ), ), ), ), ), + ), ); } @@ -2430,9 +2935,14 @@ class _ChatScreenState extends State onReplyTap: _jumpToMessage, ); - final pressable = _LongPressBubble( + final pressable = _SelectableMessageRow( message: message, isMe: isMe, + selectedIds: _selectedIds, + selectionAnim: _selectionAnim, + isSelectionActive: () => _selectionMode, + onToggleSelection: () => _toggleSelection(message), + onEnterSelection: () => _enterSelection(message), onDelete: () => _confirmDeleteMessage(message, isMe), onEdit: _canEditMessage(message) ? () => _startEditMessage(message) @@ -4019,39 +4529,44 @@ class _SwipeToReplyState extends State<_SwipeToReply> } } -class _LongPressBubble extends StatefulWidget { +class _SelectableMessageRow extends StatefulWidget { final Widget child; final CachedMessage message; final bool isMe; + final ValueListenable> selectedIds; + final Animation selectionAnim; + final bool Function() isSelectionActive; + final VoidCallback onToggleSelection; + final VoidCallback onEnterSelection; final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; - const _LongPressBubble({ + const _SelectableMessageRow({ required this.child, required this.message, required this.isMe, + required this.selectedIds, + required this.selectionAnim, + required this.isSelectionActive, + required this.onToggleSelection, + required this.onEnterSelection, required this.onDelete, this.onEdit, this.onReply, }); @override - State<_LongPressBubble> createState() => _LongPressBubbleState(); + State<_SelectableMessageRow> createState() => _SelectableMessageRowState(); } -class _LongPressBubbleState extends State<_LongPressBubble> { +class _SelectableMessageRowState extends State<_SelectableMessageRow> { + static const double _gutterWidth = 40; + final GlobalKey _boundaryKey = GlobalKey(); - MessageActionsController? _controller; + Offset? _lastTapDown; - @override - void dispose() { - _controller?.commit(); - _controller = null; - super.dispose(); - } - - void _onLongPressStart(LongPressStartDetails details) { + void _openMenu() { final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); @@ -4069,35 +4584,27 @@ class _LongPressBubbleState extends State<_LongPressBubble> { return; } - Haptics.medium(); + Haptics.tap(); final controller = MessageActionsController(); - controller.attach(details.globalPosition); - _controller = controller; - showMessageActions( context: ctx, snapshot: snapshot, originRect: rect, - tapPoint: details.globalPosition, + tapPoint: _lastTapDown ?? rect.center, isMe: widget.isMe, messageText: widget.message.text, controller: controller, style: AppMessageActionsStyle.current.value, + interaction: MessageActionsInteraction.tap, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, - onDispose: () { - if (identical(_controller, controller)) { - _controller = null; - } - controller.dispose(); - }, + onDispose: controller.dispose, ); } void _onSecondaryTapDown(TapDownDetails details) { - if (_controller != null) return; final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); @@ -4107,8 +4614,6 @@ class _LongPressBubbleState extends State<_LongPressBubble> { final rect = origin & renderObject.size; final controller = MessageActionsController(); - _controller = controller; - showMessageActions( context: ctx, originRect: rect, @@ -4121,31 +4626,103 @@ class _LongPressBubbleState extends State<_LongPressBubble> { onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, - onDispose: () { - if (identical(_controller, controller)) { - _controller = null; - } - controller.dispose(); - }, + onDispose: controller.dispose, + ); + } + + void _handleTap() { + if (widget.isSelectionActive()) { + widget.onToggleSelection(); + } else { + _openMenu(); + } + } + + void _handleLongPress() { + if (widget.isSelectionActive()) { + widget.onToggleSelection(); + } else { + widget.onEnterSelection(); + } + } + + Widget _buildCheckCircle(bool selected, ColorScheme cs) { + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? cs.primary : Colors.transparent, + border: Border.all( + color: selected + ? cs.primary + : cs.onSurfaceVariant.withValues(alpha: 0.6), + width: 2, + ), + ), + child: selected + ? Icon(Symbols.check, size: 16, weight: 700, color: cs.onPrimary) + : null, ); } @override Widget build(BuildContext context) { - return Listener( - behavior: HitTestBehavior.deferToChild, - onPointerMove: (event) => _controller?.updatePointer(event.position), - onPointerUp: (event) => _controller?.commit(), - onPointerCancel: (event) => _controller?.commit(), - child: GestureDetector( - behavior: HitTestBehavior.deferToChild, - onLongPressStart: _onLongPressStart, - onLongPressMoveUpdate: (d) => - _controller?.updatePointer(d.globalPosition), - onLongPressEnd: (_) => _controller?.commit(), - onSecondaryTapDown: _onSecondaryTapDown, - child: RepaintBoundary(key: _boundaryKey, child: widget.child), - ), + if (widget.message.isControl) return widget.child; + final cs = Theme.of(context).colorScheme; + + return AnimatedBuilder( + animation: widget.selectionAnim, + builder: (context, _) { + final t = Curves.easeOut.transform( + widget.selectionAnim.value.clamp(0.0, 1.0), + ); + return ValueListenableBuilder>( + valueListenable: widget.selectedIds, + builder: (context, selected, _) { + final isSelected = selected.contains(widget.message.id); + final active = selected.isNotEmpty; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => _lastTapDown = d.globalPosition, + onTap: _handleTap, + onLongPress: _handleLongPress, + onSecondaryTapDown: active ? null : _onSecondaryTapDown, + child: ColoredBox( + color: isSelected + ? cs.primary.withValues(alpha: 0.10) + : Colors.transparent, + child: Stack( + children: [ + RepaintBoundary( + key: _boundaryKey, + child: IgnorePointer( + ignoring: active, + child: Padding( + padding: EdgeInsets.only(left: _gutterWidth * t), + child: widget.child, + ), + ), + ), + if (t > 0) + Positioned( + left: 8, + bottom: 10, + child: Opacity( + opacity: t, + child: _buildCheckCircle(isSelected, cs), + ), + ), + ], + ), + ), + ); + }, + ); + }, ); } } diff --git a/lib/frontend/widgets/chat_menu_overlay.dart b/lib/frontend/widgets/chat_menu_overlay.dart new file mode 100644 index 0000000..030dfb9 --- /dev/null +++ b/lib/frontend/widgets/chat_menu_overlay.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/utils/haptics.dart'; + +class ChatMenuItem { + final IconData icon; + final String label; + final VoidCallback? onTap; + final bool showChevron; + final bool dividerAfter; + final bool destructive; + + const ChatMenuItem({ + required this.icon, + required this.label, + this.onTap, + this.showChevron = false, + this.dividerAfter = false, + this.destructive = false, + }); +} + +void showChatMenu({ + required BuildContext context, + required Rect anchorRect, + required List items, +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => _ChatMenuLayer( + anchorRect: anchorRect, + items: items, + onDismiss: () { + if (entry.mounted) entry.remove(); + }, + ), + ); + overlay.insert(entry); + Haptics.medium(); +} + +class _ChatMenuLayer extends StatefulWidget { + final Rect anchorRect; + final List items; + final VoidCallback onDismiss; + + const _ChatMenuLayer({ + required this.anchorRect, + required this.items, + required this.onDismiss, + }); + + @override + State<_ChatMenuLayer> createState() => _ChatMenuLayerState(); +} + +class _ChatMenuLayerState extends State<_ChatMenuLayer> + with SingleTickerProviderStateMixin { + static const double _menuWidth = 290.0; + static const double _hMargin = 8.0; + static const double _gap = 6.0; + + late final AnimationController _animController; + late final Animation _animation; + bool _closing = false; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + reverseDuration: const Duration(milliseconds: 160), + ); + _animation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _animController.forward(); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + Future _close() async { + if (!mounted || _closing) return; + _closing = true; + try { + await _animController.reverse(); + } catch (_) {} + if (!mounted) return; + widget.onDismiss(); + } + + void _onItemTap(ChatMenuItem item) { + Haptics.tap(); + _close().then((_) => item.onTap?.call()); + } + + Rect _resolveRect(Size screen) { + double left = widget.anchorRect.right - _menuWidth; + left = left.clamp(_hMargin, screen.width - _menuWidth - _hMargin); + double top = widget.anchorRect.bottom + _gap; + return Rect.fromLTWH(left, top, _menuWidth, 0); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final screen = MediaQuery.sizeOf(context); + final rect = _resolveRect(screen); + return AnimatedBuilder( + animation: _animation, + builder: (ctx, child) { + final t = _animation.value.clamp(0.0, 1.0); + final scale = 0.9 + 0.1 * t; + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _close, + behavior: HitTestBehavior.opaque, + child: const SizedBox.expand(), + ), + ), + Positioned( + left: rect.left, + top: rect.top, + width: rect.width, + child: Opacity( + opacity: t, + child: Transform.scale( + scale: scale, + alignment: Alignment.topRight, + child: child, + ), + ), + ), + ], + ); + }, + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + elevation: 12, + shadowColor: Colors.black.withValues(alpha: 0.45), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 6), + for (final item in widget.items) ...[ + _ChatMenuRow(item: item, onTap: () => _onItemTap(item)), + if (item.dividerAfter) + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), + ], + const SizedBox(height: 6), + ], + ), + ), + ); + } +} + +class _ChatMenuRow extends StatelessWidget { + final ChatMenuItem item; + final VoidCallback onTap; + + const _ChatMenuRow({required this.item, required this.onTap}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fg = item.destructive ? cs.error : cs.onSurface; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15), + child: Row( + children: [ + Icon(item.icon, size: 24, weight: 350, color: fg), + const SizedBox(width: 18), + Expanded( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + if (item.showChevron) + Icon( + Symbols.chevron_right, + size: 22, + weight: 400, + color: cs.onSurface.withValues(alpha: 0.7), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 20c16a8..786d3ec 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -1,23 +1,43 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -void showCustomNotification(BuildContext context, String message) { - showCustomNotificationOnOverlay(Overlay.of(context), message); +const Duration _defaultNotificationDuration = Duration(milliseconds: 2600); + +void showCustomNotification( + BuildContext context, + String message, { + Duration? duration, +}) { + showCustomNotificationOnOverlay( + Overlay.of(context), + message, + duration: duration, + ); } -void showCustomNotificationOnOverlay(OverlayState overlay, String message) { +void showCustomNotificationOnOverlay( + OverlayState overlay, + String message, { + Duration? duration, +}) { + final total = duration ?? _defaultNotificationDuration; final entry = OverlayEntry( - builder: (context) => CustomNotification(message: message), + builder: (context) => CustomNotification(message: message, duration: total), ); overlay.insert(entry); - Future.delayed(const Duration(milliseconds: 2600), () { + Future.delayed(total, () { if (entry.mounted) entry.remove(); }); } class CustomNotification extends StatefulWidget { final String message; - const CustomNotification({required this.message, super.key}); + final Duration duration; + const CustomNotification({ + required this.message, + this.duration = _defaultNotificationDuration, + super.key, + }); @override State createState() => _CustomNotificationState(); @@ -38,7 +58,8 @@ class _CustomNotificationState extends State ); _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); _controller.forward(); - Future.delayed(const Duration(milliseconds: 2300), () { + final fadeOutDelay = widget.duration - const Duration(milliseconds: 300); + Future.delayed(fadeOutDelay > Duration.zero ? fadeOutDelay : Duration.zero, () { if (mounted) _controller.reverse(); }); } diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 034b584..1fc9669 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -10,7 +10,7 @@ import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; -enum MessageActionsInteraction { dragAndRelease, click } +enum MessageActionsInteraction { dragAndRelease, click, tap } class MessageActionsController extends ChangeNotifier { Offset? pointer; @@ -247,7 +247,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final menuHeight = n * itemHeight + vPad * 2; late double menuX; late double menuY; - if (widget.interaction == MessageActionsInteraction.click) { + if (widget.interaction != MessageActionsInteraction.dragAndRelease) { final spaceBelow = screenSize.height - widget.tapPoint.dy - 8; _showBelow = spaceBelow >= menuHeight || widget.tapPoint.dy < menuHeight; final rawY = _showBelow @@ -446,7 +446,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final cs = Theme.of(context).colorScheme; final eased = Curves.easeOutCubic.transform(t); final scale = 0.88 + 0.12 * eased; - final isClick = widget.interaction == MessageActionsInteraction.click; + final tapAnchored = + widget.interaction != MessageActionsInteraction.dragAndRelease; return Positioned( left: _menuRect.left, top: _menuRect.top, @@ -456,7 +457,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> opacity: eased, child: Transform.scale( scale: scale, - alignment: isClick + alignment: tapAnchored ? Alignment(-1.0, _showBelow ? -1.0 : 1.0) : Alignment( widget.isMe ? 1.0 : -1.0, @@ -476,7 +477,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _ListMenuItem( action: _actions[i], highlighted: _hoveredIndex == i, - onHoverChanged: isClick + onHoverChanged: tapAnchored ? (hovered) { if (hovered) { if (_hoveredIndex != i) { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 50850bd..de4947b 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -386,9 +386,7 @@ class MessageBubble extends StatelessWidget { ); } - return GestureDetector( - onTap: Haptics.tap, - child: Padding( + return Padding( padding: EdgeInsets.only( left: 12, right: 12, @@ -453,8 +451,7 @@ class MessageBubble extends StatelessWidget { ], ), ), - ), - ); + ); } Map? _resolveReactionInfo() { diff --git a/lib/main.dart b/lib/main.dart index c1705cc..ea561f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -217,9 +217,12 @@ class KometAppState extends State StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; StreamSubscription? _callIncomingSub; + StreamSubscription? _serverErrorSub; Timer? _scheduleTimer; String? _lastVpnNotice; DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); + String? _lastServerError; + DateTime _lastServerErrorAt = DateTime.fromMillisecondsSinceEpoch(0); late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); @@ -325,6 +328,21 @@ class KometAppState extends State showCustomNotificationOnOverlay(overlay, msg); } }); + + _serverErrorSub = api.errorStream.listen((msg) { + final now = DateTime.now(); + if (msg == _lastServerError && + now.difference(_lastServerErrorAt).inSeconds < 3) { + return; + } + _lastServerError = msg; + _lastServerErrorAt = now; + + final overlay = KometApp.navigatorKey.currentState?.overlay; + if (overlay != null) { + showCustomNotificationOnOverlay(overlay, msg); + } + }); } Future _onIncomingCall(IncomingCall call) async { @@ -348,6 +366,7 @@ class KometAppState extends State _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); _callIncomingSub?.cancel(); + _serverErrorSub?.cancel(); _scheduleTimer?.cancel(); AppThemeModeConfig.current.removeListener(_onThemeModeChanged); AppAmoled.current.removeListener(_onAmoledChanged);