Фронт сообщений в чате, пока только текстовые
This commit is contained in:
+15
-2
@@ -12,9 +12,9 @@ import '../core/transport/sender.dart';
|
|||||||
import '../core/utils/logger.dart';
|
import '../core/utils/logger.dart';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'dart:io';
|
|
||||||
import 'package:timezone/data/latest_all.dart' as tz;
|
|
||||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
enum SessionState { disconnected, connecting, connected, online }
|
enum SessionState { disconnected, connecting, connected, online }
|
||||||
|
|
||||||
@@ -239,6 +239,19 @@ class Api {
|
|||||||
_dispatcher.clearPending();
|
_dispatcher.clearPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reconnectAndLogin() async {
|
||||||
|
await connect();
|
||||||
|
if (_sessionState == SessionState.online && _onReconnectCallback != null) {
|
||||||
|
_onReconnectCallback!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Function()? _onReconnectCallback;
|
||||||
|
|
||||||
|
void setReconnectCallback(void Function() callback) {
|
||||||
|
_onReconnectCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
void _startPinging() {
|
void _startPinging() {
|
||||||
_pingTimer?.cancel();
|
_pingTimer?.cancel();
|
||||||
_pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) {
|
_pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) {
|
||||||
|
|||||||
@@ -151,11 +151,13 @@ class MessagesModule {
|
|||||||
text: m['text'] as String?,
|
text: m['text'] as String?,
|
||||||
time: (m['time'] as int?) ?? 0,
|
time: (m['time'] as int?) ?? 0,
|
||||||
status: m['status'] as String?,
|
status: m['status'] as String?,
|
||||||
payload: m
|
payload: m.cast<String, dynamic>(),
|
||||||
.cast<
|
|
||||||
String,
|
|
||||||
dynamic
|
|
||||||
>(), // Сохраняем весь пакет для гибкости (аттачи и т.д.)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> sendMessage(int accountId, int chatId, String text) async {
|
||||||
|
final payload = {'chatId': chatId, 'text': text};
|
||||||
|
|
||||||
|
await _api.sendRequest(Opcode.msgSend, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../main.dart';
|
|||||||
import '../../../backend/api.dart';
|
import '../../../backend/api.dart';
|
||||||
import '../../../backend/modules/messages.dart';
|
import '../../../backend/modules/messages.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
|
import '../../widgets/message_bubble.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final int chatId;
|
final int chatId;
|
||||||
@@ -25,10 +26,13 @@ class ChatScreen extends StatefulWidget {
|
|||||||
class _ChatScreenState extends State<ChatScreen>
|
class _ChatScreenState extends State<ChatScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
final TextEditingController _messageController = TextEditingController();
|
final TextEditingController _messageController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
bool _hasText = false;
|
bool _hasText = false;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isSending = false;
|
||||||
late AnimationController _shimmerController;
|
late AnimationController _shimmerController;
|
||||||
List<CachedMessage> _messages = [];
|
List<CachedMessage> _messages = [];
|
||||||
|
int _myId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -44,16 +48,17 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||||
final myId = activeProfile?.id ?? 0;
|
_myId = activeProfile?.id ?? 0;
|
||||||
|
|
||||||
final cachedRows = await AppDatabase.loadMessages(
|
final cachedRows = await AppDatabase.loadMessages(
|
||||||
myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
);
|
);
|
||||||
if (mounted && cachedRows.isNotEmpty) {
|
if (mounted && cachedRows.isNotEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList();
|
_messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList();
|
||||||
|
_messages.sort((a, b) => a.time.compareTo(b.time));
|
||||||
if (api.state == SessionState.online) {
|
if (api.state == SessionState.online) {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -61,9 +66,9 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await messagesModule.fetchHistory(myId, widget.chatId);
|
await messagesModule.fetchHistory(_myId, widget.chatId);
|
||||||
final updatedRows = await AppDatabase.loadMessages(
|
final updatedRows = await AppDatabase.loadMessages(
|
||||||
myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
);
|
);
|
||||||
@@ -72,6 +77,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_messages = updatedRows
|
_messages = updatedRows
|
||||||
.map((r) => CachedMessage.fromDbRow(r))
|
.map((r) => CachedMessage.fromDbRow(r))
|
||||||
.toList();
|
.toList();
|
||||||
|
_messages.sort((a, b) => a.time.compareTo(b.time));
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,6 +95,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.removeListener(_onTextChanged);
|
_messageController.removeListener(_onTextChanged);
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
_shimmerController.dispose();
|
_shimmerController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -102,6 +109,73 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
final text = _messageController.text.trim();
|
||||||
|
if (text.isEmpty || _myId == 0) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSending = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
final tempMessage = CachedMessage(
|
||||||
|
id: tempId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: _myId,
|
||||||
|
text: text,
|
||||||
|
time: now,
|
||||||
|
status: 'sending',
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_messages.add(tempMessage);
|
||||||
|
_messageController.clear();
|
||||||
|
_hasText = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
_scrollToBottom();
|
||||||
|
|
||||||
|
await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||||||
|
|
||||||
|
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||||
|
if (index != -1) {
|
||||||
|
setState(() {
|
||||||
|
_messages[index] = CachedMessage(
|
||||||
|
id: tempId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: _myId,
|
||||||
|
text: text,
|
||||||
|
time: now,
|
||||||
|
status: 'sent',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error sending message: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isSending = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
@@ -131,10 +205,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
backgroundColor: cs.primaryContainer,
|
backgroundColor: cs.primaryContainer,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||||
style: TextStyle(
|
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
|
||||||
color: cs.onPrimaryContainer,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -178,11 +249,9 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child: _isLoading && _messages.isEmpty
|
||||||
_isLoading ||
|
|
||||||
(_messages.isEmpty && api.state != SessionState.online)
|
|
||||||
? _buildShimmerLoading()
|
? _buildShimmerLoading()
|
||||||
: const SizedBox.shrink(),
|
: _buildMessagesList(),
|
||||||
),
|
),
|
||||||
_buildInputArea(context),
|
_buildInputArea(context),
|
||||||
],
|
],
|
||||||
@@ -190,6 +259,44 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMessagesList() {
|
||||||
|
if (_messages.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No messages yet',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
reverse: true,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = _messages[_messages.length - 1 - index];
|
||||||
|
final isMe = message.senderId == _myId;
|
||||||
|
final prevMessage = index < _messages.length - 1
|
||||||
|
? _messages[_messages.length - 2 - index]
|
||||||
|
: null;
|
||||||
|
final nextMessage = index > 0
|
||||||
|
? _messages[_messages.length - index]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return MessageBubble(
|
||||||
|
message: message,
|
||||||
|
isMe: isMe,
|
||||||
|
myId: _myId,
|
||||||
|
prevMessage: prevMessage,
|
||||||
|
nextMessage: nextMessage,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildShimmerLoading() {
|
Widget _buildShimmerLoading() {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _shimmerController,
|
animation: _shimmerController,
|
||||||
@@ -316,20 +423,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
|
||||||
Symbols.face,
|
|
||||||
color: mutedIcon,
|
|
||||||
size: 24,
|
|
||||||
weight: 400,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
style: TextStyle(
|
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||||
color: cs.onSurface,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
textAlignVertical: TextAlignVertical.center,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
@@ -379,11 +478,14 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
color: _hasText ? cs.primary : cs.surfaceContainerHighest,
|
color: _hasText ? cs.primary : cs.surfaceContainerHighest,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: GestureDetector(
|
||||||
_hasText ? Symbols.send : Symbols.mic,
|
onTap: _hasText ? _sendMessage : null,
|
||||||
color: _hasText ? cs.onPrimary : cs.onSurface,
|
child: Icon(
|
||||||
size: 24,
|
_hasText ? Symbols.send : Symbols.mic,
|
||||||
weight: 400,
|
color: _hasText ? cs.onPrimary : cs.onSurface,
|
||||||
|
size: 24,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,725 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import '../../backend/modules/messages.dart';
|
||||||
|
|
||||||
|
enum MessageType { text, attachment, voice }
|
||||||
|
|
||||||
|
enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle }
|
||||||
|
|
||||||
|
class MessageBubble extends StatelessWidget {
|
||||||
|
final CachedMessage message;
|
||||||
|
final bool isMe;
|
||||||
|
final int myId;
|
||||||
|
final CachedMessage? prevMessage;
|
||||||
|
final CachedMessage? nextMessage;
|
||||||
|
|
||||||
|
const MessageBubble({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.isMe,
|
||||||
|
required this.myId,
|
||||||
|
this.prevMessage,
|
||||||
|
this.nextMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isGroupedWithNext {
|
||||||
|
if (nextMessage == null) return false;
|
||||||
|
if (nextMessage!.senderId != message.senderId) return false;
|
||||||
|
final timeDiff = nextMessage!.time - message.time;
|
||||||
|
return timeDiff < 300000;
|
||||||
|
}
|
||||||
|
|
||||||
|
BubbleShape get shape {
|
||||||
|
final hasPrevFromMe = prevMessage?.senderId == message.senderId;
|
||||||
|
final prevTimeDiff = hasPrevFromMe
|
||||||
|
? message.time - prevMessage!.time
|
||||||
|
: 999999999;
|
||||||
|
|
||||||
|
final hasNextFromMe = nextMessage?.senderId == message.senderId;
|
||||||
|
final nextTimeDiff = hasNextFromMe
|
||||||
|
? nextMessage!.time - message.time
|
||||||
|
: 999999999;
|
||||||
|
|
||||||
|
final bool groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000;
|
||||||
|
final bool groupedWithNext = hasNextFromMe && nextTimeDiff < 300000;
|
||||||
|
|
||||||
|
if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle;
|
||||||
|
if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop;
|
||||||
|
if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom;
|
||||||
|
|
||||||
|
return BubbleShape.groupedMiddle;
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageType get contentType {
|
||||||
|
final payload = message.payload;
|
||||||
|
if (payload == null) return MessageType.text;
|
||||||
|
|
||||||
|
final attachments = payload['attachments'];
|
||||||
|
if (attachments is List && attachments.isNotEmpty)
|
||||||
|
return MessageType.attachment;
|
||||||
|
|
||||||
|
final voice = payload['voice'];
|
||||||
|
if (voice != null) return MessageType.voice;
|
||||||
|
|
||||||
|
return MessageType.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие
|
||||||
|
BorderRadius get _borderRadius {
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return BorderRadius.only(
|
||||||
|
topLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||||
|
topRight: isMe ? Radius.circular(4) : Radius.circular(20),
|
||||||
|
bottomLeft: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||||
|
bottomRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||||
|
);
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return BorderRadius.only(
|
||||||
|
topLeft: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||||
|
topRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||||
|
bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||||
|
bottomRight: isMe ? Radius.circular(4) : Radius.circular(20),
|
||||||
|
);
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return BorderRadius.only(
|
||||||
|
topLeft: isMe ? Radius.circular(20) : Radius.circular(20),
|
||||||
|
topRight: isMe ? Radius.circular(20) : Radius.circular(20),
|
||||||
|
bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||||
|
bottomRight: isMe ? Radius.circular(4) : Radius.circular(20),
|
||||||
|
);
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return BorderRadius.only(
|
||||||
|
topLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||||
|
topRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||||
|
bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4),
|
||||||
|
bottomRight: isMe ? Radius.circular(4) : Radius.circular(4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ВРОДЕ отступы между сообщениями, если они выглядят адекватно не трогайте пж я ради них кишку на шею намотал
|
||||||
|
// ОТСТУПЫ МЕЖДУ СООБЩЕНИЯМИ (top/bottom margin)
|
||||||
|
// Зависит от shape (группировка) И contentType (тип контента)
|
||||||
|
double get topMargin {
|
||||||
|
switch (contentType) {
|
||||||
|
case MessageType.text:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return 6;
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return 4;
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
case MessageType.attachment:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return 6;
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return 4;
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
case MessageType.voice:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return 4;
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get bottomMargin {
|
||||||
|
switch (contentType) {
|
||||||
|
case MessageType.text:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return 4;
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
case MessageType.attachment:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return 4;
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
case MessageType.voice:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return 1;
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return 4;
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внутренний отступ, размеры типа я хз
|
||||||
|
EdgeInsets get padding {
|
||||||
|
switch (contentType) {
|
||||||
|
case MessageType.text:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
}
|
||||||
|
case MessageType.attachment:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return const EdgeInsets.all(0);
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return const EdgeInsets.all(0);
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return const EdgeInsets.all(0);
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return const EdgeInsets.all(0);
|
||||||
|
}
|
||||||
|
case MessageType.voice:
|
||||||
|
switch (shape) {
|
||||||
|
case BubbleShape.groupedMiddle:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||||||
|
case BubbleShape.singleTop:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
case BubbleShape.singleBottom:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
case BubbleShape.singleMiddle:
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final isDark = cs.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: isMe ? 60 : 12,
|
||||||
|
right: isMe ? 12 : 60,
|
||||||
|
top: topMargin,
|
||||||
|
bottom: bottomMargin,
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe
|
||||||
|
? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF))
|
||||||
|
: (isDark
|
||||||
|
? cs.surfaceContainerHighest
|
||||||
|
: const Color(0xFFE9E9EB)),
|
||||||
|
borderRadius: _borderRadius,
|
||||||
|
),
|
||||||
|
padding: padding,
|
||||||
|
child: _buildContent(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
switch (contentType) {
|
||||||
|
case MessageType.attachment:
|
||||||
|
return _buildAttachmentContent(context);
|
||||||
|
case MessageType.voice:
|
||||||
|
return _buildVoiceContent(context);
|
||||||
|
case MessageType.text:
|
||||||
|
default:
|
||||||
|
return _buildTextContent(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextContent(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final isDark = cs.brightness == Brightness.dark;
|
||||||
|
final textColor = isMe
|
||||||
|
? Colors.white
|
||||||
|
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
message.text ?? '',
|
||||||
|
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
_formatTime(message.time),
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withValues(alpha: 0.7),
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAttachmentContent(BuildContext context) {
|
||||||
|
final payload = message.payload;
|
||||||
|
final attachments = payload?['attachments'] as List?;
|
||||||
|
if (attachments == null || attachments.isEmpty) {
|
||||||
|
return _buildTextContent(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
final attachment = attachments.first as Map<String, dynamic>?;
|
||||||
|
if (attachment == null) return _buildTextContent(context);
|
||||||
|
|
||||||
|
final type = attachment['type'] as String?;
|
||||||
|
final url = attachment['url'] ?? attachment['path'];
|
||||||
|
final thumbnail = attachment['thumbnail'];
|
||||||
|
|
||||||
|
if (type == 'image' || type == 'photo') {
|
||||||
|
return _buildImageAttachment(context, url, thumbnail);
|
||||||
|
} else if (type == 'video') {
|
||||||
|
return _buildVideoAttachment(context, url, attachment['duration']);
|
||||||
|
} else if (type == 'file') {
|
||||||
|
return _buildFileAttachment(context, attachment['name'] ?? 'File', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildTextContent(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageAttachment(
|
||||||
|
BuildContext ctx,
|
||||||
|
dynamic url,
|
||||||
|
dynamic thumbnail,
|
||||||
|
) {
|
||||||
|
final cs = Theme.of(ctx).colorScheme;
|
||||||
|
final imageUrl = url?.toString() ?? '';
|
||||||
|
final thumbUrl = thumbnail?.toString() ?? '';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (thumbUrl.isNotEmpty)
|
||||||
|
Image.network(
|
||||||
|
thumbUrl,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs),
|
||||||
|
)
|
||||||
|
else if (imageUrl.isNotEmpty)
|
||||||
|
Image.network(
|
||||||
|
imageUrl,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildImagePlaceholder(cs),
|
||||||
|
if (imageUrl.isNotEmpty)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(onTap: () {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildMeta(ctx),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagePlaceholder(ColorScheme cs) {
|
||||||
|
return Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVideoAttachment(
|
||||||
|
BuildContext ctx,
|
||||||
|
dynamic url,
|
||||||
|
dynamic duration,
|
||||||
|
) {
|
||||||
|
final cs = Theme.of(ctx).colorScheme;
|
||||||
|
final videoUrl = url?.toString() ?? '';
|
||||||
|
final durationSec = duration as int? ?? 0;
|
||||||
|
final durationStr = _formatDuration(durationSec);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 200,
|
||||||
|
height: 150,
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Symbols.videocam,
|
||||||
|
size: 48,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 8,
|
||||||
|
left: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
durationStr,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (videoUrl.isNotEmpty)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(onTap: () {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildMeta(ctx),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileAttachment(BuildContext ctx, String fileName, dynamic url) {
|
||||||
|
final cs = Theme.of(ctx).colorScheme;
|
||||||
|
final isDark = cs.brightness == Brightness.dark;
|
||||||
|
final textColor = isMe
|
||||||
|
? Colors.white
|
||||||
|
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 220,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe
|
||||||
|
? Colors.white.withValues(alpha: 0.15)
|
||||||
|
: (isDark ? cs.surface : const Color(0xFFFFFFFF)),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isMe
|
||||||
|
? Colors.white.withValues(alpha: 0.3)
|
||||||
|
: cs.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe
|
||||||
|
? Colors.white.withValues(alpha: 0.2)
|
||||||
|
: cs.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Symbols.description,
|
||||||
|
color: isMe ? Colors.white : cs.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fileName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Tap to download',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withValues(alpha: 0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildMeta(ctx),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVoiceContent(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final isDark = cs.brightness == Brightness.dark;
|
||||||
|
final textColor = isMe
|
||||||
|
? Colors.white
|
||||||
|
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||||
|
final payload = message.payload;
|
||||||
|
final voice = payload?['voice'] as Map<String, dynamic>?;
|
||||||
|
final duration = voice?['duration'] as int? ?? 0;
|
||||||
|
final url = voice?['url']?.toString() ?? '';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
_VoiceMessageBubble(
|
||||||
|
duration: duration,
|
||||||
|
url: url,
|
||||||
|
textColor: textColor,
|
||||||
|
isMe: isMe,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildMeta(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMeta(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final isDark = cs.brightness == Brightness.dark;
|
||||||
|
final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatTime(message.time),
|
||||||
|
style: TextStyle(color: timeColor, fontSize: 11),
|
||||||
|
),
|
||||||
|
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIcon(BuildContext context) {
|
||||||
|
final status = message.status;
|
||||||
|
IconData icon;
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
if (status == null || status == 'sending' || status == 'pending') {
|
||||||
|
icon = Symbols.check;
|
||||||
|
color = Colors.white70;
|
||||||
|
} else {
|
||||||
|
switch (status) {
|
||||||
|
case 'sent':
|
||||||
|
icon = Symbols.check;
|
||||||
|
color = Colors.white70;
|
||||||
|
case 'delivered':
|
||||||
|
icon = Symbols.done_all;
|
||||||
|
color = Colors.white70;
|
||||||
|
case 'read':
|
||||||
|
icon = Symbols.done_all;
|
||||||
|
color = const Color(0xFF34C759);
|
||||||
|
case 'error':
|
||||||
|
icon = Symbols.error;
|
||||||
|
color = Colors.redAccent;
|
||||||
|
default:
|
||||||
|
icon = Symbols.check;
|
||||||
|
color = Colors.white70;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Icon(icon, size: 14, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int timestamp) {
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||||
|
final hour = dt.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = dt.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final min = seconds ~/ 60;
|
||||||
|
final sec = seconds % 60;
|
||||||
|
return '$min:${sec.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VoiceMessageBubble extends StatefulWidget {
|
||||||
|
final int duration;
|
||||||
|
final String url;
|
||||||
|
final Color textColor;
|
||||||
|
final bool isMe;
|
||||||
|
|
||||||
|
const _VoiceMessageBubble({
|
||||||
|
required this.duration,
|
||||||
|
required this.url,
|
||||||
|
required this.textColor,
|
||||||
|
required this.isMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||||
|
bool _isPlaying = false;
|
||||||
|
double _progress = 0.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final isDark = cs.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 220,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _togglePlay,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.isMe
|
||||||
|
? Colors.white.withValues(alpha: 0.2)
|
||||||
|
: cs.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_isPlaying ? Symbols.pause : Symbols.play_arrow,
|
||||||
|
color: widget.isMe ? Colors.white : cs.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.isMe
|
||||||
|
? Colors.white.withValues(alpha: 0.2)
|
||||||
|
: (isDark
|
||||||
|
? cs.surfaceContainerHighest
|
||||||
|
: const Color(0xFFD1D1D6)),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FractionallySizedBox(
|
||||||
|
widthFactor: _progress.clamp(0.0, 1.0),
|
||||||
|
child: Container(
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.isMe
|
||||||
|
? Colors.white.withValues(alpha: 0.5)
|
||||||
|
: cs.primary,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
_formatDuration(widget.duration),
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor.withValues(alpha: 0.8),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePlay() {
|
||||||
|
setState(() {
|
||||||
|
_isPlaying = !_isPlaying;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final min = seconds ~/ 60;
|
||||||
|
final sec = seconds % 60;
|
||||||
|
return '$min:${sec.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ class KometAppState extends State<KometApp> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_locale = widget.initialLocale;
|
_locale = widget.initialLocale;
|
||||||
|
|
||||||
|
api.setReconnectCallback(() async {
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId != null) {
|
||||||
|
await accountModule.login(accountId: accountId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
api.sessionExpiredStream.listen((SessionExpiredException e) async {
|
api.sessionExpiredStream.listen((SessionExpiredException e) async {
|
||||||
if (_isLoggingOut) return;
|
if (_isLoggingOut) return;
|
||||||
_isLoggingOut = true;
|
_isLoggingOut = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user