Compare commits
31
Commits
main
..
46c22300e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c22300e9 | ||
|
|
d9a20e40b0 | ||
|
|
945134f029 | ||
|
|
f6400ce3ac | ||
|
|
8c8ef55b58 | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
+10
@@ -87,3 +87,13 @@ postgres_data/
|
|||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
*.txt
|
*.txt
|
||||||
|
|
||||||
|
# Android / Kotlin Mobile
|
||||||
|
client-mobile/.gradle/
|
||||||
|
client-mobile/.idea/
|
||||||
|
client-mobile/.run/
|
||||||
|
client-mobile/build/
|
||||||
|
client-mobile/.cxx/
|
||||||
|
client-mobile/local.properties
|
||||||
|
client-mobile/*.iml
|
||||||
|
client-mobile/.kotlin/
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ public interface IMessageRepository
|
|||||||
|
|
||||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
|
||||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
protected List<Guid> _readByUsers = new();
|
|
||||||
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
|
||||||
|
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||||
@@ -56,14 +53,4 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkAsRead(Guid userId)
|
|
||||||
{
|
|
||||||
if (!_readByUsers.Contains(userId))
|
|
||||||
{
|
|
||||||
_readByUsers.Add(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-21
@@ -1,15 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using System.Linq;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel.Storage;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
||||||
|
|
||||||
@@ -21,21 +19,17 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly IFileStorageService _fileStorage;
|
private readonly IFileStorageService _fileStorage;
|
||||||
private readonly IChatsUnitOfWork _uow;
|
private readonly IChatsUnitOfWork _uow;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
|
||||||
|
|
||||||
public LeaveOrDeleteChatCommandHandler(
|
public LeaveOrDeleteChatCommandHandler(
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
|
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
IFileStorageService fileStorage,
|
IFileStorageService fileStorage,
|
||||||
IChatsUnitOfWork uow,
|
IChatsUnitOfWork uow)
|
||||||
IHubContext<ChatHub> hubContext)
|
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_hubContext = hubContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
||||||
@@ -64,14 +58,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
// DELETE ALL MESSAGES AND FILES FIRST
|
// DELETE ALL MESSAGES AND FILES FIRST
|
||||||
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
||||||
_chatRepository.Remove(chat);
|
_chatRepository.Remove(chat);
|
||||||
|
|
||||||
// Notify all remaining members that the chat was deleted
|
|
||||||
|
|
||||||
foreach (var member in chat.Members)
|
|
||||||
{
|
|
||||||
await _hubContext.Clients.User(member.UserId.ToString())
|
|
||||||
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _uow.SaveChangesAsync(cancellationToken);
|
await _uow.SaveChangesAsync(cancellationToken);
|
||||||
@@ -82,7 +68,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
||||||
{
|
{
|
||||||
// Get all messages directly from Mongo (not paged)
|
// Get all messages directly from Mongo (not paged)
|
||||||
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
||||||
|
|||||||
+3
-8
@@ -13,7 +13,7 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||||
|
|
||||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||||
|
|
||||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||||
{
|
{
|
||||||
@@ -41,12 +41,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
List<Message> messages;
|
List<Message> messages;
|
||||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||||
|
|
||||||
if (request.AfterSequenceId.HasValue)
|
if (request.Pivot.HasValue)
|
||||||
{
|
|
||||||
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
|
|
||||||
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
|
|
||||||
}
|
|
||||||
else if (request.Pivot.HasValue)
|
|
||||||
{
|
{
|
||||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -159,7 +154,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(message as StoryMessage)?.StoryMediaType,
|
(message as StoryMessage)?.StoryMediaType,
|
||||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
|
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||||
reactions?.Select(r =>
|
reactions?.Select(r =>
|
||||||
{
|
{
|
||||||
senders.TryGetValue(r.UserId, out var ru);
|
senders.TryGetValue(r.UserId, out var ru);
|
||||||
|
|||||||
+4
-24
@@ -1,8 +1,7 @@
|
|||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
|
||||||
@@ -12,13 +11,11 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
|||||||
{
|
{
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IMessageRepository _messageRepository;
|
|
||||||
|
|
||||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_messageRepository = messageRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||||
@@ -31,23 +28,6 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
|||||||
|
|
||||||
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
||||||
|
|
||||||
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
|
|
||||||
var messages = await _messageRepository.GetChatMessagesAfterAsync(
|
|
||||||
request.ChatId,
|
|
||||||
0,
|
|
||||||
1000,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
foreach (var message in messages)
|
|
||||||
{
|
|
||||||
if (message.SequenceId <= request.LastReadSequenceId &&
|
|
||||||
message.SenderId != request.UserId &&
|
|
||||||
!message.IsReadBy(request.UserId))
|
|
||||||
{
|
|
||||||
message.MarkAsRead(request.UserId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
+3
-4
@@ -3,10 +3,10 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -41,8 +41,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||||
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
var result = messages.Select(message =>
|
var result = messages.Select(message => {
|
||||||
{
|
|
||||||
var textMessage = message as TextMessage;
|
var textMessage = message as TextMessage;
|
||||||
var mediaMessage = message as MediaMessage;
|
var mediaMessage = message as MediaMessage;
|
||||||
var storyMessage = message as StoryMessage;
|
var storyMessage = message as StoryMessage;
|
||||||
@@ -67,7 +66,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||||
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
|
new List<ReadByDto>()
|
||||||
);
|
);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -1,8 +1,8 @@
|
|||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
@@ -192,7 +192,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||||
senderMember.UpdateDeliveredCursor(message.Id);
|
senderMember.UpdateDeliveredCursor(message.Id);
|
||||||
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
|
|
||||||
|
|
||||||
// 5. ���������
|
// 5. ���������
|
||||||
_messageRepository.Add(message);
|
_messageRepository.Add(message);
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Auth.Domain;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Messaging.Domain;
|
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Edit;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Pin;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.React;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Vote;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.React;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Pin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Vote;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Edit;
|
||||||
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -158,7 +158,6 @@ public sealed class ChatHub : Hub
|
|||||||
await _sender.Send(command);
|
await _sender.Send(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
|
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
||||||
{
|
{
|
||||||
ChatId = request.ChatId.ToString(),
|
ChatId = request.ChatId.ToString(),
|
||||||
@@ -351,8 +350,8 @@ public sealed class ChatHub : Hub
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(targetUserId))
|
if (string.IsNullOrEmpty(targetUserId))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
||||||
@@ -402,12 +401,12 @@ public sealed class ChatHub : Hub
|
|||||||
|
|
||||||
if (!chatId.HasValue)
|
if (!chatId.HasValue)
|
||||||
{
|
{
|
||||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||||
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
||||||
{
|
{
|
||||||
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
||||||
if (personalChat != null) chatId = personalChat.Id;
|
if (personalChat != null) chatId = personalChat.Id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
||||||
|
|||||||
@@ -19,17 +19,9 @@ public static class MessagesEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("api/messages").RequireAuthorization();
|
var group = app.MapGroup("api/messages").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("chat/{chatId:guid}", async (
|
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
[FromRoute] Guid chatId,
|
|
||||||
[FromQuery] string? cursor,
|
|
||||||
[FromQuery] long? afterSequenceId,
|
|
||||||
[FromQuery] long? pivot,
|
|
||||||
[FromQuery] int? limit,
|
|
||||||
ISender sender,
|
|
||||||
IUserContext userContext,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
|
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
// ================== Прочитано ==================
|
|
||||||
protected List<Guid> _readByUsers = new();
|
|
||||||
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
|
||||||
|
|
||||||
// ================== Инфраструктурный конструктор EF ==================
|
// ================== Инфраструктурный конструктор EF ==================
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
@@ -93,16 +89,6 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkAsRead(Guid userId)
|
|
||||||
{
|
|
||||||
if (!_readByUsers.Contains(userId))
|
|
||||||
{
|
|
||||||
_readByUsers.Add(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
|||||||
size = m.Size
|
size = m.Size
|
||||||
}).ToList() ?? (object)Array.Empty<object>(),
|
}).ToList() ?? (object)Array.Empty<object>(),
|
||||||
sender = senderObj,
|
sender = senderObj,
|
||||||
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
|
readBy = new List<object>(),
|
||||||
storyId = (message as StoryMessage)?.StoryId,
|
storyId = (message as StoryMessage)?.StoryId,
|
||||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
||||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||||
|
|||||||
@@ -95,20 +95,6 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var builder = Builders<Message>.Filter;
|
|
||||||
var filter = builder.And(
|
|
||||||
builder.Eq(m => m.ChatId, chatId),
|
|
||||||
builder.Gt(m => m.SequenceId, sequenceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return await _messages.Find(filter)
|
|
||||||
.SortBy(m => m.SequenceId)
|
|
||||||
.Limit(limit)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var builder = Builders<Message>.Filter;
|
var builder = Builders<Message>.Filter;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("com.google.dagger.hilt.android")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
kotlin("kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.knot.messager"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ru.knot.messager"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подключаем все наши папки с кодом как sourceSets
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
java.srcDirs(
|
||||||
|
"src/main/kotlin",
|
||||||
|
"../auth",
|
||||||
|
"../chats",
|
||||||
|
"../core",
|
||||||
|
"../calls",
|
||||||
|
"../stories",
|
||||||
|
"../contacts",
|
||||||
|
"../profiles",
|
||||||
|
"../settings",
|
||||||
|
"../navigation"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
|
}
|
||||||
|
// Включаем оптимизации компилятора Compose
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// AndroidX & UI
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.1")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
|
// Network & SignalR
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||||
|
|
||||||
|
// WebRTC
|
||||||
|
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||||
|
|
||||||
|
// Media3 (ExoPlayer)
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-ui:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-common:1.2.0")
|
||||||
|
|
||||||
|
// Images & GIF
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-video:2.5.0")
|
||||||
|
|
||||||
|
// Security
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// UCrop (Image Cropping)
|
||||||
|
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||||
|
|
||||||
|
// Firebase (Push Notifications)
|
||||||
|
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// Room
|
||||||
|
val room_version = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$room_version")
|
||||||
|
implementation("androidx.room:room-ktx:$room_version")
|
||||||
|
kapt("androidx.room:room-compiler:$room_version")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "483917912506",
|
||||||
|
"project_id": "knot-bad1a",
|
||||||
|
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "ru.knot.messager"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="ru.knot.messager">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name="com.knot.messenger.MainApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.KnotMessenger"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.knot.messenger.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.KnotMessenger">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="core.notifications.data.KnotFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import navigation.AppNavigation
|
||||||
|
import core.presentation.theme.KnotTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
@javax.inject.Inject
|
||||||
|
lateinit var navigationManager: core.utils.NavigationManager
|
||||||
|
|
||||||
|
@javax.inject.Inject
|
||||||
|
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
signalrNotificationObserver.start()
|
||||||
|
|
||||||
|
intent.getStringExtra("chatId")?.let { chatId ->
|
||||||
|
navigationManager.navigateToChat(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request notifications permission for Android 13+
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
androidx.core.app.ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
101
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
KnotTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
AppNavigation(navigationManager = navigationManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent?.getStringExtra("chatId")?.let { chatId ->
|
||||||
|
navigationManager.navigateToChat(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.VideoFrameDecoder
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MainApplication : Application(), ImageLoaderFactory {
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.components {
|
||||||
|
add(VideoFrameDecoder.Factory())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Цветная иконка для notification -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#6C5CE7"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
|
||||||
|
</vector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Knot</string>
|
||||||
|
<string name="login">Login</string>
|
||||||
|
<string name="register">Register</string>
|
||||||
|
<string name="username">Username</string>
|
||||||
|
<string name="password">Password</string>
|
||||||
|
<string name="display_name">Display Name</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="server_connection">Server Connection</string>
|
||||||
|
<string name="api_base_url">API Base URL</string>
|
||||||
|
<string name="server_features">Server Features</string>
|
||||||
|
<string name="stories">Stories</string>
|
||||||
|
<string name="polls">Polls</string>
|
||||||
|
<string name="calls">Calls</string>
|
||||||
|
<string name="groups">Groups</string>
|
||||||
|
<string name="enabled">Enabled</string>
|
||||||
|
<string name="disabled">Disabled</string>
|
||||||
|
<string name="limits">Limits</string>
|
||||||
|
<string name="max_file_size">Max File Size</string>
|
||||||
|
<string name="max_group_members">Max Group Members</string>
|
||||||
|
<string name="message">Message</string>
|
||||||
|
<string name="call">Call</string>
|
||||||
|
<string name="block">Block</string>
|
||||||
|
<string name="profile">Profile</string>
|
||||||
|
<string name="confirm_password">Confirm Password</string>
|
||||||
|
<string name="passwords_not_match">Passwords do not match</string>
|
||||||
|
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||||
|
<string name="already_have_account">Already have an account? Login</string>
|
||||||
|
<string name="error_occurred">An error occurred</string>
|
||||||
|
<string name="loading">Loading...</string>
|
||||||
|
<string name="chats_title">Chats</string>
|
||||||
|
<string name="contacts_title">Contacts</string>
|
||||||
|
<string name="stories_title">Stories</string>
|
||||||
|
<string name="create_story">Create Story</string>
|
||||||
|
<string name="send_message_hint">Type a message...</string>
|
||||||
|
<string name="reply_to_user">Reply to %1$s...</string>
|
||||||
|
<string name="story_editor">STORY EDITOR</string>
|
||||||
|
<string name="publish">PUBLISH</string>
|
||||||
|
<string name="start_creation">START CREATION</string>
|
||||||
|
<string name="text_tool">TEXT</string>
|
||||||
|
<string name="crop_tool">CROP</string>
|
||||||
|
<string name="stickers_tool">STICKERS</string>
|
||||||
|
<string name="brush_tool">BRUSH</string>
|
||||||
|
<string name="filters_tool">FILTERS</string>
|
||||||
|
<string name="remove">Remove</string>
|
||||||
|
<string name="no_chats_found">No chats found</string>
|
||||||
|
<string name="typing">typing...</string>
|
||||||
|
<string name="video_call">Video Call</string>
|
||||||
|
<string name="emoji">Emoji</string>
|
||||||
|
<string name="attach">Attach</string>
|
||||||
|
<string name="message_placeholder">Message...</string>
|
||||||
|
<string name="voice_message">Voice Message</string>
|
||||||
|
<string name="send">Send</string>
|
||||||
|
<string name="reply_photo">Photo</string>
|
||||||
|
<string name="reply_video">Video</string>
|
||||||
|
<string name="reply_audio">Audio</string>
|
||||||
|
<string name="reply_file">File</string>
|
||||||
|
<string name="reply_gif">GIF</string>
|
||||||
|
<string name="reply_prefix">Reply to </string>
|
||||||
|
<string name="reply_self">yourself</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Knot</string>
|
||||||
|
<string name="login">Войти</string>
|
||||||
|
<string name="register">Регистрация</string>
|
||||||
|
<string name="username">Имя пользователя</string>
|
||||||
|
<string name="password">Пароль</string>
|
||||||
|
<string name="display_name">Отображаемое имя</string>
|
||||||
|
<string name="settings">Настройки</string>
|
||||||
|
<string name="save">Сохранить</string>
|
||||||
|
<string name="back">Назад</string>
|
||||||
|
<string name="server_connection">Подключение к серверу</string>
|
||||||
|
<string name="api_base_url">API Base URL</string>
|
||||||
|
<string name="server_features">Функции сервера</string>
|
||||||
|
<string name="stories">Истории</string>
|
||||||
|
<string name="polls">Опросы</string>
|
||||||
|
<string name="calls">Звонки</string>
|
||||||
|
<string name="groups">Группы</string>
|
||||||
|
<string name="enabled">Включено</string>
|
||||||
|
<string name="disabled">Отключено</string>
|
||||||
|
<string name="limits">Лимиты</string>
|
||||||
|
<string name="max_file_size">Макс. размер файла</string>
|
||||||
|
<string name="max_group_members">Макс. участников в группе</string>
|
||||||
|
<string name="message">Сообщение</string>
|
||||||
|
<string name="call">Позвонить</string>
|
||||||
|
<string name="block">Заблокировать</string>
|
||||||
|
<string name="profile">Профиль</string>
|
||||||
|
<string name="confirm_password">Подтвердите пароль</string>
|
||||||
|
<string name="passwords_not_match">Пароли не совпадают</string>
|
||||||
|
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
|
||||||
|
<string name="already_have_account">Уже есть аккаунт? Войти</string>
|
||||||
|
<string name="error_occurred">Произошла ошибка</string>
|
||||||
|
<string name="loading">Загрузка...</string>
|
||||||
|
<string name="chats_title">Чаты</string>
|
||||||
|
<string name="contacts_title">Контакты</string>
|
||||||
|
<string name="stories_title">Истории</string>
|
||||||
|
<string name="create_story">Создать историю</string>
|
||||||
|
<string name="send_message_hint">Напишите сообщение...</string>
|
||||||
|
<string name="reply_to_user">Ответить %1$s...</string>
|
||||||
|
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
|
||||||
|
<string name="publish">ОПУБЛИКОВАТЬ</string>
|
||||||
|
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
|
||||||
|
<string name="text_tool">ТЕКСТ</string>
|
||||||
|
<string name="crop_tool">ОБРЕЗКА</string>
|
||||||
|
<string name="stickers_tool">СТИКЕРЫ</string>
|
||||||
|
<string name="brush_tool">КИСТЬ</string>
|
||||||
|
<string name="filters_tool">ФИЛЬТРЫ</string>
|
||||||
|
<string name="remove">Удалить</string>
|
||||||
|
<string name="no_chats_found">Чаты не найдены</string>
|
||||||
|
<string name="typing">печатает...</string>
|
||||||
|
<string name="video_call">Видеозвонок</string>
|
||||||
|
<string name="emoji">Эмодзи</string>
|
||||||
|
<string name="attach">Прикрепить</string>
|
||||||
|
<string name="message_placeholder">Сообщение...</string>
|
||||||
|
<string name="voice_message">Голосовое сообщение</string>
|
||||||
|
<string name="send">Отправить</string>
|
||||||
|
<string name="search_hint">Поиск...</string>
|
||||||
|
<string name="online">В сети</string>
|
||||||
|
<string name="last_seen">Был(а): %1$s</string>
|
||||||
|
<string name="last_seen_recently">недавно</string>
|
||||||
|
<string name="all">Все</string>
|
||||||
|
<string name="online_tab">Онлайн</string>
|
||||||
|
<string name="blocked">Заблокированные</string>
|
||||||
|
<string name="media">Медиа</string>
|
||||||
|
<string name="notifications">Уведомления</string>
|
||||||
|
<string name="mute">Без звука</string>
|
||||||
|
<string name="unmute">Включить звук</string>
|
||||||
|
<string name="log_out">Выйти из аккаунта</string>
|
||||||
|
<string name="bio">О себе</string>
|
||||||
|
<string name="edit_profile">Редактировать профиль</string>
|
||||||
|
<string name="username_label">Имя пользователя</string>
|
||||||
|
<string name="change_photo">Изменить фото</string>
|
||||||
|
<string name="cancel">Отмена</string>
|
||||||
|
<string name="crop">Обрезать</string>
|
||||||
|
<string name="chats">Чаты</string>
|
||||||
|
<string name="contacts_tab">Контакты</string>
|
||||||
|
<string name="profile_tab">Профиль</string>
|
||||||
|
<string name="saving">Сохранение...</string>
|
||||||
|
<string name="reply_photo">Фото</string>
|
||||||
|
<string name="reply_video">Видео</string>
|
||||||
|
<string name="reply_audio">Аудио</string>
|
||||||
|
<string name="reply_file">Файл</string>
|
||||||
|
<string name="reply_gif">GIF</string>
|
||||||
|
<string name="reply_prefix">Ответ </string>
|
||||||
|
<string name="reply_self">самому себе</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">#0F0F10</item>
|
||||||
|
<item name="android:windowBackground">#0F0F10</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Для отладки: доверяем пользовательским сертификатам -->
|
||||||
|
<debug-overrides>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="user" />
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</debug-overrides>
|
||||||
|
|
||||||
|
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||||
|
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||||
|
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package auth.data.remote.api
|
||||||
|
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.data.remote.dto.AuthResponse
|
||||||
|
import auth.data.remote.dto.RefreshTokenRequest
|
||||||
|
import core.domain.model.ServerConfigModel
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface AuthApi {
|
||||||
|
@POST("auth/login")
|
||||||
|
suspend fun login(@Body request: AuthRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("auth/register")
|
||||||
|
suspend fun register(@Body request: AuthRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("auth/refresh")
|
||||||
|
suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse
|
||||||
|
|
||||||
|
@GET("config")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun getConfig(): ServerConfigModel
|
||||||
|
|
||||||
|
@POST("auth/push-token")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun updatePushToken(@Body token: String): Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package auth.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class AuthRequest(
|
||||||
|
@SerializedName("userName") val userName: String,
|
||||||
|
@SerializedName("password") val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthResponse(
|
||||||
|
@SerializedName("accessToken") val accessToken: String?,
|
||||||
|
@SerializedName("refreshToken") val refreshToken: String?,
|
||||||
|
@SerializedName("user") val user: UserDto?,
|
||||||
|
@SerializedName("userId") val userId: String?,
|
||||||
|
@SerializedName("username") val username: String?,
|
||||||
|
@SerializedName("displayName") val displayName: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("userName") val userName: String,
|
||||||
|
@SerializedName("displayName") val displayName: String?,
|
||||||
|
@SerializedName("avatarUrl") val avatarUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RefreshTokenRequest(
|
||||||
|
@SerializedName("refreshToken") val refreshToken: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package auth.data.repository
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.data.remote.dto.RefreshTokenRequest
|
||||||
|
import auth.domain.model.AuthResult
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
private val api: AuthApi,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
|
||||||
|
|
||||||
|
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
||||||
|
return try {
|
||||||
|
val response = api.login(AuthRequest(userName, password))
|
||||||
|
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||||
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
|
tokenManager.saveToken(token, userId, response.refreshToken)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
refreshToken = response.refreshToken,
|
||||||
|
userId = userId,
|
||||||
|
userName = response.username ?: userName,
|
||||||
|
displayName = response.displayName ?: response.username ?: userName,
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun register(userName: String, password: String): Result<AuthResult> {
|
||||||
|
return try {
|
||||||
|
val response = api.register(AuthRequest(userName, password))
|
||||||
|
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||||
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
|
tokenManager.saveToken(token, userId, response.refreshToken)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
refreshToken = response.refreshToken,
|
||||||
|
userId = userId,
|
||||||
|
userName = response.username ?: userName,
|
||||||
|
displayName = response.displayName ?: response.username ?: userName,
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun logout() {
|
||||||
|
tokenManager.deleteToken()
|
||||||
|
_isAuthenticated.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAuthenticated(): Boolean {
|
||||||
|
return _isAuthenticated.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
|
||||||
|
return _isAuthenticated.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchConfig(): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val config = api.getConfig()
|
||||||
|
serverConfig.saveServerConfig(config)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updatePushToken(token: String) {
|
||||||
|
try {
|
||||||
|
api.updatePushToken(token)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshToken(): Result<AuthResult> {
|
||||||
|
val currentRefreshToken = tokenManager.getRefreshToken()
|
||||||
|
if (currentRefreshToken == null) {
|
||||||
|
return Result.failure(Exception("Refresh token is null"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken))
|
||||||
|
val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null"))
|
||||||
|
val newRefreshToken = response.refreshToken
|
||||||
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
|
tokenManager.saveToken(newAccessToken, userId, newRefreshToken)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = newAccessToken,
|
||||||
|
refreshToken = newRefreshToken,
|
||||||
|
userId = userId,
|
||||||
|
userName = response.username ?: "",
|
||||||
|
displayName = response.displayName ?: "",
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package auth.di
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.repository.AuthRepositoryImpl
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AuthModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
||||||
|
return retrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRepository(
|
||||||
|
api: AuthApi,
|
||||||
|
tokenManager: TokenManager,
|
||||||
|
serverConfig: ServerConfig
|
||||||
|
): AuthRepository {
|
||||||
|
return AuthRepositoryImpl(api, tokenManager, serverConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth.domain.model
|
||||||
|
|
||||||
|
data class AuthResult(
|
||||||
|
val token: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val userId: String,
|
||||||
|
val userName: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth.domain.repository
|
||||||
|
|
||||||
|
import auth.domain.model.AuthResult
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
suspend fun login(userName: String, password: String): Result<AuthResult>
|
||||||
|
suspend fun register(userName: String, password: String): Result<AuthResult>
|
||||||
|
suspend fun logout()
|
||||||
|
suspend fun fetchConfig(): Result<Unit>
|
||||||
|
fun isAuthenticated(): Boolean
|
||||||
|
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
|
||||||
|
suspend fun updatePushToken(token: String)
|
||||||
|
suspend fun refreshToken(): Result<AuthResult>
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class AuthState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isAuthenticated: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(
|
||||||
|
private val repository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
||||||
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.isAuthenticatedFlow().collect { authenticated ->
|
||||||
|
_state.update { it.copy(isAuthenticated = authenticated) }
|
||||||
|
if (authenticated) {
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkAuth() {
|
||||||
|
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(userName: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.login(userName, password)
|
||||||
|
.onSuccess {
|
||||||
|
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(userName: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.register(userName, password)
|
||||||
|
.onSuccess {
|
||||||
|
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshToken() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.refreshToken()
|
||||||
|
.onSuccess {
|
||||||
|
// Token refreshed successfully
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
// Refresh failed, will trigger logout via AuthInterceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePushToken() {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
val token = task.result
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updatePushToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
onNavigateToRegister: () -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLoginSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var userName by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(state.isAuthenticated) {
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.login)) },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userName,
|
||||||
|
onValueChange = { userName = it },
|
||||||
|
label = { Text(stringResource(R.string.username)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login(userName, password) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = userName.isNotBlank() && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.login))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onNavigateToRegister) {
|
||||||
|
Text(stringResource(R.string.no_account_register))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
onNavigateToLogin: () -> Unit,
|
||||||
|
onRegisterSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var userName by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
|
||||||
|
|
||||||
|
LaunchedEffect(state.isAuthenticated) {
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
onRegisterSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text(stringResource(R.string.register)) })
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userName,
|
||||||
|
onValueChange = { userName = it },
|
||||||
|
label = { Text(stringResource(R.string.username)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = { Text(stringResource(R.string.confirm_password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (password == confirmPassword) {
|
||||||
|
errorMessage = null
|
||||||
|
viewModel.register(userName, password)
|
||||||
|
} else {
|
||||||
|
errorMessage = passwordsNotMatchMsg
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.register))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onNavigateToLogin) {
|
||||||
|
Text(stringResource(R.string.already_have_account))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayError = state.error ?: errorMessage
|
||||||
|
if (displayError != null) {
|
||||||
|
Text(
|
||||||
|
text = displayError,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.0" apply false
|
||||||
|
id("com.android.library") version "8.2.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
|
id("com.google.dagger.hilt.android") version "2.48" apply false
|
||||||
|
id("com.google.gms.google-services") version "4.4.0" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package calls.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
class CallAudioManager(private val context: Context) {
|
||||||
|
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
private var originalMode: Int = AudioManager.MODE_NORMAL
|
||||||
|
private var originalIsSpeakerphoneOn: Boolean = false
|
||||||
|
|
||||||
|
fun startCallMode(isVideoCall: Boolean) {
|
||||||
|
originalMode = audioManager.mode
|
||||||
|
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
|
||||||
|
|
||||||
|
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
|
setSpeakerphoneOn(isVideoCall)
|
||||||
|
|
||||||
|
// Запрашиваем фокус аудио
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val playbackAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
.setAudioAttributes(playbackAttributes)
|
||||||
|
.build()
|
||||||
|
audioManager.requestAudioFocus(focusRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeakerphoneOn(on: Boolean) {
|
||||||
|
audioManager.isSpeakerphoneOn = on
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopCallMode() {
|
||||||
|
audioManager.mode = originalMode
|
||||||
|
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
|
||||||
|
audioManager.abandonAudioFocus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package calls.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.webrtc.*
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class GroupWebRtcManager @Inject constructor(private val context: Context) {
|
||||||
|
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
|
||||||
|
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||||
|
|
||||||
|
private fun createFactory(): PeerConnectionFactory {
|
||||||
|
PeerConnectionFactory.initialize(
|
||||||
|
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||||
|
)
|
||||||
|
return PeerConnectionFactory.builder()
|
||||||
|
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||||
|
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||||
|
.createPeerConnectionFactory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
|
||||||
|
val iceServers = listOf(
|
||||||
|
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||||
|
)
|
||||||
|
val pc = factory.createPeerConnection(iceServers, observer)
|
||||||
|
if (pc != null) {
|
||||||
|
peerConnections[userId] = pc
|
||||||
|
}
|
||||||
|
return pc
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeParticipant(userId: String) {
|
||||||
|
peerConnections[userId]?.dispose()
|
||||||
|
peerConnections.remove(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
|
||||||
|
|
||||||
|
fun closeAll() {
|
||||||
|
peerConnections.values.forEach { it.dispose() }
|
||||||
|
peerConnections.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package calls.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.webrtc.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class WebRtcManager @Inject constructor(private val context: Context) {
|
||||||
|
private var peerConnection: PeerConnection? = null
|
||||||
|
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||||
|
|
||||||
|
// Аудио и видео источники
|
||||||
|
private val videoSource by lazy { factory.createVideoSource(false) }
|
||||||
|
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
|
||||||
|
|
||||||
|
private fun createFactory(): PeerConnectionFactory {
|
||||||
|
PeerConnectionFactory.initialize(
|
||||||
|
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||||
|
)
|
||||||
|
return PeerConnectionFactory.builder()
|
||||||
|
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||||
|
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||||
|
.createPeerConnectionFactory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializePeerConnection(observer: PeerConnection.Observer) {
|
||||||
|
val iceServers = listOf(
|
||||||
|
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||||
|
)
|
||||||
|
peerConnection = factory.createPeerConnection(iceServers, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createOffer(observer: SdpObserver) {
|
||||||
|
peerConnection?.createOffer(observer, MediaConstraints())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
|
||||||
|
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addIceCandidate(candidate: IceCandidate) {
|
||||||
|
peerConnection?.addIceCandidate(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
peerConnection?.dispose()
|
||||||
|
peerConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package calls.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallScreen(
|
||||||
|
viewModel: CallViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF0F0F10),
|
||||||
|
Color(0xFF161618),
|
||||||
|
Color(0xFF6366F1).copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Контент звонка (Аватар или Видео)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = 100.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = state.callerAvatar,
|
||||||
|
name = state.callerName,
|
||||||
|
size = 140.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.callerName,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (state.status) {
|
||||||
|
CallStatus.INCOMING -> "Входящий звонок..."
|
||||||
|
CallStatus.OUTGOING -> "Вызов..."
|
||||||
|
CallStatus.CONNECTED -> "00:00" // TODO: Timer
|
||||||
|
CallStatus.ENDED -> "Звонок завершен"
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки управления (Внизу)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 60.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (state.status == CallStatus.INCOMING) {
|
||||||
|
// Кнопка отклонить
|
||||||
|
CallActionButton(
|
||||||
|
icon = Icons.Default.CallEnd,
|
||||||
|
backgroundColor = Color.Red,
|
||||||
|
onClick = { viewModel.endCall(); onBack() }
|
||||||
|
)
|
||||||
|
// Кнопка принять
|
||||||
|
CallActionButton(
|
||||||
|
icon = Icons.Default.Call,
|
||||||
|
backgroundColor = Color(0xFF10B981), // Green
|
||||||
|
onClick = { viewModel.acceptCall() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Стандартные кнопки во время разговора
|
||||||
|
IconButton(
|
||||||
|
onClick = { /* viewModel.toggleMic() */ },
|
||||||
|
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
CallActionButton(
|
||||||
|
icon = Icons.Default.CallEnd,
|
||||||
|
backgroundColor = Color.Red,
|
||||||
|
onClick = { viewModel.endCall(); onBack() }
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { /* viewModel.toggleSpeaker() */ },
|
||||||
|
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallActionButton(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
backgroundColor: Color,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onClick,
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
contentColor = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package calls.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import calls.data.remote.WebRtcManager
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.webrtc.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
|
||||||
|
|
||||||
|
data class CallState(
|
||||||
|
val status: CallStatus = CallStatus.IDLE,
|
||||||
|
val chatId: String? = null,
|
||||||
|
val callerName: String = "",
|
||||||
|
val callerAvatar: String? = null,
|
||||||
|
val isMuted: Boolean = false,
|
||||||
|
val isSpeakerOn: Boolean = false,
|
||||||
|
val localVideoTrack: VideoTrack? = null,
|
||||||
|
val remoteVideoTrack: VideoTrack? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CallViewModel @Inject constructor(
|
||||||
|
private val webRtcManager: WebRtcManager,
|
||||||
|
private val signalrClient: ChatHubClient
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(CallState())
|
||||||
|
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeSignaling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignaling() {
|
||||||
|
signalrClient.events
|
||||||
|
.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.CallIncoming -> onIncomingCall(event)
|
||||||
|
is ChatEvent.CallAnswered -> onCallAnswered(event)
|
||||||
|
is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
|
||||||
|
is ChatEvent.CallEnded -> onCallEnded()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onIncomingCall(event: ChatEvent.CallIncoming) {
|
||||||
|
_state.update { it.copy(
|
||||||
|
status = CallStatus.INCOMING,
|
||||||
|
chatId = event.chatId,
|
||||||
|
callerName = "User ${event.from}" // TODO: Load actual user info
|
||||||
|
) }
|
||||||
|
// Set remote description from offer
|
||||||
|
webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
|
||||||
|
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||||
|
override fun onSetSuccess() {}
|
||||||
|
override fun onCreateFailure(p0: String?) {}
|
||||||
|
override fun onSetFailure(p0: String?) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCallAnswered(event: ChatEvent.CallAnswered) {
|
||||||
|
_state.update { it.copy(status = CallStatus.CONNECTED) }
|
||||||
|
webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
|
||||||
|
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||||
|
override fun onSetSuccess() {}
|
||||||
|
override fun onCreateFailure(p0: String?) {}
|
||||||
|
override fun onSetFailure(p0: String?) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
|
||||||
|
// Parse candidate JSON and add to peer connection
|
||||||
|
// webRtcManager.addIceCandidate(...)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCallEnded() {
|
||||||
|
_state.update { it.copy(status = CallStatus.ENDED) }
|
||||||
|
webRtcManager.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptCall() {
|
||||||
|
val chatId = _state.value.chatId ?: return
|
||||||
|
// Create answer and send via SignalR
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endCall() {
|
||||||
|
val chatId = _state.value.chatId ?: return
|
||||||
|
// Send call_end via SignalR
|
||||||
|
onCallEnded()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package calls.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import calls.data.remote.GroupWebRtcManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.webrtc.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ParticipantState(
|
||||||
|
val userId: String,
|
||||||
|
val videoTrack: VideoTrack? = null,
|
||||||
|
val isAudioMuted: Boolean = false,
|
||||||
|
val isVideoDisabled: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GroupCallState(
|
||||||
|
val chatId: String? = null,
|
||||||
|
val participants: Map<String, ParticipantState> = emptyMap(),
|
||||||
|
val isMicEnabled: Boolean = true,
|
||||||
|
val isCameraEnabled: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class GroupCallViewModel @Inject constructor(
|
||||||
|
private val webRtcManager: GroupWebRtcManager,
|
||||||
|
private val signalrClient: ChatHubClient
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(GroupCallState())
|
||||||
|
val state: StateFlow<GroupCallState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeSignalREvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignalREvents() {
|
||||||
|
signalrClient.events.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
|
||||||
|
is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
|
||||||
|
is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
|
||||||
|
is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
|
||||||
|
// Дополнительные обработчики ICE кандидатов и т.д.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUserJoined(userId: String) {
|
||||||
|
// Создаем PeerConnection для нового участника
|
||||||
|
// Логика идентична портированному CallModal.tsx
|
||||||
|
_state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUserLeft(userId: String) {
|
||||||
|
webRtcManager.removeParticipant(userId)
|
||||||
|
_state.update { it.copy(participants = it.participants - userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleOffer(from: String, sdp: String) {
|
||||||
|
// Установка RemoteDescription и создание Answer
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAnswer(from: String, sdp: String) {
|
||||||
|
// Установка RemoteDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleMic() {
|
||||||
|
_state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
|
||||||
|
// Логика управления AudioTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCamera() {
|
||||||
|
_state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
|
||||||
|
// Логика управления VideoTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
webRtcManager.closeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package calls.presentation.components
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import org.webrtc.EglBase
|
||||||
|
import org.webrtc.SurfaceViewRenderer
|
||||||
|
import org.webrtc.VideoTrack
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoGrid(
|
||||||
|
participants: Map<String, VideoTrack?>,
|
||||||
|
localVideoTrack: VideoTrack?
|
||||||
|
) {
|
||||||
|
val eglBaseContext = remember { EglBase.create().eglBaseContext }
|
||||||
|
val allVideoTracks = remember(participants, localVideoTrack) {
|
||||||
|
listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(8.dp)
|
||||||
|
) {
|
||||||
|
items(allVideoTracks) { track ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
|
||||||
|
) {
|
||||||
|
VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoRenderer(
|
||||||
|
videoTrack: VideoTrack,
|
||||||
|
eglBaseContext: EglBase.Context
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
SurfaceViewRenderer(context).apply {
|
||||||
|
init(eglBaseContext, null)
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view ->
|
||||||
|
videoTrack.addSink(view)
|
||||||
|
},
|
||||||
|
onRelease = { view ->
|
||||||
|
videoTrack.removeSink(view)
|
||||||
|
view.release()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package chats.data.remote.api
|
||||||
|
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
data class SendMessageRequest(
|
||||||
|
val content: String?,
|
||||||
|
val type: String = "text",
|
||||||
|
val attachments: List<AttachmentRequest>? = null,
|
||||||
|
val replyToId: String? = null,
|
||||||
|
val quote: String? = null,
|
||||||
|
val forwardedFromId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttachmentRequest(
|
||||||
|
val type: String,
|
||||||
|
val url: String,
|
||||||
|
val fileName: String,
|
||||||
|
val fileSize: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ChatApi {
|
||||||
|
@GET("chats")
|
||||||
|
suspend fun getChats(): List<ChatDto>
|
||||||
|
|
||||||
|
@GET("messages/chat/{chatId}")
|
||||||
|
suspend fun getMessages(
|
||||||
|
@Path("chatId") chatId: String,
|
||||||
|
@Query("cursor") cursor: String? = null,
|
||||||
|
@Query("pivot") pivot: Long? = null,
|
||||||
|
@Query("limit") limit: Int? = 50
|
||||||
|
): List<MessageDto>
|
||||||
|
|
||||||
|
@POST("messages/chat/{chatId}")
|
||||||
|
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("messages/upload")
|
||||||
|
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
|
||||||
|
|
||||||
|
// Klipy GIF API
|
||||||
|
@GET("klipy/trending")
|
||||||
|
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
|
||||||
|
|
||||||
|
@GET("klipy/search")
|
||||||
|
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
|
||||||
|
|
||||||
|
@GET("klipy/categories")
|
||||||
|
suspend fun getGifCategories(): GifCategoriesResponse
|
||||||
|
|
||||||
|
@POST("klipy/shared/{id}")
|
||||||
|
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
|
||||||
|
|
||||||
|
@POST("messages/{messageId}/reactions")
|
||||||
|
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
|
||||||
|
|
||||||
|
@POST("chats/personal")
|
||||||
|
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
|
||||||
|
|
||||||
|
@POST("chats/{chatId}/typing")
|
||||||
|
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||||
|
|
||||||
|
@POST("chats/{chatId}/read")
|
||||||
|
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
||||||
|
|
||||||
|
@DELETE("messages/{messageId}")
|
||||||
|
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
|
||||||
|
|
||||||
|
@PUT("messages/{messageId}")
|
||||||
|
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatePersonalChatRequest(
|
||||||
|
val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KlipyResponse(
|
||||||
|
val data: KlipyDataWrapper
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KlipyDataWrapper(
|
||||||
|
val data: List<KlipyGifDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KlipyGifDto(
|
||||||
|
val id: String,
|
||||||
|
val images: GifImagesDto? = null,
|
||||||
|
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||||
|
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||||
|
val media_formats: Map<String, GifImageSourceDto>? = null,
|
||||||
|
val title: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifImagesDto(
|
||||||
|
val fixed_height: GifImageSourceDto? = null,
|
||||||
|
val original: GifImageSourceDto? = null,
|
||||||
|
val fixed_height_small: GifImageSourceDto? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifImageSourceDto(
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifCategoryDto(
|
||||||
|
val category: String,
|
||||||
|
val preview_url: String,
|
||||||
|
val query: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FileUploadResponse(
|
||||||
|
val url: String,
|
||||||
|
val filename: String,
|
||||||
|
val size: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifCategoriesResponse(
|
||||||
|
val data: GifCategoriesData
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifCategoriesData(
|
||||||
|
val categories: List<GifCategoryDto>
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package chats.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class UserBasicDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("username") val username: String? = null,
|
||||||
|
@SerializedName("displayName") val displayName: String? = null,
|
||||||
|
@SerializedName("avatarUrl") val avatarUrl: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessageDto(
|
||||||
|
@SerializedName("id", alternate = ["Id"]) val id: String,
|
||||||
|
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||||
|
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
|
||||||
|
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
|
||||||
|
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
|
||||||
|
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
|
||||||
|
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
|
||||||
|
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
|
||||||
|
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
|
||||||
|
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
|
||||||
|
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
|
||||||
|
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
|
||||||
|
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
|
||||||
|
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReactionDto(
|
||||||
|
@SerializedName("emoji") val emoji: String,
|
||||||
|
@SerializedName("count") val count: Int,
|
||||||
|
@SerializedName("isSetByMe") val isSetByMe: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaItemDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("url") val url: String,
|
||||||
|
@SerializedName("filename") val filename: String? = null,
|
||||||
|
@SerializedName("size") val size: Long? = null,
|
||||||
|
@SerializedName("duration") val duration: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("name") val name: String? = null,
|
||||||
|
@SerializedName("avatar") val avatar: String? = null,
|
||||||
|
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
|
||||||
|
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
|
||||||
|
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatMemberDto(
|
||||||
|
@SerializedName("userId") val userId: String,
|
||||||
|
@SerializedName("user") val user: UserBasicDto? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
|
||||||
|
sealed class ChatEvent {
|
||||||
|
data class NewMessage(val message: MessageDto) : ChatEvent()
|
||||||
|
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
||||||
|
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
||||||
|
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
||||||
|
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class UserOnline(val userId: String) : ChatEvent()
|
||||||
|
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
|
||||||
|
data class NewChat(val chat: ChatDto) : ChatEvent()
|
||||||
|
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
|
||||||
|
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
|
||||||
|
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
|
||||||
|
|
||||||
|
// Call Events (WebRTC Signaling)
|
||||||
|
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
|
||||||
|
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
|
||||||
|
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
|
||||||
|
data class CallEnded(val chatId: String) : ChatEvent()
|
||||||
|
|
||||||
|
// Group Call Events
|
||||||
|
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
|
||||||
|
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
|
||||||
|
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
|
||||||
|
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import com.microsoft.signalr.HubConnection
|
||||||
|
import com.microsoft.signalr.HubConnectionBuilder
|
||||||
|
import com.microsoft.signalr.HubConnectionState
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
data class ReadMessagesRequest(
|
||||||
|
val chatId: String,
|
||||||
|
val lastReadMessageId: String,
|
||||||
|
val lastReadSequenceId: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessagesReadEvent(
|
||||||
|
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
|
||||||
|
) {
|
||||||
|
val effectiveChatId: String get() = chatId ?: ""
|
||||||
|
val effectiveUserId: String get() = userId ?: ""
|
||||||
|
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ReactionEvent(
|
||||||
|
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ChatHubClient @Inject constructor() {
|
||||||
|
private var hubConnection: HubConnection? = null
|
||||||
|
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
|
||||||
|
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||||
|
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
private var lastBaseUrl: String? = null
|
||||||
|
private var lastToken: String? = null
|
||||||
|
|
||||||
|
fun connect(baseUrl: String, accessToken: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
|
||||||
|
|
||||||
|
lastBaseUrl = baseUrl
|
||||||
|
lastToken = accessToken
|
||||||
|
_status.value = ConnectionStatus.CONNECTING
|
||||||
|
|
||||||
|
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
|
||||||
|
.withAccessTokenProvider(Single.just(accessToken))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
setupHandlers()
|
||||||
|
|
||||||
|
hubConnection?.onClosed { exception ->
|
||||||
|
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
scope.launch {
|
||||||
|
delay(5000)
|
||||||
|
connect(baseUrl, accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
hubConnection?.start()?.blockingAwait()
|
||||||
|
_status.value = ConnectionStatus.CONNECTED
|
||||||
|
Log.d("ChatHubClient", "SignalR Connected")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatHubClient", "SignalR Connection Error", e)
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupHandlers() {
|
||||||
|
hubConnection?.let { conn ->
|
||||||
|
conn.on("new_message", { message: MessageDto ->
|
||||||
|
_events.tryEmit(ChatEvent.NewMessage(message))
|
||||||
|
}, MessageDto::class.java)
|
||||||
|
|
||||||
|
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
|
||||||
|
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
|
||||||
|
}, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("message_deleted", { messageId: String, chatId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("messages_read", { data: MessagesReadEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.MessagesRead(
|
||||||
|
data.effectiveChatId,
|
||||||
|
data.effectiveUserId,
|
||||||
|
data.effectiveLastReadSequenceId
|
||||||
|
))
|
||||||
|
}, MessagesReadEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("user_typing", { data: ReactionEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("user_stopped_typing", { data: ReactionEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("user_online", { userId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.UserOnline(userId))
|
||||||
|
}, String::class.java)
|
||||||
|
|
||||||
|
conn.on("new_chat", { chat: ChatDto ->
|
||||||
|
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||||
|
}, ChatDto::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_added", { data: ReactionEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||||
|
data.messageId ?: "",
|
||||||
|
data.chatId ?: "",
|
||||||
|
data.userId ?: "",
|
||||||
|
data.emoji ?: "",
|
||||||
|
isRemoved = false
|
||||||
|
))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_removed", { data: ReactionEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||||
|
data.messageId ?: "",
|
||||||
|
data.chatId ?: "",
|
||||||
|
data.userId ?: "",
|
||||||
|
data.emoji ?: "",
|
||||||
|
isRemoved = true
|
||||||
|
))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
// WebRTC Signaling Handlers
|
||||||
|
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
|
||||||
|
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("call_answered", { chatId: String, answer: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("ice_candidate", { chatId: String, candidate: String ->
|
||||||
|
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("call_ended", { chatId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallEnded(chatId))
|
||||||
|
}, String::class.java)
|
||||||
|
conn.on("message_pinned", { data: Map<String, Any> ->
|
||||||
|
// The web version expects a message object, but here we might get a partial DTO or just IDs.
|
||||||
|
// Let's assume we get { chatId, message: MessageDto } based on web
|
||||||
|
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
|
||||||
|
// For simplicity, we might needs a dedicated DTO if it fails.
|
||||||
|
}, Map::class.java)
|
||||||
|
|
||||||
|
conn.on("message_pinned", { chatId: String, message: MessageDto ->
|
||||||
|
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
|
||||||
|
}, String::class.java, MessageDto::class.java)
|
||||||
|
|
||||||
|
conn.on("message_unpinned", { chatId: String, messageId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
hubConnection?.stop()
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReaction(messageId: String, chatId: String, emoji: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("add_reaction", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId,
|
||||||
|
"emoji" to emoji
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
|
||||||
|
} else {
|
||||||
|
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeReaction(messageId: String, chatId: String, emoji: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("remove_reaction", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId,
|
||||||
|
"emoji" to emoji
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinMessage(messageId: String, chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("pin_message", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpinMessage(messageId: String, chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("unpin_message", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readMessages(request: ReadMessagesRequest) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("read_messages", request)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinChat(chatId: String) {
|
||||||
|
scope.launch {
|
||||||
|
// Wait for connection to be established if it's currently connecting
|
||||||
|
var attempts = 0
|
||||||
|
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
|
||||||
|
delay(500)
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("join_chat", chatId)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Joined chat room: $chatId")
|
||||||
|
} else {
|
||||||
|
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendTypingIndicator(chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("typing_start", chatId)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendUserStoppedTyping(chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("typing_stop", chatId)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import core.notifications.data.ActiveChatTracker
|
||||||
|
import core.notifications.data.NotificationHelper
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SignalRNotificationObserver @Inject constructor(
|
||||||
|
private val signalrClient: ChatHubClient,
|
||||||
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val chatRepository: chats.domain.repository.ChatRepository,
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var isStarted = false
|
||||||
|
private val processedMessageIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
val total = chats.sumOf { it.unreadCount }
|
||||||
|
activeChatTracker.setTotalUnreadCount(total)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore load error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (isStarted) return
|
||||||
|
isStarted = true
|
||||||
|
|
||||||
|
// Initial count load
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
signalrClient.events
|
||||||
|
.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.NewMessage -> {
|
||||||
|
val currentUserId = tokenManager.getUserId()
|
||||||
|
val message = event.message
|
||||||
|
|
||||||
|
// Don't show if it's our message or already processed
|
||||||
|
if (message.senderId == currentUserId) return@onEach
|
||||||
|
if (processedMessageIds.contains(message.id)) return@onEach
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
processedMessageIds.add(message.id)
|
||||||
|
if (processedMessageIds.size > 200) {
|
||||||
|
processedMessageIds.remove(processedMessageIds.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment immediately for UI feedback
|
||||||
|
activeChatTracker.incrementUnreadCount()
|
||||||
|
|
||||||
|
// Refresh total count from source of truth in background
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
// Don't show if this chat is currently open
|
||||||
|
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
|
||||||
|
|
||||||
|
NotificationHelper.showNotification(
|
||||||
|
context = context,
|
||||||
|
title = message.sender?.displayName ?: "Новое сообщение",
|
||||||
|
body = message.content ?: "Вам прислали вложение",
|
||||||
|
type = "chat",
|
||||||
|
chatId = message.chatId,
|
||||||
|
notificationId = message.id.hashCode(),
|
||||||
|
totalCount = activeChatTracker.totalUnreadCount.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ChatEvent.MessagesRead -> {
|
||||||
|
// If anyone read messages, sync our total count
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package chats.data.repository
|
||||||
|
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.api.SendMessageRequest
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.data.remote.dto.MediaItemDto
|
||||||
|
import chats.data.remote.dto.ReactionDto
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import chats.data.remote.signalr.ReadMessagesRequest
|
||||||
|
import core.security.TokenManager
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class ChatRepositoryImpl @Inject constructor(
|
||||||
|
private val api: ChatApi,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
|
) : ChatRepository {
|
||||||
|
private val gson = com.google.gson.Gson()
|
||||||
|
|
||||||
|
override suspend fun getChats(): List<Chat> {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||||
|
// Кэш отключён - всегда возвращаем пустой поток
|
||||||
|
return kotlinx.coroutines.flow.flowOf(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
return try {
|
||||||
|
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
|
||||||
|
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
|
||||||
|
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мапим в доменные модели. По умолчанию считаем прочитанными,
|
||||||
|
// так как unreadCount нам тут не критичен для истории.
|
||||||
|
messages.map { msg ->
|
||||||
|
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(
|
||||||
|
chatId: String,
|
||||||
|
content: String?,
|
||||||
|
type: String,
|
||||||
|
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||||
|
replyToId: String?,
|
||||||
|
forwardedFromId: String?
|
||||||
|
): Message {
|
||||||
|
val request = SendMessageRequest(
|
||||||
|
content = content,
|
||||||
|
type = type,
|
||||||
|
attachments = attachments,
|
||||||
|
replyToId = replyToId,
|
||||||
|
forwardedFromId = forwardedFromId
|
||||||
|
)
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val userId = tokenManager.getUserId() ?: ""
|
||||||
|
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||||
|
api.addReaction(messageId, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendTypingStatus(chatId: String) {
|
||||||
|
api.sendTypingStatus(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
|
||||||
|
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveMessage(message: Message) {
|
||||||
|
// Кэш отключён
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
|
// Локальное удаление не поддерживается без кэша
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadMedia(file: java.io.File): String {
|
||||||
|
val mimeType = when (file.extension.lowercase()) {
|
||||||
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
|
"png" -> "image/png"
|
||||||
|
"webp" -> "image/webp"
|
||||||
|
"mp4" -> "video/mp4"
|
||||||
|
"mp3", "m4a", "wav" -> "audio/mpeg"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
|
||||||
|
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||||
|
return api.uploadFile(body).url
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||||
|
return api.getTrendingGifs(page).data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||||
|
return api.searchGifs(query, page).data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
||||||
|
return api.getGifCategories().data.categories
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createPersonalChat(userId: String): Chat {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
|
||||||
|
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
||||||
|
api.deleteMessage(messageId, forEveryone)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||||
|
val request = SendMessageRequest(content = content)
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package chats.data.repository
|
||||||
|
|
||||||
|
import chats.data.remote.dto.*
|
||||||
|
import chats.domain.model.*
|
||||||
|
|
||||||
|
// Mappers
|
||||||
|
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
||||||
|
val chatName = name ?: if (type == "personal") {
|
||||||
|
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
|
||||||
|
} else "Group Chat"
|
||||||
|
|
||||||
|
val chatAvatar = (avatar ?: if (type == "personal") {
|
||||||
|
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
|
||||||
|
} else null)?.ensureAbsoluteUrl(baseUrl)
|
||||||
|
|
||||||
|
return Chat(
|
||||||
|
id = id,
|
||||||
|
type = type,
|
||||||
|
name = chatName,
|
||||||
|
avatar = chatAvatar,
|
||||||
|
unreadCount = unreadCount,
|
||||||
|
lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
||||||
|
val domainMediaType = when (type) {
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> MediaType.FILE
|
||||||
|
else -> when (media.firstOrNull()?.type) {
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> MediaType.FILE
|
||||||
|
else -> MediaType.TEXT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Message(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId ?: "",
|
||||||
|
senderId = senderId ?: "",
|
||||||
|
senderName = sender?.displayName ?: "Unknown",
|
||||||
|
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId ?: 0,
|
||||||
|
createdAt = createdAt ?: "",
|
||||||
|
media = media.map {
|
||||||
|
chats.domain.model.Media(
|
||||||
|
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
||||||
|
type = it.type ?: "unknown",
|
||||||
|
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
||||||
|
filename = it.filename,
|
||||||
|
size = it.size,
|
||||||
|
duration = it.duration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
mediaType = domainMediaType,
|
||||||
|
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
|
||||||
|
isRead = senderId == currentUserId,
|
||||||
|
isPinned = isPinned ?: false,
|
||||||
|
isForwarded = forwardedFromId != null,
|
||||||
|
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
|
||||||
|
replyTo = replyTo?.toDomain(currentUserId, baseUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
||||||
|
return if (this.startsWith("http")) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
val base = baseUrl.removeSuffix("/")
|
||||||
|
val path = if (this.startsWith("/")) this else "/$this"
|
||||||
|
"$base$path"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package chats.di
|
||||||
|
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.repository.ChatRepositoryImpl
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object ChatModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatApi(retrofit: Retrofit): ChatApi {
|
||||||
|
return retrofit.create(ChatApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatRepository(
|
||||||
|
api: ChatApi,
|
||||||
|
tokenManager: TokenManager,
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
|
): ChatRepository {
|
||||||
|
return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatHubClient(): ChatHubClient {
|
||||||
|
return ChatHubClient()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package chats.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Chat(
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val name: String,
|
||||||
|
val avatar: String?,
|
||||||
|
val unreadCount: Int,
|
||||||
|
val lastMessage: Message?
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package chats.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Message(
|
||||||
|
val id: String,
|
||||||
|
val chatId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val senderName: String,
|
||||||
|
val senderAvatar: String? = null,
|
||||||
|
val content: String?,
|
||||||
|
val sequenceId: Int,
|
||||||
|
val createdAt: String,
|
||||||
|
val media: List<Media> = emptyList(),
|
||||||
|
val mediaType: MediaType = MediaType.TEXT,
|
||||||
|
val reactions: Map<String, Int> = emptyMap(),
|
||||||
|
val isRead: Boolean = false,
|
||||||
|
val isPinned: Boolean = false,
|
||||||
|
val isForwarded: Boolean = false,
|
||||||
|
val forwardedFromName: String? = null,
|
||||||
|
val replyTo: Message? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Media(
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val url: String,
|
||||||
|
val filename: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val duration: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class MediaType {
|
||||||
|
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package chats.domain.repository
|
||||||
|
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.model.Message
|
||||||
|
|
||||||
|
interface ChatRepository {
|
||||||
|
suspend fun getChats(): List<Chat>
|
||||||
|
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
|
||||||
|
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
|
||||||
|
suspend fun sendMessage(
|
||||||
|
chatId: String,
|
||||||
|
content: String?,
|
||||||
|
type: String = "text",
|
||||||
|
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
|
||||||
|
replyToId: String? = null,
|
||||||
|
forwardedFromId: String? = null
|
||||||
|
): Message
|
||||||
|
suspend fun addReaction(messageId: String, emoji: String)
|
||||||
|
suspend fun sendTypingStatus(chatId: String)
|
||||||
|
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
||||||
|
suspend fun saveMessage(message: Message)
|
||||||
|
suspend fun deleteLocalMessage(messageId: String)
|
||||||
|
suspend fun uploadMedia(file: java.io.File): String
|
||||||
|
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
|
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
|
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
||||||
|
suspend fun createPersonalChat(userId: String): Chat
|
||||||
|
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
|
||||||
|
suspend fun editMessage(messageId: String, content: String): Message
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package chats.domain.usecase
|
||||||
|
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.api.FileUploadResponse
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UploadMediaUseCase @Inject constructor(
|
||||||
|
private val api: ChatApi
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
|
||||||
|
return try {
|
||||||
|
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
|
||||||
|
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||||
|
val response = api.uploadFile(body)
|
||||||
|
Result.success(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,803 @@
|
|||||||
|
package chats.presentation.chat_detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.repository.toDomain
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import core.utils.copyUriToFile
|
||||||
|
import core.utils.ImageUtils
|
||||||
|
|
||||||
|
import chats.data.remote.api.KlipyGifDto
|
||||||
|
|
||||||
|
data class ChatDetailState(
|
||||||
|
val messages: List<Message> = emptyList(),
|
||||||
|
val chatName: String? = null,
|
||||||
|
val chatAvatar: String? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isLoadingMore: Boolean = false,
|
||||||
|
val isTyping: Boolean = false,
|
||||||
|
val typingUser: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val canCall: Boolean = true,
|
||||||
|
val maxFileSize: Long = 100 * 1024 * 1024,
|
||||||
|
val trendingGifs: List<KlipyGifDto> = emptyList(),
|
||||||
|
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
||||||
|
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||||||
|
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||||
|
val isGifsLoading: Boolean = false,
|
||||||
|
val initialScrollIndex: Int? = null,
|
||||||
|
val pendingAttachments: List<File> = emptyList(),
|
||||||
|
val isUploading: Boolean = false,
|
||||||
|
val isCompressionEnabled: Boolean = true,
|
||||||
|
val inputText: String = "",
|
||||||
|
val replyingMessage: Message? = null,
|
||||||
|
val editingMessage: Message? = null,
|
||||||
|
val forwardingMessages: List<Message> = emptyList(),
|
||||||
|
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
|
||||||
|
val selectedMessageIds: Set<String> = emptySet(),
|
||||||
|
val pinnedMessages: List<Message> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatDetailViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
private val signalrClient: ChatHubClient,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
|
||||||
|
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
|
||||||
|
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatDetailState())
|
||||||
|
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var currentChatId: String? = null
|
||||||
|
private var typingTimerJob: Job? = null
|
||||||
|
private var signalrEventsJob: Job? = null
|
||||||
|
private var lastTypingSentTime: Long = 0
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val config = serverConfig.getServerConfig()
|
||||||
|
val savedCompression = prefs.getBoolean("compression_enabled", true)
|
||||||
|
_state.update { it.copy(
|
||||||
|
canCall = config.features.calls,
|
||||||
|
maxFileSize = config.limits.maxFileSize,
|
||||||
|
isCompressionEnabled = savedCompression
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCompression() {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val newValue = !currentState.isCompressionEnabled
|
||||||
|
prefs.edit().putBoolean("compression_enabled", newValue).apply()
|
||||||
|
currentState.copy(isCompressionEnabled = newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentUserId(): String {
|
||||||
|
return tokenManager.getUserId() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChatId(chatId: String) {
|
||||||
|
if (currentChatId == chatId) return
|
||||||
|
currentChatId = chatId
|
||||||
|
activeChatTracker.setChatId(chatId)
|
||||||
|
|
||||||
|
_state.update { it.copy(
|
||||||
|
messages = emptyList(),
|
||||||
|
isLoading = true,
|
||||||
|
initialScrollIndex = null
|
||||||
|
) }
|
||||||
|
|
||||||
|
// Ensure SignalR is connected and join the chat room
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
val baseUrl = serverConfig.getBaseUrl()
|
||||||
|
if (token != null && baseUrl.isNotBlank()) {
|
||||||
|
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||||||
|
signalrClient.joinChat(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChatInfo(chatId)
|
||||||
|
observeSignalREvents(chatId)
|
||||||
|
|
||||||
|
// Initial sync from network
|
||||||
|
refreshMessages(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMessages(messages: List<Message>) {
|
||||||
|
val sortedMessages = messages.sortedByDescending { it.sequenceId }
|
||||||
|
_state.update { it.copy(
|
||||||
|
messages = sortedMessages,
|
||||||
|
isLoading = false,
|
||||||
|
initialScrollIndex = 0 // In reverse layout, 0 is the bottom
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInitialScrollDone() {
|
||||||
|
_state.update { it.copy(initialScrollIndex = -1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshMessages(chatId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val messages = repository.getMessages(chatId)
|
||||||
|
updateMessages(messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMoreMessages() {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
if (_state.value.isLoading || _state.value.isLoadingMore) return
|
||||||
|
|
||||||
|
val oldestMsg = _state.value.messages.lastOrNull() ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoadingMore = true) }
|
||||||
|
try {
|
||||||
|
android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}")
|
||||||
|
val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString())
|
||||||
|
if (moreMessages.isNotEmpty()) {
|
||||||
|
val newSorted = moreMessages.sortedByDescending { it.sequenceId }
|
||||||
|
_state.update { currentState ->
|
||||||
|
// Избегаем дубликатов
|
||||||
|
val existingIds = currentState.messages.map { it.id }.toSet()
|
||||||
|
val uniqueMore = newSorted.filter { it.id !in existingIds }
|
||||||
|
currentState.copy(messages = currentState.messages + uniqueMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_state.update { it.copy(isLoadingMore = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadChatInfo(chatId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val chats = repository.getChats()
|
||||||
|
val chat = chats.find { it.id == chatId }
|
||||||
|
chat?.let { c ->
|
||||||
|
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore info load error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||||
|
_state.update { s ->
|
||||||
|
val updatedMessages = s.messages.map { msg ->
|
||||||
|
if (msg.id == messageId) {
|
||||||
|
val currentReactions = msg.reactions.toMutableMap()
|
||||||
|
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
|
||||||
|
msg.copy(reactions = currentReactions)
|
||||||
|
} else msg
|
||||||
|
}
|
||||||
|
s.copy(messages = updatedMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For handling reaction removed event
|
||||||
|
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||||
|
_state.update { s ->
|
||||||
|
val updatedMessages = s.messages.map { msg ->
|
||||||
|
if (msg.id == messageId) {
|
||||||
|
val currentReactions = msg.reactions.toMutableMap()
|
||||||
|
val count = currentReactions[emoji] ?: 0
|
||||||
|
if (count > 1) {
|
||||||
|
currentReactions[emoji] = count - 1
|
||||||
|
} else {
|
||||||
|
currentReactions.remove(emoji)
|
||||||
|
}
|
||||||
|
msg.copy(reactions = currentReactions)
|
||||||
|
} else msg
|
||||||
|
}
|
||||||
|
s.copy(messages = updatedMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignalREvents(chatId: String) {
|
||||||
|
signalrEventsJob?.cancel()
|
||||||
|
signalrEventsJob = signalrClient.events
|
||||||
|
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
|
||||||
|
.filter { event ->
|
||||||
|
val eventChatId = when(event) {
|
||||||
|
is ChatEvent.NewMessage -> event.message.chatId
|
||||||
|
is ChatEvent.ReactionUpdated -> event.chatId
|
||||||
|
is ChatEvent.UserTyping -> event.chatId
|
||||||
|
is ChatEvent.MessagesRead -> event.chatId
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventChatId == null) return@filter false
|
||||||
|
|
||||||
|
val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId)
|
||||||
|
if (match) {
|
||||||
|
android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event")
|
||||||
|
}
|
||||||
|
match
|
||||||
|
}
|
||||||
|
.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.NewMessage -> {
|
||||||
|
android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically")
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
|
||||||
|
|
||||||
|
_state.update { currentState ->
|
||||||
|
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
|
||||||
|
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.ReactionUpdated -> {
|
||||||
|
if (event.isRemoved) {
|
||||||
|
removeMessageReaction(event.messageId, event.userId, event.emoji)
|
||||||
|
} else {
|
||||||
|
updateMessageReaction(event.messageId, event.userId, event.emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.MessageDeleted -> {
|
||||||
|
_state.update { currentState ->
|
||||||
|
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.MessageEdited -> {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updated = currentState.messages.map { msg ->
|
||||||
|
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
|
||||||
|
}
|
||||||
|
currentState.copy(messages = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.UserTyping -> {
|
||||||
|
_state.update { it.copy(isTyping = true) }
|
||||||
|
typingTimerJob?.cancel()
|
||||||
|
typingTimerJob = viewModelScope.launch {
|
||||||
|
delay(3000)
|
||||||
|
_state.update { it.copy(isTyping = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.UserStoppedTyping -> {
|
||||||
|
_state.update { it.copy(isTyping = false) }
|
||||||
|
}
|
||||||
|
is ChatEvent.MessagesRead -> {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedMessages = currentState.messages.map { msg ->
|
||||||
|
if (msg.sequenceId <= event.lastReadSequenceId) {
|
||||||
|
msg.copy(isRead = true)
|
||||||
|
} else msg
|
||||||
|
}
|
||||||
|
currentState.copy(messages = updatedMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.MessagePinned -> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
_state.update { s ->
|
||||||
|
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
|
||||||
|
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.MessageUnpinned -> {
|
||||||
|
_state.update { s ->
|
||||||
|
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChatId() {
|
||||||
|
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
|
||||||
|
signalrEventsJob?.cancel()
|
||||||
|
signalrEventsJob = null
|
||||||
|
currentChatId = null
|
||||||
|
activeChatTracker.setChatId(null)
|
||||||
|
_state.update { it.copy(messages = emptyList(), isLoading = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAsRead() {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
val messages = _state.value.messages
|
||||||
|
if (messages.isEmpty()) return
|
||||||
|
|
||||||
|
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
|
||||||
|
val currentUserId = getCurrentUserId()
|
||||||
|
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Мгновенно обновляем в памяти для "галочек"
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedMessages = currentState.messages.map { msg ->
|
||||||
|
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
|
||||||
|
msg.copy(isRead = true)
|
||||||
|
} else msg
|
||||||
|
}
|
||||||
|
currentState.copy(messages = updatedMessages)
|
||||||
|
}
|
||||||
|
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
||||||
|
signalrNotificationObserver.refresh()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInputTextChanged(text: String) {
|
||||||
|
_state.update { it.copy(inputText = text) }
|
||||||
|
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
|
||||||
|
// Отправляем индикатор набора текста
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastTypingSentTime > 1000) {
|
||||||
|
signalrClient.sendTypingIndicator(chatId)
|
||||||
|
lastTypingSentTime = now
|
||||||
|
|
||||||
|
// Отправляем "остановился печатать" через 3 секунды
|
||||||
|
typingTimerJob?.cancel()
|
||||||
|
typingTimerJob = viewModelScope.launch {
|
||||||
|
delay(3000)
|
||||||
|
signalrClient.sendUserStoppedTyping(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun onReply(message: Message) {
|
||||||
|
_state.update { it.copy(replyingMessage = message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forwardMessages(targetChatId: String, messages: List<Message>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
messages.forEach { msg ->
|
||||||
|
repository.sendMessage(
|
||||||
|
chatId = targetChatId,
|
||||||
|
content = msg.content,
|
||||||
|
type = msg.mediaType.name.lowercase(),
|
||||||
|
forwardedFromId = msg.senderId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onForward(message: Message) {
|
||||||
|
_state.update { it.copy(forwardingMessages = listOf(message)) }
|
||||||
|
loadChatsForForwarding()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChatsForForwarding() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val chats = repository.getChats()
|
||||||
|
_state.update { it.copy(availableChatsToForward = chats) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelForwarding() {
|
||||||
|
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onForwardSelectedMessages() {
|
||||||
|
val selectedIds = _state.value.selectedMessageIds
|
||||||
|
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
|
||||||
|
_state.update { it.copy(forwardingMessages = messages) }
|
||||||
|
loadChatsForForwarding()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSelection(messageId: String) {
|
||||||
|
_state.update { s ->
|
||||||
|
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
|
||||||
|
s.selectedMessageIds - messageId
|
||||||
|
} else {
|
||||||
|
s.selectedMessageIds + messageId
|
||||||
|
}
|
||||||
|
s.copy(selectedMessageIds = newSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() {
|
||||||
|
_state.update { it.copy(selectedMessageIds = emptySet()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelReply() {
|
||||||
|
_state.update { it.copy(replyingMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelEdit() {
|
||||||
|
_state.update { it.copy(editingMessage = null, inputText = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessage(message: Message, forEveryone: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.deleteMessage(message.id, forEveryone)
|
||||||
|
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
|
||||||
|
_state.update { currentState ->
|
||||||
|
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEdit(message: Message) {
|
||||||
|
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinMessage(messageId: String) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
signalrClient.pinMessage(messageId, chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpinMessage(messageId: String) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
signalrClient.unpinMessage(messageId, chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPin(message: Message) {
|
||||||
|
pinMessage(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(onFail: (String) -> Unit = {}) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
val text = _state.value.inputText
|
||||||
|
val pending = _state.value.pendingAttachments
|
||||||
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
|
val editingMsg = _state.value.editingMessage
|
||||||
|
if (text.isBlank() && pending.isEmpty()) return
|
||||||
|
|
||||||
|
// Handle Edit
|
||||||
|
if (editingMsg != null) {
|
||||||
|
_state.update { it.copy(inputText = "", editingMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val updated = repository.editMessage(editingMsg.id, text)
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedList = currentState.messages.map {
|
||||||
|
if (it.id == updated.id) updated else it
|
||||||
|
}
|
||||||
|
currentState.copy(messages = updatedList)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
|
||||||
|
onFail(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear input immediately to avoid double clicks and ensure UI experience
|
||||||
|
_state.update { it.copy(inputText = "", replyingMessage = null) }
|
||||||
|
|
||||||
|
val tempId = "temp_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
// Determine mediaType based on attachments
|
||||||
|
val mediaType = when {
|
||||||
|
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
|
||||||
|
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
|
||||||
|
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
|
||||||
|
else -> chats.domain.model.MediaType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = if (text.isBlank()) null else text,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = mediaType,
|
||||||
|
media = emptyList(),
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Upload attachments if any
|
||||||
|
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
|
||||||
|
_state.update { it.copy(isUploading = true) }
|
||||||
|
val requests = _state.value.pendingAttachments.map { file ->
|
||||||
|
val url = repository.uploadMedia(file)
|
||||||
|
chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = when {
|
||||||
|
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
|
||||||
|
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
|
||||||
|
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
|
||||||
|
else -> "file"
|
||||||
|
},
|
||||||
|
url = url,
|
||||||
|
fileName = file.name,
|
||||||
|
fileSize = file.length()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
|
||||||
|
requests
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val sentMessage = repository.sendMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
content = if (text.isBlank()) null else text,
|
||||||
|
type = if (attachmentRequests != null) "media" else "text",
|
||||||
|
attachments = attachmentRequests,
|
||||||
|
replyToId = replyToId
|
||||||
|
)
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
repository.saveMessage(sentMessage)
|
||||||
|
// Clear attachments on success
|
||||||
|
_state.update { it.copy(pendingAttachments = emptyList()) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
|
||||||
|
onFail(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReaction(messageId: String, emoji: String) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
|
||||||
|
// We rely on SignalR event to update the count to avoid double counting
|
||||||
|
// especially since domain Message doesn't track user IDs for reactions yet.
|
||||||
|
signalrClient.addReaction(messageId, chatId, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendVoiceMessage(file: File) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
|
_state.update { it.copy(replyingMessage = null) }
|
||||||
|
|
||||||
|
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = null,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = chats.domain.model.MediaType.AUDIO,
|
||||||
|
media = emptyList(),
|
||||||
|
senderName = "Вы",
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// 1. Upload the audio file
|
||||||
|
val url = repository.uploadMedia(file)
|
||||||
|
|
||||||
|
// 2. Send the message with the attachment
|
||||||
|
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = "voice",
|
||||||
|
url = url,
|
||||||
|
fileName = file.name,
|
||||||
|
fileSize = file.length()
|
||||||
|
)
|
||||||
|
|
||||||
|
val sentMessage = repository.sendMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
content = null,
|
||||||
|
type = "audio",
|
||||||
|
attachments = listOf(attachment),
|
||||||
|
replyToId = replyToId
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
repository.saveMessage(sentMessage)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val mimeType = context.contentResolver.getType(uri) ?: ""
|
||||||
|
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
|
||||||
|
ImageUtils.compressImage(context, uri)
|
||||||
|
} else {
|
||||||
|
copyUriToFile(context, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
file?.let { f ->
|
||||||
|
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatDateHeader(dateString: String): String {
|
||||||
|
return try {
|
||||||
|
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
|
||||||
|
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||||
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val date = isoFormat.parse(dateString) ?: return dateString
|
||||||
|
|
||||||
|
// Форматируем в локальное время: "14 апреля"
|
||||||
|
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalDateString(isoDate: String): String {
|
||||||
|
return try {
|
||||||
|
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||||
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val date = isoFormat.parse(isoDate) ?: return isoDate
|
||||||
|
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
|
||||||
|
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
isoDate.split("T").first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removePendingAttachment(file: File) {
|
||||||
|
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadMedia(file: File) {
|
||||||
|
if (file.length() > _state.value.maxFileSize) {
|
||||||
|
_state.update { it.copy(error = "File too large") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val url = repository.uploadMedia(file)
|
||||||
|
// After upload, we might want to send a message with this media
|
||||||
|
// For now, let's just log it or handle as per app requirements
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(error = e.localizedMessage) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadTrendingGifs() {
|
||||||
|
if (_state.value.trendingGifs.isNotEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isGifsLoading = true) }
|
||||||
|
try {
|
||||||
|
val gifs = repository.getTrendingGifs(0)
|
||||||
|
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGifCategories() {
|
||||||
|
if (_state.value.gifCategories.isNotEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val categories = repository.getGifCategories()
|
||||||
|
_state.update { it.copy(gifCategories = categories) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
fun searchGifs(query: String) {
|
||||||
|
searchJob?.cancel()
|
||||||
|
if (query.isBlank()) {
|
||||||
|
_state.update { it.copy(searchedGifs = emptyList()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchJob = viewModelScope.launch {
|
||||||
|
delay(500)
|
||||||
|
_state.update { it.copy(isGifsLoading = true) }
|
||||||
|
try {
|
||||||
|
val gifs = repository.searchGifs(query, 0)
|
||||||
|
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(isGifsLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendGif(url: String) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
val replyToId = _state.value.replyingMessage?.id
|
||||||
|
_state.update { it.copy(replyingMessage = null) }
|
||||||
|
|
||||||
|
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = url,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
|
||||||
|
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = "image",
|
||||||
|
url = url,
|
||||||
|
fileName = "gif.gif",
|
||||||
|
fileSize = 0
|
||||||
|
)
|
||||||
|
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
|
||||||
|
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
repository.saveMessage(sentMessage)
|
||||||
|
|
||||||
|
// Add to recent
|
||||||
|
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
|
||||||
|
val selectedGif = allGifs.find { gif ->
|
||||||
|
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
|
||||||
|
?: gif.files?.get("sd")?.get("gif")?.url
|
||||||
|
?: gif.file?.get("hd")?.get("gif")?.url
|
||||||
|
?: gif.file?.get("sd")?.get("gif")?.url
|
||||||
|
?: gif.media_formats?.get("gif")?.url
|
||||||
|
?: gif.images?.original?.url
|
||||||
|
gifUrl == url
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedGif?.let { gif ->
|
||||||
|
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
|
||||||
|
_state.update { it.copy(recentGifs = newList) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
_state.update { it.copy(error = e.localizedMessage) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package chats.presentation.chat_list
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chats.presentation.components.ChatItem
|
||||||
|
import stories.presentation.StoryViewModel
|
||||||
|
import stories.presentation.components.StoryThumbnail
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HorizontalDividerComponent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
thickness: androidx.compose.ui.unit.Dp = 1.dp,
|
||||||
|
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Divider(
|
||||||
|
modifier = modifier,
|
||||||
|
thickness = thickness,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatListScreen(
|
||||||
|
viewModel: ChatListViewModel,
|
||||||
|
storyViewModel: StoryViewModel,
|
||||||
|
onChatClick: (String) -> Unit,
|
||||||
|
onStoryClick: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val storyState by storyViewModel.state.collectAsState()
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadChats()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
titleContentColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
if (state.isLoading && state.chats.isEmpty()) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
} else {
|
||||||
|
LazyColumn {
|
||||||
|
// Ряд историй сверху списка чатов
|
||||||
|
item {
|
||||||
|
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
items(storyState.storyGroups.size) { index ->
|
||||||
|
val group = storyState.storyGroups[index]
|
||||||
|
StoryThumbnail(
|
||||||
|
username = group.username,
|
||||||
|
avatarUrl = group.avatar,
|
||||||
|
hasUnseen = true, // В идеале проверяем по статусам
|
||||||
|
onClick = { onStoryClick(index) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDividerComponent(
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null && state.chats.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (state.chats.isEmpty() && !state.isLoading) {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillParentMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_chats_found),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(state.chats) { chat ->
|
||||||
|
ChatItem(chat = chat, onClick = onChatClick)
|
||||||
|
HorizontalDividerComponent(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package chats.presentation.chat_list
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import chats.data.repository.toDomain
|
||||||
|
|
||||||
|
data class ChatListState(
|
||||||
|
val chats: List<Chat> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isStoriesEnabled: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatListViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
private val authRepository: auth.domain.repository.AuthRepository,
|
||||||
|
private val signalrClient: ChatHubClient,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatListState())
|
||||||
|
val state: StateFlow<ChatListState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
updatePushToken()
|
||||||
|
|
||||||
|
val isStoriesEnabled = try {
|
||||||
|
serverConfig.getServerConfig().features.stories
|
||||||
|
} catch (e: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
|
||||||
|
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
val baseUrl = serverConfig.getBaseUrl()
|
||||||
|
|
||||||
|
// Подключаемся к SignalR только если есть токен И введён URL сервера
|
||||||
|
if (token != null && baseUrl.isNotBlank()) {
|
||||||
|
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChats()
|
||||||
|
observeSignalREvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePushToken() {
|
||||||
|
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
val token = task.result
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
authRepository.updatePushToken(token)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
|
||||||
|
|
||||||
|
fun loadChats() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true) }
|
||||||
|
try {
|
||||||
|
val chats = repository.getChats()
|
||||||
|
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sortChats(chats: List<Chat>): List<Chat> {
|
||||||
|
return chats.sortedWith(compareByDescending<Chat> {
|
||||||
|
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
|
||||||
|
}.thenByDescending { it.lastMessage?.createdAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignalREvents() {
|
||||||
|
signalrClient.events
|
||||||
|
.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.NewMessage -> {
|
||||||
|
updateChatsWithNewMessage(event)
|
||||||
|
}
|
||||||
|
is ChatEvent.NewChat -> {
|
||||||
|
val currentUserId = getCurrentUserId()
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
|
||||||
|
}
|
||||||
|
is ChatEvent.MessagesRead -> {
|
||||||
|
if (event.userId == getCurrentUserId()) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedChats = currentState.chats.map { chat ->
|
||||||
|
if (chat.id == event.chatId) {
|
||||||
|
chat.copy(unreadCount = 0)
|
||||||
|
} else chat
|
||||||
|
}
|
||||||
|
currentState.copy(chats = sortChats(updatedChats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val currentUserId = getCurrentUserId()
|
||||||
|
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedChats = currentState.chats.map { chat ->
|
||||||
|
if (chat.id.equals(event.message.chatId, ignoreCase = true)) {
|
||||||
|
val isMyMessage = event.message.senderId == currentUserId
|
||||||
|
val isAlreadySeen = chat.lastMessage?.id == event.message.id
|
||||||
|
|
||||||
|
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
|
||||||
|
val newCount = if (isMyMessage || isAlreadySeen) {
|
||||||
|
chat.unreadCount
|
||||||
|
} else {
|
||||||
|
chat.unreadCount + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAlreadySeen) {
|
||||||
|
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.copy(
|
||||||
|
lastMessage = lastMsgDomain,
|
||||||
|
unreadCount = newCount
|
||||||
|
)
|
||||||
|
} else chat
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState.copy(chats = sortChats(updatedChats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatItem(
|
||||||
|
chat: Chat,
|
||||||
|
onClick: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick(chat.id) }
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = chat.avatar,
|
||||||
|
name = chat.name,
|
||||||
|
size = 50.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = chat.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
chat.lastMessage?.let {
|
||||||
|
val formattedTime = remember(it.createdAt) {
|
||||||
|
try {
|
||||||
|
it.createdAt.substringAfter('T').take(5)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formattedTime,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
val previewText = remember(chat.lastMessage) {
|
||||||
|
val msg = chat.lastMessage
|
||||||
|
if (msg == null) return@remember "No messages yet"
|
||||||
|
|
||||||
|
if (!msg.content.isNullOrBlank()) {
|
||||||
|
msg.content
|
||||||
|
} else if (msg.mediaType == MediaType.AUDIO) {
|
||||||
|
"Голосовое сообщение"
|
||||||
|
} else if (msg.media.isNotEmpty()) {
|
||||||
|
when (msg.mediaType) {
|
||||||
|
MediaType.IMAGE -> "Фото"
|
||||||
|
MediaType.VIDEO -> "Видео"
|
||||||
|
MediaType.FILE -> "Файл"
|
||||||
|
else -> "Медиа"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Сообщение"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = previewText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.Gray,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (chat.unreadCount > 0) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = chat.unreadCount.toString(),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmojiPicker(
|
||||||
|
onEmojiSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "✨") // Базовый набор
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(6),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
items(emojis) { emoji ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clickable { onEmojiSelected(emoji) },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = emoji, fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import chats.data.remote.api.KlipyGifDto
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KlipyPicker(
|
||||||
|
gifs: List<KlipyGifDto>,
|
||||||
|
isLoading: Boolean,
|
||||||
|
onGifSelected: (String) -> Unit,
|
||||||
|
onSearch: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageLoader = coil.ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = {
|
||||||
|
query = it
|
||||||
|
onSearch(it)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
placeholder = { Text("Search GIFs...") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(4.dp)
|
||||||
|
) {
|
||||||
|
items(gifs) { gif ->
|
||||||
|
val url = gif.images?.fixed_height?.url ?: ""
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(url)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
imageLoader = imageLoader,
|
||||||
|
contentDescription = gif.title,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.aspectRatio(1.5f)
|
||||||
|
.clickable { onGifSelected(url) },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import core.utils.LinkMetadata
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LinkPreview(
|
||||||
|
url: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentColor: Color = Color.White
|
||||||
|
) {
|
||||||
|
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(linkMetadataCache[url]) }
|
||||||
|
var loading by remember(url) { mutableStateOf(metadata == null) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
if (metadata != null) {
|
||||||
|
loading = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
try {
|
||||||
|
val client = okhttp3.OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = okhttp3.Request.Builder()
|
||||||
|
.url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}")
|
||||||
|
.header("User-Agent", "KnotMessenger/1.0 (Android)")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body?.string()
|
||||||
|
if (body != null) {
|
||||||
|
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
|
||||||
|
if (json.has("status") && json.get("status").asString == "success") {
|
||||||
|
val data = json.getAsJsonObject("data")
|
||||||
|
|
||||||
|
val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString
|
||||||
|
val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString
|
||||||
|
val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString
|
||||||
|
|
||||||
|
if (title != null || description != null || imageUrl != null) {
|
||||||
|
metadata = LinkMetadata(
|
||||||
|
url = url,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
imageUrl = imageUrl
|
||||||
|
)
|
||||||
|
metadata?.let { linkMetadataCache[url] = it }
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
Text(
|
||||||
|
text = "Загрузка предпросмотра...",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = contentColor.copy(alpha = 0.5f),
|
||||||
|
modifier = modifier.padding(vertical = 4.dp),
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentMetadata = metadata ?: LinkMetadata(url = url)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.2f))
|
||||||
|
.clickable {
|
||||||
|
try {
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
.border(
|
||||||
|
width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp,
|
||||||
|
color = Color.White.copy(alpha = 0.1f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(3.dp)
|
||||||
|
.background(Color(0xFF3096E5))
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(10.dp)) {
|
||||||
|
val domain = remember(url) {
|
||||||
|
try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = domain,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(0xFF3096E5),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 2.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = currentMetadata.title ?: url,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
currentMetadata.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor.copy(alpha = 0.7f),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMetadata.imageUrl?.let {
|
||||||
|
AsyncImage(
|
||||||
|
model = it,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 200.dp)
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.rounded.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import chats.data.remote.api.KlipyGifDto
|
||||||
|
import core.utils.CoilUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class MediaTab { EMOJI, GIF }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaPicker(
|
||||||
|
trendingGifs: List<KlipyGifDto>,
|
||||||
|
searchedGifs: List<KlipyGifDto>,
|
||||||
|
recentGifs: List<KlipyGifDto> = emptyList(),
|
||||||
|
gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||||
|
isGifsLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onEmojiSelected: (String) -> Unit,
|
||||||
|
onGifSelected: (String) -> Unit,
|
||||||
|
onGifSearch: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var activeTab by remember { mutableStateOf(MediaTab.EMOJI) }
|
||||||
|
|
||||||
|
// Вычисляем индексы для прыжков (учитываем заголовки)
|
||||||
|
val emojiCategories = remember {
|
||||||
|
listOf(
|
||||||
|
EmojiCategory("Недавние", Icons.Rounded.Schedule, listOf("👍", "❤️", "😂", "😍", "🙏", "🔥", "😭", "😊")),
|
||||||
|
EmojiCategory("Смайлы", Icons.Rounded.EmojiEmotions, listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇")),
|
||||||
|
EmojiCategory("Животные", Icons.Rounded.Pets, listOf("🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷")),
|
||||||
|
EmojiCategory("Еда", Icons.Rounded.Fastfood, listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭")),
|
||||||
|
EmojiCategory("Спорт", Icons.Rounded.SportsFootball, listOf("⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🥅", "🏒")),
|
||||||
|
EmojiCategory("Транспорт", Icons.Rounded.DirectionsCar, listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛")),
|
||||||
|
EmojiCategory("Объекты", Icons.Rounded.Lightbulb, listOf("💡", "🔦", "🕯️", "🪔", "📔", "📕", "📖", "💚", "💙", "💜", "🤎", "🖤")),
|
||||||
|
EmojiCategory("Музыка", Icons.Rounded.MusicNote, listOf("🎵", "🎶", "🎼", "🎹", "🎸", "🎻", "🎷", "🎺", "🥁", "🎤", "🎧", "📻")),
|
||||||
|
EmojiCategory("Флаги", Icons.Rounded.Flag, listOf("🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️🌈", "🏳️⚧️", "🏴☠️", "🇦🇱", "🇩🇿", "🇦🇸", "🇦🇩"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val categoryIndices = remember(emojiCategories) {
|
||||||
|
var currentIdx = 0
|
||||||
|
emojiCategories.map { cat ->
|
||||||
|
val startIdx = currentIdx
|
||||||
|
currentIdx += 1 + cat.emojis.size
|
||||||
|
cat.icon to startIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(450.dp)
|
||||||
|
.background(Color(0xFF1C1C1C), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
||||||
|
) {
|
||||||
|
// Табы (Эмодзи / GIF)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color(0xFF2C2C2C), RoundedCornerShape(12.dp))
|
||||||
|
.padding(2.dp)
|
||||||
|
) {
|
||||||
|
MediaTabItem(
|
||||||
|
title = "ЭМОДЗИ",
|
||||||
|
isSelected = activeTab == MediaTab.EMOJI,
|
||||||
|
onClick = { activeTab = MediaTab.EMOJI },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
MediaTabItem(
|
||||||
|
title = "GIF",
|
||||||
|
isSelected = activeTab == MediaTab.GIF,
|
||||||
|
onClick = { activeTab = MediaTab.GIF },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Crossfade(targetState = activeTab, label = "media_tab") { tab ->
|
||||||
|
when (tab) {
|
||||||
|
MediaTab.EMOJI -> EmojiContent(
|
||||||
|
emojiCategories = emojiCategories,
|
||||||
|
categoryIndices = categoryIndices,
|
||||||
|
onEmojiSelected = onEmojiSelected
|
||||||
|
)
|
||||||
|
MediaTab.GIF -> GifContent(
|
||||||
|
trendingGifs = trendingGifs,
|
||||||
|
searchedGifs = searchedGifs,
|
||||||
|
recentGifs = recentGifs,
|
||||||
|
gifCategories = gifCategories,
|
||||||
|
isGifsLoading = isGifsLoading,
|
||||||
|
error = error,
|
||||||
|
onGifSelected = onGifSelected,
|
||||||
|
onGifSearch = onGifSearch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaTabItem(
|
||||||
|
title: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(if (isSelected) Color(0xFF3C3C3C) else Color.Transparent)
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = if (isSelected) Color.White else Color.Gray,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmojiContent(
|
||||||
|
emojiCategories: List<EmojiCategory>,
|
||||||
|
categoryIndices: List<Pair<ImageVector, Int>>,
|
||||||
|
onEmojiSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val scrollState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Категории
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(categoryIndices) { (icon, index) ->
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Gray,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clickable {
|
||||||
|
scope.launch {
|
||||||
|
scrollState.animateScrollToItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
TextField(
|
||||||
|
value = "",
|
||||||
|
onValueChange = {},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
placeholder = { Text("Поиск", color = Color.Gray) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color(0xFF2C2C2C),
|
||||||
|
unfocusedContainerColor = Color(0xFF2C2C2C),
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(8),
|
||||||
|
state = scrollState,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
emojiCategories.forEach { category ->
|
||||||
|
// Заголовок категории
|
||||||
|
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(8) }) {
|
||||||
|
Text(
|
||||||
|
text = category.name,
|
||||||
|
color = Color.Gray,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(category.emojis, key = { emoji -> emoji }) { emoji ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clickable { onEmojiSelected(emoji) },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = emoji, fontSize = 28.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class EmojiCategory(val name: String, val icon: ImageVector, val emojis: List<String>)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GifContent(
|
||||||
|
trendingGifs: List<KlipyGifDto>,
|
||||||
|
searchedGifs: List<KlipyGifDto>,
|
||||||
|
recentGifs: List<KlipyGifDto>,
|
||||||
|
gifCategories: List<chats.data.remote.api.GifCategoryDto>,
|
||||||
|
isGifsLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onGifSelected: (String) -> Unit,
|
||||||
|
onGifSearch: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var subTab by remember { mutableStateOf("trending") }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageLoader = CoilUtils.getGifImageLoader(context)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = {
|
||||||
|
query = it
|
||||||
|
if (it.isNotEmpty()) subTab = "search"
|
||||||
|
else if (subTab == "search") subTab = "trending"
|
||||||
|
onGifSearch(it)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
placeholder = { Text("Поиск GIF...", color = Color.Gray) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color(0xFF2C2C2C),
|
||||||
|
unfocusedContainerColor = Color(0xFF2C2C2C),
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Под-вкладки GIF (Recent, Trending, Categories)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (subTab == "recent") Color(0xFF3B82F6) else Color.Gray,
|
||||||
|
modifier = Modifier.size(20.dp).clickable { subTab = "recent"; query = "" }
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.TrendingUp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (subTab == "trending") Color(0xFF3B82F6) else Color.Gray,
|
||||||
|
modifier = Modifier.size(20.dp).clickable { subTab = "trending"; query = "" }
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.GridView,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (subTab == "categories") Color(0xFF3B82F6) else Color.Gray,
|
||||||
|
modifier = Modifier.size(20.dp).clickable { subTab = "categories"; query = "" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subTab == "categories") {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
modifier = Modifier.weight(1f).padding(4.dp),
|
||||||
|
contentPadding = PaddingValues(4.dp)
|
||||||
|
) {
|
||||||
|
items(gifCategories) { category ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.aspectRatio(1.77f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFF2C2C2C))
|
||||||
|
.clickable {
|
||||||
|
query = category.query
|
||||||
|
subTab = "search"
|
||||||
|
onGifSearch(category.query)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = category.preview_url,
|
||||||
|
contentDescription = category.category,
|
||||||
|
modifier = Modifier.fillMaxSize().alpha(0.6f),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = category.category.uppercase(),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
letterSpacing = 2.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val displayGifs = when (subTab) {
|
||||||
|
"recent" -> recentGifs
|
||||||
|
"search" -> searchedGifs
|
||||||
|
else -> trendingGifs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGifsLoading && displayGifs.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = Color(0xFF3B82F6))
|
||||||
|
}
|
||||||
|
} else if (!isGifsLoading && displayGifs.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(error ?: "Ничего не найдено", color = Color.Gray)
|
||||||
|
if (error != null) {
|
||||||
|
Button(onClick = { onGifSearch(query) }) {
|
||||||
|
Text("Повторить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
modifier = Modifier.weight(1f).padding(4.dp),
|
||||||
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
|
||||||
|
val url = gif.files?.get("hd")?.get("gif")?.url
|
||||||
|
?: gif.files?.get("sd")?.get("gif")?.url
|
||||||
|
?: gif.file?.get("hd")?.get("gif")?.url
|
||||||
|
?: gif.file?.get("sd")?.get("gif")?.url
|
||||||
|
?: gif.media_formats?.get("gif")?.url
|
||||||
|
?: gif.images?.original?.url
|
||||||
|
|
||||||
|
val preview = gif.files?.get("sd")?.get("webp")?.url
|
||||||
|
?: gif.files?.get("sd")?.get("gif")?.url
|
||||||
|
?: gif.file?.get("sd")?.get("webp")?.url
|
||||||
|
?: gif.media_formats?.get("tinygif")?.url
|
||||||
|
?: gif.images?.fixed_height_small?.url
|
||||||
|
?: url
|
||||||
|
|
||||||
|
if (url != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = preview ?: url,
|
||||||
|
imageLoader = imageLoader,
|
||||||
|
contentDescription = gif.title,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color(0xFF2C2C2C))
|
||||||
|
.clickable { onGifSelected(url) },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color(0xFF3C3C3C)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(gif.title ?: "GIF", color = Color.Gray, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import ru.knot.messager.R
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import chats.domain.model.Media
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import core.presentation.components.AppVideoPlayer
|
||||||
|
import core.presentation.components.AppAudioPlayer
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import chats.presentation.components.LinkPreview
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import core.presentation.components.AppMediaLightbox
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MessageBubble(
|
||||||
|
message: Message,
|
||||||
|
isCurrentUser: Boolean,
|
||||||
|
onReactionClick: (String) -> Unit = {},
|
||||||
|
autoPlay: Boolean = false,
|
||||||
|
initialPlaybackSpeed: Float = 1.0f,
|
||||||
|
onVoiceFinished: (Float) -> Unit = {},
|
||||||
|
onReply: (Message) -> Unit = {},
|
||||||
|
onReplyClick: (Message) -> Unit = {},
|
||||||
|
onSelect: (String) -> Unit = {},
|
||||||
|
onForward: (Message) -> Unit = {},
|
||||||
|
onPin: (Message) -> Unit = {},
|
||||||
|
onEdit: (Message) -> Unit = {},
|
||||||
|
onDelete: (Message, Boolean) -> Unit = { _, _ -> },
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
isSelectionMode: Boolean = false,
|
||||||
|
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showReactionPicker by remember { mutableStateOf(false) }
|
||||||
|
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
val contentColor = Color.White
|
||||||
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val backgroundColor = when {
|
||||||
|
isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f)
|
||||||
|
showContextMenu -> Color(0xFF007AFF) // Even brighter highlight
|
||||||
|
isCurrentUser -> Color(0xFF3096E5)
|
||||||
|
else -> Color(0xFF212121)
|
||||||
|
}
|
||||||
|
|
||||||
|
val shape = if (isCurrentUser) {
|
||||||
|
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(Unit) { // Clicks anywhere in the Row row now work
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
onSelect(message.id)
|
||||||
|
} else {
|
||||||
|
showContextMenu = !showContextMenu
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = {
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
onSelect(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
var bubbleHeight by remember { mutableIntStateOf(0) }
|
||||||
|
var isNearBottom by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
bubbleHeight = coords.size.height
|
||||||
|
val screenHeight = coords.parentLayoutCoordinates?.size?.height ?: 2000
|
||||||
|
val yInWindow = coords.localToWindow(Offset.Zero).y
|
||||||
|
// If bottom of message is in the last 40% of screen, open menu UP
|
||||||
|
isNearBottom = yInWindow > screenHeight * 0.6f
|
||||||
|
},
|
||||||
|
contentAlignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
// Actual bubble
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
if (showContextMenu) {
|
||||||
|
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||||
|
val menuWidth = 220.dp
|
||||||
|
val menuWidthPx = with(density) { menuWidth.toPx() }
|
||||||
|
|
||||||
|
Popup(
|
||||||
|
alignment = if (isNearBottom) {
|
||||||
|
if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd
|
||||||
|
} else {
|
||||||
|
if (isCurrentUser) Alignment.TopStart else Alignment.TopEnd
|
||||||
|
},
|
||||||
|
offset = if (isCurrentUser) {
|
||||||
|
// Message is on RIGHT, show menu to its LEFT
|
||||||
|
IntOffset(-menuWidthPx.toInt() - 20, 0)
|
||||||
|
} else {
|
||||||
|
// Message is on LEFT, show menu to its RIGHT
|
||||||
|
IntOffset(menuWidthPx.toInt() + 20, 0)
|
||||||
|
},
|
||||||
|
onDismissRequest = { showContextMenu = false },
|
||||||
|
properties = androidx.compose.ui.window.PopupProperties(
|
||||||
|
focusable = true,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
dismissOnClickOutside = true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
MessageContextMenu(
|
||||||
|
isMyMessage = isCurrentUser,
|
||||||
|
isPinned = message.isPinned,
|
||||||
|
onReactionSelected = {
|
||||||
|
onReactionClick(it)
|
||||||
|
showContextMenu = false
|
||||||
|
},
|
||||||
|
onAction = { action ->
|
||||||
|
showContextMenu = false
|
||||||
|
when(action) {
|
||||||
|
"reply" -> onReply(message)
|
||||||
|
"select" -> onSelect(message.id)
|
||||||
|
"forward" -> onForward(message)
|
||||||
|
"pin" -> onPin(message)
|
||||||
|
"edit" -> onEdit(message)
|
||||||
|
"copy" -> {
|
||||||
|
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||||
|
val clip = android.content.ClipData.newPlainText("message", message.content)
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
}
|
||||||
|
"delete" -> {
|
||||||
|
showDeleteDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwarded Info
|
||||||
|
if (message.isForwarded) {
|
||||||
|
Text(
|
||||||
|
text = "Forwarded from ${message.forwardedFromName ?: "Unknown"}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = contentColor.copy(alpha = 0.7f),
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply Info
|
||||||
|
message.replyTo?.let { reply ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 6.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(contentColor.copy(alpha = 0.1f))
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.clickable { onReplyClick(reply) }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(2.dp)
|
||||||
|
.background(if (isCurrentUser) Color.White else Color(0xFF3096E5))
|
||||||
|
)
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val isReplyImage = reply.mediaType == MediaType.IMAGE || reply.mediaType == MediaType.VIDEO || reply.mediaType == MediaType.GIF
|
||||||
|
|
||||||
|
if (isReplyImage && reply.media.isNotEmpty()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = reply.media.first().url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(40.dp)
|
||||||
|
.clip(RoundedCornerShape(0.dp, 4.dp, 4.dp, 0.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) {
|
||||||
|
val accentColor = if (isCurrentUser) Color.White else Color(0xFF3096E5)
|
||||||
|
Text(
|
||||||
|
text = reply.senderName,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = accentColor,
|
||||||
|
maxLines = 1,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
val photoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_photo)
|
||||||
|
val videoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_video)
|
||||||
|
val voiceText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.voice_message)
|
||||||
|
val audioText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_audio)
|
||||||
|
val fileText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_file)
|
||||||
|
val gifText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_gif)
|
||||||
|
val mediaText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.media)
|
||||||
|
|
||||||
|
val replyText = remember(reply) {
|
||||||
|
if (!reply.content.isNullOrEmpty()) {
|
||||||
|
reply.content
|
||||||
|
} else {
|
||||||
|
when (reply.mediaType) {
|
||||||
|
MediaType.IMAGE -> photoText
|
||||||
|
MediaType.VIDEO -> videoText
|
||||||
|
MediaType.AUDIO -> if (reply.media.any { it.type == "voice" }) voiceText else audioText
|
||||||
|
MediaType.FILE -> fileText
|
||||||
|
MediaType.GIF -> gifText
|
||||||
|
else -> mediaText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = replyText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF Content (Klipy)
|
||||||
|
if (message.mediaType == MediaType.GIF) {
|
||||||
|
AsyncImage(
|
||||||
|
model = message.content,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { /* Handle click */ },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state for temp media messages
|
||||||
|
if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(contentColor.copy(alpha = 0.1f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = contentColor.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media Content (Attachments)
|
||||||
|
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
|
||||||
|
val mediaCount = message.media.size
|
||||||
|
if (mediaCount == 1) {
|
||||||
|
val mediaItem = message.media[0]
|
||||||
|
when {
|
||||||
|
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
|
||||||
|
AsyncImage(
|
||||||
|
model = mediaItem.url,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { onMediaClick(mediaItem) },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(240.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
AppVideoPlayer(
|
||||||
|
url = mediaItem.url,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
useController = false,
|
||||||
|
isMuted = true,
|
||||||
|
autoPlay = true
|
||||||
|
)
|
||||||
|
// Прозрачный слой поверх видео для клика
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.clickable { onMediaClick(mediaItem) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.VolumeOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
|
||||||
|
AppAudioPlayer(
|
||||||
|
url = mediaItem.url,
|
||||||
|
name = mediaItem.filename,
|
||||||
|
size = mediaItem.size,
|
||||||
|
isVoiceMessage = isVoiceMessage,
|
||||||
|
initialPlaybackSpeed = initialPlaybackSpeed,
|
||||||
|
autoPlay = autoPlay,
|
||||||
|
onFinished = onVoiceFinished,
|
||||||
|
contentColor = contentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multi-media grid
|
||||||
|
PhotoGrid(
|
||||||
|
mediaList = message.media,
|
||||||
|
isCurrentUser = isCurrentUser,
|
||||||
|
onMediaClick = { index -> onMediaClick(message.media[index]) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text Content
|
||||||
|
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
|
||||||
|
Column {
|
||||||
|
val annotatedString = remember(message.content) {
|
||||||
|
val text = message.content ?: ""
|
||||||
|
val links = core.utils.LinkParser.findLinks(text)
|
||||||
|
androidx.compose.ui.text.buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
links.forEach { link ->
|
||||||
|
val startIndex = text.indexOf(link)
|
||||||
|
if (startIndex >= 0) {
|
||||||
|
val endIndex = startIndex + link.length
|
||||||
|
addStyle(
|
||||||
|
style = androidx.compose.ui.text.SpanStyle(
|
||||||
|
color = Color(0xFF3096E5),
|
||||||
|
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
|
||||||
|
),
|
||||||
|
start = startIndex,
|
||||||
|
end = endIndex
|
||||||
|
)
|
||||||
|
addStringAnnotation(
|
||||||
|
tag = "URL",
|
||||||
|
annotation = link,
|
||||||
|
start = startIndex,
|
||||||
|
end = endIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
androidx.compose.foundation.text.ClickableText(
|
||||||
|
text = annotatedString,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
|
||||||
|
onClick = { offset ->
|
||||||
|
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
|
.firstOrNull()?.let { annotation ->
|
||||||
|
try {
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
|
||||||
|
if (links.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
LinkPreview(
|
||||||
|
url = links[0],
|
||||||
|
contentColor = contentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time and Status
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (message.reactions.isNotEmpty()) {
|
||||||
|
MessageReactions(
|
||||||
|
reactions = message.reactions,
|
||||||
|
onReactionClick = onReactionClick,
|
||||||
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val formattedTime = remember(message.createdAt) {
|
||||||
|
try {
|
||||||
|
val instant = Instant.parse(message.createdAt)
|
||||||
|
val zonedDateTime = instant.atZone(ZoneId.systemDefault())
|
||||||
|
zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
message.createdAt.substringAfter('T').take(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formattedTime,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = contentColor.copy(alpha = 0.6f),
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
if (message.isPinned) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PushPin,
|
||||||
|
contentDescription = "Pinned",
|
||||||
|
modifier = Modifier.size(12.dp).padding(start = 2.dp).graphicsLayer { rotationZ = 45f },
|
||||||
|
tint = contentColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isCurrentUser) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = contentColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // End of bubble Column
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(32.dp)
|
||||||
|
.background(Color(0xFF3096E5), CircleShape)
|
||||||
|
.padding(2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} // End of Box
|
||||||
|
} // End of Row
|
||||||
|
|
||||||
|
lightboxMediaIndex?.let { index ->
|
||||||
|
AppMediaLightbox(
|
||||||
|
mediaList = message.media,
|
||||||
|
initialIndex = index,
|
||||||
|
onClose = { lightboxMediaIndex = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
var deleteForEveryone by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = { Text("Удалить сообщение?") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Вы уверены, что хотите удалить это сообщение?")
|
||||||
|
if (isCurrentUser) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(top = 8.dp).clickable { deleteForEveryone = !deleteForEveryone }
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = deleteForEveryone,
|
||||||
|
onCheckedChange = { deleteForEveryone = it }
|
||||||
|
)
|
||||||
|
Text("Удалить у всех")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onDelete(message, deleteForEveryone)
|
||||||
|
showDeleteDialog = false
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935))
|
||||||
|
) {
|
||||||
|
Text("Удалить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageReactions(
|
||||||
|
reactions: Map<String, Int>,
|
||||||
|
onReactionClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
reactions.forEach { (emoji, count) ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White.copy(alpha = 0.2f))
|
||||||
|
.clickable { onReactionClick(emoji) }
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = emoji, fontSize = 12.sp)
|
||||||
|
if (count > 1) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(text = count.toString(), fontSize = 10.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(contentColor.copy(alpha = 0.1f))
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Description, contentDescription = null, tint = contentColor)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
val fileName = media.filename ?: "File"
|
||||||
|
Text(
|
||||||
|
text = fileName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
val sizeStr = media.size?.let {
|
||||||
|
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
|
||||||
|
} ?: "File"
|
||||||
|
Text(
|
||||||
|
text = sizeStr,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = contentColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
if (mediaList.isEmpty()) return
|
||||||
|
|
||||||
|
val columns = when {
|
||||||
|
mediaList.size == 1 -> 1
|
||||||
|
mediaList.size <= 4 -> 2
|
||||||
|
else -> 3
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
val rows = (mediaList.size + columns - 1) / columns
|
||||||
|
for (i in 0 until rows) {
|
||||||
|
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
for (j in 0 until columns) {
|
||||||
|
val index = i * columns + j
|
||||||
|
if (index < mediaList.size) {
|
||||||
|
GridItem(
|
||||||
|
media = mediaList[index],
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(1.dp)
|
||||||
|
.clickable { onMediaClick(index) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GridItem(media: Media, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isImage = media.type.startsWith("image")
|
||||||
|
val isVideo = media.type.startsWith("video")
|
||||||
|
|
||||||
|
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
|
||||||
|
if (isImage || isVideo) {
|
||||||
|
AsyncImage(
|
||||||
|
model = media.url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
if (isVideo) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.8f),
|
||||||
|
modifier = Modifier.align(Alignment.Center).size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.InsertDriveFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = media.filename ?: "File",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = media.url.substringAfterLast(".").uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
|
fontSize = 8.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageContextMenu(
|
||||||
|
isMyMessage: Boolean,
|
||||||
|
isPinned: Boolean,
|
||||||
|
onReactionSelected: (String) -> Unit,
|
||||||
|
onAction: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(200)) +
|
||||||
|
androidx.compose.animation.scaleIn(
|
||||||
|
initialScale = 0.5f,
|
||||||
|
animationSpec = androidx.compose.animation.core.spring(
|
||||||
|
dampingRatio = 0.6f,
|
||||||
|
stiffness = 400f
|
||||||
|
),
|
||||||
|
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(
|
||||||
|
if (isVisible) 0.5f else 0.5f,
|
||||||
|
0f
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut()
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(280.dp) // Even wider for all reactions
|
||||||
|
.clip(RoundedCornerShape(20.dp)),
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
shadowElevation = 15.dp,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(0.5.dp, Color.White.copy(alpha = 0.15f))
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Extended Reactions scrollable bar
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
val telegramReactions = listOf(
|
||||||
|
"👍", "❤️", "😂", "🔥", "💯", "👏", "🤩", "🤔", "🤯", "😱", "🤬",
|
||||||
|
"👎", "💩", "🤡", "🥳", "😇", "🌚", "🙏", "👌", "🕊️", "🐳",
|
||||||
|
"🌭", "🦄", "🍌", "💊", "🍓", "🍾", "💋", "🖕", "👀", "👻",
|
||||||
|
"🤝", "🤣", "⚡", "✨", "🎈", "🎉", "🧊", "🆒", "🤨", "😐",
|
||||||
|
"🤷♂️", "💅", "🏆", "👾", "🎯", "🎲", "🍷", "🤮", "🥱", "😴",
|
||||||
|
"🤐", "🥴"
|
||||||
|
)
|
||||||
|
items(telegramReactions) { emoji ->
|
||||||
|
Text(
|
||||||
|
text = emoji,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable { onReactionSelected(emoji) }
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(color = Color.White.copy(alpha = 0.1f))
|
||||||
|
|
||||||
|
ContextMenuItem(Icons.Default.Reply, "Ответить", onClick = { onAction("reply") })
|
||||||
|
ContextMenuItem(Icons.Default.CheckCircle, "Выбрать", onClick = { onAction("select") })
|
||||||
|
ContextMenuItem(Icons.Default.Forward, "Переслать", onClick = { onAction("forward") })
|
||||||
|
ContextMenuItem(
|
||||||
|
icon = Icons.Default.PushPin,
|
||||||
|
text = if (isPinned) "Открепить" else "Закрепить",
|
||||||
|
onClick = { onAction("pin") }
|
||||||
|
)
|
||||||
|
ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") })
|
||||||
|
if (isMyMessage) {
|
||||||
|
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(color = Color.White.copy(alpha = 0.1f))
|
||||||
|
|
||||||
|
ContextMenuItem(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
"Удалить",
|
||||||
|
textColor = Color(0xFFE53935),
|
||||||
|
onClick = { onAction("delete") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContextMenuItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String,
|
||||||
|
textColor: Color = Color.White,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = textColor.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = textColor,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReactionPicker(
|
||||||
|
onReactionSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||||
|
.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
reactions.forEach { reaction ->
|
||||||
|
Text(
|
||||||
|
text = reaction,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clickable { onReactionSelected(reaction) },
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageReactions(
|
||||||
|
reactions: Map<String, Int>, // Эмодзи -> Количество
|
||||||
|
onReactionClick: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
reactions.forEach { (emoji, count) ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
|
||||||
|
.clickable { onReactionClick(emoji) }
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(text = "$emoji $count", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Reply
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeableMessageItem(
|
||||||
|
onReply: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val replyThreshold = with(density) { 60.dp.toPx() }
|
||||||
|
val maxDrag = with(density) { 90.dp.toPx() }
|
||||||
|
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
var isTriggered by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.draggable(
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
state = rememberDraggableState { delta ->
|
||||||
|
// We only allow swiping to the left (negative delta)
|
||||||
|
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
|
||||||
|
offsetX = newOffset
|
||||||
|
|
||||||
|
isTriggered = newOffset <= -replyThreshold
|
||||||
|
},
|
||||||
|
onDragStopped = {
|
||||||
|
if (offsetX <= -replyThreshold) {
|
||||||
|
onReply()
|
||||||
|
}
|
||||||
|
offsetX = 0f
|
||||||
|
isTriggered = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Reply Icon (Underneath)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
.alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Reply,
|
||||||
|
contentDescription = "Reply",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message Content
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(offsetX.roundToInt(), 0) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package contacts.data.remote.api
|
||||||
|
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ContactApi {
|
||||||
|
@GET("contacts")
|
||||||
|
suspend fun getContacts(): List<ContactDto>
|
||||||
|
|
||||||
|
@POST("contacts/request")
|
||||||
|
suspend fun addContact(@Body request: AddContactRequest): Any
|
||||||
|
|
||||||
|
@DELETE("contacts/{userId}")
|
||||||
|
suspend fun removeContact(@Path("userId") userId: String)
|
||||||
|
|
||||||
|
@GET("profiles/search")
|
||||||
|
suspend fun searchUsers(@Query("q") query: String): List<ContactDto>
|
||||||
|
|
||||||
|
@GET("contacts/requests")
|
||||||
|
suspend fun getFriendRequests(): List<FriendRequestDto>
|
||||||
|
|
||||||
|
@POST("contacts/{friendshipId}/accept")
|
||||||
|
suspend fun acceptRequest(@Path("friendshipId") friendshipId: String): Any
|
||||||
|
|
||||||
|
@POST("contacts/{friendshipId}/decline")
|
||||||
|
suspend fun declineRequest(@Path("friendshipId") friendshipId: String): Any
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FriendRequestDto(
|
||||||
|
val id: String,
|
||||||
|
val user: ContactDto,
|
||||||
|
val createdAt: String,
|
||||||
|
val isOutgoing: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContactDto(
|
||||||
|
@com.google.gson.annotations.SerializedName("id")
|
||||||
|
val id: String,
|
||||||
|
@com.google.gson.annotations.SerializedName("userName")
|
||||||
|
val userName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("username")
|
||||||
|
val username: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("displayName")
|
||||||
|
val displayName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatarUrl")
|
||||||
|
val avatarUrl: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatar")
|
||||||
|
val avatar: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("isOnline")
|
||||||
|
val isOnline: Boolean,
|
||||||
|
@com.google.gson.annotations.SerializedName("lastSeen")
|
||||||
|
val lastSeen: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("friendshipId")
|
||||||
|
val friendshipId: String? = null
|
||||||
|
) {
|
||||||
|
val effectiveUsername: String get() = username ?: userName ?: "unknown"
|
||||||
|
val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AddContactRequest(
|
||||||
|
@com.google.gson.annotations.SerializedName("contactId")
|
||||||
|
val contactId: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package contacts.data.repository
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactApi
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ContactRepositoryImpl @Inject constructor(
|
||||||
|
private val api: ContactApi
|
||||||
|
) : ContactRepository {
|
||||||
|
|
||||||
|
override suspend fun getContacts(): Result<List<ContactDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.getContacts())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addContact(userId: String): Result<ContactDto> {
|
||||||
|
return try {
|
||||||
|
val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId))
|
||||||
|
// Бэкенд возвращает статус или пустой ответ для запроса,
|
||||||
|
// так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или
|
||||||
|
// по-хорошему надо обновить доменную модель, но для начала вернем заглушку.
|
||||||
|
Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeContact(userId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.removeContact(userId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.searchUsers(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.getFriendRequests())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.acceptRequest(friendshipId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.declineRequest(friendshipId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package contacts.di
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactApi
|
||||||
|
import contacts.data.repository.ContactRepositoryImpl
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object ContactModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContactApi(retrofit: Retrofit): ContactApi {
|
||||||
|
return retrofit.create(ContactApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContactRepository(
|
||||||
|
api: ContactApi
|
||||||
|
): ContactRepository {
|
||||||
|
return ContactRepositoryImpl(api)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package contacts.domain.repository
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
|
||||||
|
interface ContactRepository {
|
||||||
|
suspend fun getContacts(): Result<List<ContactDto>>
|
||||||
|
suspend fun addContact(userId: String): Result<ContactDto>
|
||||||
|
suspend fun removeContact(userId: String): Result<Unit>
|
||||||
|
suspend fun searchUsers(query: String): Result<List<ContactDto>>
|
||||||
|
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
|
||||||
|
suspend fun acceptRequest(friendshipId: String): Result<Unit>
|
||||||
|
suspend fun declineRequest(friendshipId: String): Result<Unit>
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package contacts.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material.icons.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Cancel
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import core.presentation.theme.SoftSquareShape
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListScreen(
|
||||||
|
contacts: List<contacts.data.remote.api.ContactDto>,
|
||||||
|
requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
onContactClick: (String) -> Unit,
|
||||||
|
onSearchChange: (String) -> Unit,
|
||||||
|
onAddContact: (String) -> Unit,
|
||||||
|
onStartChat: (String) -> Unit,
|
||||||
|
onAcceptRequest: (String) -> Unit,
|
||||||
|
onDeclineRequest: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
val tabs = listOf(
|
||||||
|
stringResource(R.string.all),
|
||||||
|
stringResource(R.string.online_tab)
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.contacts_title), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
titleContentColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
// Поисковая строка
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = {
|
||||||
|
searchQuery = it
|
||||||
|
onSearchChange(it)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
shape = SoftSquareShape
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val filteredContacts = if (searchQuery.isNotEmpty()) {
|
||||||
|
contacts
|
||||||
|
} else {
|
||||||
|
contacts.filter {
|
||||||
|
if (selectedTab == 1) it.isOnline else true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Contact Requests Section
|
||||||
|
if (searchQuery.isEmpty() && requests.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Заявки в контакты",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(requests) { request ->
|
||||||
|
ContactRequestItem(
|
||||||
|
request = request,
|
||||||
|
onAccept = { onAcceptRequest(request.id) },
|
||||||
|
onDecline = { onDeclineRequest(request.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredContacts.isEmpty() && requests.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = if (searchQuery.isNotEmpty()) "Пользователи не найдены" else "Список контактов пуст",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(filteredContacts) { contact ->
|
||||||
|
ContactItem(
|
||||||
|
contact = contact,
|
||||||
|
onClick = { onContactClick(contact.id) },
|
||||||
|
onAddClick = { onAddContact(contact.id) },
|
||||||
|
onChatClick = { onStartChat(contact.id) },
|
||||||
|
isSearchMode = searchQuery.isNotEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactRequestItem(
|
||||||
|
request: contacts.data.remote.api.FriendRequestDto,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp, 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = request.user.effectiveAvatarUrl,
|
||||||
|
name = request.user.effectiveUsername,
|
||||||
|
size = 48.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = request.user.displayName ?: request.user.effectiveUsername,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (request.isOutgoing) "Исходящий запрос" else "Входящий запрос",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
if (!request.isOutgoing) {
|
||||||
|
IconButton(onClick = onAccept) {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = "Accept", tint = Color(0xFF4CAF50))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDecline) {
|
||||||
|
Icon(Icons.Default.Cancel, contentDescription = "Decline", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactItem(
|
||||||
|
contact: contacts.data.remote.api.ContactDto,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onAddClick: () -> Unit,
|
||||||
|
onChatClick: () -> Unit,
|
||||||
|
isSearchMode: Boolean
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(16.dp, 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = contact.effectiveAvatarUrl,
|
||||||
|
name = contact.effectiveUsername,
|
||||||
|
size = 56.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = contact.displayName ?: contact.effectiveUsername,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
val statusText = if (contact.isOnline) {
|
||||||
|
stringResource(R.string.online)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
if (isSearchMode) {
|
||||||
|
IconButton(onClick = onAddClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = "Add Contact",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onChatClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Chat,
|
||||||
|
contentDescription = "Start Chat",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package contacts.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import core.utils.NavigationManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ContactListState(
|
||||||
|
val contacts: List<contacts.data.remote.api.ContactDto> = emptyList(),
|
||||||
|
val requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ContactListViewModel @Inject constructor(
|
||||||
|
private val contactRepository: ContactRepository,
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val navigationManager: NavigationManager
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(ContactListState())
|
||||||
|
val state: StateFlow<ContactListState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadData() {
|
||||||
|
loadContacts()
|
||||||
|
loadRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadContacts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
contactRepository.getContacts()
|
||||||
|
.onSuccess { contacts ->
|
||||||
|
_state.update { it.copy(contacts = contacts, isLoading = false) }
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRequests() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.getFriendRequests()
|
||||||
|
.onSuccess { requests ->
|
||||||
|
_state.update { it.copy(requests = requests) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchChange(query: String) {
|
||||||
|
if (query.length < 3) {
|
||||||
|
if (query.isEmpty()) loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
contactRepository.searchUsers(query)
|
||||||
|
.onSuccess { users ->
|
||||||
|
_state.update { it.copy(contacts = users, isLoading = false) }
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.addContact(userId)
|
||||||
|
.onSuccess {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptRequest(requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.acceptRequest(requestId)
|
||||||
|
.onSuccess { loadData() }
|
||||||
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun declineRequest(requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.declineRequest(requestId)
|
||||||
|
.onSuccess { loadData() }
|
||||||
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startChat(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// В вебе мы ищем существующий чат или создаем новый.
|
||||||
|
// В мобилке мы для начала можем просто вызвать createPersonalChat.
|
||||||
|
// Бэкенд обычно возвращает существующий чат, если он уже есть.
|
||||||
|
val chat = chatRepository.createPersonalChat(userId)
|
||||||
|
navigationManager.navigateToChat(chat.id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContext(@ApplicationContext context: Context): Context {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGson(): Gson {
|
||||||
|
return Gson()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import core.network.AuthInterceptor
|
||||||
|
import core.network.DynamicBaseUrlInterceptor
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
|
||||||
|
): OkHttpClient {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.connectTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(300, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.addInterceptor(dynamicBaseUrlInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
val gson = com.google.gson.GsonBuilder()
|
||||||
|
.serializeNulls()
|
||||||
|
.create()
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl("https://api.placeholder.com/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package core.domain.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class ServerConfigModel(
|
||||||
|
@SerializedName("stories") val stories: StoriesConfig = StoriesConfig(),
|
||||||
|
@SerializedName("messages") val messages: MessagesConfig = MessagesConfig(),
|
||||||
|
@SerializedName("chats") val chats: ChatsConfig = ChatsConfig(),
|
||||||
|
@SerializedName("webRtc") val webRtc: WebRtcConfig = WebRtcConfig()
|
||||||
|
) {
|
||||||
|
// Helper to keep the presentation layer simple
|
||||||
|
val features: FeaturesConfig
|
||||||
|
get() = FeaturesConfig(
|
||||||
|
stories = stories.enabled,
|
||||||
|
polls = messages.allowPolls,
|
||||||
|
calls = webRtc.enabled && webRtc.enableVoiceCalls,
|
||||||
|
groups = true // Static for now as chats config doesn't have a simple toggle
|
||||||
|
)
|
||||||
|
|
||||||
|
val limits: LimitsConfig
|
||||||
|
get() = LimitsConfig(
|
||||||
|
maxFileSize = messages.maxFileSize,
|
||||||
|
maxGroupMembers = chats.maxGroupParticipants
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StoriesConfig(
|
||||||
|
@SerializedName("enabled") val enabled: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessagesConfig(
|
||||||
|
@SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024,
|
||||||
|
@SerializedName("allowPolls") val allowPolls: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatsConfig(
|
||||||
|
@SerializedName("maxGroupParticipants") val maxGroupParticipants: Int = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebRtcConfig(
|
||||||
|
@SerializedName("enabled") val enabled: Boolean = true,
|
||||||
|
@SerializedName("enableVoiceCalls") val enableVoiceCalls: Boolean = true,
|
||||||
|
@SerializedName("enableVideoCalls") val enableVideoCalls: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// DTOs to maintain compatibility with existing UI code if possible
|
||||||
|
data class FeaturesConfig(
|
||||||
|
val stories: Boolean,
|
||||||
|
val polls: Boolean,
|
||||||
|
val calls: Boolean,
|
||||||
|
val groups: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LimitsConfig(
|
||||||
|
val maxFileSize: Long,
|
||||||
|
val maxGroupMembers: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import core.security.TokenManager
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val navigationManager: core.utils.NavigationManager,
|
||||||
|
private val authRepositoryProvider: javax.inject.Provider<auth.domain.repository.AuthRepository>
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isRefreshing = false
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
val request = chain.request().newBuilder().apply {
|
||||||
|
token?.let {
|
||||||
|
addHeader("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
var response = chain.proceed(request)
|
||||||
|
|
||||||
|
if (response.code == 401) {
|
||||||
|
// Attempt to refresh token synchronously
|
||||||
|
val refreshedToken = refreshAuthToken()
|
||||||
|
|
||||||
|
if (refreshedToken != null) {
|
||||||
|
// Close the old response before retrying
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
// Retry the original request with the new token
|
||||||
|
val retryRequest = chain.request().newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer $refreshedToken")
|
||||||
|
.build()
|
||||||
|
response = chain.proceed(retryRequest)
|
||||||
|
} else {
|
||||||
|
// Refresh failed, logout
|
||||||
|
logoutUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAuthToken(): String? {
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Already refreshing, wait and return current token
|
||||||
|
return tokenManager.getToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return runBlocking {
|
||||||
|
isRefreshing = true
|
||||||
|
try {
|
||||||
|
val result = authRepositoryProvider.get().refreshToken()
|
||||||
|
if (result.isSuccess) {
|
||||||
|
result.getOrNull()?.token
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logoutUser() {
|
||||||
|
runBlocking {
|
||||||
|
authRepositoryProvider.get().logout()
|
||||||
|
}
|
||||||
|
navigationManager.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DynamicBaseUrlInterceptor @Inject constructor(
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val newBaseUrlString = serverConfig.getBaseUrl()
|
||||||
|
val newBaseUrl = newBaseUrlString.toHttpUrlOrNull() ?: return chain.proceed(originalRequest)
|
||||||
|
|
||||||
|
val oldUrl = originalRequest.url
|
||||||
|
|
||||||
|
// Формируем новый URL.
|
||||||
|
// Retrofit-пути у нас начинаются БЕЗ слеша (например "auth/login")
|
||||||
|
// Поэтому мы просто берем путь из настроек и добавляем к нему путь запроса.
|
||||||
|
val newUrl = newBaseUrl.newBuilder().apply {
|
||||||
|
val pathSegments = oldUrl.pathSegments
|
||||||
|
// Добавляем сегменты пути запроса к базовому пути из настроек
|
||||||
|
pathSegments.forEach { segment ->
|
||||||
|
if (segment.isNotEmpty()) {
|
||||||
|
addEncodedPathSegment(segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Копируем параметры запроса (query params)
|
||||||
|
for (i in 0 until oldUrl.querySize) {
|
||||||
|
addEncodedQueryParameter(
|
||||||
|
oldUrl.queryParameterName(i),
|
||||||
|
oldUrl.queryParameterValue(i)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.url(newUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import core.domain.model.FeaturesConfig
|
||||||
|
import core.domain.model.ServerConfigModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerConfig @Inject constructor(
|
||||||
|
private val context: Context,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
private val prefs = context.getSharedPreferences("server_config_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
private const val KEY_CONFIG_DATA = "server_config_data"
|
||||||
|
private const val DEFAULT_BASE_URL = "" // Пустой по умолчанию
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBaseUrl(): String {
|
||||||
|
return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBaseUrl(url: String) {
|
||||||
|
val formattedUrl = if (url.endsWith("/")) url else "$url/"
|
||||||
|
prefs.edit().putString(KEY_BASE_URL, formattedUrl).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveServerConfig(config: ServerConfigModel) {
|
||||||
|
android.util.Log.d("ServerConfig", "Saving new config: $config")
|
||||||
|
prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getServerConfig(): ServerConfigModel {
|
||||||
|
val json = prefs.getString(KEY_CONFIG_DATA, null) ?: return ServerConfigModel()
|
||||||
|
return try {
|
||||||
|
gson.fromJson(json, ServerConfigModel::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerConfigModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFeatureEnabled(feature: (FeaturesConfig) -> Boolean): Boolean {
|
||||||
|
return feature(getServerConfig().features)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package core.notifications.data
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ActiveChatTracker @Inject constructor() {
|
||||||
|
private val _currentChatId = MutableStateFlow<String?>(null)
|
||||||
|
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
|
||||||
|
|
||||||
|
private val _totalUnreadCount = MutableStateFlow(0)
|
||||||
|
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount.asStateFlow()
|
||||||
|
|
||||||
|
fun setChatId(chatId: String?) {
|
||||||
|
_currentChatId.value = chatId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTotalUnreadCount(count: Int) {
|
||||||
|
_totalUnreadCount.value = count
|
||||||
|
}
|
||||||
|
|
||||||
|
fun incrementUnreadCount() {
|
||||||
|
_totalUnreadCount.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package core.notifications.data
|
||||||
|
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class KnotFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var authApi: AuthApi
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var activeChatTracker: ActiveChatTracker
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
// Отправляем новый токен на сервер
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
authApi.updatePushToken(token)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ошибка логируется или игнорируется (сервер может быть недоступен)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
android.util.Log.d("FCM", "Message received: ${message.data}")
|
||||||
|
|
||||||
|
val title = message.notification?.title ?: message.data["title"]
|
||||||
|
val body = message.notification?.body ?: message.data["body"]
|
||||||
|
val type = message.data["type"] // "chat", "call", "story"
|
||||||
|
val chatId = message.data["chatId"]
|
||||||
|
val messageId = message.data["id"] ?: message.messageId
|
||||||
|
|
||||||
|
NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode(), activeChatTracker.totalUnreadCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(title: String?, body: String?, type: String?) {
|
||||||
|
NotificationHelper.showNotification(this, title, body, type, totalCount = activeChatTracker.totalUnreadCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
job.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package core.notifications.data
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
private const val CHANNEL_ID = "knot_notifications_channel"
|
||||||
|
|
||||||
|
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Knot Notifications",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Chat and system notifications"
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find our launcher activity via package manager to avoid static dependency on MainActivity
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||||
|
action = Intent.ACTION_MAIN
|
||||||
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
putExtra("type", type)
|
||||||
|
putExtra("chatId", chatId)
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, System.currentTimeMillis().toInt(), intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||||
|
.setNumber(totalCount)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
|
||||||
|
notificationManager.notify(finalNotificationId, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package core.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.VolumeUp
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackParameters
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppAudioPlayer(
|
||||||
|
url: String,
|
||||||
|
name: String? = null,
|
||||||
|
size: Long? = null,
|
||||||
|
isVoiceMessage: Boolean = false,
|
||||||
|
initialPlaybackSpeed: Float = core.utils.PlaybackManager.globalPlaybackSpeed.value,
|
||||||
|
autoPlay: Boolean = false,
|
||||||
|
onFinished: (Float) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentColor: Color = Color.White
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
|
||||||
|
|
||||||
|
// Update global speed when changed locally
|
||||||
|
LaunchedEffect(playbackSpeed) {
|
||||||
|
core.utils.PlaybackManager.globalPlaybackSpeed.value = playbackSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
val exoPlayer = remember {
|
||||||
|
ExoPlayer.Builder(context).build().apply {
|
||||||
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
|
setMediaItem(mediaItem)
|
||||||
|
playbackParameters = PlaybackParameters(playbackSpeed)
|
||||||
|
val savedPos = core.utils.PlaybackManager.getPosition(url)
|
||||||
|
seekTo(savedPos)
|
||||||
|
prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка сигнала авто-запуска
|
||||||
|
LaunchedEffect(autoPlay) {
|
||||||
|
if (autoPlay) {
|
||||||
|
exoPlayer.play()
|
||||||
|
core.utils.PlaybackManager.currentPlayingUrl.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPlaying by remember { mutableStateOf(false) }
|
||||||
|
var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) }
|
||||||
|
var duration by remember { mutableLongStateOf(0L) }
|
||||||
|
var layoutSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
|
||||||
|
// Глобальная синхронизация: если играет кто-то другой, ставим паузу
|
||||||
|
val globalPlayingUrl by core.utils.PlaybackManager.currentPlayingUrl
|
||||||
|
LaunchedEffect(globalPlayingUrl) {
|
||||||
|
if (globalPlayingUrl != url && isPlaying) {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileName = remember(url, name) {
|
||||||
|
name ?: url.substringAfterLast("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val listener = object : Player.Listener {
|
||||||
|
override fun onIsPlayingChanged(playing: Boolean) {
|
||||||
|
isPlaying = playing
|
||||||
|
if (playing) {
|
||||||
|
core.utils.PlaybackManager.currentPlayingUrl.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
|
if (state == Player.STATE_READY) {
|
||||||
|
duration = exoPlayer.duration
|
||||||
|
} else if (state == Player.STATE_ENDED) {
|
||||||
|
exoPlayer.pause() // Явно останавливаем, чтобы не было авто-реплея при seekTo
|
||||||
|
if (isVoiceMessage) {
|
||||||
|
exoPlayer.seekTo(0)
|
||||||
|
currentPosition = 0
|
||||||
|
core.utils.PlaybackManager.resetPosition(url)
|
||||||
|
}
|
||||||
|
onFinished(playbackSpeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exoPlayer.addListener(listener)
|
||||||
|
onDispose {
|
||||||
|
core.utils.PlaybackManager.savePosition(url, exoPlayer.currentPosition)
|
||||||
|
exoPlayer.removeListener(listener)
|
||||||
|
exoPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isPlaying) {
|
||||||
|
if (isPlaying) {
|
||||||
|
while (isPlaying) {
|
||||||
|
currentPosition = exoPlayer.currentPosition
|
||||||
|
core.utils.PlaybackManager.savePosition(url, currentPosition)
|
||||||
|
delay(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.12f))
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
// Header: Icon and Name
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.VolumeUp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = contentColor.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = if (isVoiceMessage) "Голосовое сообщение" else fileName,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// Play/Pause Button
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White)
|
||||||
|
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF3390EC),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
// Waveform
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(30.dp)
|
||||||
|
.onSizeChanged { layoutSize = it }
|
||||||
|
.pointerInput(duration) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
if (duration > 0 && layoutSize.width > 0) {
|
||||||
|
val pct = offset.x / layoutSize.width.toFloat()
|
||||||
|
val newPos = (pct * duration).toLong()
|
||||||
|
exoPlayer.seekTo(newPos)
|
||||||
|
currentPosition = newPos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val barCount = 40
|
||||||
|
val barSpacing = 2.dp.toPx()
|
||||||
|
val barWidth = (this.size.width - (barCount - 1) * barSpacing) / barCount
|
||||||
|
val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f
|
||||||
|
|
||||||
|
val barHeights = listOf(
|
||||||
|
0.3f, 0.5f, 0.2f, 0.7f, 0.4f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f,
|
||||||
|
0.2f, 0.6f, 0.5f, 0.8f, 0.3f, 0.7f, 0.4f, 0.6f, 0.2f, 0.8f,
|
||||||
|
0.4f, 0.5f, 0.3f, 0.7f, 0.5f, 0.6f, 0.4f, 0.3f, 0.4f, 0.6f,
|
||||||
|
0.3f, 0.2f, 0.4f, 0.5f, 0.3f, 0.6f, 0.4f, 0.7f, 0.3f, 0.5f
|
||||||
|
)
|
||||||
|
|
||||||
|
for (i in 0 until barCount) {
|
||||||
|
val x = i * (barWidth + barSpacing)
|
||||||
|
val heightPct = barHeights[i % barHeights.size]
|
||||||
|
val barHeight = this.size.height * heightPct
|
||||||
|
val isActive = (i.toFloat() / barCount) <= progress
|
||||||
|
|
||||||
|
drawRoundRect(
|
||||||
|
color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f),
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(x, (this.size.height - barHeight) / 2),
|
||||||
|
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
|
||||||
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
// Footer: Time, Size, Download
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(if (isPlaying || currentPosition > 0) currentPosition else duration),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = contentColor.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
if (!isVoiceMessage) {
|
||||||
|
size?.let {
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
val sizeStr = if (it > 1024 * 1024) String.format("%.1f MB", it / (1024.0 * 1024.0)) else "${it / 1024} KB"
|
||||||
|
Text(
|
||||||
|
text = sizeStr,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = contentColor.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVoiceMessage) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Download,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = contentColor.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.size(14.dp).clickable {
|
||||||
|
scope.launch {
|
||||||
|
core.utils.DownloadUtils.downloadMedia(context, url, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed toggle (only for voice messages or if user wants)
|
||||||
|
if (isVoiceMessage || playbackSpeed > 1.0f) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
playbackSpeed = when (playbackSpeed) {
|
||||||
|
1.0f -> 1.5f
|
||||||
|
1.5f -> 2.0f
|
||||||
|
else -> 1.0f
|
||||||
|
}
|
||||||
|
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
|
||||||
|
},
|
||||||
|
color = Color.White.copy(alpha = 0.1f),
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(durationMs: Long): String {
|
||||||
|
if (durationMs <= 0) return "0:00"
|
||||||
|
val totalSeconds = durationMs / 1000
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return String.format("%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package core.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import core.presentation.theme.SoftSquareShape
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppAvatar(
|
||||||
|
url: String?,
|
||||||
|
name: String,
|
||||||
|
size: Dp = 48.dp,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val isSavedMessages = remember(name) {
|
||||||
|
name.equals("Избранное", ignoreCase = true) || name.equals("Saved Messages", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val initials = remember(name) {
|
||||||
|
if (isSavedMessages) "" else {
|
||||||
|
val words = name.trim().split("\\s+".toRegex())
|
||||||
|
if (words.size >= 2) {
|
||||||
|
(words[0].take(1) + words[1].take(1)).uppercase()
|
||||||
|
} else if (name.length >= 2) {
|
||||||
|
name.take(2).uppercase()
|
||||||
|
} else {
|
||||||
|
name.take(1).uppercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val avatarColor = Color(0xFF4AA6F3) // Premium Telegram Blue (Web)
|
||||||
|
|
||||||
|
val cornerRadius = remember(size) {
|
||||||
|
if (size <= 32.dp) (size.value * 0.25).dp else 12.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(RoundedCornerShape(cornerRadius))
|
||||||
|
.background(avatarColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (!url.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = name,
|
||||||
|
modifier = Modifier.size(size),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else if (isSavedMessages) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bookmark,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size((size.value * 0.5).dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = initials,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = (size.value * 0.4).sp,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package core.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.calculatePan
|
||||||
|
import androidx.compose.foundation.gestures.calculateZoom
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||||
|
import androidx.compose.foundation.gestures.transformable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import chats.domain.model.Media
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AppMediaLightbox(
|
||||||
|
mediaList: List<Media>,
|
||||||
|
initialIndex: Int = 0,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
|
||||||
|
var canScroll by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
pageSpacing = 16.dp,
|
||||||
|
beyondBoundsPageCount = 1,
|
||||||
|
userScrollEnabled = canScroll
|
||||||
|
) { page ->
|
||||||
|
val media = mediaList[page]
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (media.type.startsWith("video")) {
|
||||||
|
val isCurrentPage = pagerState.currentPage == page
|
||||||
|
AppVideoPlayer(
|
||||||
|
url = media.url,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
useController = true,
|
||||||
|
autoPlay = isCurrentPage // Только если страница активна
|
||||||
|
)
|
||||||
|
} else if (media.type.startsWith("image") || media.type.contains("gif")) {
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||||
|
|
||||||
|
// Reset zoom when page changes
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
scale = 1f
|
||||||
|
offset = androidx.compose.ui.geometry.Offset.Zero
|
||||||
|
canScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onDoubleTap = {
|
||||||
|
if (scale > 1f) {
|
||||||
|
scale = 1f
|
||||||
|
offset = Offset.Zero
|
||||||
|
canScroll = true
|
||||||
|
} else {
|
||||||
|
scale = 3f
|
||||||
|
canScroll = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val zoomActive = scale > 1f || event.changes.size > 1
|
||||||
|
|
||||||
|
if (zoomActive) {
|
||||||
|
// Если мы в режиме зума - поглощаем события
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
|
||||||
|
// Сами вычисляем трансформацию
|
||||||
|
val zoomChange = event.calculateZoom()
|
||||||
|
val panChange = event.calculatePan()
|
||||||
|
|
||||||
|
scale = (scale * zoomChange).coerceIn(1f, 5f)
|
||||||
|
canScroll = scale <= 1f
|
||||||
|
|
||||||
|
if (scale > 1f) {
|
||||||
|
offset += panChange
|
||||||
|
} else {
|
||||||
|
offset = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если zoomActive == false, мы НЕ вызываем consume(),
|
||||||
|
// и событие уходит наверх в HorizontalPager
|
||||||
|
} while (event.changes.any { it.pressed })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = media.url,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(LocalContext.current),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offset.x,
|
||||||
|
translationY = offset.y
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Document / File placeholder in Lightbox
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.InsertDriveFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(100.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = media.filename ?: "File",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Handle error (no app to open)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
|
||||||
|
) {
|
||||||
|
Text("Открыть в приложении")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top Bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaList.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1} / ${mediaList.size}",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val currentMedia = mediaList[pagerState.currentPage]
|
||||||
|
scope.launch {
|
||||||
|
core.utils.DownloadUtils.downloadMedia(context, currentMedia.url, currentMedia.filename)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package core.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.rounded.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AppVideoPlayer(
|
||||||
|
url: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
useController: Boolean = true,
|
||||||
|
autoPlay: Boolean = true,
|
||||||
|
isMuted: Boolean = false,
|
||||||
|
onVideoFinished: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val exoPlayer = remember {
|
||||||
|
ExoPlayer.Builder(context).build().apply {
|
||||||
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
|
setMediaItem(mediaItem)
|
||||||
|
volume = if (isMuted) 0f else 1f
|
||||||
|
prepare()
|
||||||
|
playWhenReady = autoPlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPlaying by remember { mutableStateOf(autoPlay) }
|
||||||
|
var currentPosition by remember { mutableLongStateOf(0L) }
|
||||||
|
var duration by remember { mutableLongStateOf(0L) }
|
||||||
|
var isControlsVisible by remember { mutableStateOf(true) }
|
||||||
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val listener = object : Player.Listener {
|
||||||
|
override fun onIsPlayingChanged(playing: Boolean) {
|
||||||
|
isPlaying = playing
|
||||||
|
}
|
||||||
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
|
if (state == Player.STATE_READY) {
|
||||||
|
duration = exoPlayer.duration
|
||||||
|
} else if (state == Player.STATE_ENDED) {
|
||||||
|
onVideoFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exoPlayer.addListener(listener)
|
||||||
|
onDispose {
|
||||||
|
exoPlayer.removeListener(listener)
|
||||||
|
exoPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isPlaying, isDragging) {
|
||||||
|
if (isPlaying && !isDragging) {
|
||||||
|
while (isPlaying) {
|
||||||
|
currentPosition = exoPlayer.currentPosition
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.background(Color.Black)) {
|
||||||
|
AndroidView(
|
||||||
|
factory = {
|
||||||
|
PlayerView(it).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
this.useController = false // We use our own UI if needed
|
||||||
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize().clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
enabled = useController // Only clickable if we have controls
|
||||||
|
) {
|
||||||
|
isControlsVisible = !isControlsVisible
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isControlsVisible && useController,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.3f))
|
||||||
|
) {
|
||||||
|
// Center Controls
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) },
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Replay10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White.copy(alpha = 0.2f))
|
||||||
|
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(44.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(duration)) },
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Forward10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom Controls
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 16.dp, start = 16.dp, end = 16.dp)
|
||||||
|
) {
|
||||||
|
Slider(
|
||||||
|
value = currentPosition.toFloat(),
|
||||||
|
onValueChange = {
|
||||||
|
isDragging = true
|
||||||
|
currentPosition = it.toLong()
|
||||||
|
},
|
||||||
|
onValueChangeFinished = {
|
||||||
|
exoPlayer.seekTo(currentPosition)
|
||||||
|
isDragging = false
|
||||||
|
},
|
||||||
|
valueRange = 0f..(duration.toFloat().coerceAtLeast(1f)),
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = Color.White,
|
||||||
|
activeTrackColor = Color.White,
|
||||||
|
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
modifier = Modifier.height(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${formatTime(currentPosition)} · ${formatTime(duration)}",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = { /* Settings */ }) {
|
||||||
|
Icon(Icons.Rounded.Settings, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(ms: Long): String {
|
||||||
|
val totalSeconds = ms / 1000
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return String.format("%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package core.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.QuestionAnswer
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GlassNavigationBar(
|
||||||
|
currentRoute: String,
|
||||||
|
onNavigate: (String) -> Unit,
|
||||||
|
userAvatarUrl: String? = null,
|
||||||
|
username: String = ""
|
||||||
|
) {
|
||||||
|
// Контейнер с эффектом стекла
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.clip(RoundedCornerShape(32.dp))
|
||||||
|
.background(Color(0xFF1C1C1E).copy(alpha = 0.95f))
|
||||||
|
.border(
|
||||||
|
0.5.dp,
|
||||||
|
Color.White.copy(alpha = 0.1f),
|
||||||
|
RoundedCornerShape(32.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Default.QuestionAnswer,
|
||||||
|
label = stringResource(R.string.chats),
|
||||||
|
isSelected = currentRoute == "chat_list",
|
||||||
|
onClick = { onNavigate("chat_list") }
|
||||||
|
)
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Default.AccountCircle,
|
||||||
|
label = stringResource(R.string.contacts_tab),
|
||||||
|
isSelected = currentRoute == "contacts",
|
||||||
|
onClick = { onNavigate("contacts") }
|
||||||
|
)
|
||||||
|
NavItem(
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
|
label = stringResource(R.string.settings),
|
||||||
|
isSelected = currentRoute == "settings",
|
||||||
|
onClick = { onNavigate("settings") }
|
||||||
|
)
|
||||||
|
// Профиль с аватаром
|
||||||
|
ProfileNavItem(
|
||||||
|
avatarUrl = userAvatarUrl,
|
||||||
|
username = username,
|
||||||
|
isSelected = currentRoute.startsWith("profile"),
|
||||||
|
onClick = { onNavigate("profile") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.NavItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = label,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.size(26.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = contentColor,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.ProfileNavItem(
|
||||||
|
avatarUrl: String?,
|
||||||
|
username: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = avatarUrl,
|
||||||
|
name = username,
|
||||||
|
size = 26.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_tab),
|
||||||
|
color = contentColor,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package core.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onLogout: () -> Unit = {},
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
|
||||||
|
|
||||||
|
// Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = Color(0xFF0F0F10),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.settings), fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back), tint = Color.White)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.loadConfig() }) {
|
||||||
|
Icon(Icons.Default.Sync, contentDescription = "Refresh", tint = Color.White)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color(0xFF0F0F10),
|
||||||
|
titleContentColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
|
color = Color(0xFF3390EC)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Text(
|
||||||
|
text = "Ошибка обновления: ${state.error}",
|
||||||
|
color = Color.Red,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Server Section
|
||||||
|
SettingsSection(title = stringResource(R.string.server_connection)) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.api_base_url),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF3390EC)
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = { baseUrl = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
focusedIndicatorColor = Color(0xFF3390EC),
|
||||||
|
unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
placeholder = {
|
||||||
|
Text("Например: http://192.168.1.100:5034/api/", color = Color.Gray.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
|
||||||
|
Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Features Section
|
||||||
|
SettingsSection(title = stringResource(R.string.server_features)) {
|
||||||
|
Column {
|
||||||
|
SettingsToggleItem(
|
||||||
|
icon = Icons.Default.History,
|
||||||
|
label = stringResource(R.string.stories),
|
||||||
|
enabled = state.config.features.stories
|
||||||
|
)
|
||||||
|
SettingsToggleItem(
|
||||||
|
icon = Icons.Default.Poll,
|
||||||
|
label = stringResource(R.string.polls),
|
||||||
|
enabled = state.config.features.polls
|
||||||
|
)
|
||||||
|
SettingsToggleItem(
|
||||||
|
icon = Icons.Default.Call,
|
||||||
|
label = stringResource(R.string.calls),
|
||||||
|
enabled = state.config.features.calls
|
||||||
|
)
|
||||||
|
SettingsToggleItem(
|
||||||
|
icon = Icons.Default.Group,
|
||||||
|
label = stringResource(R.string.groups),
|
||||||
|
enabled = state.config.features.groups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Limits Section
|
||||||
|
SettingsSection(title = stringResource(R.string.limits)) {
|
||||||
|
Column {
|
||||||
|
SettingsInfoItem(
|
||||||
|
icon = Icons.Default.Storage,
|
||||||
|
label = stringResource(R.string.max_file_size),
|
||||||
|
value = "${state.config.limits.maxFileSize / (1024 * 1024)} MB"
|
||||||
|
)
|
||||||
|
SettingsInfoItem(
|
||||||
|
icon = Icons.Default.People,
|
||||||
|
label = stringResource(R.string.max_group_members),
|
||||||
|
value = state.config.limits.maxGroupMembers.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Logout Button
|
||||||
|
Button(
|
||||||
|
onClick = onLogout,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Logout, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.log_out), fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(80.dp)) // Space for bottom bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSection(title: String, content: @Composable () -> Unit) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(0xFF3390EC),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
color = Color(0xFF1C1C1E),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsToggleItem(icon: ImageVector, label: String, enabled: Boolean) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(label, color = Color.White, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
|
||||||
|
color = if (enabled) Color(0xFF3390EC) else Color(0xFFE53935),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsInfoItem(icon: ImageVector, label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(label, color = Color.White, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package core.presentation.settings
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import core.domain.model.ServerConfigModel
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SettingsState(
|
||||||
|
val baseUrl: String = "",
|
||||||
|
val config: ServerConfigModel = ServerConfigModel(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val authRepository: auth.domain.repository.AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(SettingsState())
|
||||||
|
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshState()
|
||||||
|
// loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshState() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
baseUrl = serverConfig.getBaseUrl(),
|
||||||
|
config = serverConfig.getServerConfig()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
// Не загружаем конфиг, если URL не введён
|
||||||
|
val currentUrl = serverConfig.getBaseUrl()
|
||||||
|
if (currentUrl.isBlank()) {
|
||||||
|
android.util.Log.d("SettingsViewModel", "Base URL is empty, skipping config load")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
val result = authRepository.fetchConfig()
|
||||||
|
Log.d("SettingsViewModel", "Fetch result: ${result.isSuccess}")
|
||||||
|
if (result.isSuccess) {
|
||||||
|
refreshState()
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(error = result.exceptionOrNull()?.message) }
|
||||||
|
}
|
||||||
|
_state.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBaseUrl(url: String) {
|
||||||
|
val currentUrl = serverConfig.getBaseUrl()
|
||||||
|
|
||||||
|
// Если URL действительно изменился, очищаем токен и логируем
|
||||||
|
if (currentUrl != url) {
|
||||||
|
android.util.Log.d("SettingsViewModel", "Server URL changed: $currentUrl -> $url, clearing token")
|
||||||
|
viewModelScope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serverConfig.setBaseUrl(url)
|
||||||
|
_state.update { it.copy(baseUrl = url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package core.presentation.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// Цвета из веб-версии (Dark Mode)
|
||||||
|
val DarkBackground = Color(0xFF0F0F10)
|
||||||
|
val SurfaceContainer = Color(0xFF161618)
|
||||||
|
val Primary = Color(0xFF6366F1) // Indigo/Primary из Tailwind
|
||||||
|
val PrimaryIndigo = Primary
|
||||||
|
val Indigo500 = Primary
|
||||||
|
val OnSurfaceVariant = Color(0xFF94A3B8)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = Primary,
|
||||||
|
background = DarkBackground,
|
||||||
|
surface = SurfaceContainer,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
onBackground = Color.White,
|
||||||
|
onSurface = Color.White,
|
||||||
|
onSurfaceVariant = OnSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KnotTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = DarkColors,
|
||||||
|
shapes = MaterialTheme.shapes.copy(
|
||||||
|
small = RoundedCornerShape(12.dp),
|
||||||
|
medium = SoftSquareShape,
|
||||||
|
large = RoundedCornerShape(32.dp)
|
||||||
|
),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package core.security
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TokenManager @Inject constructor(context: Context) {
|
||||||
|
private val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"auth_prefs",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveToken(token: String, userId: String? = null, refreshToken: String? = null) {
|
||||||
|
val editor = prefs.edit().putString("jwt_token", token)
|
||||||
|
if (userId != null) {
|
||||||
|
editor.putString("user_id", userId)
|
||||||
|
}
|
||||||
|
if (refreshToken != null) {
|
||||||
|
editor.putString("refresh_token", refreshToken)
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToken(): String? {
|
||||||
|
return prefs.getString("jwt_token", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRefreshToken(): String? {
|
||||||
|
return prefs.getString("refresh_token", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserId(): String? {
|
||||||
|
return prefs.getString("user_id", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteToken() {
|
||||||
|
prefs.edit().remove("jwt_token").remove("user_id").remove("refresh_token").apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
|
||||||
|
object CoilUtils {
|
||||||
|
private var gifImageLoader: ImageLoader? = null
|
||||||
|
|
||||||
|
fun getGifImageLoader(context: Context): ImageLoader {
|
||||||
|
if (gifImageLoader == null) {
|
||||||
|
gifImageLoader = ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return gifImageLoader!!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.widget.Toast
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
object DownloadUtils {
|
||||||
|
|
||||||
|
suspend fun downloadMedia(context: Context, url: String, fileName: String?) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val name = fileName ?: url.substringAfterLast("/")
|
||||||
|
val extension = name.substringAfterLast(".", "jpg")
|
||||||
|
val isImage = extension in listOf("jpg", "jpeg", "png", "webp", "gif")
|
||||||
|
val isVideo = extension in listOf("mp4", "webm", "mkv", "mov")
|
||||||
|
|
||||||
|
if (isImage || isVideo) {
|
||||||
|
saveToGallery(context, url, name, isImage)
|
||||||
|
} else {
|
||||||
|
useDownloadManager(context, url, name)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Ошибка скачивания: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun useDownloadManager(context: Context, url: String, fileName: String) {
|
||||||
|
val request = DownloadManager.Request(Uri.parse(url))
|
||||||
|
.setTitle(fileName)
|
||||||
|
.setDescription("Downloading...")
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||||
|
.setAllowedOverMetered(true)
|
||||||
|
.setAllowedOverRoaming(true)
|
||||||
|
|
||||||
|
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
downloadManager.enqueue(request)
|
||||||
|
|
||||||
|
// Show feedback
|
||||||
|
(context as? android.app.Activity)?.runOnUiThread {
|
||||||
|
Toast.makeText(context, "Загрузка началась: $fileName", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveToGallery(context: Context, url: String, fileName: String, isImage: Boolean) {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val contentValues = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, if (isImage) "image/jpeg" else "video/mp4")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, if (isImage) Environment.DIRECTORY_PICTURES else Environment.DIRECTORY_MOVIES)
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val collection = if (isImage) {
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
} else {
|
||||||
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = resolver.insert(collection, contentValues)
|
||||||
|
uri?.let {
|
||||||
|
URL(url).openStream().use { input ->
|
||||||
|
resolver.openOutputStream(it).use { output ->
|
||||||
|
input.copyTo(output!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
contentValues.clear()
|
||||||
|
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
resolver.update(it, contentValues, null, null)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Сохранено в галерею", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
fun copyUriToFile(context: Context, uri: Uri): File? {
|
||||||
|
return try {
|
||||||
|
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||||
|
val originalName = cursor?.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
val index = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (index != -1) it.getString(index) else null
|
||||||
|
} else null
|
||||||
|
} ?: "${UUID.randomUUID()}.tmp"
|
||||||
|
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
val file = File(context.cacheDir, originalName)
|
||||||
|
val outputStream = FileOutputStream(file)
|
||||||
|
inputStream?.use { input ->
|
||||||
|
outputStream.use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import com.yalantis.ucrop.UCrop
|
||||||
|
import core.presentation.theme.Indigo500
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object ImageCropper {
|
||||||
|
fun getCropIntent(
|
||||||
|
context: Context,
|
||||||
|
sourceUri: Uri,
|
||||||
|
destinationUri: Uri
|
||||||
|
): UCrop {
|
||||||
|
val options = UCrop.Options().apply {
|
||||||
|
setCompressionQuality(90)
|
||||||
|
setHideBottomControls(false)
|
||||||
|
setFreeStyleCropEnabled(false) // Фиксированный аспект
|
||||||
|
setToolbarColor(Indigo500.toArgb())
|
||||||
|
setStatusBarColor(Indigo500.toArgb())
|
||||||
|
setActiveControlsWidgetColor(Indigo500.toArgb())
|
||||||
|
setToolbarTitle("Crop Avatar")
|
||||||
|
// Настройка под "квадрат" (софт-сквайр эмулируется на стороне UI, кроп всегда 1:1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UCrop.of(sourceUri, destinationUri)
|
||||||
|
.withAspectRatio(1f, 1f)
|
||||||
|
.withMaxResultSize(1000, 1000)
|
||||||
|
.withOptions(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
object ImageUtils {
|
||||||
|
fun compressImage(context: Context, uri: Uri, maxWidth: Int = 1280, maxHeight: Int = 1280, quality: Int = 80): File? {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeStream(inputStream, null, options)
|
||||||
|
inputStream?.close()
|
||||||
|
|
||||||
|
var inSampleSize = 1
|
||||||
|
if (options.outHeight > maxHeight || options.outWidth > maxWidth) {
|
||||||
|
val halfHeight = options.outHeight / 2
|
||||||
|
val halfWidth = options.outWidth / 2
|
||||||
|
while (halfHeight / inSampleSize >= maxHeight && halfWidth / inSampleSize >= maxWidth) {
|
||||||
|
inSampleSize *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val decodeOptions = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = inSampleSize
|
||||||
|
}
|
||||||
|
val finalInputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
var bitmap = BitmapFactory.decodeStream(finalInputStream, null, decodeOptions)
|
||||||
|
finalInputStream?.close()
|
||||||
|
|
||||||
|
if (bitmap == null) return null
|
||||||
|
|
||||||
|
// Resize if still too large
|
||||||
|
if (bitmap.width > maxWidth || bitmap.height > maxHeight) {
|
||||||
|
val scale = Math.min(maxWidth.toFloat() / bitmap.width, maxHeight.toFloat() / bitmap.height)
|
||||||
|
val matrix = Matrix().apply { postScale(scale, scale) }
|
||||||
|
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(context.cacheDir, "compressed_${UUID.randomUUID()}.jpg")
|
||||||
|
val out = FileOutputStream(file)
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||||
|
out.flush()
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
file
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
data class LinkMetadata(
|
||||||
|
val url: String,
|
||||||
|
val title: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val imageUrl: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
object LinkParser {
|
||||||
|
private val URL_PATTERN = Regex(
|
||||||
|
"(?:^|[\\s])(https?://[^\\s\\n\\r\"]+)",
|
||||||
|
RegexOption.IGNORE_CASE
|
||||||
|
)
|
||||||
|
|
||||||
|
fun findLinks(text: String): List<String> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
|
sealed class NavEvent {
|
||||||
|
data class OpenChat(val chatId: String) : NavEvent()
|
||||||
|
object Logout : NavEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NavigationManager @Inject constructor() {
|
||||||
|
private val _events = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
|
||||||
|
val events = _events
|
||||||
|
|
||||||
|
fun navigateToChat(chatId: String) {
|
||||||
|
_events.tryEmit(NavEvent.OpenChat(chatId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
_events.tryEmit(NavEvent.Logout)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user