From f00f18a421410fa4a336c03bd60bca1d22f67db8 Mon Sep 17 00:00:00 2001 From: torvalds Date: Sat, 13 Jun 2026 22:17:05 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B3=D0=BE=D1=81=D1=82=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B8=20=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=B2=D1=81=D1=8F=D0=BA=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=8F=20=D0=BB=D0=B8=D0=BD=D1=83=D1=81=20=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D1=8C=D0=B4=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 17 +- lib/backend/modules/account.dart | 3 +- lib/backend/modules/chats.dart | 105 +++- lib/backend/modules/messages.dart | 102 +++- lib/backend/modules/self_check.dart | 41 ++ lib/core/cache/info_cache.dart | 28 +- lib/core/cache/self_presence.dart | 29 + lib/core/config/komet_settings.dart | 55 ++ lib/core/storage/app_database.dart | 79 ++- lib/core/utils/format.dart | 6 +- .../screens/chats/chat_list_screen.dart | 19 +- lib/frontend/screens/chats/chat_screen.dart | 414 ++++++++++--- .../profile/komet_settings_screen.dart | 172 ++++++ .../screens/profile/settings_tab.dart | 80 ++- lib/frontend/widgets/message_bubble.dart | 552 +++++++++--------- lib/frontend/widgets/online_dot.dart | 44 ++ lib/main.dart | 7 + 17 files changed, 1341 insertions(+), 412 deletions(-) create mode 100644 lib/backend/modules/self_check.dart create mode 100644 lib/core/cache/self_presence.dart create mode 100644 lib/core/config/komet_settings.dart create mode 100644 lib/frontend/screens/profile/komet_settings_screen.dart create mode 100644 lib/frontend/widgets/online_dot.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index b732b07..a0c2d1f 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:typed_data'; +import '../core/cache/self_presence.dart'; import '../core/config/config.dart'; import '../core/config/countries.dart'; +import '../core/config/komet_settings.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; import '../core/storage/device_identity.dart'; @@ -380,12 +382,21 @@ class Api { void _startPinging() { _pingTimer?.cancel(); _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { - if (_connection.isConnected) { - _sender.send(_connection, Opcode.ping, {}); - } + sendPing(interactive: !KometSettings.ghostMode.value); }); } + void sendPing({required bool interactive}) { + if (_connection.isConnected) { + _sender.send(_connection, Opcode.ping, {'interactive': interactive}); + if (interactive) { + SelfPresence.markOnline(); + } else { + SelfPresence.markOfflineFromPing(); + } + } + } + static List? _parseRegistrationCountries(dynamic payload) { if (payload is! Map) return null; final raw = payload['reg-country-code']; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index f9beb37..3a1a464 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import '../api.dart'; +import '../../core/config/komet_settings.dart'; import '../../core/protocol/chat_cache_fingerprint.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -1146,7 +1147,7 @@ class AccountModule { ) { final payload = { 'token': token, - 'interactive': true, + 'interactive': !KometSettings.ghostMode.value, 'exp': { 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), }, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 00b114c..40ba287 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import '../../core/config/komet_settings.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/cache/info_cache.dart'; @@ -180,6 +181,11 @@ class MessageRemovedEvent extends MessageEvent { const MessageRemovedEvent(super.chatId, this.messageId); } +class MessageMarkedDeletedEvent extends MessageEvent { + final String messageId; + const MessageMarkedDeletedEvent(super.chatId, this.messageId); +} + class MessageReactionsChangedEvent extends MessageEvent { final String messageId; final Map? reactionInfo; @@ -267,6 +273,11 @@ class ChatsModule { String messageId, int mark, ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final row = Map.from(rows.first); + if ((row['unread_count'] as int? ?? 0) == 0) return; + final msgIdNum = int.tryParse(messageId); if (msgIdNum != null) { try { @@ -279,10 +290,6 @@ class ChatsModule { } catch (_) {} } - final rows = await AppDatabase.loadChat(accountId, chatId); - if (rows.isEmpty) return; - final row = Map.from(rows.first); - if ((row['unread_count'] as int? ?? 0) == 0) return; row['unread_count'] = 0; await AppDatabase.saveChats([row]); _bump(); @@ -367,9 +374,21 @@ class ChatsModule { await _handleNotifMsgReactionsChanged(packet); case Opcode.notifMsgDelete: await _handleNotifMsgDelete(packet); + case Opcode.notifPresence: + _handlePresence(packet); } } + static void _handlePresence(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final userId = payload['userId']; + if (userId is! int) return; + final presence = payload['presence']; + if (presence is! Map) return; + PresenceFetch.apply(userId, Map.from(presence)); + } + static Future _handleNotifMsgDelete(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; @@ -386,13 +405,19 @@ class ChatsModule { } if (chatId == null) return; + final keepDeleted = KometSettings.viewDeleted.value; final ids = payload['messageIds']; if (ids is List) { for (final raw in ids) { final id = raw?.toString(); if (id == null || id.isEmpty) continue; - await AppDatabase.deleteMessage(accountId, chatId, id); - _messageEventsController.add(MessageRemovedEvent(chatId, id)); + if (keepDeleted) { + await AppDatabase.markMessageDeleted(accountId, chatId, id); + _messageEventsController.add(MessageMarkedDeletedEvent(chatId, id)); + } else { + await AppDatabase.deleteMessage(accountId, chatId, id); + _messageEventsController.add(MessageRemovedEvent(chatId, id)); + } } } _bump(); @@ -435,7 +460,12 @@ class ChatsModule { } if (status == 'REMOVED' && msgIdStr != null) { - await AppDatabase.deleteMessage(accountId, chatId, msgIdStr); + final keepDeleted = KometSettings.viewDeleted.value; + if (keepDeleted) { + await AppDatabase.markMessageDeleted(accountId, chatId, msgIdStr); + } else { + await AppDatabase.deleteMessage(accountId, chatId, msgIdStr); + } final cachedChat = CachedChat.fromDbRow(rows.first); if (cachedChat.lastMsgId == msgIdInt) { await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread); @@ -444,7 +474,11 @@ class ChatsModule { newRow['unread_count'] = unread; await AppDatabase.saveChats([newRow]); } - _messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr)); + _messageEventsController.add( + keepDeleted + ? MessageMarkedDeletedEvent(chatId, msgIdStr) + : MessageRemovedEvent(chatId, msgIdStr), + ); _bump(); return; } @@ -523,7 +557,12 @@ class ChatsModule { Map chatRow, { int? unread, }) async { - final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1); + final latest = await AppDatabase.loadMessages( + accountId, + chatId, + limit: 1, + onlyVisible: true, + ); final newRow = Map.from(chatRow); if (latest.isNotEmpty) { final m = latest.first; @@ -576,6 +615,54 @@ class ChatsModule { _bump(); } + static Future> reconcileDeletedFromFetch( + int accountId, + int chatId, + List serverMessages, + ) async { + if (serverMessages.isEmpty) return const []; + + final serverIds = {}; + var minTime = serverMessages.first.time; + var maxTime = serverMessages.first.time; + for (final m in serverMessages) { + serverIds.add(m.id); + if (m.time < minTime) minTime = m.time; + if (m.time > maxTime) maxTime = m.time; + } + + final cached = await AppDatabase.loadMessages( + accountId, + chatId, + limit: 300, + onlyVisible: true, + ); + + final newlyDeleted = []; + for (final row in cached) { + final id = row['id']?.toString(); + if (id == null || id.isEmpty || id.startsWith('temp_')) continue; + if (serverIds.contains(id)) continue; + + final status = row['status']?.toString(); + if (status == 'pending' || status == 'sending' || status == 'error') { + continue; + } + + final time = row['time'] is int + ? row['time'] as int + : int.tryParse(row['time']?.toString() ?? '') ?? 0; + if (time < minTime || time > maxTime) continue; + + newlyDeleted.add(id); + } + + if (newlyDeleted.isNotEmpty) { + await AppDatabase.markMessagesDeleted(accountId, chatId, newlyDeleted); + } + return newlyDeleted; + } + static Future _handleNotifMsgReactionsChanged(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index dd38010..2459869 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,13 +15,48 @@ class ContactCache { static final Map _avatarCache = {}; static final Map> _optionsCache = {}; - static void put(int id, String name) => _nameCache[id] = name; + static const _prefsKey = 'contact_cache_v1'; + static Timer? _saveTimer; + static bool _loaded = false; - static void putAvatar(int id, String? baseUrl) { - if (baseUrl != null) _avatarCache[id] = baseUrl; + static Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return; + decoded.forEach((key, value) { + final id = int.tryParse(key.toString()); + if (id == null || value is! Map) return; + final name = value['n']; + final avatar = value['a']; + final opts = value['o']; + if (name is String) _nameCache[id] = name; + if (avatar is String) _avatarCache[id] = avatar; + if (opts is List) _optionsCache[id] = opts.whereType().toSet(); + }); + } catch (_) {} } - static void putOptions(int id, Set opts) => _optionsCache[id] = opts; + static void put(int id, String name) { + _nameCache[id] = name; + _scheduleSave(); + } + + static void putAvatar(int id, String? baseUrl) { + if (baseUrl != null) { + _avatarCache[id] = baseUrl; + _scheduleSave(); + } + } + + static void putOptions(int id, Set opts) { + _optionsCache[id] = opts; + _scheduleSave(); + } static String? get(int id) => _nameCache[id]; static String? getAvatar(int id) => _avatarCache[id]; @@ -32,6 +68,40 @@ class ContactCache { _nameCache.clear(); _avatarCache.clear(); _optionsCache.clear(); + _saveTimer?.cancel(); + _saveTimer = null; + unawaited(_wipePersisted()); + } + + static void _scheduleSave() { + _saveTimer?.cancel(); + _saveTimer = Timer(const Duration(seconds: 3), () => unawaited(_save())); + } + + static Future _save() async { + final ids = { + ..._nameCache.keys, + ..._avatarCache.keys, + ..._optionsCache.keys, + }; + final map = {}; + for (final id in ids) { + final entry = {}; + final name = _nameCache[id]; + final avatar = _avatarCache[id]; + final opts = _optionsCache[id]; + if (name != null) entry['n'] = name; + if (avatar != null) entry['a'] = avatar; + if (opts != null && opts.isNotEmpty) entry['o'] = opts.toList(); + if (entry.isNotEmpty) map['$id'] = entry; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, jsonEncode(map)); + } + + static Future _wipePersisted() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKey); } } @@ -197,6 +267,7 @@ class CachedMessage { final Map? payload; final List? attachments; final bool isControl; + final bool deleted; const CachedMessage({ required this.id, @@ -209,8 +280,27 @@ class CachedMessage { this.payload, this.attachments, this.isControl = false, + this.deleted = false, }); + CachedMessage copyWith({ + String? status, + bool? deleted, + List? attachments, + }) => CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: senderId, + text: text, + time: time, + status: status ?? this.status, + payload: payload, + attachments: attachments ?? this.attachments, + isControl: isControl, + deleted: deleted ?? this.deleted, + ); + factory CachedMessage.fromDbRow(Map row) { Map? payload; final payloadRaw = row['payload']; @@ -259,6 +349,9 @@ class CachedMessage { attachments: attachments, isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false, + deleted: row['deleted'] is int + ? row['deleted'] == 1 + : row['deleted']?.toString() == '1', ); } @@ -295,6 +388,7 @@ class CachedMessage { 'time': time, 'status': status, 'payload': payload != null ? jsonEncode(payload) : null, + 'deleted': deleted ? 1 : 0, }; static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { diff --git a/lib/backend/modules/self_check.dart b/lib/backend/modules/self_check.dart new file mode 100644 index 0000000..610c8e9 --- /dev/null +++ b/lib/backend/modules/self_check.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import '../../core/cache/info_cache.dart'; +import '../../core/cache/self_presence.dart'; +import '../../core/config/komet_settings.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../api.dart'; + +class SelfCheckService { + SelfCheckService._(); + + static final SelfCheckService instance = SelfCheckService._(); + + static const Duration interval = Duration(seconds: 10); + + Api? _api; + Timer? _timer; + + void init(Api api) { + if (_api != null) return; + _api = api; + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => unawaited(_check())); + } + + Future _check() async { + final api = _api; + if (api == null || api.state != SessionState.online) return; + if (!KometSettings.selfOnlineCheck.value) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final presence = await PresenceFetch.get(accountId, forceRefresh: true); + logger.i('[SELF CHECK] id=$accountId presence=$presence'); + if (presence == null) return; + SelfPresence.applySelfCheck( + online: presence['status'] == 1, + seenSeconds: presence['seen'] is int ? presence['seen'] as int : null, + ); + } +} diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 2b8894d..74e1ae0 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; + import '../../backend/api.dart'; import '../protocol/opcode_map.dart'; @@ -132,8 +134,27 @@ class PresenceFetch { static Map? peek(int id) => _cache.peek(id); + static final Map> _live = {}; + static final ValueNotifier revision = ValueNotifier(0); + + static Map? live(int id) => _live[id] ?? _cache.peek(id); + + static bool isOnline(int id) => (live(id)?['status'] as int?) == 1; + + static void apply(int id, Map presence) { + if (id <= 0) return; + _live[id] = presence; + _cache.putValue(id, presence); + revision.value++; + } + static void invalidate(int id) => _cache.invalidate(id); - static void clear() => _cache.clear(); + + static void clear() { + _cache.clear(); + _live.clear(); + revision.value++; + } static void primeAll(Map presence) { final now = DateTime.now(); @@ -141,8 +162,11 @@ class PresenceFetch { if (value is! Map) return; final id = key is int ? key : int.tryParse(key.toString()); if (id == null) return; - _cache.putValue(id, Map.from(value), at: now); + final map = Map.from(value); + _cache.putValue(id, map, at: now); + _live[id] = map; }); + revision.value++; } static Future?> _fetch(int id) async { diff --git a/lib/core/cache/self_presence.dart b/lib/core/cache/self_presence.dart new file mode 100644 index 0000000..4f59d65 --- /dev/null +++ b/lib/core/cache/self_presence.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; + +class SelfPresence { + static final ValueNotifier isOnline = ValueNotifier(true); + static final ValueNotifier lastSeenSeconds = ValueNotifier(null); + + static int get _nowSeconds => + DateTime.now().millisecondsSinceEpoch ~/ 1000; + + static void markOnline() { + isOnline.value = true; + } + + static void markOfflineFromPing() { + if (isOnline.value || lastSeenSeconds.value == null) { + lastSeenSeconds.value = _nowSeconds; + } + isOnline.value = false; + } + + static void applySelfCheck({required bool online, int? seenSeconds}) { + if (online) { + isOnline.value = true; + } else { + isOnline.value = false; + if (seenSeconds != null) lastSeenSeconds.value = seenSeconds; + } + } +} diff --git a/lib/core/config/komet_settings.dart b/lib/core/config/komet_settings.dart new file mode 100644 index 0000000..896baaf --- /dev/null +++ b/lib/core/config/komet_settings.dart @@ -0,0 +1,55 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class KometSettings { + static const _kViewDeleted = 'komet_view_deleted'; + static const _kViewRedacted = 'komet_view_redacted'; + static const _kFullTimestamp = 'komet_full_timestamp'; + static const _kGhostMode = 'komet_ghost_mode'; + static const _kSelfOnlineCheck = 'komet_self_online_check'; + + static final ValueNotifier viewDeleted = ValueNotifier(false); + static final ValueNotifier viewRedacted = ValueNotifier(false); + static final ValueNotifier fullTimestamp = ValueNotifier(false); + static final ValueNotifier ghostMode = ValueNotifier(false); + static final ValueNotifier selfOnlineCheck = ValueNotifier(true); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + viewDeleted.value = prefs.getBool(_kViewDeleted) ?? false; + viewRedacted.value = prefs.getBool(_kViewRedacted) ?? false; + fullTimestamp.value = prefs.getBool(_kFullTimestamp) ?? false; + ghostMode.value = prefs.getBool(_kGhostMode) ?? false; + selfOnlineCheck.value = prefs.getBool(_kSelfOnlineCheck) ?? true; + } + + static Future setViewDeleted(bool value) async { + viewDeleted.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kViewDeleted, value); + } + + static Future setViewRedacted(bool value) async { + viewRedacted.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kViewRedacted, value); + } + + static Future setFullTimestamp(bool value) async { + fullTimestamp.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kFullTimestamp, value); + } + + static Future setGhostMode(bool value) async { + ghostMode.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kGhostMode, value); + } + + static Future setSelfOnlineCheck(bool value) async { + selfOnlineCheck.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kSelfOnlineCheck, value); + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index c19c8a2..83030dc 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -185,7 +185,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 12, + version: 13, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -243,6 +243,11 @@ class AppDatabase { 'ALTER TABLE chats_cache ADD COLUMN last_msg_status TEXT', ); } + if (oldVersion < 13) { + await db.execute( + 'ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0', + ); + } }, ); } @@ -344,6 +349,7 @@ class AppDatabase { time INTEGER NOT NULL, status TEXT, payload TEXT, + deleted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, account_id), FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE ) @@ -351,10 +357,17 @@ class AppDatabase { static Future saveProfile(ProfileData profile, {bool isActive = true}) async { final db = await _instance; - await db.insert( - 'profile', - profile.toDbRow(isActive: isActive), - conflictAlgorithm: ConflictAlgorithm.replace, + final row = profile.toDbRow(isActive: isActive); + final cols = row.keys.toList(); + final placeholders = List.filled(cols.length, '?').join(', '); + final updates = cols + .where((c) => c != 'id') + .map((c) => '$c = excluded.$c') + .join(', '); + await db.rawInsert( + 'INSERT INTO profile (${cols.join(', ')}) VALUES ($placeholders) ' + 'ON CONFLICT(id) DO UPDATE SET $updates', + cols.map((c) => row[c]).toList(), ); } @@ -527,6 +540,22 @@ class AppDatabase { ); } + static Future sumUnread(int accountId, {int? excludeChatId}) async { + final db = await _instance; + final where = excludeChatId != null + ? 'account_id = ? AND id != ?' + : 'account_id = ?'; + final args = excludeChatId != null + ? [accountId, excludeChatId] + : [accountId]; + final result = await db.rawQuery( + 'SELECT COALESCE(SUM(unread_count), 0) AS total ' + 'FROM chats_cache WHERE $where', + args, + ); + return (result.first['total'] as int?) ?? 0; + } + static Future findDialogChatByParticipant(int accountId, int contactId) async { final db = await _instance; final rows = await db.query( @@ -623,11 +652,14 @@ class AppDatabase { int chatId, { int? limit, int? offset, + bool onlyVisible = false, }) async { final db = await _instance; return db.query( 'messages', - where: 'account_id = ? AND chat_id = ?', + where: onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0' + : 'account_id = ? AND chat_id = ?', whereArgs: [accountId, chatId], orderBy: 'time DESC', limit: limit, @@ -635,6 +667,41 @@ class AppDatabase { ); } + static Future markMessageDeleted( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + await db.update( + 'messages', + {'deleted': 1}, + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } + + static Future markMessagesDeleted( + int accountId, + int chatId, + List messageIds, + ) async { + if (messageIds.isEmpty) return; + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final id in messageIds) { + batch.update( + 'messages', + {'deleted': 1}, + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, id], + ); + } + await batch.commit(noResult: true); + }); + } + static Future clearMessages(int accountId, int chatId) async { final db = await _instance; await db.delete( diff --git a/lib/core/utils/format.dart b/lib/core/utils/format.dart index 0987c72..c2f4f88 100644 --- a/lib/core/utils/format.dart +++ b/lib/core/utils/format.dart @@ -38,8 +38,10 @@ String formatDurationMmSs(Duration d, {bool padMinutes = false}) { String formatSecondsMmSs(int seconds, {bool padMinutes = false}) => formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes); -/// "HH:mm". -String formatClock(DateTime dt) => '${_two(dt.hour)}:${_two(dt.minute)}'; +/// "HH:mm" or "HH:mm:ss" when [withSeconds] is set. +String formatClock(DateTime dt, {bool withSeconds = false}) => withSeconds + ? '${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}' + : '${_two(dt.hour)}:${_two(dt.minute)}'; /// "5 мая 2024". String formatDateWords(DateTime dt) => diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index a1d7726..643f7e2 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; +import '../../widgets/online_dot.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; @@ -1472,7 +1473,7 @@ class _ChatListScreenState extends State previewText, _formatTime(chat.lastMsgTime), avatar ?? "", - isOnline: chat.isOnline, + presenceUserId: secondId, unreadCount: chat.unreadCount, isMuted: chat.isMuted, isVerified: isVerified, @@ -1510,7 +1511,6 @@ class _ChatListScreenState extends State (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) ? chat.iconUrl! : '', - isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.isMuted, isVerified: chat.isOfficial, @@ -2127,7 +2127,7 @@ class _ChatListScreenState extends State String message, String time, String imageUrl, { - bool isOnline = false, + int presenceUserId = 0, bool isTyping = false, bool isRead = false, int unreadCount = 0, @@ -2238,18 +2238,13 @@ class _ChatListScreenState extends State ), ), ) - else if (isOnline) + else if (presenceUserId != 0) Positioned( right: 0, bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), + child: OnlineDot( + userId: presenceUserId, + borderColor: cs.surface, ), ), ], diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 69ef379..c319af0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -35,8 +35,10 @@ import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_visual_style.dart'; +import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/online_dot.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; @@ -176,11 +178,13 @@ class _ChatScreenState extends State final Map _separatorKeys = {}; String? _lastSentId; String? _lastMarkedId; + final ValueNotifier _otherUnread = ValueNotifier(0); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + ChatsModule.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); _scrollController.addListener(_onScrollForDate); AppVisualStyle.current.addListener(_onVisualStyleChanged); @@ -205,6 +209,7 @@ class _ChatScreenState extends State _messageEventSub = ChatsModule.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); + PresenceFetch.revision.addListener(_onPresenceChanged); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -237,6 +242,7 @@ class _ChatScreenState extends State if (!mounted) return; _myId = p?.id ?? 0; _restoreDraft(); + unawaited(_refreshBadge()); ChatsModule.getChat(_myId, widget.chatId) .then((value) { @@ -255,6 +261,7 @@ class _ChatScreenState extends State _myId, widget.chatId, limit: 20, + onlyVisible: !KometSettings.viewDeleted.value, ); if (!mounted) return; if (firstRows.isNotEmpty) { @@ -326,6 +333,37 @@ class _ChatScreenState extends State ); } + bool _badgeRefreshing = false; + bool _badgeRefreshQueued = false; + + void _onChatsBump() { + if (_badgeRefreshing) { + _badgeRefreshQueued = true; + return; + } + unawaited(_runBadgeRefresh()); + } + + Future _runBadgeRefresh() async { + _badgeRefreshing = true; + try { + await _refreshBadge(); + } finally { + _badgeRefreshing = false; + if (_badgeRefreshQueued && mounted) { + _badgeRefreshQueued = false; + unawaited(_runBadgeRefresh()); + } + } + } + + Future _refreshBadge() async { + if (_myId == 0) return; + final total = + await AppDatabase.sumUnread(_myId, excludeChatId: widget.chatId); + if (mounted) _otherUnread.value = total; + } + Future _loadHistory() async { if (_myId == 0) { final activeProfile = await AppDatabase.loadActiveProfile(); @@ -340,10 +378,12 @@ class _ChatScreenState extends State } Future _loadRemainingHistory() async { + final onlyVisible = !KometSettings.viewDeleted.value; final fullRows = await AppDatabase.loadMessages( _myId, widget.chatId, limit: 100, + onlyVisible: onlyVisible, ); final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows); if (mounted && fullDecoded.length > _messages.length) { @@ -363,12 +403,23 @@ class _ChatScreenState extends State } try { - await messagesModule.fetchHistory(_myId, widget.chatId); + final serverMessages = await messagesModule.fetchHistory( + _myId, + widget.chatId, + ); ChatsModule.markHistoryFetched(widget.chatId); + if (KometSettings.viewDeleted.value) { + await ChatsModule.reconcileDeletedFromFetch( + _myId, + widget.chatId, + serverMessages, + ); + } final updatedRows = await AppDatabase.loadMessages( _myId, widget.chatId, limit: 100, + onlyVisible: onlyVisible, ); final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows); if (mounted) { @@ -446,7 +497,8 @@ class _ChatScreenState extends State a.time == b.time && a.status == b.status && a.text == b.text && - a.senderId == b.senderId; + a.senderId == b.senderId && + a.deleted == b.deleted; } bool _listsEquivalent(List a, List b) { @@ -475,6 +527,8 @@ class _ChatScreenState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); + ChatsModule.chatsChanged.removeListener(_onChatsBump); + _otherUnread.dispose(); _saveDraft(); _messageController.removeListener(_onTextChanged); _scrollController.removeListener(_onScrollForDate); @@ -502,6 +556,7 @@ class _ChatScreenState extends State t.cancel(); } _typingTimers.clear(); + PresenceFetch.revision.removeListener(_onPresenceChanged); _headerStatusNotifier.dispose(); _otherReadTime.dispose(); _messagesRev.dispose(); @@ -972,6 +1027,12 @@ class _ChatScreenState extends State _messages.removeAt(idx); _bumpMessages(); _reactionNotifiers.remove(messageId)?.dispose(); + case MessageMarkedDeletedEvent(:final messageId): + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + if (_messages[idx].deleted) return; + _messages[idx] = _messages[idx].copyWith(deleted: true); + _bumpMessages(); case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): _reactionNotifiers[messageId]?.value = reactionInfo; } @@ -981,19 +1042,97 @@ class _ChatScreenState extends State if (_myId == 0) return; final otherId = widget.chatId ^ _myId; if (otherId <= 0) return; + if (PresenceFetch.live(otherId) != null) return; try { final entry = await PresenceFetch.get(otherId); if (!mounted || entry == null) return; - _otherStatus = (entry['status'] as int?) ?? 0; - _otherSeenTime = entry['seen'] as int?; - _recomputeHeaderStatus(); + PresenceFetch.apply(otherId, entry); } catch (_) {} } + void _onPresenceChanged() { + if (!mounted || widget.chatType != 'DIALOG' || _myId == 0) return; + final otherId = widget.chatId ^ _myId; + if (otherId <= 0) return; + final p = PresenceFetch.live(otherId); + if (p == null) return; + _otherStatus = (p['status'] as int?) ?? 0; + _otherSeenTime = p['seen'] as int?; + _recomputeHeaderStatus(); + } + + Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { + if (widget.chatType != 'DIALOG' || _myId == 0) return avatar; + final otherId = widget.chatId ^ _myId; + if (otherId <= 0) return avatar; + return Stack( + children: [ + avatar, + Positioned( + right: 0, + bottom: 0, + child: OnlineDot( + userId: otherId, + borderColor: cs.surface, + size: dotSize, + ), + ), + ], + ); + } + void _onVisualStyleChanged() { if (mounted) setState(() {}); } + Widget _backWithBadge(ColorScheme cs, Widget button) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + button, + Positioned( + right: -2, + bottom: 0, + child: IgnorePointer(child: _backUnreadBadge(cs)), + ), + ], + ); + } + + Widget _backUnreadBadge(ColorScheme cs) { + return ValueListenableBuilder( + valueListenable: _otherUnread, + builder: (context, count, _) { + return AnimatedScale( + scale: count > 0 ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: Container( + constraints: const BoxConstraints(minWidth: 18), + height: 18, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: cs.surface, width: 1.5), + ), + alignment: Alignment.center, + child: _RollingCount( + count: count > 99 ? 99 : count, + style: TextStyle( + color: cs.onPrimary, + fontSize: 10.5, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ), + ); + }, + ); + } + PreferredSizeWidget _materialAppBar(ColorScheme cs) { return PreferredSize( preferredSize: Size.fromHeight(kToolbarHeight), @@ -1015,43 +1154,51 @@ class _ChatScreenState extends State elevation: 0, surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), - leading: IconButton( - icon: Icon( - widget.embedded ? Symbols.close : Symbols.arrow_back, - weight: 400, + 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); + } + }, ), - onPressed: () { - if (widget.embedded) { - widget.onClose?.call(); - } else { - Navigator.pop(context); - } - }, ), titleSpacing: 0, title: Row( children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider( - widget.imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - else - CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, - ), - ), - ), + _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( @@ -1131,9 +1278,10 @@ class _ChatScreenState extends State return; } // Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова. + final navigator = Navigator.of(context); final active = CallController.instance.activeSession; if (active != null) { - Navigator.of(context).push( + await navigator.push( MaterialPageRoute( builder: (_) => CallScreen( name: widget.name, @@ -1142,15 +1290,15 @@ class _ChatScreenState extends State ), ), ); + _onCallScreenClosed(); return; } final peerId = widget.chatId ^ _myId; if (peerId <= 0) return; - final navigator = Navigator.of(context); try { final session = await CallController.instance.startOutgoing(peerId); if (!mounted) return; - navigator.push( + await navigator.push( MaterialPageRoute( builder: (_) => CallScreen( name: widget.name, @@ -1159,18 +1307,49 @@ class _ChatScreenState extends State ), ), ); + _onCallScreenClosed(); } catch (_) { if (!mounted) return; showCustomNotification(context, 'Не удалось начать звонок'); } } + void _onCallScreenClosed() { + if (!mounted) return; + if (CallController.instance.activeSession != null) return; + unawaited(_refreshAfterCall()); + } + + Future _refreshAfterCall() async { + await Future.delayed(const Duration(milliseconds: 700)); + if (!mounted || _myId == 0) return; + try { + final serverMessages = + await messagesModule.fetchHistory(_myId, widget.chatId); + if (KometSettings.viewDeleted.value) { + await ChatsModule.reconcileDeletedFromFetch( + _myId, + widget.chatId, + serverMessages, + ); + } + final rows = await AppDatabase.loadMessages( + _myId, + widget.chatId, + limit: 100, + onlyVisible: !KometSettings.viewDeleted.value, + ); + final decoded = await CachedMessage.fromDbRowsAsync(rows); + if (mounted) _applyMergedMessages(decoded); + } catch (_) {} + } + void _seedPresenceFromChat() { if (widget.chatType != 'DIALOG' || _myId == 0) return; if (_otherStatus != 0 || _otherSeenTime != null) return; final otherId = widget.chatId ^ _myId; if (otherId <= 0) return; - final p = PresenceFetch.peek(otherId); + final p = PresenceFetch.live(otherId); if (p == null) return; _otherStatus = (p['status'] as int?) ?? 0; _otherSeenTime = p['seen'] as int?; @@ -1414,7 +1593,8 @@ class _ChatScreenState extends State if (msg.attachments != null) { for (final a in msg.attachments!) { if (a is ForwardedMessageAttachment) { - if (a.originalSenderName == null) { + if (a.originalSenderName == null && + ContactCache.get(a.originalSenderId) == null) { forwardIds.add(a.originalSenderId); } } @@ -1692,25 +1872,28 @@ class _ChatScreenState extends State padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), child: Row( children: [ - 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, + _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, + ), ), ), ), @@ -1732,31 +1915,34 @@ class _ChatScreenState extends State padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), child: Row( children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 22, - backgroundImage: CachedNetworkImageProvider( - widget.imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - else - 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', - ), - ), - ), + _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( @@ -3613,3 +3799,59 @@ class _SentMessageAnimationState extends State<_SentMessageAnimation> ); } } + +class _RollingCount extends StatefulWidget { + final int count; + final TextStyle style; + + const _RollingCount({required this.count, required this.style}); + + @override + State<_RollingCount> createState() => _RollingCountState(); +} + +class _RollingCountState extends State<_RollingCount> { + late int _count = widget.count; + bool _increasing = true; + + @override + void didUpdateWidget(_RollingCount old) { + super.didUpdateWidget(old); + if (widget.count != _count) { + _increasing = widget.count > _count; + _count = widget.count; + } + } + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, anim) { + final incoming = (child.key as ValueKey).value == _count; + final Offset begin; + if (incoming) { + begin = _increasing ? const Offset(0, -1) : const Offset(0, 1); + } else { + begin = _increasing ? const Offset(0, 1) : const Offset(0, -1); + } + return ClipRect( + child: FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween(begin: begin, end: Offset.zero).animate(anim), + child: child, + ), + ), + ); + }, + child: Text( + '${widget.count}', + key: ValueKey(widget.count), + style: widget.style, + ), + ); + } +} diff --git a/lib/frontend/screens/profile/komet_settings_screen.dart b/lib/frontend/screens/profile/komet_settings_screen.dart new file mode 100644 index 0000000..947d1b6 --- /dev/null +++ b/lib/frontend/screens/profile/komet_settings_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/komet_settings.dart'; +import '../../../main.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/section_header.dart'; + +class KometSettingsScreen extends StatelessWidget { + const KometSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E(titleText: 'Komet', backgroundColor: cs.surface), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + const SectionHeader( + 'Сообщения', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + _card(cs, [ + _toggle( + cs, + icon: Symbols.delete_history, + label: 'View deleted message', + subtitle: 'Показывать удалённые сообщения', + notifier: KometSettings.viewDeleted, + onChanged: KometSettings.setViewDeleted, + ), + _divider(cs), + _toggle( + cs, + icon: Symbols.history_edu, + label: 'View redacted message history', + subtitle: 'Показывать историю у редактированных сообщений', + notifier: KometSettings.viewRedacted, + onChanged: KometSettings.setViewRedacted, + ), + _divider(cs), + _toggle( + cs, + icon: Symbols.schedule, + label: 'View full timestamp', + subtitle: 'Показывать время в секундах у сообщений', + notifier: KometSettings.fullTimestamp, + onChanged: KometSettings.setFullTimestamp, + ), + ]), + const SizedBox(height: 20), + const SectionHeader( + 'Ghost Mode', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + _card(cs, [ + _toggle( + cs, + icon: Symbols.visibility_off, + label: 'Ghost Mode', + subtitle: 'Не отмечать вас в сети: пинги идут скрытно', + notifier: KometSettings.ghostMode, + onChanged: _setGhostMode, + ), + _divider(cs), + _toggle( + cs, + icon: Symbols.radar, + label: 'Self Online Check', + subtitle: + 'Каждые ~10 секунд сверяет, когда вы были онлайн. ' + 'Полезно для проверки ghost mode', + notifier: KometSettings.selfOnlineCheck, + onChanged: KometSettings.setSelfOnlineCheck, + ), + ]), + ], + ), + ), + ); + } + + Future _setGhostMode(bool value) async { + await KometSettings.setGhostMode(value); + api.sendPing(interactive: !value); + } + + Widget _card(ColorScheme cs, List children) { + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + child: Column(children: children), + ); + } + + Widget _divider(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ); + } + + Widget _toggle( + ColorScheme cs, { + required IconData icon, + required String label, + required String subtitle, + required ValueNotifier notifier, + required Future Function(bool) onChanged, + }) { + return ValueListenableBuilder( + valueListenable: notifier, + builder: (context, value, _) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 968b869..1e1913f 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -5,8 +5,11 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/messages.dart'; +import '../../../core/cache/self_presence.dart'; +import '../../../core/config/komet_settings.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/format.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; @@ -28,6 +31,7 @@ import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; import 'info_screen.dart'; +import 'komet_settings_screen.dart'; import 'notifications_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -286,6 +290,19 @@ class _SettingsTabState extends State { ); }, ), + _SettingsItem( + // Иконку кометы блять дайте!!!!!!!1 + icon: Symbols.auto_awesome, + label: 'Komet', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const KometSettingsScreen(), + ), + ); + }, + ), _SettingsItem( icon: Symbols.info, label: 'Info', @@ -584,7 +601,7 @@ class _SettingsTabState extends State { fontSize: 32, ), ), - const SizedBox(height: 14), + const SizedBox(height: 8), Text( name, style: TextStyle( @@ -594,7 +611,8 @@ class _SettingsTabState extends State { fontFamily: 'Outfit', ), ), - const SizedBox(height: 4), + _buildOnlineStatus(cs), + const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -627,6 +645,64 @@ class _SettingsTabState extends State { ); } + String _formatSelfSeen(int seconds) { + final dt = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); + final now = DateTime.now(); + final time = formatClock(dt); + final isToday = + dt.year == now.year && dt.month == now.month && dt.day == now.day; + if (isToday) return time; + final datePart = dt.year == now.year + ? '${dt.day} ${kRuMonthsShort[dt.month - 1]}' + : '${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}'; + return '$datePart, $time'; + } + + Widget _buildOnlineStatus(ColorScheme cs) { + return ValueListenableBuilder( + valueListenable: KometSettings.selfOnlineCheck, + builder: (context, enabled, _) { + if (!enabled) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: ValueListenableBuilder( + valueListenable: SelfPresence.isOnline, + builder: (context, online, _) => ValueListenableBuilder( + valueListenable: SelfPresence.lastSeenSeconds, + builder: (context, seen, _) { + final label = online + ? 'онлайн' + : (seen != null ? 'Был(-а) ${_formatSelfSeen(seen)}' : 'офлайн'); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Symbols.check_circle, + fill: 1, + size: 15, + color: online + ? const Color(0xFF34C759) + : cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + }, + ), + ), + ); + }, + ); + } + Widget _buildSection( BuildContext context, ColorScheme cs, { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 754c24e..1584335 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; +import '../../core/config/komet_settings.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; @@ -51,7 +52,7 @@ class _BubbleCtx { } final Expando _contentTypeCache = Expando(); -final Expando _clockTextCache = Expando(); +final Expando<({bool full, String text})> _clockTextCache = Expando(); class MessageBubble extends StatelessWidget { static const double photoMaxSize = 280.0; @@ -149,9 +150,17 @@ class MessageBubble extends StatelessWidget { return _contentTypeCache[message] ??= _computeContentType(); } - String get _clockText => _clockTextCache[message] ??= formatClock( - DateTime.fromMillisecondsSinceEpoch(message.time), - ); + String get _clockText { + final full = KometSettings.fullTimestamp.value; + final cached = _clockTextCache[message]; + if (cached != null && cached.full == full) return cached.text; + final text = formatClock( + DateTime.fromMillisecondsSinceEpoch(message.time), + withSeconds: full, + ); + _clockTextCache[message] = (full: full, text: text); + return text; + } MessageType _computeContentType() { if (message.isControl) return MessageType.control; @@ -653,6 +662,10 @@ class MessageBubble extends StatelessWidget { child: metaWidget, ), if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + if (message.deleted) ...[ + const SizedBox(width: 4), + _buildDeletedIcon(ctx), + ], ], ), ], @@ -680,6 +693,10 @@ class MessageBubble extends StatelessWidget { child: metaWidget, ), if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + if (message.deleted) ...[ + const SizedBox(width: 4), + _buildDeletedIcon(ctx), + ], ], ), ], @@ -691,9 +708,13 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, ) { final headerColor = ctx.dim; - final senderName = forwarded.originalSenderName; - final displaySender = senderName ?? forwarded.originalSenderId.toString(); - final senderAvatar = forwarded.originalSenderAvatar; + final displaySender = + forwarded.originalSenderName ?? + ContactCache.get(forwarded.originalSenderId) ?? + forwarded.originalSenderId.toString(); + final senderAvatar = + forwarded.originalSenderAvatar ?? + ContactCache.getAvatar(forwarded.originalSenderId); final origText = forwarded.originalText; final hasOrigText = origText != null && origText.isNotEmpty; @@ -760,6 +781,62 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedHeader( + _BubbleCtx ctx, + ForwardedMessageAttachment forwarded, + ) { + final headerColor = ctx.dim; + final displaySender = + forwarded.originalSenderName ?? + ContactCache.get(forwarded.originalSenderId) ?? + forwarded.originalSenderId.toString(); + final senderAvatar = + forwarded.originalSenderAvatar ?? + ContactCache.getAvatar(forwarded.originalSenderId); + return Padding( + padding: const EdgeInsets.only(left: 8, top: 8, right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), + backgroundColor: ctx.cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: ctx.cs.primaryContainer, + child: Text( + displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', + style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + displaySender, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + ForwardedMessageAttachment? _getForwardedAttachment() { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) return null; @@ -1053,59 +1130,13 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List photos, ) { - final headerColor = ctx.dim; - final displaySender = - forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); - final senderAvatar = forwarded.originalSenderAvatar; final hasCaption = message.text != null && message.text!.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only(left: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 9, - color: ctx.cs.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: 6), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), + _buildForwardedHeader(ctx, forwarded), const SizedBox(height: 4), if (hasCaption) ...[ Padding( @@ -1127,66 +1158,21 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List attachments, ) { - final headerColor = ctx.dim; - final displaySender = - forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); - final senderAvatar = forwarded.originalSenderAvatar; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(left: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 9, - color: ctx.cs.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: 6), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - const SizedBox(height: 4), - ...attachments.map((a) { - if (a is FileAttachment) { - return _buildFileAttachment(ctx, a); - } - return const SizedBox.shrink(); - }), - ], + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _buildForwardedHeader(ctx, forwarded), + const SizedBox(height: 4), + ...attachments.map((a) { + if (a is FileAttachment) { + return _buildFileAttachment(ctx, a, fill: true); + } + return const SizedBox.shrink(); + }), + ], + ), ); } @@ -1774,7 +1760,11 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { + Widget _buildFileAttachment( + _BubbleCtx ctx, + MessageAttachment file, { + bool fill = false, + }) { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; final sizeStr = formatBytes(size); @@ -1784,132 +1774,129 @@ class MessageBubble extends StatelessWidget { final preview = file is FileAttachment ? file.preview : null; final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; - return IntrinsicWidth( - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (previewUrl.isNotEmpty) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: previewUrl, - width: 240, - height: 160, - fit: BoxFit.cover, - memCacheWidth: 480, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => const SizedBox.shrink(), + final inner = Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (previewUrl.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: previewUrl, + width: 240, + height: 160, + fit: BoxFit.cover, + memCacheWidth: 480, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(height: 8), + ], + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 20, ), ), - const SizedBox(height: 8), - ], - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.primaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Symbols.description, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 20, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier( + cacheName, + ), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, + color: ctx.dim, + fontSize: 12, height: 1.2, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier( - cacheName, - ), - builder: (context, progress, _) => Text( - progress != null - ? '${(progress * 100).round()}% · $sizeStr' - : sizeStr, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, - ), - ), - ), - ], - ), + ), + ], ), - const SizedBox(width: 12), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier(cacheName), - builder: (context, progress, _) { - final downloading = progress != null; - return GestureDetector( - onTap: downloading - ? null - : () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues( - alpha: 0.12, - ) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: downloading - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - ), - ) - : Icon( - Symbols.download, + ), + const SizedBox(width: 12), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 18, ), - ), - ); - }, - ), - ], - ), - _buildMeta(ctx), - ], - ), + ) + : Icon( + Symbols.download, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 18, + ), + ), + ); + }, + ), + ], + ), + _buildMeta(ctx), + ], ), ); + return fill ? inner : IntrinsicWidth(child: inner); } Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { @@ -2047,59 +2034,12 @@ class MessageBubble extends StatelessWidget { final bgColor = isMe ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) : ctx.cs.surfaceContainerHighest; - final headerColor = ctx.dim; - - final displaySender = - forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); - final senderAvatar = forwarded.originalSenderAvatar; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only(left: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 9, - color: ctx.cs.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: 6), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), + _buildForwardedHeader(ctx, forwarded), const SizedBox(height: 4), Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), @@ -2254,6 +2194,7 @@ class MessageBubble extends StatelessWidget { url: url, textColor: ctx.text, isMe: isMe, + deleted: message.deleted, status: overrideStatus ?? message.status, time: message.time, cs: ctx.cs, @@ -2274,6 +2215,10 @@ class MessageBubble extends StatelessWidget { children: [ Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)), if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + if (message.deleted) ...[ + const SizedBox(width: 4), + _buildDeletedIcon(ctx), + ], ], ), ); @@ -2290,17 +2235,30 @@ class MessageBubble extends StatelessWidget { color: bgColor, borderRadius: BorderRadius.circular(4), ), - child: Text( - _clockText, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w500, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _clockText, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + if (message.deleted) ...[ + const SizedBox(width: 3), + const Icon(Symbols.delete, size: 11, color: Colors.white), + ], + ], ), ); } + Widget _buildDeletedIcon(_BubbleCtx ctx) { + return Icon(Symbols.delete, size: 13, color: ctx.dim); + } + Widget _buildStatusIcon(_BubbleCtx ctx) { final status = overrideStatus ?? message.status; IconData icon; @@ -2338,6 +2296,7 @@ class _VoiceMessageBubble extends StatefulWidget { final String url; final Color textColor; final bool isMe; + final bool deleted; final String? status; final int time; final ColorScheme cs; @@ -2352,6 +2311,7 @@ class _VoiceMessageBubble extends StatefulWidget { required this.url, required this.textColor, required this.isMe, + this.deleted = false, this.status, required this.time, required this.cs, @@ -2565,7 +2525,10 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { ), if (!_transcriptionVisible) ...[ Text( - formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), + formatClock( + DateTime.fromMillisecondsSinceEpoch(widget.time), + withSeconds: KometSettings.fullTimestamp.value, + ), style: TextStyle( color: widget.textColor.withValues(alpha: 0.6), fontSize: 10, @@ -2575,6 +2538,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { const SizedBox(width: 2), _buildStatusIcon(), ], + if (widget.deleted) ...[ + const SizedBox(width: 2), + Icon( + Symbols.delete, + size: 13, + color: widget.textColor.withValues(alpha: 0.6), + ), + ], ], ], ), @@ -2583,7 +2554,10 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), + formatClock( + DateTime.fromMillisecondsSinceEpoch(widget.time), + withSeconds: KometSettings.fullTimestamp.value, + ), style: TextStyle( color: widget.textColor.withValues(alpha: 0.6), fontSize: 10, @@ -2593,6 +2567,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { const SizedBox(width: 2), _buildStatusIcon(), ], + if (widget.deleted) ...[ + const SizedBox(width: 2), + Icon( + Symbols.delete, + size: 13, + color: widget.textColor.withValues(alpha: 0.6), + ), + ], ], ), ], diff --git a/lib/frontend/widgets/online_dot.dart b/lib/frontend/widgets/online_dot.dart new file mode 100644 index 0000000..14c6bce --- /dev/null +++ b/lib/frontend/widgets/online_dot.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../core/cache/info_cache.dart'; + +class OnlineDot extends StatelessWidget { + final int userId; + final double size; + final Color color; + final Color borderColor; + final double borderWidth; + + const OnlineDot({ + super.key, + required this.userId, + required this.borderColor, + this.size = 12, + this.color = const Color(0xFF2EC36B), + this.borderWidth = 2, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: PresenceFetch.revision, + builder: (context, _, _) { + final online = PresenceFetch.isOnline(userId); + return AnimatedScale( + scale: online ? 1 : 0, + duration: const Duration(milliseconds: 220), + curve: online ? Curves.easeOutBack : Curves.easeIn, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: borderColor, width: borderWidth), + ), + ), + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5f75b96..fe5d080 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,11 +11,13 @@ 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/cache/self_presence.dart'; import 'core/storage/app_instance.dart'; import 'core/storage/draft_store.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; +import 'core/config/komet_settings.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; @@ -37,6 +39,7 @@ import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; +import 'backend/modules/self_check.dart'; import 'backend/modules/webapp.dart'; import 'backend/modules/digital_id.dart'; import 'core/calls/call_controller.dart'; @@ -123,6 +126,9 @@ void main() async { final prefs = await prefsFuture; await FileHistoryCache.load(prefs); await DraftStore.instance.load(); + await KometSettings.load(); + if (KometSettings.ghostMode.value) SelfPresence.markOfflineFromPing(); + await ContactCache.load(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false; @@ -255,6 +261,7 @@ class KometAppState extends State if (status == LoginStatus.success) { CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); + SelfCheckService.instance.init(api); if (isOnemeFlavor) { await PushService.instance.init(api: api, account: accountModule); await PushService.instance.onLoginSuccess();