Compare commits
13
Commits
46c22300e9
...
android_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a426b63b7d | ||
|
|
4d4bc8edd1 | ||
|
|
40589dbb75 | ||
|
|
0b013def2e | ||
|
|
ccffc5a53c | ||
|
|
e8161d23d4 | ||
|
|
89e325556c | ||
|
|
df4feeeee5 | ||
|
|
00ed4b5959 | ||
|
|
a0b50b57b0 | ||
|
|
d4c41b333a | ||
|
|
9daa786cfb | ||
|
|
2d2e4b685e |
@@ -7,9 +7,8 @@ using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Chats.Create;
|
||||
|
||||
namespace Knot.Modules.Conversations.UnitTests.Chats;
|
||||
|
||||
@@ -7,9 +7,10 @@ using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Chats.GetChats;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,15 +5,17 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||
using Knot.Modules.Settings.Application.Settings.Abstractions;
|
||||
using Knot.Modules.Settings.Application.Settings.DTOs;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Contracts.Settings.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Conversations.UnitTests.Messages;
|
||||
|
||||
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMessagesSettings _messagesSettings;
|
||||
private readonly IIdempotencyStore _idempotencyStore;
|
||||
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||
private readonly SendMessageCommandHandler _handler;
|
||||
|
||||
public SendMessageCommandHandlerTests()
|
||||
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
|
||||
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_messagesSettings = Substitute.For<IMessagesSettings>();
|
||||
_idempotencyStore = Substitute.For<IIdempotencyStore>();
|
||||
_logger = Substitute.For<ILogger<SendMessageCommandHandler>>();
|
||||
|
||||
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig();
|
||||
var config = new MessagesConfig();
|
||||
_messagesSettings.Current.Returns(config);
|
||||
|
||||
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
|
||||
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Knot.Contracts.Conversations.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище для обеспечения идемпотентности операций.
|
||||
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
|
||||
/// </summary>
|
||||
public interface IIdempotencyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
|
||||
/// </summary>
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
TimeSpan? expiration = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -32,11 +32,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
||||
using Knot.Modules.WebRtc;
|
||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MediatR;
|
||||
|
||||
|
||||
|
||||
|
||||
+31
-40
@@ -1,9 +1,10 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||
|
||||
@@ -30,7 +31,8 @@ public sealed record SendMessageCommand(
|
||||
DateTime? PollExpiresAt = null,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null) : ICommand<Guid>;
|
||||
int? Duration = null,
|
||||
string? IdempotencyKey = null) : ICommand<Guid>;
|
||||
|
||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||
{
|
||||
@@ -39,64 +41,74 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly MediatR.IMediator _mediator;
|
||||
private readonly IMessagesSettings _messagesSettings;
|
||||
private readonly IIdempotencyStore _idempotencyStore;
|
||||
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||
|
||||
public SendMessageCommandHandler(
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
MediatR.IMediator mediator,
|
||||
IMessagesSettings messagesSettings)
|
||||
IMessagesSettings messagesSettings,
|
||||
IIdempotencyStore idempotencyStore,
|
||||
ILogger<SendMessageCommandHandler> logger)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
_messagesSettings = messagesSettings;
|
||||
_idempotencyStore = idempotencyStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. ��������� ������������� ����
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
var key = $"send_msg:{request.ChatId}:{request.IdempotencyKey}";
|
||||
return await _idempotencyStore.GetOrCreateAsync(
|
||||
key,
|
||||
factory: ct => ExecuteAsync(request, ct),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return await ExecuteAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Result<Guid>> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Проверка существования чата
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
}
|
||||
|
||||
// 2. ���������, �������� �� ����������� ����������
|
||||
// 2. Проверка, является ли отправитель участником чата
|
||||
if (!chat.Members.Any(m => m.UserId == request.SenderId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
// 3. ������� ���������
|
||||
// 3. Создание сообщения
|
||||
Message message;
|
||||
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
||||
{
|
||||
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
||||
|
||||
|
||||
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
||||
message = new StoryMessage(
|
||||
Guid.NewGuid(),
|
||||
|
||||
request.ChatId,
|
||||
|
||||
request.SenderId,
|
||||
|
||||
request.StoryId ?? Guid.Empty,
|
||||
|
||||
request.StoryMediaUrl ?? string.Empty,
|
||||
request.StoryMediaType,
|
||||
|
||||
request.Content,
|
||||
|
||||
request.ReplyToId,
|
||||
|
||||
request.ForwardedFromId,
|
||||
|
||||
DateTime.UtcNow,
|
||||
|
||||
false);
|
||||
}
|
||||
else if (request.Attachments != null && request.Attachments.Any())
|
||||
@@ -106,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
var firstAtt = request.Attachments.First();
|
||||
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||
|
||||
|
||||
message = new MediaMessage(
|
||||
Guid.NewGuid(),
|
||||
|
||||
request.ChatId,
|
||||
|
||||
request.SenderId,
|
||||
parsedType,
|
||||
|
||||
request.Content,
|
||||
|
||||
request.ReplyToId,
|
||||
|
||||
request.ForwardedFromId,
|
||||
|
||||
DateTime.UtcNow,
|
||||
|
||||
false);
|
||||
|
||||
|
||||
foreach (var att in request.Attachments)
|
||||
{
|
||||
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
|
||||
@@ -167,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
{
|
||||
message = new TextMessage(
|
||||
Guid.NewGuid(),
|
||||
|
||||
request.ChatId,
|
||||
|
||||
request.SenderId,
|
||||
|
||||
request.Content ?? string.Empty,
|
||||
|
||||
request.ReplyToId,
|
||||
|
||||
request.Quote,
|
||||
|
||||
request.ForwardedFromId,
|
||||
|
||||
DateTime.UtcNow,
|
||||
|
||||
false);
|
||||
}
|
||||
|
||||
// 4. ������������������ ��������� High-Water Mark
|
||||
// 4. Обновление High-Water Mark
|
||||
chat.IncrementSequenceId();
|
||||
message.SetSequenceId(chat.LastMessageSequenceId);
|
||||
|
||||
@@ -193,19 +188,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||
senderMember.UpdateDeliveredCursor(message.Id);
|
||||
|
||||
// 5. ���������
|
||||
// 5. Сохранение
|
||||
_messageRepository.Add(message);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _mediator.Publish(new MessageSentDomainEvent(
|
||||
message.Id,
|
||||
|
||||
message.ChatId,
|
||||
|
||||
message.SenderId,
|
||||
|
||||
message.Content),
|
||||
|
||||
cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||
using Knot.Modules.Conversations.Infrastructure.Services;
|
||||
@@ -8,7 +10,6 @@ using Knot.Shared.Kernel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations;
|
||||
|
||||
@@ -42,7 +43,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
||||
|
||||
|
||||
services.AddMemoryCache();
|
||||
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация хранилища идемпотентности на основе IMemoryCache.
|
||||
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
|
||||
/// </summary>
|
||||
public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public MemoryCacheIdempotencyStore(IMemoryCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
TimeSpan? expiration = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null)
|
||||
{
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check после получения блокировки
|
||||
if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null)
|
||||
{
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken);
|
||||
|
||||
var options = new MemoryCacheEntryOptions()
|
||||
.SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24))
|
||||
.SetPriority(CacheItemPriority.Normal);
|
||||
|
||||
_cache.Set(key, value, options);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -55,11 +55,14 @@ public static class MessagesEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||
});
|
||||
|
||||
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
|
||||
{
|
||||
var attachments = request.Attachments?.Select(a =>
|
||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||
|
||||
// Получаем idempotency ключ из заголовка
|
||||
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
|
||||
|
||||
var command = new SendMessageCommand(
|
||||
chatId,
|
||||
userContext.UserId,
|
||||
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
|
||||
attachments,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId);
|
||||
request.ForwardedFromId,
|
||||
IdempotencyKey: idempotencyKey.ToString());
|
||||
|
||||
var result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
channel: stable
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
- platform: android
|
||||
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
@@ -0,0 +1,125 @@
|
||||
# Инструкция по запуску проекта
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
1. **Flutter SDK** (версия 3.0.0 или выше)
|
||||
- Установите Flutter: https://docs.flutter.dev/get-started/install
|
||||
- Проверьте установку: `flutter doctor`
|
||||
|
||||
2. **Android Studio** или **VS Code** с Flutter плагином
|
||||
|
||||
3. **Android SDK** (для сборки под Android)
|
||||
- MinSDK: 21
|
||||
- TargetSDK: 34
|
||||
- CompileSDK: 34
|
||||
|
||||
## Установка зависимостей
|
||||
|
||||
```bash
|
||||
cd client-mobile
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
## Генерация кода
|
||||
|
||||
Проект использует code generation для:
|
||||
- Freezed (immutable модели)
|
||||
- AutoRoute (навигация)
|
||||
- Isar (база данных)
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Для автоматической генерации при изменениях:
|
||||
```bash
|
||||
dart run build_runner watch --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
## Запуск приложения
|
||||
|
||||
### Android
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Сборка релиза
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Общие компоненты
|
||||
│ ├── constants/ # Константы приложения
|
||||
│ ├── errors/ # Обработка ошибок
|
||||
│ ├── network/ # Сетевой клиент (Dio)
|
||||
│ └── theme/ # Темы оформления
|
||||
├── features/ # Функциональные модули
|
||||
│ ├── auth/ # Аутентификация
|
||||
│ │ ├── data/ # Data layer
|
||||
│ │ ├── domain/ # Domain layer
|
||||
│ │ └── presentation/ # UI layer (BLoC, страницы)
|
||||
│ ├── chat/ # Чаты
|
||||
│ ├── profile/ # Профиль
|
||||
│ └── settings/ # Настройки
|
||||
└── internal/ # Внутренняя конфигурация
|
||||
├── di/ # Dependency Injection (GetIt)
|
||||
└── router/ # Навигация
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
Проект следует принципам **Clean Architecture**:
|
||||
|
||||
- **Domain Layer**: Бизнес-логика, entities, use cases, repository interfaces
|
||||
- **Data Layer**: Реализации репозиториев, datasources, модели
|
||||
- **Presentation Layer**: UI, BLoC, страницы, виджеты
|
||||
|
||||
## State Management
|
||||
|
||||
Используется **flutter_bloc** для управления состоянием:
|
||||
- Каждый feature имеет свой BLoC
|
||||
- События и состояния генерируются через Freezed
|
||||
- DI через GetIt
|
||||
|
||||
## Навигация
|
||||
|
||||
Используется **auto_route** для декларативной навигации.
|
||||
|
||||
## База данных
|
||||
|
||||
Используется **Isar** - быстрая NoSQL база данных для Flutter.
|
||||
|
||||
## Сетевые запросы
|
||||
|
||||
- **Dio** для REST API запросов
|
||||
- **SignalR** для real-time обновлений
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Анализ кода
|
||||
flutter analyze
|
||||
|
||||
# Форматирование
|
||||
dart format .
|
||||
|
||||
# Очистка
|
||||
flutter clean
|
||||
|
||||
# Проверка зависимостей
|
||||
flutter pub outdated
|
||||
```
|
||||
|
||||
## Контакты
|
||||
|
||||
Для вопросов и предложений обращайтесь к команде разработки.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Messenger App
|
||||
|
||||
A Telegram-like mobile messenger application built with Flutter using Clean Architecture.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project follows Clean Architecture principles with the following structure:
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Core utilities, network, errors, theme
|
||||
├── features/ # Feature modules (auth, chat, profile, settings)
|
||||
│ └── [feature]/
|
||||
│ ├── data/ # Data layer (repositories, datasources)
|
||||
│ ├── domain/ # Domain layer (entities, usecases, repository interfaces)
|
||||
│ └── presentation/ # UI layer (bloc, pages, widgets)
|
||||
└── internal/ # App configuration, DI, routing
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **State Management**: flutter_bloc, bloc
|
||||
- **Architecture & DI**: get_it, injectable, freezed_annotation
|
||||
- **Network & Real-time**: dio, signalr_netcore
|
||||
- **Navigation**: auto_route
|
||||
- **Database**: isar, isar_flutter_libs
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Flutter dependencies:
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
2. Generate code:
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
3. Run the app:
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Authentication (Login/Register)
|
||||
- Real-time Chat
|
||||
- Contacts
|
||||
- Profile Management
|
||||
- Settings
|
||||
@@ -0,0 +1,8 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
avoid_print: false
|
||||
prefer_single_quotes: true
|
||||
@@ -0,0 +1,7 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
@@ -0,0 +1,69 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.example.messenger_app"
|
||||
compileSdkVersion 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.messenger_app"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdkVersion 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {}
|
||||
@@ -0,0 +1,28 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Messenger"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.messenger_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,25 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
afterEvaluate { project ->
|
||||
if (project.hasProperty("android")) {
|
||||
project.android {
|
||||
if (namespace == null) {
|
||||
namespace project.group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
||||
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.6.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
@@ -1,135 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,58 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<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.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,63 +0,0 @@
|
||||
<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>
|
||||
@@ -1,85 +0,0 @@
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,131 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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?
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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?
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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
@@ -1,803 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,843 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,74 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user