feat: гост мод и фиксы разные всякие я линус торвальдс
This commit is contained in:
+14
-3
@@ -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<CountryName>? _parseRegistrationCountries(dynamic payload) {
|
||||
if (payload is! Map) return null;
|
||||
final raw = payload['reg-country-code'];
|
||||
|
||||
@@ -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 = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'interactive': true,
|
||||
'interactive': !KometSettings.ghostMode.value,
|
||||
'exp': {
|
||||
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
|
||||
},
|
||||
|
||||
@@ -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<String, dynamic>? 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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.from(presence));
|
||||
}
|
||||
|
||||
static Future<void> _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<String, dynamic> 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<String, dynamic>.from(chatRow);
|
||||
if (latest.isNotEmpty) {
|
||||
final m = latest.first;
|
||||
@@ -576,6 +615,54 @@ class ChatsModule {
|
||||
_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 {
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
|
||||
@@ -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<int, String> _avatarCache = {};
|
||||
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) {
|
||||
if (baseUrl != null) _avatarCache[id] = baseUrl;
|
||||
static Future<void> 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<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? 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<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 List<MessageAttachment>? 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<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) {
|
||||
Map<String, dynamic>? 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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+26
-2
@@ -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<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 clear() => _cache.clear();
|
||||
|
||||
static void clear() {
|
||||
_cache.clear();
|
||||
_live.clear();
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
static void primeAll(Map<dynamic, dynamic> 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<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 {
|
||||
|
||||
Vendored
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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<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 {
|
||||
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<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 {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<ChatListScreen>
|
||||
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<ChatListScreen>
|
||||
(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<ChatListScreen>
|
||||
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<ChatListScreen>
|
||||
),
|
||||
),
|
||||
)
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<ChatScreen>
|
||||
final Map<int, GlobalKey> _separatorKeys = {};
|
||||
String? _lastSentId;
|
||||
String? _lastMarkedId;
|
||||
final ValueNotifier<int> _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<ChatScreen>
|
||||
_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<ChatScreen>
|
||||
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<ChatScreen>
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 20,
|
||||
onlyVisible: !KometSettings.viewDeleted.value,
|
||||
);
|
||||
if (!mounted) return;
|
||||
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 {
|
||||
if (_myId == 0) {
|
||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||
@@ -340,10 +378,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _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<ChatScreen>
|
||||
}
|
||||
|
||||
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<ChatScreen>
|
||||
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<CachedMessage> a, List<CachedMessage> b) {
|
||||
@@ -475,6 +527,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
@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<ChatScreen>
|
||||
t.cancel();
|
||||
}
|
||||
_typingTimers.clear();
|
||||
PresenceFetch.revision.removeListener(_onPresenceChanged);
|
||||
_headerStatusNotifier.dispose();
|
||||
_otherReadTime.dispose();
|
||||
_messagesRev.dispose();
|
||||
@@ -972,6 +1027,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_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<ChatScreen>
|
||||
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<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) {
|
||||
return PreferredSize(
|
||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
||||
@@ -1015,43 +1154,51 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
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<ChatScreen>
|
||||
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<ChatScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
_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<ChatScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
_onCallScreenClosed();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
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() {
|
||||
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<ChatScreen>
|
||||
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<ChatScreen>
|
||||
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<ChatScreen>
|
||||
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<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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsTab> {
|
||||
);
|
||||
},
|
||||
),
|
||||
_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<SettingsTab> {
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
@@ -594,7 +611,8 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
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<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(
|
||||
BuildContext context,
|
||||
ColorScheme cs, {
|
||||
|
||||
@@ -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<MessageType> _contentTypeCache = Expando<MessageType>();
|
||||
final Expando<String> _clockTextCache = Expando<String>();
|
||||
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<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;
|
||||
|
||||
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<MessageAttachment> 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<double?>(
|
||||
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<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?>(
|
||||
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<double?>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<KometApp>
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user