немножко реал тайма в chat screen
This commit is contained in:
@@ -153,6 +153,42 @@ class ChatsModule {
|
|||||||
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||||
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||||
|
|
||||||
|
static StreamSubscription<Packet>? _globalPushSub;
|
||||||
|
|
||||||
|
static void attachGlobalPushHandlers(Api api) {
|
||||||
|
_globalPushSub?.cancel();
|
||||||
|
_globalPushSub = api.pushStream.listen(_handleGlobalPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _handleGlobalPush(Packet packet) async {
|
||||||
|
switch (packet.opcode) {
|
||||||
|
case Opcode.notifMark:
|
||||||
|
await _handleNotifMark(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _handleNotifMark(Packet packet) async {
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
final chatId = payload['chatId'];
|
||||||
|
if (chatId is! int) return;
|
||||||
|
final userId = payload['userId'];
|
||||||
|
if (userId is! int) return;
|
||||||
|
final mark = payload['mark'];
|
||||||
|
if (mark is! int) return;
|
||||||
|
if (payload['setAsUnread'] == true) return;
|
||||||
|
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId == null) return;
|
||||||
|
|
||||||
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
final cached = CachedChat.fromDbRow(rows.first);
|
||||||
|
if (cached.participants[userId] == mark) return;
|
||||||
|
cached.participants[userId] = mark;
|
||||||
|
await AppDatabase.saveChats([cached.toDbRow()]);
|
||||||
|
}
|
||||||
|
|
||||||
static final Set<int> _pendingContactUpdates = {};
|
static final Set<int> _pendingContactUpdates = {};
|
||||||
static Timer? _contactFlushTimer;
|
static Timer? _contactFlushTimer;
|
||||||
static Future<void>? _contactFlushFuture;
|
static Future<void>? _contactFlushFuture;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const int _maxDecompressedSize = 1048576; // 1 MB
|
|||||||
|
|
||||||
/// Типы команд в протоколе
|
/// Типы команд в протоколе
|
||||||
abstract class CmdType {
|
abstract class CmdType {
|
||||||
static const int request = 0; // запрос клиента
|
static const int request = 0; // запрос клиента / пуш от сервера (направление определяет смысл)
|
||||||
static const int push = 1; // пуш от сервера
|
static const int push = 0; // пуш от сервера (имеет смысл только для incoming)
|
||||||
|
|
||||||
static const int ok = 1; // ответ: ок
|
static const int ok = 1; // ответ: ок
|
||||||
static const int notFound = 2; // ответ: не найдено
|
static const int notFound = 2; // ответ: не найдено
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
Map<String, dynamic>? _contactData;
|
Map<String, dynamic>? _contactData;
|
||||||
int? _seenTime;
|
int? _seenTime;
|
||||||
bool _isOnline = false;
|
bool _isOnline = false;
|
||||||
|
int _presenceStatus = 0;
|
||||||
bool _isBot = false;
|
bool _isBot = false;
|
||||||
|
|
||||||
// CHAT
|
// CHAT
|
||||||
@@ -144,7 +145,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
|
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
|
||||||
if (p is Map) {
|
if (p is Map) {
|
||||||
_seenTime = p['seen'] as int?;
|
_seenTime = p['seen'] as int?;
|
||||||
_isOnline = ((p['status'] as int?) ?? 0) > 0;
|
final st = (p['status'] as int?) ?? 0;
|
||||||
|
_presenceStatus = st;
|
||||||
|
_isOnline = st == 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
_onlineCount = 0;
|
_onlineCount = 0;
|
||||||
_members = memberIds.map((id) {
|
_members = memberIds.map((id) {
|
||||||
final pres = presenceMap[id];
|
final pres = presenceMap[id];
|
||||||
final online = ((pres?['status'] as int?) ?? 0) > 0;
|
final online = (pres?['status'] as int?) == 1;
|
||||||
if (online) _onlineCount++;
|
if (online) _onlineCount++;
|
||||||
final isAdmin =
|
final isAdmin =
|
||||||
admins.containsKey(id.toString()) || admins.containsKey(id);
|
admins.containsKey(id.toString()) || admins.containsKey(id);
|
||||||
@@ -328,7 +331,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
case 'DIALOG':
|
case 'DIALOG':
|
||||||
if (_isBot) return 'Бот';
|
if (_isBot) return 'Бот';
|
||||||
if (_isOnline) return 'В сети';
|
if (_isOnline) return 'В сети';
|
||||||
if (_seenTime != null) return _formatLastSeen(_seenTime!);
|
if (_presenceStatus == 3) return 'был(-а) недавно';
|
||||||
|
if (_seenTime != null && _seenTime! > 0) {
|
||||||
|
return 'был(-а) ${_formatLastSeen(_seenTime!)}';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
case 'CHAT':
|
case 'CHAT':
|
||||||
final total =
|
final total =
|
||||||
@@ -1075,8 +1081,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
|
|
||||||
// ─── HELPERS ─────────────────────────────────────────────────────────────
|
// ─── HELPERS ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String _formatLastSeen(int ms) {
|
String _formatLastSeen(int secondsSinceEpoch) {
|
||||||
final diff = DateTime.now().millisecondsSinceEpoch - ms;
|
final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000;
|
||||||
if (diff < 60000) return 'только что';
|
if (diff < 60000) return 'только что';
|
||||||
if (diff < 3600000) return '${diff ~/ 60000} мин назад';
|
if (diff < 3600000) return '${diff ~/ 60000} мин назад';
|
||||||
if (diff < 86400000) return '${diff ~/ 3600000} ч назад';
|
if (diff < 86400000) return '${diff ~/ 3600000} ч назад';
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../../backend/api.dart';
|
import '../../../backend/api.dart';
|
||||||
import '../../../backend/modules/messages.dart';
|
import '../../../backend/modules/messages.dart';
|
||||||
|
import '../../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../../core/protocol/packet.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
import '../../../core/config/app_cache_extent.dart';
|
import '../../../core/config/app_cache_extent.dart';
|
||||||
@@ -75,6 +77,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
||||||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
||||||
StreamSubscription<UploadEvent>? _uploadSub;
|
StreamSubscription<UploadEvent>? _uploadSub;
|
||||||
|
StreamSubscription<Packet>? _pushSub;
|
||||||
|
final Set<int> _typingUserIds = {};
|
||||||
|
final Map<int, Timer> _typingTimers = {};
|
||||||
|
int _otherStatus = 0;
|
||||||
|
int? _otherSeenTime;
|
||||||
|
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
|
||||||
int _tempIdCounter = 0;
|
int _tempIdCounter = 0;
|
||||||
late final AnimationController _attachAnim;
|
late final AnimationController _attachAnim;
|
||||||
|
|
||||||
@@ -107,6 +115,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
reverseDuration: const Duration(milliseconds: 240),
|
reverseDuration: const Duration(milliseconds: 240),
|
||||||
);
|
);
|
||||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||||
|
_pushSub = api.pushStream
|
||||||
|
.where((p) =>
|
||||||
|
p.opcode == Opcode.notifMessage ||
|
||||||
|
p.opcode == Opcode.notifMark ||
|
||||||
|
p.opcode == Opcode.notifTyping)
|
||||||
|
.listen(_onIncomingPush);
|
||||||
_floatingDateAnimController = AnimationController(
|
_floatingDateAnimController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
@@ -127,8 +141,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
||||||
if (mounted && value.isNotEmpty) {
|
if (mounted && value.isNotEmpty) {
|
||||||
setState(() { chat = value.first; });
|
setState(() { chat = value.first; });
|
||||||
|
_recomputeHeaderStatus();
|
||||||
}
|
}
|
||||||
}).catchError((_) {});
|
}).catchError((_) {});
|
||||||
|
if (widget.chatType == 'DIALOG') {
|
||||||
|
unawaited(_loadOtherPresence());
|
||||||
|
}
|
||||||
|
|
||||||
final cachedRows = await AppDatabase.loadMessages(
|
final cachedRows = await AppDatabase.loadMessages(
|
||||||
_myId,
|
_myId,
|
||||||
@@ -184,6 +202,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
||||||
_showAttachmentPanel.dispose();
|
_showAttachmentPanel.dispose();
|
||||||
_uploadSub?.cancel();
|
_uploadSub?.cancel();
|
||||||
|
_pushSub?.cancel();
|
||||||
|
for (final t in _typingTimers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
_typingTimers.clear();
|
||||||
|
_headerStatusNotifier.dispose();
|
||||||
_uploadStatus.dispose();
|
_uploadStatus.dispose();
|
||||||
_attachAnim.dispose();
|
_attachAnim.dispose();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
@@ -222,6 +246,161 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
return 'sent';
|
return 'sent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onIncomingPush(Packet packet) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (packet.opcode) {
|
||||||
|
case Opcode.notifMessage:
|
||||||
|
_onIncomingMessage(packet);
|
||||||
|
case Opcode.notifMark:
|
||||||
|
_onMessageRead(packet);
|
||||||
|
case Opcode.notifTyping:
|
||||||
|
_onTyping(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOtherPresence() async {
|
||||||
|
if (_myId == 0) return;
|
||||||
|
final otherId = widget.chatId ^ _myId;
|
||||||
|
if (otherId <= 0) return;
|
||||||
|
try {
|
||||||
|
final p = await api.sendRequest(
|
||||||
|
Opcode.contactPresence,
|
||||||
|
{'contactIds': [otherId]},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final presence = (p.payload as Map?)?['presence'] as Map?;
|
||||||
|
final entry = presence?[otherId.toString()] ?? presence?[otherId];
|
||||||
|
if (entry is Map) {
|
||||||
|
_otherStatus = (entry['status'] as int?) ?? 0;
|
||||||
|
_otherSeenTime = entry['seen'] as int?;
|
||||||
|
_recomputeHeaderStatus();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLastSeen(int secondsSinceEpoch) {
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
|
||||||
|
final diff = DateTime.now().difference(dt);
|
||||||
|
if (diff.inMinutes < 2) return 'Был(-а) только что';
|
||||||
|
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
|
||||||
|
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
|
||||||
|
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
|
||||||
|
const months = [
|
||||||
|
'янв', 'фев', 'мар', 'апр', 'мая', 'июн',
|
||||||
|
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
|
||||||
|
];
|
||||||
|
return 'Был(-а) ${dt.day} ${months[dt.month - 1]} ${dt.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recomputeHeaderStatus() {
|
||||||
|
_headerStatusNotifier.value = _headerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _headerStatus() {
|
||||||
|
if (_typingUserIds.isNotEmpty) return 'Печатает...';
|
||||||
|
if (widget.chatType == 'CHAT') {
|
||||||
|
return '${chat?.participants.length ?? 0} участников';
|
||||||
|
}
|
||||||
|
if (widget.chatType == 'CHANNEL') {
|
||||||
|
return '${chat?.participants.length ?? 0} подписчиков';
|
||||||
|
}
|
||||||
|
if (_otherStatus == 1) return 'В сети';
|
||||||
|
if (_otherStatus == 3) return 'Был(-а) недавно';
|
||||||
|
final s = _otherSeenTime;
|
||||||
|
if (s != null && s > 0) return _formatLastSeen(s);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTyping(Packet packet) {
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
if (payload['chatId'] != widget.chatId) return;
|
||||||
|
final userId = payload['userId'];
|
||||||
|
if (userId is! int || userId == _myId) return;
|
||||||
|
|
||||||
|
_typingTimers[userId]?.cancel();
|
||||||
|
_typingTimers[userId] = Timer(const Duration(seconds: 10), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
_typingUserIds.remove(userId);
|
||||||
|
_typingTimers.remove(userId);
|
||||||
|
_recomputeHeaderStatus();
|
||||||
|
});
|
||||||
|
if (_typingUserIds.add(userId)) {
|
||||||
|
_recomputeHeaderStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearTyping(int userId) {
|
||||||
|
_typingTimers.remove(userId)?.cancel();
|
||||||
|
if (_typingUserIds.remove(userId)) {
|
||||||
|
_recomputeHeaderStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessageRead(Packet packet) {
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
if (payload['chatId'] != widget.chatId) return;
|
||||||
|
final userId = payload['userId'];
|
||||||
|
if (userId is! int || userId == _myId) return;
|
||||||
|
final mark = payload['mark'];
|
||||||
|
if (mark is! int) return;
|
||||||
|
if (payload['setAsUnread'] == true) return;
|
||||||
|
final c = chat;
|
||||||
|
if (c == null) return;
|
||||||
|
if (c.participants[userId] == mark) return;
|
||||||
|
setState(() {
|
||||||
|
c.participants[userId] = mark;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIncomingMessage(Packet packet) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
final chatId = payload['chatId'];
|
||||||
|
if (chatId != widget.chatId) return;
|
||||||
|
final msg = payload['message'];
|
||||||
|
if (msg is! Map) return;
|
||||||
|
|
||||||
|
final senderId = msg['sender'];
|
||||||
|
if (senderId is! int) return;
|
||||||
|
if (senderId == _myId) return;
|
||||||
|
|
||||||
|
final msgId = msg['id']?.toString();
|
||||||
|
if (msgId == null || msgId.isEmpty) return;
|
||||||
|
if (_messages.any((m) => m.id == msgId)) return;
|
||||||
|
|
||||||
|
List<MessageAttachment>? attachments;
|
||||||
|
final attaches = msg['attaches'];
|
||||||
|
if (attaches is List && attaches.isNotEmpty) {
|
||||||
|
attachments = attaches
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = CachedMessage(
|
||||||
|
id: msgId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: senderId,
|
||||||
|
text: msg['text'] as String?,
|
||||||
|
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
status: 'sent',
|
||||||
|
payload: Map<String, dynamic>.from(msg),
|
||||||
|
attachments: attachments,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_lastSentId = msgId;
|
||||||
|
_messages.add(cached);
|
||||||
|
});
|
||||||
|
_clearTyping(senderId);
|
||||||
|
Haptics.tap();
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
final text = _messageController.text.trim();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty || _myId == 0) return;
|
if (text.isEmpty || _myId == 0) return;
|
||||||
@@ -508,7 +687,6 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
|
|
||||||
// TODO: Локализация
|
// TODO: Локализация
|
||||||
// TODO: Cклонения
|
// TODO: Cклонения
|
||||||
final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently";
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
@@ -583,12 +761,15 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text(
|
ValueListenableBuilder<String>(
|
||||||
status,
|
valueListenable: _headerStatusNotifier,
|
||||||
style: TextStyle(
|
builder: (context, status, _) => Text(
|
||||||
color: cs.onSurfaceVariant,
|
status,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w400,
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -697,7 +878,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
overrideStatus: _effectiveStatus(message),
|
overrideStatus: _effectiveStatus(message),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isMe && message.id == _lastSentId) {
|
if (message.id == _lastSentId) {
|
||||||
return _SentMessageAnimation(
|
return _SentMessageAnimation(
|
||||||
key: ValueKey('anim_${message.id}'),
|
key: ValueKey('anim_${message.id}'),
|
||||||
onComplete: () {
|
onComplete: () {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
Map<String, dynamic>? _contact;
|
Map<String, dynamic>? _contact;
|
||||||
int? _seenTime;
|
int? _seenTime;
|
||||||
bool _isOnline = false;
|
int _presenceStatus = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -57,7 +57,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
|
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
|
||||||
if (p is Map) {
|
if (p is Map) {
|
||||||
_seenTime = p['seen'] as int?;
|
_seenTime = p['seen'] as int?;
|
||||||
_isOnline = ((p['status'] as int?) ?? 0) > 0;
|
_presenceStatus = (p['status'] as int?) ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -101,7 +101,8 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
|
|
||||||
String _subtitle() {
|
String _subtitle() {
|
||||||
if (_isBot) return 'Бот';
|
if (_isBot) return 'Бот';
|
||||||
if (_isOnline) return 'В сети';
|
if (_presenceStatus == 1) return 'В сети';
|
||||||
|
if (_presenceStatus == 3) return 'Был(-а) недавно';
|
||||||
if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!);
|
if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
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 '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
|
import '../../../core/storage/token_storage.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';
|
||||||
|
import '../auth/login_screen.dart';
|
||||||
import '../auth/proxy_settings_sheet.dart';
|
import '../auth/proxy_settings_sheet.dart';
|
||||||
import 'customization_screen.dart';
|
import 'customization_screen.dart';
|
||||||
import 'performance_screen.dart';
|
import 'performance_screen.dart';
|
||||||
@@ -26,6 +28,8 @@ class SettingsTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsTabState extends State<SettingsTab> {
|
class _SettingsTabState extends State<SettingsTab> {
|
||||||
|
static const bool _showLogoutButton = false;
|
||||||
|
|
||||||
ProfileData? _profile;
|
ProfileData? _profile;
|
||||||
bool _isPhoneVisible = false;
|
bool _isPhoneVisible = false;
|
||||||
String? _appVersionLabel;
|
String? _appVersionLabel;
|
||||||
@@ -94,6 +98,83 @@ class _SettingsTabState extends State<SettingsTab> {
|
|||||||
if (mounted) setState(() => _hapticsEnabled = value);
|
if (mounted) setState(() => _hapticsEnabled = value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmLogout() async {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final confirmed = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: cs.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Выйти из аккаунта?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Сессия будет сброшена. Локальный кеш сохранится — войдёшь снова в этот же аккаунт.',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: cs.error,
|
||||||
|
foregroundColor: cs.onError,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Выйти'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
await _doLogout();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doLogout() async {
|
||||||
|
final navState = KometApp.navigatorKey.currentState;
|
||||||
|
try {
|
||||||
|
await api.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId != null) {
|
||||||
|
await TokenStorage.deleteToken(accountId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.connect();
|
||||||
|
} catch (_) {}
|
||||||
|
if (navState != null) {
|
||||||
|
await navState.pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
@@ -426,31 +507,51 @@ child: _buildSection(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Stack(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
Row(
|
||||||
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: MouseRegion(
|
children: [
|
||||||
cursor: SystemMouseCursors.click,
|
GestureDetector(
|
||||||
child: _PhoneSpoiler(
|
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
||||||
text: phone,
|
child: MouseRegion(
|
||||||
isVisible: _isPhoneVisible,
|
cursor: SystemMouseCursors.click,
|
||||||
style: TextStyle(
|
child: _PhoneSpoiler(
|
||||||
color: cs.onSurfaceVariant,
|
text: phone,
|
||||||
fontSize: 14,
|
isVisible: _isPhoneVisible,
|
||||||
fontWeight: FontWeight.w400,
|
style: TextStyle(
|
||||||
letterSpacing: 0.5,
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
_isPhoneVisible ? Symbols.visibility : Symbols.visibility_off,
|
||||||
|
size: 14,
|
||||||
|
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_showLogoutButton)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Выйти',
|
||||||
|
icon: Icon(
|
||||||
|
Symbols.logout,
|
||||||
|
color: cs.error,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
onPressed: _confirmLogout,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
_isPhoneVisible ? Symbols.visibility : Symbols.visibility_off,
|
|
||||||
size: 14,
|
|
||||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
|
import 'backend/modules/chats.dart';
|
||||||
import 'backend/modules/contacts.dart';
|
import 'backend/modules/contacts.dart';
|
||||||
import 'backend/modules/file_uploader.dart';
|
import 'backend/modules/file_uploader.dart';
|
||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
@@ -53,6 +54,7 @@ void main() async {
|
|||||||
if (activeAccountId != null) {
|
if (activeAccountId != null) {
|
||||||
await ContactsModule.primeCacheFromDb(activeAccountId);
|
await ContactsModule.primeCacheFromDb(activeAccountId);
|
||||||
}
|
}
|
||||||
|
ChatsModule.attachGlobalPushHandlers(api);
|
||||||
await api.connect();
|
await api.connect();
|
||||||
|
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
|||||||
Reference in New Issue
Block a user