feat: гост мод и фиксы разные всякие я линус торвальдс
This commit is contained in:
+14
-3
@@ -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,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]),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 '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 {
|
||||||
|
|||||||
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);
|
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(
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 '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();
|
||||||
|
|||||||
Reference in New Issue
Block a user