diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 6a84839..402cf92 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -12,9 +12,9 @@ import '../core/transport/sender.dart'; import '../core/utils/logger.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:timezone/data/latest_all.dart' as tz; +import 'dart:io'; enum SessionState { disconnected, connecting, connected, online } @@ -239,6 +239,19 @@ class Api { _dispatcher.clearPending(); } + Future reconnectAndLogin() async { + await connect(); + if (_sessionState == SessionState.online && _onReconnectCallback != null) { + _onReconnectCallback!(); + } + } + + void Function()? _onReconnectCallback; + + void setReconnectCallback(void Function() callback) { + _onReconnectCallback = callback; + } + void _startPinging() { _pingTimer?.cancel(); _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 2cc90a7..7473e76 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -151,11 +151,13 @@ class MessagesModule { text: m['text'] as String?, time: (m['time'] as int?) ?? 0, status: m['status'] as String?, - payload: m - .cast< - String, - dynamic - >(), // Сохраняем весь пакет для гибкости (аттачи и т.д.) + payload: m.cast(), ); } + + Future sendMessage(int accountId, int chatId, String text) async { + final payload = {'chatId': chatId, 'text': text}; + + await _api.sendRequest(Opcode.msgSend, payload); + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e7dfa6d..3a611ab 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { final int chatId; @@ -25,10 +26,13 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); bool _hasText = false; bool _isLoading = true; + bool _isSending = false; late AnimationController _shimmerController; List _messages = []; + int _myId = 0; @override void initState() { @@ -44,16 +48,17 @@ class _ChatScreenState extends State Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); - final myId = activeProfile?.id ?? 0; + _myId = activeProfile?.id ?? 0; final cachedRows = await AppDatabase.loadMessages( - myId, + _myId, widget.chatId, limit: 100, ); if (mounted && cachedRows.isNotEmpty) { setState(() { _messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList(); + _messages.sort((a, b) => a.time.compareTo(b.time)); if (api.state == SessionState.online) { _isLoading = false; } @@ -61,9 +66,9 @@ class _ChatScreenState extends State } try { - await messagesModule.fetchHistory(myId, widget.chatId); + await messagesModule.fetchHistory(_myId, widget.chatId); final updatedRows = await AppDatabase.loadMessages( - myId, + _myId, widget.chatId, limit: 100, ); @@ -72,6 +77,7 @@ class _ChatScreenState extends State _messages = updatedRows .map((r) => CachedMessage.fromDbRow(r)) .toList(); + _messages.sort((a, b) => a.time.compareTo(b.time)); _isLoading = false; }); } @@ -89,6 +95,7 @@ class _ChatScreenState extends State void dispose() { _messageController.removeListener(_onTextChanged); _messageController.dispose(); + _scrollController.dispose(); _shimmerController.dispose(); super.dispose(); } @@ -102,6 +109,73 @@ class _ChatScreenState extends State } } + Future _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 Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -131,10 +205,7 @@ class _ChatScreenState extends State backgroundColor: cs.primaryContainer, child: Text( widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, - ), + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), ), ), const SizedBox(width: 12), @@ -178,11 +249,9 @@ class _ChatScreenState extends State body: Column( children: [ Expanded( - child: - _isLoading || - (_messages.isEmpty && api.state != SessionState.online) + child: _isLoading && _messages.isEmpty ? _buildShimmerLoading() - : const SizedBox.shrink(), + : _buildMessagesList(), ), _buildInputArea(context), ], @@ -190,6 +259,44 @@ class _ChatScreenState extends State ); } + 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() { return AnimatedBuilder( animation: _shimmerController, @@ -316,20 +423,12 @@ class _ChatScreenState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, - ), + Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), const SizedBox(width: 12), Expanded( child: TextField( controller: _messageController, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - ), + style: TextStyle(color: cs.onSurface, fontSize: 16), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, @@ -379,11 +478,14 @@ class _ChatScreenState extends State color: _hasText ? cs.primary : cs.surfaceContainerHighest, shape: BoxShape.circle, ), - child: Icon( - _hasText ? Symbols.send : Symbols.mic, - color: _hasText ? cs.onPrimary : cs.onSurface, - size: 24, - weight: 400, + child: GestureDetector( + onTap: _hasText ? _sendMessage : null, + child: Icon( + _hasText ? Symbols.send : Symbols.mic, + color: _hasText ? cs.onPrimary : cs.onSurface, + size: 24, + weight: 400, + ), ), ), ], diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e69de29..208d29c 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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?; + 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?; + 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')}'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 84fcf52..691248e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,6 +62,14 @@ class KometAppState extends State { void initState() { super.initState(); _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 { if (_isLoggingOut) return; _isLoggingOut = true;