feat: гост мод и фиксы разные всякие я линус торвальдс

This commit is contained in:
Jganenokk
2026-06-13 22:19:45 +07:00
parent e84cdf15e3
commit 814e969c4a
17 changed files with 1341 additions and 412 deletions
+14 -3
View File
@@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import '../core/cache/self_presence.dart';
import '../core/config/config.dart'; import '../core/config/config.dart';
import '../core/config/countries.dart'; import '../core/config/countries.dart';
import '../core/config/komet_settings.dart';
import '../core/protocol/opcode_map.dart'; import '../core/protocol/opcode_map.dart';
import '../core/protocol/packet.dart'; import '../core/protocol/packet.dart';
import '../core/storage/device_identity.dart'; import '../core/storage/device_identity.dart';
@@ -380,12 +382,21 @@ class Api {
void _startPinging() { void _startPinging() {
_pingTimer?.cancel(); _pingTimer?.cancel();
_pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) {
if (_connection.isConnected) { sendPing(interactive: !KometSettings.ghostMode.value);
_sender.send(_connection, Opcode.ping, {});
}
}); });
} }
void sendPing({required bool interactive}) {
if (_connection.isConnected) {
_sender.send(_connection, Opcode.ping, {'interactive': interactive});
if (interactive) {
SelfPresence.markOnline();
} else {
SelfPresence.markOfflineFromPing();
}
}
}
static List<CountryName>? _parseRegistrationCountries(dynamic payload) { static List<CountryName>? _parseRegistrationCountries(dynamic payload) {
if (payload is! Map) return null; if (payload is! Map) return null;
final raw = payload['reg-country-code']; final raw = payload['reg-country-code'];
+2 -1
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../api.dart'; import '../api.dart';
import '../../core/config/komet_settings.dart';
import '../../core/protocol/chat_cache_fingerprint.dart'; import '../../core/protocol/chat_cache_fingerprint.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart'; import '../../core/protocol/packet.dart';
@@ -1146,7 +1147,7 @@ class AccountModule {
) { ) {
final payload = <dynamic, dynamic>{ final payload = <dynamic, dynamic>{
'token': token, 'token': token,
'interactive': true, 'interactive': !KometSettings.ghostMode.value,
'exp': { 'exp': {
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
}, },
+96 -9
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../core/config/komet_settings.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart'; import '../../core/protocol/packet.dart';
import '../../core/cache/info_cache.dart'; import '../../core/cache/info_cache.dart';
@@ -180,6 +181,11 @@ class MessageRemovedEvent extends MessageEvent {
const MessageRemovedEvent(super.chatId, this.messageId); const MessageRemovedEvent(super.chatId, this.messageId);
} }
class MessageMarkedDeletedEvent extends MessageEvent {
final String messageId;
const MessageMarkedDeletedEvent(super.chatId, this.messageId);
}
class MessageReactionsChangedEvent extends MessageEvent { class MessageReactionsChangedEvent extends MessageEvent {
final String messageId; final String messageId;
final Map<String, dynamic>? reactionInfo; final Map<String, dynamic>? reactionInfo;
@@ -267,6 +273,11 @@ class ChatsModule {
String messageId, String messageId,
int mark, int mark,
) async { ) async {
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
final row = Map<String, dynamic>.from(rows.first);
if ((row['unread_count'] as int? ?? 0) == 0) return;
final msgIdNum = int.tryParse(messageId); final msgIdNum = int.tryParse(messageId);
if (msgIdNum != null) { if (msgIdNum != null) {
try { try {
@@ -279,10 +290,6 @@ class ChatsModule {
} catch (_) {} } catch (_) {}
} }
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
final row = Map<String, dynamic>.from(rows.first);
if ((row['unread_count'] as int? ?? 0) == 0) return;
row['unread_count'] = 0; row['unread_count'] = 0;
await AppDatabase.saveChats([row]); await AppDatabase.saveChats([row]);
_bump(); _bump();
@@ -367,9 +374,21 @@ class ChatsModule {
await _handleNotifMsgReactionsChanged(packet); await _handleNotifMsgReactionsChanged(packet);
case Opcode.notifMsgDelete: case Opcode.notifMsgDelete:
await _handleNotifMsgDelete(packet); 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<String, dynamic>.from(presence));
}
static Future<void> _handleNotifMsgDelete(Packet packet) async { static Future<void> _handleNotifMsgDelete(Packet packet) async {
final payload = packet.payload; final payload = packet.payload;
if (payload is! Map) return; if (payload is! Map) return;
@@ -386,13 +405,19 @@ class ChatsModule {
} }
if (chatId == null) return; if (chatId == null) return;
final keepDeleted = KometSettings.viewDeleted.value;
final ids = payload['messageIds']; final ids = payload['messageIds'];
if (ids is List) { if (ids is List) {
for (final raw in ids) { for (final raw in ids) {
final id = raw?.toString(); final id = raw?.toString();
if (id == null || id.isEmpty) continue; if (id == null || id.isEmpty) continue;
await AppDatabase.deleteMessage(accountId, chatId, id); if (keepDeleted) {
_messageEventsController.add(MessageRemovedEvent(chatId, id)); await AppDatabase.markMessageDeleted(accountId, chatId, id);
_messageEventsController.add(MessageMarkedDeletedEvent(chatId, id));
} else {
await AppDatabase.deleteMessage(accountId, chatId, id);
_messageEventsController.add(MessageRemovedEvent(chatId, id));
}
} }
} }
_bump(); _bump();
@@ -435,7 +460,12 @@ class ChatsModule {
} }
if (status == 'REMOVED' && msgIdStr != null) { 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); final cachedChat = CachedChat.fromDbRow(rows.first);
if (cachedChat.lastMsgId == msgIdInt) { if (cachedChat.lastMsgId == msgIdInt) {
await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread); await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread);
@@ -444,7 +474,11 @@ class ChatsModule {
newRow['unread_count'] = unread; newRow['unread_count'] = unread;
await AppDatabase.saveChats([newRow]); await AppDatabase.saveChats([newRow]);
} }
_messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr)); _messageEventsController.add(
keepDeleted
? MessageMarkedDeletedEvent(chatId, msgIdStr)
: MessageRemovedEvent(chatId, msgIdStr),
);
_bump(); _bump();
return; return;
} }
@@ -523,7 +557,12 @@ class ChatsModule {
Map<String, dynamic> chatRow, { Map<String, dynamic> chatRow, {
int? unread, int? unread,
}) async { }) async {
final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1); final latest = await AppDatabase.loadMessages(
accountId,
chatId,
limit: 1,
onlyVisible: true,
);
final newRow = Map<String, dynamic>.from(chatRow); final newRow = Map<String, dynamic>.from(chatRow);
if (latest.isNotEmpty) { if (latest.isNotEmpty) {
final m = latest.first; final m = latest.first;
@@ -576,6 +615,54 @@ class ChatsModule {
_bump(); _bump();
} }
static Future<List<String>> reconcileDeletedFromFetch(
int accountId,
int chatId,
List<CachedMessage> serverMessages,
) async {
if (serverMessages.isEmpty) return const [];
final serverIds = <String>{};
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 = <String>[];
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<void> _handleNotifMsgReactionsChanged(Packet packet) async { static Future<void> _handleNotifMsgReactionsChanged(Packet packet) async {
final payload = packet.payload; final payload = packet.payload;
if (payload is! Map) return; if (payload is! Map) return;
+98 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -14,13 +15,48 @@ class ContactCache {
static final Map<int, String> _avatarCache = {}; static final Map<int, String> _avatarCache = {};
static final Map<int, Set<String>> _optionsCache = {}; static final Map<int, Set<String>> _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) { static Future<void> load() async {
if (baseUrl != null) _avatarCache[id] = baseUrl; 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<String>().toSet();
});
} catch (_) {}
} }
static void putOptions(int id, Set<String> 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<String> opts) {
_optionsCache[id] = opts;
_scheduleSave();
}
static String? get(int id) => _nameCache[id]; static String? get(int id) => _nameCache[id];
static String? getAvatar(int id) => _avatarCache[id]; static String? getAvatar(int id) => _avatarCache[id];
@@ -32,6 +68,40 @@ class ContactCache {
_nameCache.clear(); _nameCache.clear();
_avatarCache.clear(); _avatarCache.clear();
_optionsCache.clear(); _optionsCache.clear();
_saveTimer?.cancel();
_saveTimer = null;
unawaited(_wipePersisted());
}
static void _scheduleSave() {
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(seconds: 3), () => unawaited(_save()));
}
static Future<void> _save() async {
final ids = <int>{
..._nameCache.keys,
..._avatarCache.keys,
..._optionsCache.keys,
};
final map = <String, dynamic>{};
for (final id in ids) {
final entry = <String, dynamic>{};
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<void> _wipePersisted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKey);
} }
} }
@@ -197,6 +267,7 @@ class CachedMessage {
final Map<String, dynamic>? payload; final Map<String, dynamic>? payload;
final List<MessageAttachment>? attachments; final List<MessageAttachment>? attachments;
final bool isControl; final bool isControl;
final bool deleted;
const CachedMessage({ const CachedMessage({
required this.id, required this.id,
@@ -209,8 +280,27 @@ class CachedMessage {
this.payload, this.payload,
this.attachments, this.attachments,
this.isControl = false, this.isControl = false,
this.deleted = false,
}); });
CachedMessage copyWith({
String? status,
bool? deleted,
List<MessageAttachment>? 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<String, dynamic> row) { factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
Map<String, dynamic>? payload; Map<String, dynamic>? payload;
final payloadRaw = row['payload']; final payloadRaw = row['payload'];
@@ -259,6 +349,9 @@ class CachedMessage {
attachments: attachments, attachments: attachments,
isControl: isControl:
attachments?.any((a) => a.type == AttachmentType.control) ?? false, 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, 'time': time,
'status': status, 'status': status,
'payload': payload != null ? jsonEncode(payload) : null, 'payload': payload != null ? jsonEncode(payload) : null,
'deleted': deleted ? 1 : 0,
}; };
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
+41
View File
@@ -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<void> _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,
);
}
}
+26 -2
View File
@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../backend/api.dart'; import '../../backend/api.dart';
import '../protocol/opcode_map.dart'; import '../protocol/opcode_map.dart';
@@ -132,8 +134,27 @@ class PresenceFetch {
static Map<String, dynamic>? peek(int id) => _cache.peek(id); static Map<String, dynamic>? peek(int id) => _cache.peek(id);
static final Map<int, Map<String, dynamic>> _live = {};
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Map<String, dynamic>? 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<String, dynamic> presence) {
if (id <= 0) return;
_live[id] = presence;
_cache.putValue(id, presence);
revision.value++;
}
static void invalidate(int id) => _cache.invalidate(id); 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<dynamic, dynamic> presence) { static void primeAll(Map<dynamic, dynamic> presence) {
final now = DateTime.now(); final now = DateTime.now();
@@ -141,8 +162,11 @@ class PresenceFetch {
if (value is! Map) return; if (value is! Map) return;
final id = key is int ? key : int.tryParse(key.toString()); final id = key is int ? key : int.tryParse(key.toString());
if (id == null) return; if (id == null) return;
_cache.putValue(id, Map<String, dynamic>.from(value), at: now); final map = Map<String, dynamic>.from(value);
_cache.putValue(id, map, at: now);
_live[id] = map;
}); });
revision.value++;
} }
static Future<Map<String, dynamic>?> _fetch(int id) async { static Future<Map<String, dynamic>?> _fetch(int id) async {
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/foundation.dart';
class SelfPresence {
static final ValueNotifier<bool> isOnline = ValueNotifier(true);
static final ValueNotifier<int?> 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;
}
}
}
+55
View File
@@ -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<bool> viewDeleted = ValueNotifier(false);
static final ValueNotifier<bool> viewRedacted = ValueNotifier(false);
static final ValueNotifier<bool> fullTimestamp = ValueNotifier(false);
static final ValueNotifier<bool> ghostMode = ValueNotifier(false);
static final ValueNotifier<bool> selfOnlineCheck = ValueNotifier(true);
static Future<void> 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<void> setViewDeleted(bool value) async {
viewDeleted.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kViewDeleted, value);
}
static Future<void> setViewRedacted(bool value) async {
viewRedacted.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kViewRedacted, value);
}
static Future<void> setFullTimestamp(bool value) async {
fullTimestamp.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kFullTimestamp, value);
}
static Future<void> setGhostMode(bool value) async {
ghostMode.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kGhostMode, value);
}
static Future<void> setSelfOnlineCheck(bool value) async {
selfOnlineCheck.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSelfOnlineCheck, value);
}
}
+73 -6
View File
@@ -185,7 +185,7 @@ class AppDatabase {
await _migrateLegacyDb(target); await _migrateLegacyDb(target);
return openDatabase( return openDatabase(
target, target,
version: 12, version: 13,
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => _createTables(db), onCreate: (db, _) => _createTables(db),
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: (db, oldVersion, newVersion) async {
@@ -243,6 +243,11 @@ class AppDatabase {
'ALTER TABLE chats_cache ADD COLUMN last_msg_status TEXT', '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, time INTEGER NOT NULL,
status TEXT, status TEXT,
payload TEXT, payload TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (id, account_id), PRIMARY KEY (id, account_id),
FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
) )
@@ -351,10 +357,17 @@ class AppDatabase {
static Future<void> saveProfile(ProfileData profile, {bool isActive = true}) async { static Future<void> saveProfile(ProfileData profile, {bool isActive = true}) async {
final db = await _instance; final db = await _instance;
await db.insert( final row = profile.toDbRow(isActive: isActive);
'profile', final cols = row.keys.toList();
profile.toDbRow(isActive: isActive), final placeholders = List.filled(cols.length, '?').join(', ');
conflictAlgorithm: ConflictAlgorithm.replace, 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<int> 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<int?> findDialogChatByParticipant(int accountId, int contactId) async { static Future<int?> findDialogChatByParticipant(int accountId, int contactId) async {
final db = await _instance; final db = await _instance;
final rows = await db.query( final rows = await db.query(
@@ -623,11 +652,14 @@ class AppDatabase {
int chatId, { int chatId, {
int? limit, int? limit,
int? offset, int? offset,
bool onlyVisible = false,
}) async { }) async {
final db = await _instance; final db = await _instance;
return db.query( return db.query(
'messages', '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], whereArgs: [accountId, chatId],
orderBy: 'time DESC', orderBy: 'time DESC',
limit: limit, limit: limit,
@@ -635,6 +667,41 @@ class AppDatabase {
); );
} }
static Future<void> 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<void> markMessagesDeleted(
int accountId,
int chatId,
List<String> 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<void> clearMessages(int accountId, int chatId) async { static Future<void> clearMessages(int accountId, int chatId) async {
final db = await _instance; final db = await _instance;
await db.delete( await db.delete(
+4 -2
View File
@@ -38,8 +38,10 @@ String formatDurationMmSs(Duration d, {bool padMinutes = false}) {
String formatSecondsMmSs(int seconds, {bool padMinutes = false}) => String formatSecondsMmSs(int seconds, {bool padMinutes = false}) =>
formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes); formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes);
/// "HH:mm". /// "HH:mm" or "HH:mm:ss" when [withSeconds] is set.
String formatClock(DateTime dt) => '${_two(dt.hour)}:${_two(dt.minute)}'; 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". /// "5 мая 2024".
String formatDateWords(DateTime dt) => String formatDateWords(DateTime dt) =>
@@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'create_group_flow.dart'; import 'create_group_flow.dart';
import '../../widgets/adaptive_shell.dart'; import '../../widgets/adaptive_shell.dart';
import '../../widgets/online_dot.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
@@ -1472,7 +1473,7 @@ class _ChatListScreenState extends State<ChatListScreen>
previewText, previewText,
_formatTime(chat.lastMsgTime), _formatTime(chat.lastMsgTime),
avatar ?? "", avatar ?? "",
isOnline: chat.isOnline, presenceUserId: secondId,
unreadCount: chat.unreadCount, unreadCount: chat.unreadCount,
isMuted: chat.isMuted, isMuted: chat.isMuted,
isVerified: isVerified, isVerified: isVerified,
@@ -1510,7 +1511,6 @@ class _ChatListScreenState extends State<ChatListScreen>
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty) (chat.iconUrl != null && chat.iconUrl!.isNotEmpty)
? chat.iconUrl! ? chat.iconUrl!
: '', : '',
isOnline: chat.isOnline,
unreadCount: chat.unreadCount, unreadCount: chat.unreadCount,
isMuted: chat.isMuted, isMuted: chat.isMuted,
isVerified: chat.isOfficial, isVerified: chat.isOfficial,
@@ -2127,7 +2127,7 @@ class _ChatListScreenState extends State<ChatListScreen>
String message, String message,
String time, String time,
String imageUrl, { String imageUrl, {
bool isOnline = false, int presenceUserId = 0,
bool isTyping = false, bool isTyping = false,
bool isRead = false, bool isRead = false,
int unreadCount = 0, int unreadCount = 0,
@@ -2238,18 +2238,13 @@ class _ChatListScreenState extends State<ChatListScreen>
), ),
), ),
) )
else if (isOnline) else if (presenceUserId != 0)
Positioned( Positioned(
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Container( child: OnlineDot(
width: 12, userId: presenceUserId,
height: 12, borderColor: cs.surface,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
border: Border.all(color: cs.surface, width: 2),
),
), ),
), ),
], ],
+328 -86
View File
@@ -35,8 +35,10 @@ import '../../../core/config/app_message_actions_style.dart';
import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_swipe_back_desktop.dart';
import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_pranks.dart';
import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_visual_style.dart';
import '../../../core/config/komet_settings.dart';
import '../../../models/attachment.dart'; import '../../../models/attachment.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/online_dot.dart';
import '../../widgets/message_bubble.dart'; import '../../widgets/message_bubble.dart';
import '../../widgets/theme_reveal.dart'; import '../../widgets/theme_reveal.dart';
import '../../widgets/message_actions_overlay.dart'; import '../../widgets/message_actions_overlay.dart';
@@ -176,11 +178,13 @@ class _ChatScreenState extends State<ChatScreen>
final Map<int, GlobalKey> _separatorKeys = {}; final Map<int, GlobalKey> _separatorKeys = {};
String? _lastSentId; String? _lastSentId;
String? _lastMarkedId; String? _lastMarkedId;
final ValueNotifier<int> _otherUnread = ValueNotifier(0);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
ChatsModule.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged); _messageController.addListener(_onTextChanged);
_scrollController.addListener(_onScrollForDate); _scrollController.addListener(_onScrollForDate);
AppVisualStyle.current.addListener(_onVisualStyleChanged); AppVisualStyle.current.addListener(_onVisualStyleChanged);
@@ -205,6 +209,7 @@ class _ChatScreenState extends State<ChatScreen>
_messageEventSub = ChatsModule.messageEvents _messageEventSub = ChatsModule.messageEvents
.where((e) => e.chatId == widget.chatId) .where((e) => e.chatId == widget.chatId)
.listen(_onMessageEvent); .listen(_onMessageEvent);
PresenceFetch.revision.addListener(_onPresenceChanged);
_floatingDateAnimController = AnimationController( _floatingDateAnimController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
@@ -237,6 +242,7 @@ class _ChatScreenState extends State<ChatScreen>
if (!mounted) return; if (!mounted) return;
_myId = p?.id ?? 0; _myId = p?.id ?? 0;
_restoreDraft(); _restoreDraft();
unawaited(_refreshBadge());
ChatsModule.getChat(_myId, widget.chatId) ChatsModule.getChat(_myId, widget.chatId)
.then((value) { .then((value) {
@@ -255,6 +261,7 @@ class _ChatScreenState extends State<ChatScreen>
_myId, _myId,
widget.chatId, widget.chatId,
limit: 20, limit: 20,
onlyVisible: !KometSettings.viewDeleted.value,
); );
if (!mounted) return; if (!mounted) return;
if (firstRows.isNotEmpty) { if (firstRows.isNotEmpty) {
@@ -326,6 +333,37 @@ class _ChatScreenState extends State<ChatScreen>
); );
} }
bool _badgeRefreshing = false;
bool _badgeRefreshQueued = false;
void _onChatsBump() {
if (_badgeRefreshing) {
_badgeRefreshQueued = true;
return;
}
unawaited(_runBadgeRefresh());
}
Future<void> _runBadgeRefresh() async {
_badgeRefreshing = true;
try {
await _refreshBadge();
} finally {
_badgeRefreshing = false;
if (_badgeRefreshQueued && mounted) {
_badgeRefreshQueued = false;
unawaited(_runBadgeRefresh());
}
}
}
Future<void> _refreshBadge() async {
if (_myId == 0) return;
final total =
await AppDatabase.sumUnread(_myId, excludeChatId: widget.chatId);
if (mounted) _otherUnread.value = total;
}
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
if (_myId == 0) { if (_myId == 0) {
final activeProfile = await AppDatabase.loadActiveProfile(); final activeProfile = await AppDatabase.loadActiveProfile();
@@ -340,10 +378,12 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<void> _loadRemainingHistory() async { Future<void> _loadRemainingHistory() async {
final onlyVisible = !KometSettings.viewDeleted.value;
final fullRows = await AppDatabase.loadMessages( final fullRows = await AppDatabase.loadMessages(
_myId, _myId,
widget.chatId, widget.chatId,
limit: 100, limit: 100,
onlyVisible: onlyVisible,
); );
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows); final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
if (mounted && fullDecoded.length > _messages.length) { if (mounted && fullDecoded.length > _messages.length) {
@@ -363,12 +403,23 @@ class _ChatScreenState extends State<ChatScreen>
} }
try { try {
await messagesModule.fetchHistory(_myId, widget.chatId); final serverMessages = await messagesModule.fetchHistory(
_myId,
widget.chatId,
);
ChatsModule.markHistoryFetched(widget.chatId); ChatsModule.markHistoryFetched(widget.chatId);
if (KometSettings.viewDeleted.value) {
await ChatsModule.reconcileDeletedFromFetch(
_myId,
widget.chatId,
serverMessages,
);
}
final updatedRows = await AppDatabase.loadMessages( final updatedRows = await AppDatabase.loadMessages(
_myId, _myId,
widget.chatId, widget.chatId,
limit: 100, limit: 100,
onlyVisible: onlyVisible,
); );
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows); final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
if (mounted) { if (mounted) {
@@ -446,7 +497,8 @@ class _ChatScreenState extends State<ChatScreen>
a.time == b.time && a.time == b.time &&
a.status == b.status && a.status == b.status &&
a.text == b.text && a.text == b.text &&
a.senderId == b.senderId; a.senderId == b.senderId &&
a.deleted == b.deleted;
} }
bool _listsEquivalent(List<CachedMessage> a, List<CachedMessage> b) { bool _listsEquivalent(List<CachedMessage> a, List<CachedMessage> b) {
@@ -475,6 +527,8 @@ class _ChatScreenState extends State<ChatScreen>
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
ChatsModule.chatsChanged.removeListener(_onChatsBump);
_otherUnread.dispose();
_saveDraft(); _saveDraft();
_messageController.removeListener(_onTextChanged); _messageController.removeListener(_onTextChanged);
_scrollController.removeListener(_onScrollForDate); _scrollController.removeListener(_onScrollForDate);
@@ -502,6 +556,7 @@ class _ChatScreenState extends State<ChatScreen>
t.cancel(); t.cancel();
} }
_typingTimers.clear(); _typingTimers.clear();
PresenceFetch.revision.removeListener(_onPresenceChanged);
_headerStatusNotifier.dispose(); _headerStatusNotifier.dispose();
_otherReadTime.dispose(); _otherReadTime.dispose();
_messagesRev.dispose(); _messagesRev.dispose();
@@ -972,6 +1027,12 @@ class _ChatScreenState extends State<ChatScreen>
_messages.removeAt(idx); _messages.removeAt(idx);
_bumpMessages(); _bumpMessages();
_reactionNotifiers.remove(messageId)?.dispose(); _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): case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
_reactionNotifiers[messageId]?.value = reactionInfo; _reactionNotifiers[messageId]?.value = reactionInfo;
} }
@@ -981,19 +1042,97 @@ class _ChatScreenState extends State<ChatScreen>
if (_myId == 0) return; if (_myId == 0) return;
final otherId = widget.chatId ^ _myId; final otherId = widget.chatId ^ _myId;
if (otherId <= 0) return; if (otherId <= 0) return;
if (PresenceFetch.live(otherId) != null) return;
try { try {
final entry = await PresenceFetch.get(otherId); final entry = await PresenceFetch.get(otherId);
if (!mounted || entry == null) return; if (!mounted || entry == null) return;
_otherStatus = (entry['status'] as int?) ?? 0; PresenceFetch.apply(otherId, entry);
_otherSeenTime = entry['seen'] as int?;
_recomputeHeaderStatus();
} catch (_) {} } 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() { void _onVisualStyleChanged() {
if (mounted) setState(() {}); 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<int>(
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) { PreferredSizeWidget _materialAppBar(ColorScheme cs) {
return PreferredSize( return PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight), preferredSize: Size.fromHeight(kToolbarHeight),
@@ -1015,43 +1154,51 @@ class _ChatScreenState extends State<ChatScreen>
elevation: 0, elevation: 0,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface), iconTheme: IconThemeData(color: cs.onSurface),
leading: IconButton( leading: _backWithBadge(
icon: Icon( cs,
widget.embedded ? Symbols.close : Symbols.arrow_back, IconButton(
weight: 400, 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, titleSpacing: 0,
title: Row( title: Row(
children: [ children: [
if (widget.imageUrl.isNotEmpty) _withOnlineDot(
CircleAvatar( cs,
radius: 18, widget.imageUrl.isNotEmpty
backgroundImage: CachedNetworkImageProvider( ? CircleAvatar(
widget.imageUrl, radius: 18,
maxWidth: 144, backgroundImage: CachedNetworkImageProvider(
maxHeight: 144, widget.imageUrl,
), maxWidth: 144,
) maxHeight: 144,
else ),
CircleAvatar( )
radius: 18, : CircleAvatar(
backgroundColor: cs.primaryContainer, radius: 18,
child: Text( backgroundColor: cs.primaryContainer,
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', child: Text(
style: TextStyle( widget.name.isNotEmpty
color: cs.onPrimaryContainer, ? widget.name[0].toUpperCase()
fontSize: 12, : '?',
), style: TextStyle(
), color: cs.onPrimaryContainer,
), fontSize: 12,
),
),
),
dotSize: 11,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -1131,9 +1278,10 @@ class _ChatScreenState extends State<ChatScreen>
return; return;
} }
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова. // Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
final navigator = Navigator.of(context);
final active = CallController.instance.activeSession; final active = CallController.instance.activeSession;
if (active != null) { if (active != null) {
Navigator.of(context).push( await navigator.push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => CallScreen( builder: (_) => CallScreen(
name: widget.name, name: widget.name,
@@ -1142,15 +1290,15 @@ class _ChatScreenState extends State<ChatScreen>
), ),
), ),
); );
_onCallScreenClosed();
return; return;
} }
final peerId = widget.chatId ^ _myId; final peerId = widget.chatId ^ _myId;
if (peerId <= 0) return; if (peerId <= 0) return;
final navigator = Navigator.of(context);
try { try {
final session = await CallController.instance.startOutgoing(peerId); final session = await CallController.instance.startOutgoing(peerId);
if (!mounted) return; if (!mounted) return;
navigator.push( await navigator.push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => CallScreen( builder: (_) => CallScreen(
name: widget.name, name: widget.name,
@@ -1159,18 +1307,49 @@ class _ChatScreenState extends State<ChatScreen>
), ),
), ),
); );
_onCallScreenClosed();
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
showCustomNotification(context, 'Не удалось начать звонок'); showCustomNotification(context, 'Не удалось начать звонок');
} }
} }
void _onCallScreenClosed() {
if (!mounted) return;
if (CallController.instance.activeSession != null) return;
unawaited(_refreshAfterCall());
}
Future<void> _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() { void _seedPresenceFromChat() {
if (widget.chatType != 'DIALOG' || _myId == 0) return; if (widget.chatType != 'DIALOG' || _myId == 0) return;
if (_otherStatus != 0 || _otherSeenTime != null) return; if (_otherStatus != 0 || _otherSeenTime != null) return;
final otherId = widget.chatId ^ _myId; final otherId = widget.chatId ^ _myId;
if (otherId <= 0) return; if (otherId <= 0) return;
final p = PresenceFetch.peek(otherId); final p = PresenceFetch.live(otherId);
if (p == null) return; if (p == null) return;
_otherStatus = (p['status'] as int?) ?? 0; _otherStatus = (p['status'] as int?) ?? 0;
_otherSeenTime = p['seen'] as int?; _otherSeenTime = p['seen'] as int?;
@@ -1414,7 +1593,8 @@ class _ChatScreenState extends State<ChatScreen>
if (msg.attachments != null) { if (msg.attachments != null) {
for (final a in msg.attachments!) { for (final a in msg.attachments!) {
if (a is ForwardedMessageAttachment) { if (a is ForwardedMessageAttachment) {
if (a.originalSenderName == null) { if (a.originalSenderName == null &&
ContactCache.get(a.originalSenderId) == null) {
forwardIds.add(a.originalSenderId); forwardIds.add(a.originalSenderId);
} }
} }
@@ -1692,25 +1872,28 @@ class _ChatScreenState extends State<ChatScreen>
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
child: Row( child: Row(
children: [ children: [
SizedBox( _backWithBadge(
width: 56, cs,
height: 56, SizedBox(
child: GlossyPill( width: 56,
onTap: () { height: 56,
if (widget.embedded) { child: GlossyPill(
widget.onClose?.call(); onTap: () {
} else { if (widget.embedded) {
Navigator.pop(context); widget.onClose?.call();
} } else {
}, Navigator.pop(context);
child: Center( }
child: Icon( },
widget.embedded child: Center(
? Symbols.close child: Icon(
: Symbols.arrow_back, widget.embedded
color: cs.onSurface, ? Symbols.close
weight: 500, : Symbols.arrow_back,
size: 24, color: cs.onSurface,
weight: 500,
size: 24,
),
), ),
), ),
), ),
@@ -1732,31 +1915,34 @@ class _ChatScreenState extends State<ChatScreen>
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
child: Row( child: Row(
children: [ children: [
if (widget.imageUrl.isNotEmpty) _withOnlineDot(
CircleAvatar( cs,
radius: 22, widget.imageUrl.isNotEmpty
backgroundImage: CachedNetworkImageProvider( ? CircleAvatar(
widget.imageUrl, radius: 22,
maxWidth: 144, backgroundImage:
maxHeight: 144, CachedNetworkImageProvider(
), widget.imageUrl,
) maxWidth: 144,
else maxHeight: 144,
CircleAvatar( ),
radius: 22, )
backgroundColor: cs.primaryContainer, : CircleAvatar(
child: Text( radius: 22,
widget.name.isNotEmpty backgroundColor: cs.primaryContainer,
? widget.name[0].toUpperCase() child: Text(
: '?', widget.name.isNotEmpty
style: TextStyle( ? widget.name[0].toUpperCase()
color: cs.onPrimaryContainer, : '?',
fontSize: 16, style: TextStyle(
fontWeight: FontWeight.w600, color: cs.onPrimaryContainer,
fontFamily: 'Outfit', fontSize: 16,
), fontWeight: FontWeight.w600,
), fontFamily: 'Outfit',
), ),
),
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( 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<int>).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<int>(widget.count),
style: widget.style,
),
);
}
}
@@ -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<void> _setGhostMode(bool value) async {
await KometSettings.setGhostMode(value);
api.sendPing(interactive: !value);
}
Widget _card(ColorScheme cs, List<Widget> 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<bool> notifier,
required Future<void> Function(bool) onChanged,
}) {
return ValueListenableBuilder<bool>(
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),
],
),
),
),
);
},
);
}
}
+78 -2
View File
@@ -5,8 +5,11 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../backend/modules/chats.dart'; import '../../../backend/modules/chats.dart';
import '../../../backend/modules/messages.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/app_database.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
@@ -28,6 +31,7 @@ import 'debug_menu_screen.dart';
import 'devices_screen.dart'; import 'devices_screen.dart';
import 'edit_profile_screen.dart'; import 'edit_profile_screen.dart';
import 'info_screen.dart'; import 'info_screen.dart';
import 'komet_settings_screen.dart';
import 'notifications_screen.dart'; import 'notifications_screen.dart';
import 'security_screen.dart'; import 'security_screen.dart';
import 'spoof_screen.dart'; import 'spoof_screen.dart';
@@ -286,6 +290,19 @@ class _SettingsTabState extends State<SettingsTab> {
); );
}, },
), ),
_SettingsItem(
// Иконку кометы блять дайте!!!!!!!1
icon: Symbols.auto_awesome,
label: 'Komet',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const KometSettingsScreen(),
),
);
},
),
_SettingsItem( _SettingsItem(
icon: Symbols.info, icon: Symbols.info,
label: 'Info', label: 'Info',
@@ -584,7 +601,7 @@ class _SettingsTabState extends State<SettingsTab> {
fontSize: 32, fontSize: 32,
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 8),
Text( Text(
name, name,
style: TextStyle( style: TextStyle(
@@ -594,7 +611,8 @@ class _SettingsTabState extends State<SettingsTab> {
fontFamily: 'Outfit', fontFamily: 'Outfit',
), ),
), ),
const SizedBox(height: 4), _buildOnlineStatus(cs),
const SizedBox(height: 6),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -627,6 +645,64 @@ class _SettingsTabState extends State<SettingsTab> {
); );
} }
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<bool>(
valueListenable: KometSettings.selfOnlineCheck,
builder: (context, enabled, _) {
if (!enabled) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: ValueListenableBuilder<bool>(
valueListenable: SelfPresence.isOnline,
builder: (context, online, _) => ValueListenableBuilder<int?>(
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( Widget _buildSection(
BuildContext context, BuildContext context,
ColorScheme cs, { ColorScheme cs, {
+267 -285
View File
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart'; import '../../backend/modules/messages.dart';
import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart'; import '../../core/config/app_bubble_shape.dart';
import '../../core/config/komet_settings.dart';
import '../../core/utils/bubble_radius.dart'; import '../../core/utils/bubble_radius.dart';
import '../../core/utils/format.dart'; import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
@@ -51,7 +52,7 @@ class _BubbleCtx {
} }
final Expando<MessageType> _contentTypeCache = Expando<MessageType>(); final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
final Expando<String> _clockTextCache = Expando<String>(); final Expando<({bool full, String text})> _clockTextCache = Expando();
class MessageBubble extends StatelessWidget { class MessageBubble extends StatelessWidget {
static const double photoMaxSize = 280.0; static const double photoMaxSize = 280.0;
@@ -149,9 +150,17 @@ class MessageBubble extends StatelessWidget {
return _contentTypeCache[message] ??= _computeContentType(); return _contentTypeCache[message] ??= _computeContentType();
} }
String get _clockText => _clockTextCache[message] ??= formatClock( String get _clockText {
DateTime.fromMillisecondsSinceEpoch(message.time), 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() { MessageType _computeContentType() {
if (message.isControl) return MessageType.control; if (message.isControl) return MessageType.control;
@@ -653,6 +662,10 @@ class MessageBubble extends StatelessWidget {
child: metaWidget, child: metaWidget,
), ),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], 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, child: metaWidget,
), ),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], 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, ForwardedMessageAttachment forwarded,
) { ) {
final headerColor = ctx.dim; final headerColor = ctx.dim;
final senderName = forwarded.originalSenderName; final displaySender =
final displaySender = senderName ?? forwarded.originalSenderId.toString(); forwarded.originalSenderName ??
final senderAvatar = forwarded.originalSenderAvatar; ContactCache.get(forwarded.originalSenderId) ??
forwarded.originalSenderId.toString();
final senderAvatar =
forwarded.originalSenderAvatar ??
ContactCache.getAvatar(forwarded.originalSenderId);
final origText = forwarded.originalText; final origText = forwarded.originalText;
final hasOrigText = origText != null && origText.isNotEmpty; 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() { ForwardedMessageAttachment? _getForwardedAttachment() {
final attachments = message.attachments; final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) return null; if (attachments == null || attachments.isEmpty) return null;
@@ -1053,59 +1130,13 @@ class MessageBubble extends StatelessWidget {
ForwardedMessageAttachment forwarded, ForwardedMessageAttachment forwarded,
List<PhotoAttachment> photos, List<PhotoAttachment> photos,
) { ) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
final hasCaption = message.text != null && message.text!.isNotEmpty; final hasCaption = message.text != null && message.text!.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Padding( _buildForwardedHeader(ctx, forwarded),
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), const SizedBox(height: 4),
if (hasCaption) ...[ if (hasCaption) ...[
Padding( Padding(
@@ -1127,66 +1158,21 @@ class MessageBubble extends StatelessWidget {
ForwardedMessageAttachment forwarded, ForwardedMessageAttachment forwarded,
List<MessageAttachment> attachments, List<MessageAttachment> attachments,
) { ) {
final headerColor = ctx.dim; return IntrinsicWidth(
final displaySender = child: Column(
forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); crossAxisAlignment: CrossAxisAlignment.stretch,
final senderAvatar = forwarded.originalSenderAvatar; mainAxisSize: MainAxisSize.min,
children: [
return Column( _buildForwardedHeader(ctx, forwarded),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(height: 4),
mainAxisSize: MainAxisSize.min, ...attachments.map((a) {
children: [ if (a is FileAttachment) {
Padding( return _buildFileAttachment(ctx, a, fill: true);
padding: const EdgeInsets.only(left: 8), }
child: Row( return const SizedBox.shrink();
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();
}),
],
); );
} }
@@ -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 name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0; final size = (file as dynamic).size as int? ?? 0;
final sizeStr = formatBytes(size); final sizeStr = formatBytes(size);
@@ -1784,132 +1774,129 @@ class MessageBubble extends StatelessWidget {
final preview = file is FileAttachment ? file.preview : null; final preview = file is FileAttachment ? file.preview : null;
final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; final previewUrl = preview?.baseUrl ?? preview?.previewData ?? '';
return IntrinsicWidth( final inner = Padding(
child: Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (previewUrl.isNotEmpty) ...[
if (previewUrl.isNotEmpty) ...[ ClipRRect(
ClipRRect( borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(10), child: CachedNetworkImage(
child: CachedNetworkImage( imageUrl: previewUrl,
imageUrl: previewUrl, width: 240,
width: 240, height: 160,
height: 160, fit: BoxFit.cover,
fit: BoxFit.cover, memCacheWidth: 480,
memCacheWidth: 480, fadeInDuration: const Duration(milliseconds: 120),
fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => const SizedBox.shrink(),
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), const SizedBox(width: 10),
], Flexible(
Row( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Text(
width: 38, name,
height: 38, style: TextStyle(
decoration: BoxDecoration( color: ctx.text,
color: isMe fontSize: 14,
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) fontWeight: FontWeight.w500,
: ctx.cs.primaryContainer, height: 1.2,
borderRadius: BorderRadius.circular(10), ),
), maxLines: 2,
child: Icon( overflow: TextOverflow.ellipsis,
Symbols.description, ),
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, const SizedBox(height: 2),
size: 20, ValueListenableBuilder<double?>(
), valueListenable: MediaDownloadProgress.notifier(
), cacheName,
const SizedBox(width: 10), ),
Flexible( builder: (context, progress, _) => Text(
child: Column( progress != null
crossAxisAlignment: CrossAxisAlignment.start, ? '${(progress * 100).round()}% · $sizeStr'
mainAxisSize: MainAxisSize.min, : sizeStr,
children: [
Text(
name,
style: TextStyle( style: TextStyle(
color: ctx.text, color: ctx.dim,
fontSize: 14, fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2, height: 1.2,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), ),
ValueListenableBuilder<double?>( ],
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<double?>( const SizedBox(width: 12),
valueListenable: MediaDownloadProgress.notifier(cacheName), ValueListenableBuilder<double?>(
builder: (context, progress, _) { valueListenable: MediaDownloadProgress.notifier(cacheName),
final downloading = progress != null; builder: (context, progress, _) {
return GestureDetector( final downloading = progress != null;
onTap: downloading return GestureDetector(
? null onTap: downloading
: () => _downloadFile(ctx.context, file, name), ? null
child: Container( : () => _downloadFile(ctx.context, file, name),
width: 34, child: Container(
height: 34, width: 34,
decoration: BoxDecoration( height: 34,
color: isMe decoration: BoxDecoration(
? ctx.cs.onPrimaryContainer.withValues( color: isMe
alpha: 0.12, ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
) : ctx.cs.surfaceContainerHighest,
: ctx.cs.surfaceContainerHighest, shape: BoxShape.circle,
shape: BoxShape.circle, ),
), child: downloading
child: downloading ? Padding(
? Padding( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8), child: CircularProgressIndicator(
child: CircularProgressIndicator( strokeWidth: 2,
strokeWidth: 2, value: progress > 0 ? progress : null,
value: progress > 0 ? progress : null,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
),
)
: Icon(
Symbols.download,
color: isMe color: isMe
? ctx.cs.onPrimaryContainer ? ctx.cs.onPrimaryContainer
: ctx.cs.primary, : ctx.cs.primary,
size: 18,
), ),
), )
); : Icon(
}, Symbols.download,
), color: isMe
], ? ctx.cs.onPrimaryContainer
), : ctx.cs.primary,
_buildMeta(ctx), size: 18,
], ),
), ),
);
},
),
],
),
_buildMeta(ctx),
],
), ),
); );
return fill ? inner : IntrinsicWidth(child: inner);
} }
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
@@ -2047,59 +2034,12 @@ class MessageBubble extends StatelessWidget {
final bgColor = isMe final bgColor = isMe
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
: ctx.cs.surfaceContainerHighest; : ctx.cs.surfaceContainerHighest;
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Padding( _buildForwardedHeader(ctx, forwarded),
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), const SizedBox(height: 4),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@@ -2254,6 +2194,7 @@ class MessageBubble extends StatelessWidget {
url: url, url: url,
textColor: ctx.text, textColor: ctx.text,
isMe: isMe, isMe: isMe,
deleted: message.deleted,
status: overrideStatus ?? message.status, status: overrideStatus ?? message.status,
time: message.time, time: message.time,
cs: ctx.cs, cs: ctx.cs,
@@ -2274,6 +2215,10 @@ class MessageBubble extends StatelessWidget {
children: [ children: [
Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)), Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], 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, color: bgColor,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Row(
_clockText, mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
color: Colors.white, Text(
fontSize: 10, _clockText,
fontWeight: FontWeight.w500, 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) { Widget _buildStatusIcon(_BubbleCtx ctx) {
final status = overrideStatus ?? message.status; final status = overrideStatus ?? message.status;
IconData icon; IconData icon;
@@ -2338,6 +2296,7 @@ class _VoiceMessageBubble extends StatefulWidget {
final String url; final String url;
final Color textColor; final Color textColor;
final bool isMe; final bool isMe;
final bool deleted;
final String? status; final String? status;
final int time; final int time;
final ColorScheme cs; final ColorScheme cs;
@@ -2352,6 +2311,7 @@ class _VoiceMessageBubble extends StatefulWidget {
required this.url, required this.url,
required this.textColor, required this.textColor,
required this.isMe, required this.isMe,
this.deleted = false,
this.status, this.status,
required this.time, required this.time,
required this.cs, required this.cs,
@@ -2565,7 +2525,10 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
), ),
if (!_transcriptionVisible) ...[ if (!_transcriptionVisible) ...[
Text( Text(
formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), formatClock(
DateTime.fromMillisecondsSinceEpoch(widget.time),
withSeconds: KometSettings.fullTimestamp.value,
),
style: TextStyle( style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6), color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10, fontSize: 10,
@@ -2575,6 +2538,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
const SizedBox(width: 2), const SizedBox(width: 2),
_buildStatusIcon(), _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, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Text( Text(
formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), formatClock(
DateTime.fromMillisecondsSinceEpoch(widget.time),
withSeconds: KometSettings.fullTimestamp.value,
),
style: TextStyle( style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6), color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10, fontSize: 10,
@@ -2593,6 +2567,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
const SizedBox(width: 2), const SizedBox(width: 2),
_buildStatusIcon(), _buildStatusIcon(),
], ],
if (widget.deleted) ...[
const SizedBox(width: 2),
Icon(
Symbols.delete,
size: 13,
color: widget.textColor.withValues(alpha: 0.6),
),
],
], ],
), ),
], ],
+44
View File
@@ -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<int>(
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),
),
),
);
},
);
}
}
+7
View File
@@ -11,11 +11,13 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'backend/api.dart'; import 'backend/api.dart';
import 'core/cache/info_cache.dart'; import 'core/cache/info_cache.dart';
import 'core/cache/self_presence.dart';
import 'core/storage/app_instance.dart'; import 'core/storage/app_instance.dart';
import 'core/storage/draft_store.dart'; import 'core/storage/draft_store.dart';
import 'core/config/app_accent.dart'; import 'core/config/app_accent.dart';
import 'core/config/app_amoled.dart'; import 'core/config/app_amoled.dart';
import 'core/config/app_bubble_behavior.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_bubble_shape.dart';
import 'core/config/app_cache_extent.dart'; import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.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/messages.dart';
import 'backend/modules/outbox.dart'; import 'backend/modules/outbox.dart';
import 'backend/modules/polls.dart'; import 'backend/modules/polls.dart';
import 'backend/modules/self_check.dart';
import 'backend/modules/webapp.dart'; import 'backend/modules/webapp.dart';
import 'backend/modules/digital_id.dart'; import 'backend/modules/digital_id.dart';
import 'core/calls/call_controller.dart'; import 'core/calls/call_controller.dart';
@@ -123,6 +126,9 @@ void main() async {
final prefs = await prefsFuture; final prefs = await prefsFuture;
await FileHistoryCache.load(prefs); await FileHistoryCache.load(prefs);
await DraftStore.instance.load(); 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 initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false; final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false;
@@ -255,6 +261,7 @@ class KometAppState extends State<KometApp>
if (status == LoginStatus.success) { if (status == LoginStatus.success) {
CallController.instance.init(api); CallController.instance.init(api);
OutboxService.instance.init(api, messagesModule); OutboxService.instance.init(api, messagesModule);
SelfCheckService.instance.init(api);
if (isOnemeFlavor) { if (isOnemeFlavor) {
await PushService.instance.init(api: api, account: accountModule); await PushService.instance.init(api: api, account: accountModule);
await PushService.instance.onLoginSuccess(); await PushService.instance.onLoginSuccess();