34 Commits
Author SHA1 Message Date
Халимов Рустам 46c22300e9 Почти плавный скрол чата 2026-04-28 00:16:52 +03:00
Халимов Рустам d9a20e40b0 Сборка перед кэшированием 2026-04-23 01:31:51 +03:00
Халимов Рустам 945134f029 Нормальные токены 2026-04-19 22:55:38 +03:00
Халимов Рустам f6400ce3ac Merge branch 'main' into android 2026-04-19 22:31:31 +03:00
Халимов Рустам d9462069e2 Миграции 2026-04-19 22:22:43 +03:00
Халимов Рустам 9e715fe3ab Токены, правильная работа 2026-04-19 22:13:20 +03:00
Халимов Рустам 8c8ef55b58 Merge branch 'main' into android 2026-04-19 21:24:40 +03:00
Халимов Рустам 70acad56fb Удаление сообщений 2026-04-19 21:05:56 +03:00
Халимов Рустам 629fddfca0 Реакции, но с багом 2026-04-18 00:41:05 +03:00
Халимов Рустам b68f68a1f2 Анимация, список реакций 2026-04-17 23:51:46 +03:00
Халимов Рустам 754e9e8ad0 Меню для сообщений 2026-04-17 23:44:11 +03:00
Халимов Рустам ed7521a563 Правка ответов, локализация, поддержка медиа 2026-04-17 22:07:33 +03:00
Халимов Рустам bef30c2c86 Ответы 2026-04-17 21:59:53 +03:00
Халимов Рустам 8c00d1376d Событие печати, смена адреса, иконки 2026-04-17 16:23:52 +03:00
Халимов Рустам cea3f4d669 Профиль, редактирование без аватара 2026-04-16 15:41:22 +03:00
Халимов Рустам 8409c51842 Пуши 2026-04-15 02:40:52 +03:00
Халимов Рустам 9560a9235f Сохранение состояния при сворачивании 2026-04-15 02:19:03 +03:00
Халимов Рустам f17edfc0da Зум и пролистывание 2026-04-15 02:12:43 +03:00
Халимов Рустам c6bebec599 Кнопка вниз 2026-04-15 02:01:46 +03:00
Халимов Рустам 5a71e5bbfa Подгрузка старых сообщений 2026-04-15 01:55:23 +03:00
Халимов Рустам 5b0133d55e Прокрутка чата 2026-04-15 01:35:22 +03:00
Халимов Рустам dcb733ac01 Нормальный чат 2026-04-15 01:30:48 +03:00
Халимов Рустам 487cb1b12b Рабочие счетчики и переработка чата 2026-04-15 01:14:55 +03:00
Халимов Рустам 58fdf1aca1 Чат, вложения 2026-04-14 21:53:44 +03:00
Халимов Рустам 118f8b8971 Прочтение 2026-04-14 15:09:44 +03:00
Халимов Рустам 8165b74e43 Отправка гиф 2026-04-14 13:55:01 +03:00
Халимов Рустам 8ce4bc714f Гиф 2026-04-14 13:46:17 +03:00
Халимов Рустам 2d0bc0d75c Чат, правки 2026-04-14 12:52:03 +03:00
Халимов Рустам d7e75797ef Ответы, аудио, голосовые 2026-04-14 12:29:32 +03:00
Халимов Рустам 58bbdae26c Настройки 2026-04-14 11:46:55 +03:00
Халимов Рустам 3310a3c4a4 Правка чата 2026-04-14 11:31:20 +03:00
Халимов Рустам d8b0d86534 Чат 2026-04-14 10:48:07 +03:00
Халимов Рустам dc051fa9ae Авторизация, нерабочий чат 2026-04-14 01:41:51 +03:00
Халимов Рустам 1fb1be47dd Приложение 2026-04-14 01:15:54 +03:00
139 changed files with 12954 additions and 629 deletions
+10
View File
@@ -87,3 +87,13 @@ postgres_data/
tmp/
*.txt
# Android / Kotlin Mobile
client-mobile/.gradle/
client-mobile/.idea/
client-mobile/.run/
client-mobile/build/
client-mobile/.cxx/
client-mobile/local.properties
client-mobile/*.iml
client-mobile/.kotlin/
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
{
string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user);
}
@@ -7,5 +7,7 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
}
@@ -19,4 +19,12 @@ public class UserContract
public bool IsExternal { get; set; }
public string? Domain { get; set; }
public DateTime? LastSeen { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; }
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
}
+2 -1
View File
@@ -13,7 +13,8 @@
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440
"ExpiryInMinutes": 1440,
"RefreshExpiryInDays": 30
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}
@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
}
@@ -1,4 +1,6 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository)
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
}
// Check if access token needs to be refreshed (less than 1 hour remaining)
string? newAccessToken = null;
// We can't directly check the current token's expiry here, but we can
// always issue a new token if the user is authenticated
// For now, let's issue a new token on every request (simplified approach)
// A better approach would be to parse the incoming token and check expiry
newAccessToken = _tokenProvider.Generate(user);
var response = new AuthResponseDto
{
AccessToken = string.Empty,
AccessToken = newAccessToken,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// . AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
}
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
// Save refresh token to database
user.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = string.Empty,
RefreshToken = refreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}
@@ -0,0 +1,7 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
@@ -0,0 +1,65 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}
@@ -1,5 +1,4 @@
using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions;
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// .
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// .
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
}
// 1. username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2.
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3.
var user = User.Create(
request.Username,
passwordHash,
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email,
request.Bio);
// 4. - Domain User
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
repoWithDomainUserAdd?.Add(user);
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
repoImpl?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
// Get the saved user as contract
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (userContract == null)
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
}
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
userContract.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(userContract, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
RefreshToken = refreshToken,
UserId = userContract.Id,
Username = userContract.Username,
DisplayName = userContract.DisplayName
});
}
}
+8 -2
View File
@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; }
public void Ban() {
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber;
}
public void SetRefreshToken(string? refreshToken)
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
public void SetBannedUntil(DateTime? bannedUntil)
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
}
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline,
IsExternal = IsExternal,
Domain = _domain,
LastSeen = LastSeen
LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
};
}
}
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
return Convert.ToBase64String(randomBytes);
}
public DateTime GetRefreshTokenExpiry()
{
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
return DateTime.UtcNow.AddDays(expiryInDays);
}
public string Generate(Guid userId, string username, string displayName, string? avatar)
{
var claims = new Claim[]
@@ -0,0 +1,100 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}
@@ -1,12 +1,13 @@
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Auth.Presentation.Endpoints;
@@ -28,6 +29,12 @@ public static class AuthEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
@@ -32,7 +32,8 @@ public record MessageDetailDto(
bool? PollIsMultipleChoice = null,
bool? PollIsAnonymous = null,
bool? PollIsClosed = null,
List<Guid>? UserVotedOptionIds = null
List<Guid>? UserVotedOptionIds = null,
bool IsDeletedForUser = false
);
public record ReplyToMessageDto(
@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
if (request.DeleteForAll)
{
// Only message sender can delete for everyone
if (message.SenderId == request.UserId)
{
message.Delete();
}
else
{
// If not the sender, just delete for current user
message.DeleteForUser(request.UserId);
}
}
else
{
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
await _messageRepository.UpdateAsync(message, cancellationToken);
}
if (request.DeleteForAll)
// Notify all clients in the chat about the deletion
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = true
});
}
else
{
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = request.DeleteForAll
});
return global::Knot.Shared.Kernel.Result.Success();
}
@@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -115,6 +115,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
senders.TryGetValue(message.SenderId, out var sender);
reactionsByMessage.TryGetValue(message.Id, out var reactions);
Message? replyMsg = null;
if (message.ReplyToId.HasValue)
{
@@ -141,7 +142,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
) : null,
message is TextMessage tm ? tm.Quote : null,
(message as TextMessage)?.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
@@ -154,19 +155,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
reactions?.Select(r => {
reactions?.Select(r =>
{
senders.TryGetValue(r.UserId, out var ru);
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
}).ToList() ?? new List<MessageReactionDto>(),
(message as CallMessage)?.CallType,
(message as CallMessage)?.CallStatus,
(message as CallMessage)?.Duration,
(message as PollMessage)?.Options.Select(o => {
(message as PollMessage)?.Options.Select(o =>
{
var pm = (PollMessage)message;
var voters = pm.IsAnonymous == false
? pm.Votes
.Where(v => v.OptionId == o.Id)
.Select(v => {
.Select(v =>
{
senders.TryGetValue(v.UserId, out var vu);
return vu != null
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
@@ -174,12 +178,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
})
.ToList()
: null;
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
}).ToList(),
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
}).ToList(),
(message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsAnonymous,
(message as PollMessage)?.IsClosed,
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
message.IsDeletedForUser(request.UserId)
));
}
+135
View File
@@ -0,0 +1,135 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
kotlin("kapt")
}
android {
namespace = "ru.knot.messager"
compileSdk = 34
defaultConfig {
applicationId = "ru.knot.messager"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
// Подключаем все наши папки с кодом как sourceSets
sourceSets {
getByName("main") {
java.srcDirs(
"src/main/kotlin",
"../auth",
"../chats",
"../core",
"../calls",
"../stories",
"../contacts",
"../profiles",
"../settings",
"../navigation"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
// Включаем оптимизации компилятора Compose
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// AndroidX & UI
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.navigation:navigation-compose:2.7.5")
implementation("androidx.compose.material:material-icons-extended")
// Hilt
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Network & SignalR
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.microsoft.signalr:signalr:7.0.0")
// WebRTC
implementation("com.github.webrtc-sdk:android:104.5112.01")
// Media3 (ExoPlayer)
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media3:media3-ui:1.2.0")
implementation("androidx.media3:media3-common:1.2.0")
// Images & GIF
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0")
implementation("io.coil-kt:coil-svg:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
// Security
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// UCrop (Image Cropping)
implementation("com.github.yalantis:ucrop:2.2.8")
// Firebase (Push Notifications)
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// Room
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "483917912506",
"project_id": "knot-bad1a",
"storage_bucket": "knot-bad1a.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
"android_client_info": {
"package_name": "ru.knot.messager"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ru.knot.messager">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name="com.knot.messenger.MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.KnotMessenger"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name="com.knot.messenger.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KnotMessenger">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="core.notifications.data.KnotFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,58 @@
package com.knot.messenger
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import navigation.AppNavigation
import core.presentation.theme.KnotTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@javax.inject.Inject
lateinit var navigationManager: core.utils.NavigationManager
@javax.inject.Inject
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
signalrNotificationObserver.start()
intent.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
// Request notifications permission for Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.app.ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
101
)
}
setContent {
KnotTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation(navigationManager = navigationManager)
}
}
}
}
override fun onNewIntent(intent: android.content.Intent?) {
super.onNewIntent(intent)
intent?.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
}
}
@@ -0,0 +1,18 @@
package com.knot.messenger
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MainApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(VideoFrameDecoder.Factory())
}
.build()
}
}
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Цветная иконка для notification -->
<path
android:fillColor="#6C5CE7"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,63 @@
<resources>
<string name="app_name">Knot</string>
<string name="login">Login</string>
<string name="register">Register</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="display_name">Display Name</string>
<string name="settings">Settings</string>
<string name="save">Save</string>
<string name="back">Back</string>
<string name="server_connection">Server Connection</string>
<string name="api_base_url">API Base URL</string>
<string name="server_features">Server Features</string>
<string name="stories">Stories</string>
<string name="polls">Polls</string>
<string name="calls">Calls</string>
<string name="groups">Groups</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="limits">Limits</string>
<string name="max_file_size">Max File Size</string>
<string name="max_group_members">Max Group Members</string>
<string name="message">Message</string>
<string name="call">Call</string>
<string name="block">Block</string>
<string name="profile">Profile</string>
<string name="confirm_password">Confirm Password</string>
<string name="passwords_not_match">Passwords do not match</string>
<string name="no_account_register">Don\'t have an account? Register</string>
<string name="already_have_account">Already have an account? Login</string>
<string name="error_occurred">An error occurred</string>
<string name="loading">Loading...</string>
<string name="chats_title">Chats</string>
<string name="contacts_title">Contacts</string>
<string name="stories_title">Stories</string>
<string name="create_story">Create Story</string>
<string name="send_message_hint">Type a message...</string>
<string name="reply_to_user">Reply to %1$s...</string>
<string name="story_editor">STORY EDITOR</string>
<string name="publish">PUBLISH</string>
<string name="start_creation">START CREATION</string>
<string name="text_tool">TEXT</string>
<string name="crop_tool">CROP</string>
<string name="stickers_tool">STICKERS</string>
<string name="brush_tool">BRUSH</string>
<string name="filters_tool">FILTERS</string>
<string name="remove">Remove</string>
<string name="no_chats_found">No chats found</string>
<string name="typing">typing...</string>
<string name="video_call">Video Call</string>
<string name="emoji">Emoji</string>
<string name="attach">Attach</string>
<string name="message_placeholder">Message...</string>
<string name="voice_message">Voice Message</string>
<string name="send">Send</string>
<string name="reply_photo">Photo</string>
<string name="reply_video">Video</string>
<string name="reply_audio">Audio</string>
<string name="reply_file">File</string>
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Reply to </string>
<string name="reply_self">yourself</string>
</resources>
@@ -0,0 +1,85 @@
<resources>
<string name="app_name">Knot</string>
<string name="login">Войти</string>
<string name="register">Регистрация</string>
<string name="username">Имя пользователя</string>
<string name="password">Пароль</string>
<string name="display_name">Отображаемое имя</string>
<string name="settings">Настройки</string>
<string name="save">Сохранить</string>
<string name="back">Назад</string>
<string name="server_connection">Подключение к серверу</string>
<string name="api_base_url">API Base URL</string>
<string name="server_features">Функции сервера</string>
<string name="stories">Истории</string>
<string name="polls">Опросы</string>
<string name="calls">Звонки</string>
<string name="groups">Группы</string>
<string name="enabled">Включено</string>
<string name="disabled">Отключено</string>
<string name="limits">Лимиты</string>
<string name="max_file_size">Макс. размер файла</string>
<string name="max_group_members">Макс. участников в группе</string>
<string name="message">Сообщение</string>
<string name="call">Позвонить</string>
<string name="block">Заблокировать</string>
<string name="profile">Профиль</string>
<string name="confirm_password">Подтвердите пароль</string>
<string name="passwords_not_match">Пароли не совпадают</string>
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
<string name="already_have_account">Уже есть аккаунт? Войти</string>
<string name="error_occurred">Произошла ошибка</string>
<string name="loading">Загрузка...</string>
<string name="chats_title">Чаты</string>
<string name="contacts_title">Контакты</string>
<string name="stories_title">Истории</string>
<string name="create_story">Создать историю</string>
<string name="send_message_hint">Напишите сообщение...</string>
<string name="reply_to_user">Ответить %1$s...</string>
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
<string name="publish">ОПУБЛИКОВАТЬ</string>
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
<string name="text_tool">ТЕКСТ</string>
<string name="crop_tool">ОБРЕЗКА</string>
<string name="stickers_tool">СТИКЕРЫ</string>
<string name="brush_tool">КИСТЬ</string>
<string name="filters_tool">ФИЛЬТРЫ</string>
<string name="remove">Удалить</string>
<string name="no_chats_found">Чаты не найдены</string>
<string name="typing">печатает...</string>
<string name="video_call">Видеозвонок</string>
<string name="emoji">Эмодзи</string>
<string name="attach">Прикрепить</string>
<string name="message_placeholder">Сообщение...</string>
<string name="voice_message">Голосовое сообщение</string>
<string name="send">Отправить</string>
<string name="search_hint">Поиск...</string>
<string name="online">В сети</string>
<string name="last_seen">Был(а): %1$s</string>
<string name="last_seen_recently">недавно</string>
<string name="all">Все</string>
<string name="online_tab">Онлайн</string>
<string name="blocked">Заблокированные</string>
<string name="media">Медиа</string>
<string name="notifications">Уведомления</string>
<string name="mute">Без звука</string>
<string name="unmute">Включить звук</string>
<string name="log_out">Выйти из аккаунта</string>
<string name="bio">О себе</string>
<string name="edit_profile">Редактировать профиль</string>
<string name="username_label">Имя пользователя</string>
<string name="change_photo">Изменить фото</string>
<string name="cancel">Отмена</string>
<string name="crop">Обрезать</string>
<string name="chats">Чаты</string>
<string name="contacts_tab">Контакты</string>
<string name="profile_tab">Профиль</string>
<string name="saving">Сохранение...</string>
<string name="reply_photo">Фото</string>
<string name="reply_video">Видео</string>
<string name="reply_audio">Аудио</string>
<string name="reply_file">Файл</string>
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Ответ </string>
<string name="reply_self">самому себе</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">#0F0F10</item>
<item name="android:windowBackground">#0F0F10</item>
</style>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Для отладки: доверяем пользовательским сертификатам -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
<certificates src="system" />
</trust-anchors>
</debug-overrides>
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.0.0/8</domain>
<domain includeSubdomains="true">172.16.0.0/12</domain>
<domain includeSubdomains="true">192.168.0.0/16</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,29 @@
package auth.data.remote.api
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.AuthResponse
import auth.data.remote.dto.RefreshTokenRequest
import core.domain.model.ServerConfigModel
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
interface AuthApi {
@POST("auth/login")
suspend fun login(@Body request: AuthRequest): AuthResponse
@POST("auth/register")
suspend fun register(@Body request: AuthRequest): AuthResponse
@POST("auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse
@GET("config")
@Headers("Cache-Control: no-cache")
suspend fun getConfig(): ServerConfigModel
@POST("auth/push-token")
@Headers("Cache-Control: no-cache")
suspend fun updatePushToken(@Body token: String): Unit
}
@@ -0,0 +1,28 @@
package auth.data.remote.dto
import com.google.gson.annotations.SerializedName
data class AuthRequest(
@SerializedName("userName") val userName: String,
@SerializedName("password") val password: String
)
data class AuthResponse(
@SerializedName("accessToken") val accessToken: String?,
@SerializedName("refreshToken") val refreshToken: String?,
@SerializedName("user") val user: UserDto?,
@SerializedName("userId") val userId: String?,
@SerializedName("username") val username: String?,
@SerializedName("displayName") val displayName: String?
)
data class UserDto(
@SerializedName("id") val id: String,
@SerializedName("userName") val userName: String,
@SerializedName("displayName") val displayName: String?,
@SerializedName("avatarUrl") val avatarUrl: String?
)
data class RefreshTokenRequest(
@SerializedName("refreshToken") val refreshToken: String
)
@@ -0,0 +1,131 @@
package auth.data.repository
import auth.data.remote.api.AuthApi
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.RefreshTokenRequest
import auth.domain.model.AuthResult
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
private val api: AuthApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig
) : AuthRepository {
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
override suspend fun login(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.login(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId, response.refreshToken)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
refreshToken = response.refreshToken,
userId = userId,
userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun register(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.register(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId, response.refreshToken)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
refreshToken = response.refreshToken,
userId = userId,
userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
tokenManager.deleteToken()
_isAuthenticated.value = false
}
override fun isAuthenticated(): Boolean {
return _isAuthenticated.value
}
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
return _isAuthenticated.asStateFlow()
}
override suspend fun fetchConfig(): Result<Unit> {
return try {
val config = api.getConfig()
serverConfig.saveServerConfig(config)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updatePushToken(token: String) {
try {
api.updatePushToken(token)
} catch (e: Exception) {
// Silent fail
}
}
override suspend fun refreshToken(): Result<AuthResult> {
val currentRefreshToken = tokenManager.getRefreshToken()
if (currentRefreshToken == null) {
return Result.failure(Exception("Refresh token is null"))
}
return try {
val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken))
val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null"))
val newRefreshToken = response.refreshToken
val userId = response.userId ?: ""
tokenManager.saveToken(newAccessToken, userId, newRefreshToken)
_isAuthenticated.value = true
Result.success(
AuthResult(
token = newAccessToken,
refreshToken = newRefreshToken,
userId = userId,
userName = response.username ?: "",
displayName = response.displayName ?: "",
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package auth.di
import auth.data.remote.api.AuthApi
import auth.data.repository.AuthRepositoryImpl
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi {
return retrofit.create(AuthApi::class.java)
}
@Provides
@Singleton
fun provideAuthRepository(
api: AuthApi,
tokenManager: TokenManager,
serverConfig: ServerConfig
): AuthRepository {
return AuthRepositoryImpl(api, tokenManager, serverConfig)
}
}
@@ -0,0 +1,10 @@
package auth.domain.model
data class AuthResult(
val token: String,
val refreshToken: String?,
val userId: String,
val userName: String,
val displayName: String,
val avatarUrl: String?
)
@@ -0,0 +1,14 @@
package auth.domain.repository
import auth.domain.model.AuthResult
interface AuthRepository {
suspend fun login(userName: String, password: String): Result<AuthResult>
suspend fun register(userName: String, password: String): Result<AuthResult>
suspend fun logout()
suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
suspend fun updatePushToken(token: String)
suspend fun refreshToken(): Result<AuthResult>
}
@@ -0,0 +1,94 @@
package auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import auth.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import com.google.firebase.messaging.FirebaseMessaging
import javax.inject.Inject
data class AuthState(
val isLoading: Boolean = false,
val error: String? = null,
val isAuthenticated: Boolean = false
)
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.isAuthenticatedFlow().collect { authenticated ->
_state.update { it.copy(isAuthenticated = authenticated) }
if (authenticated) {
updatePushToken()
}
}
}
}
fun checkAuth() {
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
}
fun login(userName: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.login(userName, password)
.onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun register(userName: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.register(userName, password)
.onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun refreshToken() {
viewModelScope.launch {
repository.refreshToken()
.onSuccess {
// Token refreshed successfully
}
.onFailure {
// Refresh failed, will trigger logout via AuthInterceptor
}
}
}
private fun updatePushToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
repository.updatePushToken(token)
}
}
}
}
}
@@ -0,0 +1,95 @@
package auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onNavigateToRegister: () -> Unit,
onNavigateToSettings: () -> Unit,
onLoginSuccess: () -> Unit
) {
val state by viewModel.state.collectAsState()
var userName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
LaunchedEffect(state.isAuthenticated) {
if (state.isAuthenticated) {
onLoginSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.login)) },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = { viewModel.login(userName, password) },
modifier = Modifier.fillMaxWidth(),
enabled = userName.isNotBlank() && password.isNotBlank()
) {
Text(stringResource(R.string.login))
}
TextButton(onClick = onNavigateToRegister) {
Text(stringResource(R.string.no_account_register))
}
}
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
@@ -0,0 +1,106 @@
package auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
viewModel: AuthViewModel,
onNavigateToLogin: () -> Unit,
onRegisterSuccess: () -> Unit
) {
val state by viewModel.state.collectAsState()
var userName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
LaunchedEffect(state.isAuthenticated) {
if (state.isAuthenticated) {
onRegisterSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.register)) })
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(R.string.confirm_password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
if (password == confirmPassword) {
errorMessage = null
viewModel.register(userName, password)
} else {
errorMessage = passwordsNotMatchMsg
}
},
modifier = Modifier.fillMaxWidth(),
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
) {
Text(stringResource(R.string.register))
}
TextButton(onClick = onNavigateToLogin) {
Text(stringResource(R.string.already_have_account))
}
}
val displayError = state.error ?: errorMessage
if (displayError != null) {
Text(
text = displayError,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("com.android.library") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
id("com.google.gms.google-services") version "4.4.0" apply false
}
@@ -0,0 +1,43 @@
package calls.data.remote
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
class CallAudioManager(private val context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var originalMode: Int = AudioManager.MODE_NORMAL
private var originalIsSpeakerphoneOn: Boolean = false
fun startCallMode(isVideoCall: Boolean) {
originalMode = audioManager.mode
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setSpeakerphoneOn(isVideoCall)
// Запрашиваем фокус аудио
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val playbackAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(playbackAttributes)
.build()
audioManager.requestAudioFocus(focusRequest)
}
}
fun setSpeakerphoneOn(on: Boolean) {
audioManager.isSpeakerphoneOn = on
}
fun stopCallMode() {
audioManager.mode = originalMode
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
audioManager.abandonAudioFocus(null)
}
}
@@ -0,0 +1,46 @@
package calls.data.remote
import android.content.Context
import org.webrtc.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GroupWebRtcManager @Inject constructor(private val context: Context) {
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
private val factory: PeerConnectionFactory by lazy { createFactory() }
private fun createFactory(): PeerConnectionFactory {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
return PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
}
fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
val pc = factory.createPeerConnection(iceServers, observer)
if (pc != null) {
peerConnections[userId] = pc
}
return pc
}
fun removeParticipant(userId: String) {
peerConnections[userId]?.dispose()
peerConnections.remove(userId)
}
fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
fun closeAll() {
peerConnections.values.forEach { it.dispose() }
peerConnections.clear()
}
}
@@ -0,0 +1,50 @@
package calls.data.remote
import android.content.Context
import org.webrtc.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebRtcManager @Inject constructor(private val context: Context) {
private var peerConnection: PeerConnection? = null
private val factory: PeerConnectionFactory by lazy { createFactory() }
// Аудио и видео источники
private val videoSource by lazy { factory.createVideoSource(false) }
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
private fun createFactory(): PeerConnectionFactory {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
return PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
}
fun initializePeerConnection(observer: PeerConnection.Observer) {
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
peerConnection = factory.createPeerConnection(iceServers, observer)
}
fun createOffer(observer: SdpObserver) {
peerConnection?.createOffer(observer, MediaConstraints())
}
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
}
fun addIceCandidate(candidate: IceCandidate) {
peerConnection?.addIceCandidate(candidate)
}
fun close() {
peerConnection?.dispose()
peerConnection = null
}
}
@@ -0,0 +1,148 @@
package calls.presentation
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.presentation.components.AppAvatar
import ru.knot.messager.R
@Composable
fun CallScreen(
viewModel: CallViewModel,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF0F0F10),
Color(0xFF161618),
Color(0xFF6366F1).copy(alpha = 0.2f)
)
)
)
) {
// Контент звонка (Аватар или Видео)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 100.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppAvatar(
url = state.callerAvatar,
name = state.callerName,
size = 140.dp
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = state.callerName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold
)
Text(
text = when (state.status) {
CallStatus.INCOMING -> "Входящий звонок..."
CallStatus.OUTGOING -> "Вызов..."
CallStatus.CONNECTED -> "00:00" // TODO: Timer
CallStatus.ENDED -> "Звонок завершен"
else -> ""
},
style = MaterialTheme.typography.bodyLarge,
color = Color.White.copy(alpha = 0.7f)
)
}
// Кнопки управления (Внизу)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 60.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (state.status == CallStatus.INCOMING) {
// Кнопка отклонить
CallActionButton(
icon = Icons.Default.CallEnd,
backgroundColor = Color.Red,
onClick = { viewModel.endCall(); onBack() }
)
// Кнопка принять
CallActionButton(
icon = Icons.Default.Call,
backgroundColor = Color(0xFF10B981), // Green
onClick = { viewModel.acceptCall() }
)
} else {
// Стандартные кнопки во время разговора
IconButton(
onClick = { /* viewModel.toggleMic() */ },
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
) {
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
}
CallActionButton(
icon = Icons.Default.CallEnd,
backgroundColor = Color.Red,
onClick = { viewModel.endCall(); onBack() }
)
IconButton(
onClick = { /* viewModel.toggleSpeaker() */ },
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
) {
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
}
}
}
}
}
}
@Composable
fun CallActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
backgroundColor: Color,
onClick: () -> Unit
) {
FloatingActionButton(
onClick = onClick,
containerColor = backgroundColor,
contentColor = Color.White,
shape = CircleShape,
modifier = Modifier.size(64.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
}
}
@@ -0,0 +1,99 @@
package calls.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import calls.data.remote.WebRtcManager
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.*
import javax.inject.Inject
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
data class CallState(
val status: CallStatus = CallStatus.IDLE,
val chatId: String? = null,
val callerName: String = "",
val callerAvatar: String? = null,
val isMuted: Boolean = false,
val isSpeakerOn: Boolean = false,
val localVideoTrack: VideoTrack? = null,
val remoteVideoTrack: VideoTrack? = null
)
@HiltViewModel
class CallViewModel @Inject constructor(
private val webRtcManager: WebRtcManager,
private val signalrClient: ChatHubClient
) : ViewModel() {
private val _state = MutableStateFlow(CallState())
val state: StateFlow<CallState> = _state.asStateFlow()
init {
observeSignaling()
}
private fun observeSignaling() {
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.CallIncoming -> onIncomingCall(event)
is ChatEvent.CallAnswered -> onCallAnswered(event)
is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
is ChatEvent.CallEnded -> onCallEnded()
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun onIncomingCall(event: ChatEvent.CallIncoming) {
_state.update { it.copy(
status = CallStatus.INCOMING,
chatId = event.chatId,
callerName = "User ${event.from}" // TODO: Load actual user info
) }
// Set remote description from offer
webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
})
}
private fun onCallAnswered(event: ChatEvent.CallAnswered) {
_state.update { it.copy(status = CallStatus.CONNECTED) }
webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
})
}
private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
// Parse candidate JSON and add to peer connection
// webRtcManager.addIceCandidate(...)
}
private fun onCallEnded() {
_state.update { it.copy(status = CallStatus.ENDED) }
webRtcManager.close()
}
fun acceptCall() {
val chatId = _state.value.chatId ?: return
// Create answer and send via SignalR
}
fun endCall() {
val chatId = _state.value.chatId ?: return
// Send call_end via SignalR
onCallEnded()
}
}
@@ -0,0 +1,87 @@
package calls.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent
import calls.data.remote.GroupWebRtcManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.*
import javax.inject.Inject
data class ParticipantState(
val userId: String,
val videoTrack: VideoTrack? = null,
val isAudioMuted: Boolean = false,
val isVideoDisabled: Boolean = false
)
data class GroupCallState(
val chatId: String? = null,
val participants: Map<String, ParticipantState> = emptyMap(),
val isMicEnabled: Boolean = true,
val isCameraEnabled: Boolean = true
)
@HiltViewModel
class GroupCallViewModel @Inject constructor(
private val webRtcManager: GroupWebRtcManager,
private val signalrClient: ChatHubClient
) : ViewModel() {
private val _state = MutableStateFlow(GroupCallState())
val state: StateFlow<GroupCallState> = _state.asStateFlow()
init {
observeSignalREvents()
}
private fun observeSignalREvents() {
signalrClient.events.onEach { event ->
when (event) {
is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
// Дополнительные обработчики ICE кандидатов и т.д.
else -> Unit
}
}.launchIn(viewModelScope)
}
private fun handleUserJoined(userId: String) {
// Создаем PeerConnection для нового участника
// Логика идентична портированному CallModal.tsx
_state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
}
private fun handleUserLeft(userId: String) {
webRtcManager.removeParticipant(userId)
_state.update { it.copy(participants = it.participants - userId) }
}
private fun handleOffer(from: String, sdp: String) {
// Установка RemoteDescription и создание Answer
}
private fun handleAnswer(from: String, sdp: String) {
// Установка RemoteDescription
}
fun toggleMic() {
_state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
// Логика управления AudioTrack
}
fun toggleCamera() {
_state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
// Логика управления VideoTrack
}
override fun onCleared() {
super.onCleared()
webRtcManager.closeAll()
}
}
@@ -0,0 +1,67 @@
package calls.presentation.components
import android.view.ViewGroup
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import org.webrtc.EglBase
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
fun VideoGrid(
participants: Map<String, VideoTrack?>,
localVideoTrack: VideoTrack?
) {
val eglBaseContext = remember { EglBase.create().eglBaseContext }
val allVideoTracks = remember(participants, localVideoTrack) {
listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
}
LazyVerticalGrid(
columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
items(allVideoTracks) { track ->
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
) {
VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
}
}
}
}
@Composable
fun VideoRenderer(
videoTrack: VideoTrack,
eglBaseContext: EglBase.Context
) {
AndroidView(
factory = { context ->
SurfaceViewRenderer(context).apply {
init(eglBaseContext, null)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
},
update = { view ->
videoTrack.addSink(view)
},
onRelease = { view ->
videoTrack.removeSink(view)
view.release()
}
)
}
@@ -0,0 +1,123 @@
package chats.data.remote.api
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import retrofit2.http.*
data class SendMessageRequest(
val content: String?,
val type: String = "text",
val attachments: List<AttachmentRequest>? = null,
val replyToId: String? = null,
val quote: String? = null,
val forwardedFromId: String? = null
)
data class AttachmentRequest(
val type: String,
val url: String,
val fileName: String,
val fileSize: Long
)
interface ChatApi {
@GET("chats")
suspend fun getChats(): List<ChatDto>
@GET("messages/chat/{chatId}")
suspend fun getMessages(
@Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null,
@Query("pivot") pivot: Long? = null,
@Query("limit") limit: Int? = 50
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
@Multipart
@POST("messages/upload")
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
// Klipy GIF API
@GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
@GET("klipy/search")
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
@GET("klipy/categories")
suspend fun getGifCategories(): GifCategoriesResponse
@POST("klipy/shared/{id}")
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@POST("chats/{chatId}/read")
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
@DELETE("messages/{messageId}")
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
@PUT("messages/{messageId}")
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
}
data class CreatePersonalChatRequest(
val userId: String
)
data class KlipyResponse(
val data: KlipyDataWrapper
)
data class KlipyDataWrapper(
val data: List<KlipyGifDto>
)
data class KlipyGifDto(
val id: String,
val images: GifImagesDto? = null,
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
val media_formats: Map<String, GifImageSourceDto>? = null,
val title: String? = null
)
data class GifImagesDto(
val fixed_height: GifImageSourceDto? = null,
val original: GifImageSourceDto? = null,
val fixed_height_small: GifImageSourceDto? = null
)
data class GifImageSourceDto(
val url: String
)
data class GifCategoryDto(
val category: String,
val preview_url: String,
val query: String
)
data class FileUploadResponse(
val url: String,
val filename: String,
val size: Long
)
data class GifCategoriesResponse(
val data: GifCategoriesData
)
data class GifCategoriesData(
val categories: List<GifCategoryDto>
)
@@ -0,0 +1,57 @@
package chats.data.remote.dto
import com.google.gson.annotations.SerializedName
data class UserBasicDto(
@SerializedName("id") val id: String,
@SerializedName("username") val username: String? = null,
@SerializedName("displayName") val displayName: String? = null,
@SerializedName("avatarUrl") val avatarUrl: String? = null
)
data class MessageDto(
@SerializedName("id", alternate = ["Id"]) val id: String,
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
)
data class ReactionDto(
@SerializedName("emoji") val emoji: String,
@SerializedName("count") val count: Int,
@SerializedName("isSetByMe") val isSetByMe: Boolean
)
data class MediaItemDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("url") val url: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("duration") val duration: Double? = null
)
data class ChatDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)
data class ChatMemberDto(
@SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto? = null
)
@@ -0,0 +1,33 @@
package chats.data.remote.signalr
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
sealed class ChatEvent {
data class NewMessage(val message: MessageDto) : ChatEvent()
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserOnline(val userId: String) : ChatEvent()
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
data class NewChat(val chat: ChatDto) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
// Call Events (WebRTC Signaling)
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
data class CallEnded(val chatId: String) : ChatEvent()
// Group Call Events
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
}
@@ -0,0 +1,280 @@
package chats.data.remote.signalr
import android.util.Log
import io.reactivex.rxjava3.core.Single
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.delay
data class ReadMessagesRequest(
val chatId: String,
val lastReadMessageId: String,
val lastReadSequenceId: Int
)
data class MessagesReadEvent(
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
) {
val effectiveChatId: String get() = chatId ?: ""
val effectiveUserId: String get() = userId ?: ""
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
}
data class ReactionEvent(
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
)
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton
class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private var lastBaseUrl: String? = null
private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
lastBaseUrl = baseUrl
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken))
.build()
setupHandlers()
hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
_status.value = ConnectionStatus.DISCONNECTED
scope.launch {
delay(5000)
connect(baseUrl, accessToken)
}
}
scope.launch {
try {
hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected")
} catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED
}
}
}
private fun setupHandlers() {
hubConnection?.let { conn ->
conn.on("new_message", { message: MessageDto ->
_events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java)
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
}, String::class.java, String::class.java, String::class.java)
conn.on("message_deleted", { messageId: String, chatId: String ->
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
}, String::class.java, String::class.java)
conn.on("messages_read", { data: MessagesReadEvent ->
_events.tryEmit(ChatEvent.MessagesRead(
data.effectiveChatId,
data.effectiveUserId,
data.effectiveLastReadSequenceId
))
}, MessagesReadEvent::class.java)
conn.on("user_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_stopped_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java)
conn.on("new_chat", { chat: ChatDto ->
_events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java)
conn.on("reaction_added", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = false
))
}, ReactionEvent::class.java)
conn.on("reaction_removed", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = true
))
}, ReactionEvent::class.java)
// WebRTC Signaling Handlers
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
}, String::class.java, String::class.java, String::class.java, String::class.java)
conn.on("call_answered", { chatId: String, answer: String ->
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
}, String::class.java, String::class.java)
conn.on("ice_candidate", { chatId: String, candidate: String ->
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
}, String::class.java, String::class.java)
conn.on("call_ended", { chatId: String ->
_events.tryEmit(ChatEvent.CallEnded(chatId))
}, String::class.java)
conn.on("message_pinned", { data: Map<String, Any> ->
// The web version expects a message object, but here we might get a partial DTO or just IDs.
// Let's assume we get { chatId, message: MessageDto } based on web
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
// For simplicity, we might needs a dedicated DTO if it fails.
}, Map::class.java)
conn.on("message_pinned", { chatId: String, message: MessageDto ->
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
}, String::class.java, MessageDto::class.java)
conn.on("message_unpinned", { chatId: String, messageId: String ->
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
}, String::class.java, String::class.java)
}
}
fun disconnect() {
hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
}
fun addReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("add_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
} else {
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
}
}
fun removeReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("remove_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
}
}
fun pinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("pin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
?.subscribe()
}
}
fun unpinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("unpin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
?.subscribe()
}
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("read_messages", request)
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
}
}
fun joinChat(chatId: String) {
scope.launch {
// Wait for connection to be established if it's currently connecting
var attempts = 0
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
delay(500)
attempts++
}
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("join_chat", chatId)
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
?.subscribe()
Log.d("ChatHubClient", "Joined chat room: $chatId")
} else {
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
}
}
}
fun sendTypingIndicator(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_start", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
}
}
fun sendUserStoppedTyping(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_stop", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
}
}
}
@@ -0,0 +1,94 @@
package chats.data.remote.signalr
import android.content.Context
import core.notifications.data.ActiveChatTracker
import core.notifications.data.NotificationHelper
import core.security.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
private val chatRepository: chats.domain.repository.ChatRepository,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false
private val processedMessageIds = mutableSetOf<String>()
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
} catch (e: Exception) {
// Ignore load error
}
}
}
fun start() {
if (isStarted) return
isStarted = true
// Initial count load
refresh()
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message or already processed
if (message.senderId == currentUserId) return@onEach
if (processedMessageIds.contains(message.id)) return@onEach
// Mark as processed
processedMessageIds.add(message.id)
if (processedMessageIds.size > 200) {
processedMessageIds.remove(processedMessageIds.first())
}
// Increment immediately for UI feedback
activeChatTracker.incrementUnreadCount()
// Refresh total count from source of truth in background
refresh()
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode(),
totalCount = activeChatTracker.totalUnreadCount.value
)
}
is ChatEvent.MessagesRead -> {
// If anyone read messages, sync our total count
refresh()
}
else -> Unit
}
}
.launchIn(scope)
}
}
@@ -0,0 +1,151 @@
package chats.data.repository
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import chats.data.remote.dto.MediaItemDto
import chats.data.remote.dto.ReactionDto
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import chats.data.remote.signalr.ReadMessagesRequest
import core.security.TokenManager
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject
import kotlinx.coroutines.flow.map
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository {
private val gson = com.google.gson.Gson()
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
}
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
// Кэш отключён - всегда возвращаем пустой поток
return kotlinx.coroutines.flow.flowOf(emptyList())
}
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = tokenManager.getUserId() ?: ""
return try {
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
if (messages.isNotEmpty()) {
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
}
// Мапим в доменные модели. По умолчанию считаем прочитанными,
// так как unreadCount нам тут не критичен для истории.
messages.map { msg ->
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
}
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
emptyList()
}
}
override suspend fun sendMessage(
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?,
forwardedFromId: String?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments,
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
}
override suspend fun addReaction(messageId: String, emoji: String) {
api.addReaction(messageId, emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
}
}
override suspend fun saveMessage(message: Message) {
// Кэш отключён
}
override suspend fun deleteLocalMessage(messageId: String) {
// Локальное удаление не поддерживается без кэша
}
override suspend fun uploadMedia(file: java.io.File): String {
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"mp3", "m4a", "wav" -> "audio/mpeg"
else -> "application/octet-stream"
}
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url
}
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.getTrendingGifs(page).data.data
}
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.searchGifs(query, page).data.data
}
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
}
}
@@ -0,0 +1,79 @@
package chats.data.repository
import chats.data.remote.dto.*
import chats.domain.model.*
// Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
val chatName = name ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
} else "Group Chat"
val chatAvatar = (avatar ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
} else null)?.ensureAbsoluteUrl(baseUrl)
return Chat(
id = id,
type = type,
name = chatName,
avatar = chatAvatar,
unreadCount = unreadCount,
lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
)
}
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
val domainMediaType = when (type) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> when (media.firstOrNull()?.type) {
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> MediaType.TEXT
}
}
return Message(
id = id,
chatId = chatId ?: "",
senderId = senderId ?: "",
senderName = sender?.displayName ?: "Unknown",
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content,
sequenceId = sequenceId ?: 0,
createdAt = createdAt ?: "",
media = media.map {
chats.domain.model.Media(
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type ?: "unknown",
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
)
},
mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
isRead = senderId == currentUserId,
isPinned = isPinned ?: false,
isForwarded = forwardedFromId != null,
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
replyTo = replyTo?.toDomain(currentUserId, baseUrl)
)
}
fun String.ensureAbsoluteUrl(baseUrl: String): String {
return if (this.startsWith("http")) {
this
} else {
val base = baseUrl.removeSuffix("/")
val path = if (this.startsWith("/")) this else "/$this"
"$base$path"
}
}
+42
View File
@@ -0,0 +1,42 @@
package chats.di
import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ChatModule {
@Provides
@Singleton
fun provideChatApi(retrofit: Retrofit): ChatApi {
return retrofit.create(ChatApi::class.java)
}
@Provides
@Singleton
fun provideChatRepository(
api: ChatApi,
tokenManager: TokenManager,
serverConfig: ServerConfig,
hubClient: chats.data.remote.signalr.ChatHubClient
): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
}
@Provides
@Singleton
fun provideChatHubClient(): ChatHubClient {
return ChatHubClient()
}
}
@@ -0,0 +1,13 @@
package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Chat(
val id: String,
val type: String,
val name: String,
val avatar: String?,
val unreadCount: Int,
val lastMessage: Message?
)
@@ -0,0 +1,37 @@
package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Message(
val id: String,
val chatId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String? = null,
val content: String?,
val sequenceId: Int,
val createdAt: String,
val media: List<Media> = emptyList(),
val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false,
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyTo: Message? = null
)
@Immutable
data class Media(
val id: String,
val type: String,
val url: String,
val filename: String? = null,
val size: Long? = null,
val duration: Double? = null
)
enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
}
@@ -0,0 +1,31 @@
package chats.domain.repository
import chats.domain.model.Chat
import chats.domain.model.Message
interface ChatRepository {
suspend fun getChats(): List<Chat>
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
suspend fun sendMessage(
chatId: String,
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
replyToId: String? = null,
forwardedFromId: String? = null
): Message
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
suspend fun saveMessage(message: Message)
suspend fun deleteLocalMessage(messageId: String)
suspend fun uploadMedia(file: java.io.File): String
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
suspend fun createPersonalChat(userId: String): Chat
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
suspend fun editMessage(messageId: String, content: String): Message
}
@@ -0,0 +1,24 @@
package chats.domain.usecase
import chats.data.remote.api.ChatApi
import chats.data.remote.api.FileUploadResponse
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import javax.inject.Inject
class UploadMediaUseCase @Inject constructor(
private val api: ChatApi
) {
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
return try {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
val response = api.uploadFile(body)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,803 @@
package chats.presentation.chat_detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.toDomain
import chats.domain.model.Message
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto
data class ChatDetailState(
val messages: List<Message> = emptyList(),
val chatName: String? = null,
val chatAvatar: String? = null,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val isTyping: Boolean = false,
val typingUser: String? = null,
val error: String? = null,
val canCall: Boolean = true,
val maxFileSize: Long = 100 * 1024 * 1024,
val trendingGifs: List<KlipyGifDto> = emptyList(),
val searchedGifs: List<KlipyGifDto> = emptyList(),
val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false,
val initialScrollIndex: Int? = null,
val pendingAttachments: List<File> = emptyList(),
val isUploading: Boolean = false,
val isCompressionEnabled: Boolean = true,
val inputText: String = "",
val replyingMessage: Message? = null,
val editingMessage: Message? = null,
val forwardingMessages: List<Message> = emptyList(),
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
val selectedMessageIds: Set<String> = emptySet(),
val pinnedMessages: List<Message> = emptyList()
)
@HiltViewModel
class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState())
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
private var currentChatId: String? = null
private var typingTimerJob: Job? = null
private var signalrEventsJob: Job? = null
private var lastTypingSentTime: Long = 0
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
init {
val config = serverConfig.getServerConfig()
val savedCompression = prefs.getBoolean("compression_enabled", true)
_state.update { it.copy(
canCall = config.features.calls,
maxFileSize = config.limits.maxFileSize,
isCompressionEnabled = savedCompression
) }
}
fun toggleCompression() {
_state.update { currentState ->
val newValue = !currentState.isCompressionEnabled
prefs.edit().putBoolean("compression_enabled", newValue).apply()
currentState.copy(isCompressionEnabled = newValue)
}
}
fun getCurrentUserId(): String {
return tokenManager.getUserId() ?: ""
}
fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId
activeChatTracker.setChatId(chatId)
_state.update { it.copy(
messages = emptyList(),
isLoading = true,
initialScrollIndex = null
) }
// Ensure SignalR is connected and join the chat room
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
signalrClient.joinChat(chatId)
}
loadChatInfo(chatId)
observeSignalREvents(chatId)
// Initial sync from network
refreshMessages(chatId)
}
private fun updateMessages(messages: List<Message>) {
val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { it.copy(
messages = sortedMessages,
isLoading = false,
initialScrollIndex = 0 // In reverse layout, 0 is the bottom
) }
}
fun onInitialScrollDone() {
_state.update { it.copy(initialScrollIndex = -1) }
}
fun refreshMessages(chatId: String) {
viewModelScope.launch {
val messages = repository.getMessages(chatId)
updateMessages(messages)
}
}
fun loadMoreMessages() {
val chatId = currentChatId ?: return
if (_state.value.isLoading || _state.value.isLoadingMore) return
val oldestMsg = _state.value.messages.lastOrNull() ?: return
viewModelScope.launch {
_state.update { it.copy(isLoadingMore = true) }
try {
android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}")
val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString())
if (moreMessages.isNotEmpty()) {
val newSorted = moreMessages.sortedByDescending { it.sequenceId }
_state.update { currentState ->
// Избегаем дубликатов
val existingIds = currentState.messages.map { it.id }.toSet()
val uniqueMore = newSorted.filter { it.id !in existingIds }
currentState.copy(messages = currentState.messages + uniqueMore)
}
}
} finally {
_state.update { it.copy(isLoadingMore = false) }
}
}
}
private fun loadChatInfo(chatId: String) {
viewModelScope.launch {
try {
val chats = repository.getChats()
val chat = chats.find { it.id == chatId }
chat?.let { c ->
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
}
} catch (e: Exception) {
// Ignore info load error
}
}
}
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
// For handling reaction removed event
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
val count = currentReactions[emoji] ?: 0
if (count > 1) {
currentReactions[emoji] = count - 1
} else {
currentReactions.remove(emoji)
}
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
private fun observeSignalREvents(chatId: String) {
signalrEventsJob?.cancel()
signalrEventsJob = signalrClient.events
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
.filter { event ->
val eventChatId = when(event) {
is ChatEvent.NewMessage -> event.message.chatId
is ChatEvent.ReactionUpdated -> event.chatId
is ChatEvent.UserTyping -> event.chatId
is ChatEvent.MessagesRead -> event.chatId
else -> null
}
if (eventChatId == null) return@filter false
val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId)
if (match) {
android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event")
}
match
}
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
_state.update { currentState ->
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
}
}
is ChatEvent.ReactionUpdated -> {
if (event.isRemoved) {
removeMessageReaction(event.messageId, event.userId, event.emoji)
} else {
updateMessageReaction(event.messageId, event.userId, event.emoji)
}
}
is ChatEvent.MessageDeleted -> {
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
}
}
is ChatEvent.MessageEdited -> {
_state.update { currentState ->
val updated = currentState.messages.map { msg ->
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
}
currentState.copy(messages = updated)
}
}
is ChatEvent.UserTyping -> {
_state.update { it.copy(isTyping = true) }
typingTimerJob?.cancel()
typingTimerJob = viewModelScope.launch {
delay(3000)
_state.update { it.copy(isTyping = false) }
}
}
is ChatEvent.UserStoppedTyping -> {
_state.update { it.copy(isTyping = false) }
}
is ChatEvent.MessagesRead -> {
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
if (msg.sequenceId <= event.lastReadSequenceId) {
msg.copy(isRead = true)
} else msg
}
currentState.copy(messages = updatedMessages)
}
}
is ChatEvent.MessagePinned -> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s ->
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
}
}
is ChatEvent.MessageUnpinned -> {
_state.update { s ->
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
}
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
fun clearChatId() {
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
signalrEventsJob?.cancel()
signalrEventsJob = null
currentChatId = null
activeChatTracker.setChatId(null)
_state.update { it.copy(messages = emptyList(), isLoading = false) }
}
fun markAsRead() {
val chatId = currentChatId ?: return
val messages = _state.value.messages
if (messages.isEmpty()) return
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
val currentUserId = getCurrentUserId()
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
viewModelScope.launch {
try {
// Мгновенно обновляем в памяти для "галочек"
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
msg.copy(isRead = true)
} else msg
}
currentState.copy(messages = updatedMessages)
}
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
signalrNotificationObserver.refresh()
} catch (e: Exception) {
// Ignore
}
}
}
fun onInputTextChanged(text: String) {
_state.update { it.copy(inputText = text) }
val chatId = currentChatId ?: return
// Отправляем индикатор набора текста
val now = System.currentTimeMillis()
if (now - lastTypingSentTime > 1000) {
signalrClient.sendTypingIndicator(chatId)
lastTypingSentTime = now
// Отправляем "остановился печатать" через 3 секунды
typingTimerJob?.cancel()
typingTimerJob = viewModelScope.launch {
delay(3000)
signalrClient.sendUserStoppedTyping(chatId)
}
}
}
fun onReply(message: Message) {
_state.update { it.copy(replyingMessage = message) }
}
fun forwardMessages(targetChatId: String, messages: List<Message>) {
viewModelScope.launch {
messages.forEach { msg ->
repository.sendMessage(
chatId = targetChatId,
content = msg.content,
type = msg.mediaType.name.lowercase(),
forwardedFromId = msg.senderId
)
}
}
}
fun onForward(message: Message) {
_state.update { it.copy(forwardingMessages = listOf(message)) }
loadChatsForForwarding()
}
fun loadChatsForForwarding() {
viewModelScope.launch {
try {
val chats = repository.getChats()
_state.update { it.copy(availableChatsToForward = chats) }
} catch (e: Exception) {
// Ignore
}
}
}
fun cancelForwarding() {
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
}
fun onForwardSelectedMessages() {
val selectedIds = _state.value.selectedMessageIds
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
_state.update { it.copy(forwardingMessages = messages) }
loadChatsForForwarding()
}
fun toggleSelection(messageId: String) {
_state.update { s ->
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
s.selectedMessageIds - messageId
} else {
s.selectedMessageIds + messageId
}
s.copy(selectedMessageIds = newSelection)
}
}
fun clearSelection() {
_state.update { it.copy(selectedMessageIds = emptySet()) }
}
fun cancelReply() {
_state.update { it.copy(replyingMessage = null) }
}
fun cancelEdit() {
_state.update { it.copy(editingMessage = null, inputText = "") }
}
fun deleteMessage(message: Message, forEveryone: Boolean) {
viewModelScope.launch {
try {
repository.deleteMessage(message.id, forEveryone)
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
}
} catch (e: Exception) {
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
}
}
}
fun onEdit(message: Message) {
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
}
fun pinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.pinMessage(messageId, chatId)
}
fun unpinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.unpinMessage(messageId, chatId)
}
fun onPin(message: Message) {
pinMessage(message.id)
}
fun sendMessage(onFail: (String) -> Unit = {}) {
val chatId = currentChatId ?: return
val text = _state.value.inputText
val pending = _state.value.pendingAttachments
val replyToId = _state.value.replyingMessage?.id
val editingMsg = _state.value.editingMessage
if (text.isBlank() && pending.isEmpty()) return
// Handle Edit
if (editingMsg != null) {
_state.update { it.copy(inputText = "", editingMessage = null) }
viewModelScope.launch {
try {
val updated = repository.editMessage(editingMsg.id, text)
_state.update { currentState ->
val updatedList = currentState.messages.map {
if (it.id == updated.id) updated else it
}
currentState.copy(messages = updatedList)
}
} catch (e: Exception) {
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
onFail(text)
}
}
return
}
// Clear input immediately to avoid double clicks and ensure UI experience
_state.update { it.copy(inputText = "", replyingMessage = null) }
val tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
// Determine mediaType based on attachments
val mediaType = when {
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
else -> chats.domain.model.MediaType.TEXT
}
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = if (text.isBlank()) null else text,
createdAt = java.util.Date().toString(),
mediaType = mediaType,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
// Upload attachments if any
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
_state.update { it.copy(isUploading = true) }
val requests = _state.value.pendingAttachments.map { file ->
val url = repository.uploadMedia(file)
chats.data.remote.api.AttachmentRequest(
type = when {
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
else -> "file"
},
url = url,
fileName = file.name,
fileSize = file.length()
)
}
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
requests
} else {
null
}
val sentMessage = repository.sendMessage(
chatId = chatId,
content = if (text.isBlank()) null else text,
type = if (attachmentRequests != null) "media" else "text",
attachments = attachmentRequests,
replyToId = replyToId
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Clear attachments on success
_state.update { it.copy(pendingAttachments = emptyList()) }
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
onFail(text)
}
}
}
fun addReaction(messageId: String, emoji: String) {
val chatId = currentChatId ?: return
// We rely on SignalR event to update the count to avoid double counting
// especially since domain Message doesn't track user IDs for reactions yet.
signalrClient.addReaction(messageId, chatId, emoji)
}
fun sendVoiceMessage(file: File) {
val chatId = currentChatId ?: return
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_voice_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = null,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.AUDIO,
media = emptyList(),
senderName = "Вы",
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
// 1. Upload the audio file
val url = repository.uploadMedia(file)
// 2. Send the message with the attachment
val attachment = chats.data.remote.api.AttachmentRequest(
type = "voice",
url = url,
fileName = file.name,
fileSize = file.length()
)
val sentMessage = repository.sendMessage(
chatId = chatId,
content = null,
type = "audio",
attachments = listOf(attachment),
replyToId = replyToId
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
}
}
}
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
viewModelScope.launch {
val mimeType = context.contentResolver.getType(uri) ?: ""
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
ImageUtils.compressImage(context, uri)
} else {
copyUriToFile(context, uri)
}
file?.let { f ->
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
}
}
}
fun formatDateHeader(dateString: String): String {
return try {
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(dateString) ?: return dateString
// Форматируем в локальное время: "14 апреля"
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
} catch (e: Exception) {
dateString
}
}
fun getLocalDateString(isoDate: String): String {
return try {
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(isoDate) ?: return isoDate
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
} catch (e: Exception) {
isoDate.split("T").first()
}
}
fun removePendingAttachment(file: File) {
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
}
fun uploadMedia(file: File) {
if (file.length() > _state.value.maxFileSize) {
_state.update { it.copy(error = "File too large") }
return
}
viewModelScope.launch {
try {
val url = repository.uploadMedia(file)
// After upload, we might want to send a message with this media
// For now, let's just log it or handle as per app requirements
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
fun loadTrendingGifs() {
if (_state.value.trendingGifs.isNotEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.getTrendingGifs(0)
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
}
}
}
fun loadGifCategories() {
if (_state.value.gifCategories.isNotEmpty()) return
viewModelScope.launch {
try {
val categories = repository.getGifCategories()
_state.update { it.copy(gifCategories = categories) }
} catch (e: Exception) {
// Ignore failure
}
}
}
private var searchJob: Job? = null
fun searchGifs(query: String) {
searchJob?.cancel()
if (query.isBlank()) {
_state.update { it.copy(searchedGifs = emptyList()) }
return
}
searchJob = viewModelScope.launch {
delay(500)
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.searchGifs(query, 0)
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false) }
}
}
}
fun sendGif(url: String) {
val chatId = currentChatId ?: return
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_gif_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = url,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
val attachment = chats.data.remote.api.AttachmentRequest(
type = "image",
url = url,
fileName = "gif.gif",
fileSize = 0
)
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
val selectedGif = allGifs.find { gif ->
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
gifUrl == url
}
selectedGif?.let { gif ->
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
_state.update { it.copy(recentGifs = newList) }
}
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
}
@@ -0,0 +1,127 @@
package chats.presentation.chat_list
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chats.presentation.components.ChatItem
import stories.presentation.StoryViewModel
import stories.presentation.components.StoryThumbnail
import ru.knot.messager.R
@Composable
fun HorizontalDividerComponent(
modifier: Modifier = Modifier,
thickness: androidx.compose.ui.unit.Dp = 1.dp,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
) {
androidx.compose.material3.Divider(
modifier = modifier,
thickness = thickness,
color = color
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatListScreen(
viewModel: ChatListViewModel,
storyViewModel: StoryViewModel,
onChatClick: (String) -> Unit,
onStoryClick: (Int) -> Unit
) {
val state by viewModel.state.collectAsState()
val storyState by storyViewModel.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.loadChats()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (state.isLoading && state.chats.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn {
// Ряд историй сверху списка чатов
item {
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
items(storyState.storyGroups.size) { index ->
val group = storyState.storyGroups[index]
StoryThumbnail(
username = group.username,
avatarUrl = group.avatar,
hasUnseen = true, // В идеале проверяем по статусам
onClick = { onStoryClick(index) }
)
}
}
HorizontalDividerComponent(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
if (state.error != null && state.chats.isEmpty()) {
item {
Text(
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
} else if (state.chats.isEmpty() && !state.isLoading) {
item {
Box(modifier = Modifier.fillParentMaxSize()) {
Text(
text = stringResource(R.string.no_chats_found),
modifier = Modifier.align(Alignment.Center)
)
}
}
} else {
items(state.chats) { chat ->
ChatItem(chat = chat, onClick = onChatClick)
HorizontalDividerComponent(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
}
@@ -0,0 +1,156 @@
package chats.presentation.chat_list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.domain.model.Chat
import chats.domain.repository.ChatRepository
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent
import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import chats.data.repository.toDomain
data class ChatListState(
val chats: List<Chat> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isStoriesEnabled: Boolean = true
)
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val repository: ChatRepository,
private val authRepository: auth.domain.repository.AuthRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : ViewModel() {
private val _state = MutableStateFlow(ChatListState())
val state: StateFlow<ChatListState> = _state.asStateFlow()
init {
updatePushToken()
val isStoriesEnabled = try {
serverConfig.getServerConfig().features.stories
} catch (e: Exception) {
true
}
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
// Подключаемся к SignalR только если есть токен И введён URL сервера
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
}
loadChats()
observeSignalREvents()
}
private fun updatePushToken() {
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
try {
authRepository.updatePushToken(token)
} catch (e: Exception) {
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}")
}
}
}
}
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val chats = repository.getChats()
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun sortChats(chats: List<Chat>): List<Chat> {
return chats.sortedWith(compareByDescending<Chat> {
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
}.thenByDescending { it.lastMessage?.createdAt })
}
private fun observeSignalREvents() {
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
updateChatsWithNewMessage(event)
}
is ChatEvent.NewChat -> {
val currentUserId = getCurrentUserId()
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
}
is ChatEvent.MessagesRead -> {
if (event.userId == getCurrentUserId()) {
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.chatId) {
chat.copy(unreadCount = 0)
} else chat
}
currentState.copy(chats = sortChats(updatedChats))
}
}
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id.equals(event.message.chatId, ignoreCase = true)) {
val isMyMessage = event.message.senderId == currentUserId
val isAlreadySeen = chat.lastMessage?.id == event.message.id
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
val newCount = if (isMyMessage || isAlreadySeen) {
chat.unreadCount
} else {
chat.unreadCount + 1
}
if (!isAlreadySeen) {
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
}
chat.copy(
lastMessage = lastMsgDomain,
unreadCount = newCount
)
} else chat
}
currentState.copy(chats = sortChats(updatedChats))
}
}
}
@@ -0,0 +1,124 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chats.domain.model.Chat
import androidx.compose.foundation.shape.RoundedCornerShape
import core.presentation.components.AppAvatar
import chats.domain.model.MediaType
import androidx.compose.runtime.remember
@Composable
fun ChatItem(
chat: Chat,
onClick: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(chat.id) }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = chat.avatar,
name = chat.name,
size = 50.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = chat.name,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
chat.lastMessage?.let {
val formattedTime = remember(it.createdAt) {
try {
it.createdAt.substringAfter('T').take(5)
} catch (e: Exception) {
""
}
}
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val previewText = remember(chat.lastMessage) {
val msg = chat.lastMessage
if (msg == null) return@remember "No messages yet"
if (!msg.content.isNullOrBlank()) {
msg.content
} else if (msg.mediaType == MediaType.AUDIO) {
"Голосовое сообщение"
} else if (msg.media.isNotEmpty()) {
when (msg.mediaType) {
MediaType.IMAGE -> "Фото"
MediaType.VIDEO -> "Видео"
MediaType.FILE -> "Файл"
else -> "Медиа"
}
} else {
"Сообщение"
}
}
Text(
text = previewText,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (chat.unreadCount > 0) {
Box(
modifier = Modifier
.padding(start = 8.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = chat.unreadCount.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
@@ -0,0 +1,39 @@
package chats.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun EmojiPicker(
onEmojiSelected: (String) -> Unit
) {
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "") // Базовый набор
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(8.dp)
) {
items(emojis) { emoji ->
Box(
modifier = Modifier
.size(48.dp)
.clickable { onEmojiSelected(emoji) },
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 24.sp)
}
}
}
}
@@ -0,0 +1,84 @@
package chats.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import chats.data.remote.api.KlipyGifDto
@Composable
fun KlipyPicker(
gifs: List<KlipyGifDto>,
isLoading: Boolean,
onGifSelected: (String) -> Unit,
onSearch: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
val context = LocalContext.current
val imageLoader = coil.ImageLoader.Builder(context)
.components {
if (android.os.Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
OutlinedTextField(
value = query,
onValueChange = {
query = it
onSearch(it)
},
modifier = Modifier.fillMaxWidth().padding(8.dp),
placeholder = { Text("Search GIFs...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
)
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(4.dp)
) {
items(gifs) { gif ->
val url = gif.images?.fixed_height?.url ?: ""
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.build(),
imageLoader = imageLoader,
contentDescription = gif.title,
modifier = Modifier
.padding(4.dp)
.aspectRatio(1.5f)
.clickable { onGifSelected(url) },
contentScale = ContentScale.Crop
)
}
}
}
}
}
@@ -0,0 +1,181 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.utils.LinkMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
@Composable
fun LinkPreview(
url: String,
modifier: Modifier = Modifier,
contentColor: Color = Color.White
) {
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(linkMetadataCache[url]) }
var loading by remember(url) { mutableStateOf(metadata == null) }
val context = LocalContext.current
LaunchedEffect(url) {
if (metadata != null) {
loading = false
return@LaunchedEffect
}
loading = true
try {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}")
.header("User-Agent", "KnotMessenger/1.0 (Android)")
.build()
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (body != null) {
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
if (json.has("status") && json.get("status").asString == "success") {
val data = json.getAsJsonObject("data")
val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString
val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString
val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString
if (title != null || description != null || imageUrl != null) {
metadata = LinkMetadata(
url = url,
title = title,
description = description,
imageUrl = imageUrl
)
metadata?.let { linkMetadataCache[url] = it }
}
Unit
}
Unit
}
Unit
} else {
android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}")
}
}
Unit
}
} catch (e: Exception) {
android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e)
} finally {
loading = false
}
}
if (loading) {
Text(
text = "Загрузка предпросмотра...",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.5f),
modifier = modifier.padding(vertical = 4.dp),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
return
}
val currentMetadata = metadata ?: LinkMetadata(url = url)
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.2f))
.clickable {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {}
}
.border(
width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp,
color = Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp)
)
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(3.dp)
.background(Color(0xFF3096E5))
)
Column(modifier = Modifier.padding(10.dp)) {
val domain = remember(url) {
try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" }
}
Text(
text = domain,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3096E5),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 2.dp)
)
Text(
text = currentMetadata.title ?: url,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
currentMetadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
}
}
currentMetadata.imageUrl?.let {
AsyncImage(
model = it,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
contentScale = ContentScale.Crop
)
}
}
}
@@ -0,0 +1,416 @@
package chats.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import chats.data.remote.api.KlipyGifDto
import core.utils.CoilUtils
import kotlinx.coroutines.launch
enum class MediaTab { EMOJI, GIF }
@Composable
fun MediaPicker(
trendingGifs: List<KlipyGifDto>,
searchedGifs: List<KlipyGifDto>,
recentGifs: List<KlipyGifDto> = emptyList(),
gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
isGifsLoading: Boolean,
error: String?,
onEmojiSelected: (String) -> Unit,
onGifSelected: (String) -> Unit,
onGifSearch: (String) -> Unit,
modifier: Modifier = Modifier
) {
var activeTab by remember { mutableStateOf(MediaTab.EMOJI) }
// Вычисляем индексы для прыжков (учитываем заголовки)
val emojiCategories = remember {
listOf(
EmojiCategory("Недавние", Icons.Rounded.Schedule, listOf("👍", "❤️", "😂", "😍", "🙏", "🔥", "😭", "😊")),
EmojiCategory("Смайлы", Icons.Rounded.EmojiEmotions, listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇")),
EmojiCategory("Животные", Icons.Rounded.Pets, listOf("🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷")),
EmojiCategory("Еда", Icons.Rounded.Fastfood, listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭")),
EmojiCategory("Спорт", Icons.Rounded.SportsFootball, listOf("", "🏀", "🏈", "", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🥅", "🏒")),
EmojiCategory("Транспорт", Icons.Rounded.DirectionsCar, listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛")),
EmojiCategory("Объекты", Icons.Rounded.Lightbulb, listOf("💡", "🔦", "🕯️", "🪔", "📔", "📕", "📖", "💚", "💙", "💜", "🤎", "🖤")),
EmojiCategory("Музыка", Icons.Rounded.MusicNote, listOf("🎵", "🎶", "🎼", "🎹", "🎸", "🎻", "🎷", "🎺", "🥁", "🎤", "🎧", "📻")),
EmojiCategory("Флаги", Icons.Rounded.Flag, listOf("🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇱", "🇩🇿", "🇦🇸", "🇦🇩"))
)
}
val categoryIndices = remember(emojiCategories) {
var currentIdx = 0
emojiCategories.map { cat ->
val startIdx = currentIdx
currentIdx += 1 + cat.emojis.size
cat.icon to startIdx
}
}
Column(
modifier = modifier
.fillMaxWidth()
.height(450.dp)
.background(Color(0xFF1C1C1C), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
) {
// Табы (Эмодзи / GIF)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.background(Color(0xFF2C2C2C), RoundedCornerShape(12.dp))
.padding(2.dp)
) {
MediaTabItem(
title = "ЭМОДЗИ",
isSelected = activeTab == MediaTab.EMOJI,
onClick = { activeTab = MediaTab.EMOJI },
modifier = Modifier.weight(1f)
)
MediaTabItem(
title = "GIF",
isSelected = activeTab == MediaTab.GIF,
onClick = { activeTab = MediaTab.GIF },
modifier = Modifier.weight(1f)
)
}
Crossfade(targetState = activeTab, label = "media_tab") { tab ->
when (tab) {
MediaTab.EMOJI -> EmojiContent(
emojiCategories = emojiCategories,
categoryIndices = categoryIndices,
onEmojiSelected = onEmojiSelected
)
MediaTab.GIF -> GifContent(
trendingGifs = trendingGifs,
searchedGifs = searchedGifs,
recentGifs = recentGifs,
gifCategories = gifCategories,
isGifsLoading = isGifsLoading,
error = error,
onGifSelected = onGifSelected,
onGifSearch = onGifSearch
)
}
}
}
}
@Composable
private fun MediaTabItem(
title: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(if (isSelected) Color(0xFF3C3C3C) else Color.Transparent)
.clickable { onClick() }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = title,
color = if (isSelected) Color.White else Color.Gray,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun EmojiContent(
emojiCategories: List<EmojiCategory>,
categoryIndices: List<Pair<ImageVector, Int>>,
onEmojiSelected: (String) -> Unit
) {
val scrollState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
// Категории
LazyRow(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categoryIndices) { (icon, index) ->
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier
.size(24.dp)
.clickable {
scope.launch {
scrollState.animateScrollToItem(index)
}
}
)
}
}
// Поиск
TextField(
value = "",
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clip(RoundedCornerShape(8.dp)),
placeholder = { Text("Поиск", color = Color.Gray) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2C2C2C),
unfocusedContainerColor = Color(0xFF2C2C2C),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
LazyVerticalGrid(
columns = GridCells.Fixed(8),
state = scrollState,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
emojiCategories.forEach { category ->
// Заголовок категории
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(8) }) {
Text(
text = category.name,
color = Color.Gray,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(8.dp)
)
}
items(category.emojis, key = { emoji -> emoji }) { emoji ->
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable { onEmojiSelected(emoji) },
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 28.sp)
}
}
}
}
}
}
data class EmojiCategory(val name: String, val icon: ImageVector, val emojis: List<String>)
@Composable
private fun GifContent(
trendingGifs: List<KlipyGifDto>,
searchedGifs: List<KlipyGifDto>,
recentGifs: List<KlipyGifDto>,
gifCategories: List<chats.data.remote.api.GifCategoryDto>,
isGifsLoading: Boolean,
error: String?,
onGifSelected: (String) -> Unit,
onGifSearch: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
var subTab by remember { mutableStateOf("trending") }
val context = LocalContext.current
val imageLoader = CoilUtils.getGifImageLoader(context)
Column(modifier = Modifier.fillMaxSize()) {
TextField(
value = query,
onValueChange = {
query = it
if (it.isNotEmpty()) subTab = "search"
else if (subTab == "search") subTab = "trending"
onGifSearch(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp)),
placeholder = { Text("Поиск GIF...", color = Color.Gray) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2C2C2C),
unfocusedContainerColor = Color(0xFF2C2C2C),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
// Под-вкладки GIF (Recent, Trending, Categories)
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Rounded.Schedule,
contentDescription = null,
tint = if (subTab == "recent") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "recent"; query = "" }
)
Icon(
Icons.Rounded.TrendingUp,
contentDescription = null,
tint = if (subTab == "trending") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "trending"; query = "" }
)
Icon(
Icons.Rounded.GridView,
contentDescription = null,
tint = if (subTab == "categories") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "categories"; query = "" }
)
}
if (subTab == "categories") {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp)
) {
items(gifCategories) { category ->
Box(
modifier = Modifier
.padding(4.dp)
.aspectRatio(1.77f)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2C2C2C))
.clickable {
query = category.query
subTab = "search"
onGifSearch(category.query)
}
) {
AsyncImage(
model = category.preview_url,
contentDescription = category.category,
modifier = Modifier.fillMaxSize().alpha(0.6f),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = category.category.uppercase(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Black,
letterSpacing = 2.sp
)
}
}
}
}
} else {
val displayGifs = when (subTab) {
"recent" -> recentGifs
"search" -> searchedGifs
else -> trendingGifs
}
if (isGifsLoading && displayGifs.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color(0xFF3B82F6))
}
} else if (!isGifsLoading && displayGifs.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(error ?: "Ничего не найдено", color = Color.Gray)
if (error != null) {
Button(onClick = { onGifSearch(query) }) {
Text("Повторить")
}
}
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
val url = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
val preview = gif.files?.get("sd")?.get("webp")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("webp")?.url
?: gif.media_formats?.get("tinygif")?.url
?: gif.images?.fixed_height_small?.url
?: url
if (url != null) {
AsyncImage(
model = preview ?: url,
imageLoader = imageLoader,
contentDescription = gif.title,
modifier = Modifier
.padding(2.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF2C2C2C))
.clickable { onGifSelected(url) },
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.padding(2.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF3C3C3C)),
contentAlignment = Alignment.Center
) {
Text(gif.title ?: "GIF", color = Color.Gray, fontSize = 10.sp)
}
}
}
}
}
}
}
}
@@ -0,0 +1,843 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.painterResource
import ru.knot.messager.R
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.Media
import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
import core.presentation.components.AppAvatar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import chats.presentation.components.LinkPreview
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import core.presentation.components.AppMediaLightbox
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: Message,
isCurrentUser: Boolean,
onReactionClick: (String) -> Unit = {},
autoPlay: Boolean = false,
initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {},
onReply: (Message) -> Unit = {},
onReplyClick: (Message) -> Unit = {},
onSelect: (String) -> Unit = {},
onForward: (Message) -> Unit = {},
onPin: (Message) -> Unit = {},
onEdit: (Message) -> Unit = {},
onDelete: (Message, Boolean) -> Unit = { _, _ -> },
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onMediaClick: (chats.domain.model.Media) -> Unit = {}
) {
val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) }
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
val contentColor = Color.White
var showContextMenu by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val backgroundColor = when {
isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f)
showContextMenu -> Color(0xFF007AFF) // Even brighter highlight
isCurrentUser -> Color(0xFF3096E5)
else -> Color(0xFF212121)
}
val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else {
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
Row(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) { // Clicks anywhere in the Row row now work
detectTapGestures(
onTap = {
if (isSelectionMode) {
onSelect(message.id)
} else {
showContextMenu = !showContextMenu
}
},
onLongPress = {
if (!isSelectionMode) {
onSelect(message.id)
}
}
)
}
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
var bubbleHeight by remember { mutableIntStateOf(0) }
var isNearBottom by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.onGloballyPositioned { coords ->
bubbleHeight = coords.size.height
val screenHeight = coords.parentLayoutCoordinates?.size?.height ?: 2000
val yInWindow = coords.localToWindow(Offset.Zero).y
// If bottom of message is in the last 40% of screen, open menu UP
isNearBottom = yInWindow > screenHeight * 0.6f
},
contentAlignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
) {
// Actual bubble
Column(
modifier = Modifier
.clip(shape)
.background(backgroundColor)
.padding(8.dp)
) {
if (showContextMenu) {
val density = androidx.compose.ui.platform.LocalDensity.current
val menuWidth = 220.dp
val menuWidthPx = with(density) { menuWidth.toPx() }
Popup(
alignment = if (isNearBottom) {
if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd
} else {
if (isCurrentUser) Alignment.TopStart else Alignment.TopEnd
},
offset = if (isCurrentUser) {
// Message is on RIGHT, show menu to its LEFT
IntOffset(-menuWidthPx.toInt() - 20, 0)
} else {
// Message is on LEFT, show menu to its RIGHT
IntOffset(menuWidthPx.toInt() + 20, 0)
},
onDismissRequest = { showContextMenu = false },
properties = androidx.compose.ui.window.PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
MessageContextMenu(
isMyMessage = isCurrentUser,
isPinned = message.isPinned,
onReactionSelected = {
onReactionClick(it)
showContextMenu = false
},
onAction = { action ->
showContextMenu = false
when(action) {
"reply" -> onReply(message)
"select" -> onSelect(message.id)
"forward" -> onForward(message)
"pin" -> onPin(message)
"edit" -> onEdit(message)
"copy" -> {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("message", message.content)
clipboard.setPrimaryClip(clip)
}
"delete" -> {
showDeleteDialog = true
}
}
}
)
}
}
// Forwarded Info
if (message.isForwarded) {
Text(
text = "Forwarded from ${message.forwardedFromName ?: "Unknown"}",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.7f),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Reply Info
message.replyTo?.let { reply ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f))
.height(IntrinsicSize.Min)
.clickable { onReplyClick(reply) }
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(2.dp)
.background(if (isCurrentUser) Color.White else Color(0xFF3096E5))
)
val context = androidx.compose.ui.platform.LocalContext.current
val isReplyImage = reply.mediaType == MediaType.IMAGE || reply.mediaType == MediaType.VIDEO || reply.mediaType == MediaType.GIF
if (isReplyImage && reply.media.isNotEmpty()) {
AsyncImage(
model = reply.media.first().url,
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
.width(40.dp)
.clip(RoundedCornerShape(0.dp, 4.dp, 4.dp, 0.dp)),
contentScale = ContentScale.Crop
)
}
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) {
val accentColor = if (isCurrentUser) Color.White else Color(0xFF3096E5)
Text(
text = reply.senderName,
style = MaterialTheme.typography.labelMedium,
color = accentColor,
maxLines = 1,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
val photoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_photo)
val videoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_video)
val voiceText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.voice_message)
val audioText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_audio)
val fileText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_file)
val gifText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_gif)
val mediaText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.media)
val replyText = remember(reply) {
if (!reply.content.isNullOrEmpty()) {
reply.content
} else {
when (reply.mediaType) {
MediaType.IMAGE -> photoText
MediaType.VIDEO -> videoText
MediaType.AUDIO -> if (reply.media.any { it.type == "voice" }) voiceText else audioText
MediaType.FILE -> fileText
MediaType.GIF -> gifText
else -> mediaText
}
}
}
Text(
text = replyText,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
// GIF Content (Klipy)
if (message.mediaType == MediaType.GIF) {
AsyncImage(
model = message.content,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { /* Handle click */ },
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(4.dp))
}
// Loading state for temp media messages
if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = contentColor.copy(alpha = 0.5f)
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Media Content (Attachments)
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
val mediaCount = message.media.size
if (mediaCount == 1) {
val mediaItem = message.media[0]
when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage(
model = mediaItem.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onMediaClick(mediaItem) },
contentScale = ContentScale.Crop
)
}
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.clip(RoundedCornerShape(8.dp))
) {
AppVideoPlayer(
url = mediaItem.url,
modifier = Modifier.fillMaxSize(),
useController = false,
isMuted = true,
autoPlay = true
)
// Прозрачный слой поверх видео для клика
Box(modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.clickable { onMediaClick(mediaItem) }
)
Icon(
imageVector = Icons.Default.VolumeOff,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(20.dp)
)
}
}
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
AppAudioPlayer(
url = mediaItem.url,
name = mediaItem.filename,
size = mediaItem.size,
isVoiceMessage = isVoiceMessage,
initialPlaybackSpeed = initialPlaybackSpeed,
autoPlay = autoPlay,
onFinished = onVoiceFinished,
contentColor = contentColor
)
}
else -> {
FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
}
}
} else {
// Multi-media grid
PhotoGrid(
mediaList = message.media,
isCurrentUser = isCurrentUser,
onMediaClick = { index -> onMediaClick(message.media[index]) },
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Column {
val annotatedString = remember(message.content) {
val text = message.content ?: ""
val links = core.utils.LinkParser.findLinks(text)
androidx.compose.ui.text.buildAnnotatedString {
append(text)
links.forEach { link ->
val startIndex = text.indexOf(link)
if (startIndex >= 0) {
val endIndex = startIndex + link.length
addStyle(
style = androidx.compose.ui.text.SpanStyle(
color = Color(0xFF3096E5),
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = "URL",
annotation = link,
start = startIndex,
end = endIndex
)
}
}
}
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.foundation.text.ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
context.startActivity(intent)
} catch (e: Exception) {}
}
}
)
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
if (links.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LinkPreview(
url = links[0],
contentColor = contentColor
)
}
}
}
// Time and Status
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick,
modifier = Modifier.padding(end = 4.dp)
)
}
val formattedTime = remember(message.createdAt) {
try {
val instant = Instant.parse(message.createdAt)
val zonedDateTime = instant.atZone(ZoneId.systemDefault())
zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (e: Exception) {
message.createdAt.substringAfter('T').take(5)
}
}
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f),
fontSize = 11.sp
)
if (message.isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(12.dp).padding(start = 2.dp).graphicsLayer { rotationZ = 45f },
tint = contentColor.copy(alpha = 0.6f)
)
}
if (isCurrentUser) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
}
}
} // End of bubble Column
if (isSelected) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.align(Alignment.Center)
.size(32.dp)
.background(Color(0xFF3096E5), CircleShape)
.padding(2.dp)
)
}
} // End of Box
} // End of Row
lightboxMediaIndex?.let { index ->
AppMediaLightbox(
mediaList = message.media,
initialIndex = index,
onClose = { lightboxMediaIndex = null }
)
}
if (showDeleteDialog) {
var deleteForEveryone by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Удалить сообщение?") },
text = {
Column {
Text("Вы уверены, что хотите удалить это сообщение?")
if (isCurrentUser) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp).clickable { deleteForEveryone = !deleteForEveryone }
) {
Checkbox(
checked = deleteForEveryone,
onCheckedChange = { deleteForEveryone = it }
)
Text("Удалить у всех")
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onDelete(message, deleteForEveryone)
showDeleteDialog = false
},
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935))
) {
Text("Удалить")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Отмена")
}
}
)
}
}
@Composable
fun MessageReactions(
reactions: Map<String, Int>,
onReactionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { (emoji, count) ->
Box(
modifier = Modifier
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { onReactionClick(emoji) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = emoji, fontSize = 12.sp)
if (count > 1) {
Spacer(modifier = Modifier.width(2.dp))
Text(text = count.toString(), fontSize = 10.sp, color = Color.White)
}
}
}
}
}
}
@Composable
fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Description, contentDescription = null, tint = contentColor)
}
Column(
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
) {
val fileName = media.filename ?: "File"
Text(
text = fileName,
style = MaterialTheme.typography.bodySmall,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val sizeStr = media.size?.let {
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
} ?: "File"
Text(
text = sizeStr,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f)
)
}
Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp))
}
}
@Composable
fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
if (mediaList.isEmpty()) return
val columns = when {
mediaList.size == 1 -> 1
mediaList.size <= 4 -> 2
else -> 3
}
Column(modifier = modifier) {
val rows = (mediaList.size + columns - 1) / columns
for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
for (j in 0 until columns) {
val index = i * columns + j
if (index < mediaList.size) {
GridItem(
media = mediaList[index],
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(index) }
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
fun GridItem(media: Media, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isImage = media.type.startsWith("image")
val isVideo = media.type.startsWith("video")
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
if (isImage || isVideo) {
AsyncImage(
model = media.url,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isVideo) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.Center).size(32.dp)
)
}
} else {
Column(
modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(32.dp)
)
Text(
text = media.filename ?: "File",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Text(
text = media.url.substringAfterLast(".").uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.5f),
fontSize = 8.sp
)
}
}
}
}
@Composable
fun MessageContextMenu(
isMyMessage: Boolean,
isPinned: Boolean,
onReactionSelected: (String) -> Unit,
onAction: (String) -> Unit
) {
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
androidx.compose.animation.AnimatedVisibility(
visible = isVisible,
enter = androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(200)) +
androidx.compose.animation.scaleIn(
initialScale = 0.5f,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = 0.6f,
stiffness = 400f
),
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(
if (isVisible) 0.5f else 0.5f,
0f
)
),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut()
) {
androidx.compose.material3.Surface(
modifier = Modifier
.width(280.dp) // Even wider for all reactions
.clip(RoundedCornerShape(20.dp)),
color = Color(0xFF1E1E1E),
shadowElevation = 15.dp,
border = androidx.compose.foundation.BorderStroke(0.5.dp, Color.White.copy(alpha = 0.15f))
) {
Column {
// Extended Reactions scrollable bar
androidx.compose.foundation.lazy.LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(horizontal = 12.dp)
) {
val telegramReactions = listOf(
"👍", "❤️", "😂", "🔥", "💯", "👏", "🤩", "🤔", "🤯", "😱", "🤬",
"👎", "💩", "🤡", "🥳", "😇", "🌚", "🙏", "👌", "🕊️", "🐳",
"🌭", "🦄", "🍌", "💊", "🍓", "🍾", "💋", "🖕", "👀", "👻",
"🤝", "🤣", "", "", "🎈", "🎉", "🧊", "🆒", "🤨", "😐",
"🤷‍♂️", "💅", "🏆", "👾", "🎯", "🎲", "🍷", "🤮", "🥱", "😴",
"🤐", "🥴"
)
items(telegramReactions) { emoji ->
Text(
text = emoji,
fontSize = 28.sp,
modifier = Modifier
.clip(CircleShape)
.clickable { onReactionSelected(emoji) }
.padding(4.dp)
)
}
}
Divider(color = Color.White.copy(alpha = 0.1f))
ContextMenuItem(Icons.Default.Reply, "Ответить", onClick = { onAction("reply") })
ContextMenuItem(Icons.Default.CheckCircle, "Выбрать", onClick = { onAction("select") })
ContextMenuItem(Icons.Default.Forward, "Переслать", onClick = { onAction("forward") })
ContextMenuItem(
icon = Icons.Default.PushPin,
text = if (isPinned) "Открепить" else "Закрепить",
onClick = { onAction("pin") }
)
ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") })
if (isMyMessage) {
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
}
Divider(color = Color.White.copy(alpha = 0.1f))
ContextMenuItem(
Icons.Default.Delete,
"Удалить",
textColor = Color(0xFFE53935),
onClick = { onAction("delete") }
)
}
}
}
}
@Composable
fun ContextMenuItem(
icon: ImageVector,
text: String,
textColor: Color = Color.White,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = textColor.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = text,
color = textColor,
style = MaterialTheme.typography.bodyMedium
)
}
}
@@ -0,0 +1,61 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ReactionPicker(
onReactionSelected: (String) -> Unit
) {
val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
Row(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface, CircleShape)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
reactions.forEach { reaction ->
Text(
text = reaction,
modifier = Modifier
.size(32.dp)
.clickable { onReactionSelected(reaction) },
fontSize = 20.sp
)
}
}
}
@Composable
fun MessageReactions(
reactions: Map<String, Int>, // Эмодзи -> Количество
onReactionClick: (String) -> Unit
) {
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { (emoji, count) ->
Box(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.clickable { onReactionClick(emoji) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(text = "$emoji $count", fontSize = 12.sp)
}
}
}
}
@@ -0,0 +1,87 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Reply
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun SwipeableMessageItem(
onReply: () -> Unit,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val replyThreshold = with(density) { 60.dp.toPx() }
val maxDrag = with(density) { 90.dp.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var isTriggered by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
// We only allow swiping to the left (negative delta)
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
offsetX = newOffset
isTriggered = newOffset <= -replyThreshold
},
onDragStopped = {
if (offsetX <= -replyThreshold) {
onReply()
}
offsetX = 0f
isTriggered = false
}
)
) {
// Reply Icon (Underneath)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.size(36.dp)
.alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
.clip(CircleShape)
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Reply,
contentDescription = "Reply",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
// Message Content
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.fillMaxWidth()
) {
content()
}
}
}
@@ -0,0 +1,62 @@
package contacts.data.remote.api
import retrofit2.http.*
interface ContactApi {
@GET("contacts")
suspend fun getContacts(): List<ContactDto>
@POST("contacts/request")
suspend fun addContact(@Body request: AddContactRequest): Any
@DELETE("contacts/{userId}")
suspend fun removeContact(@Path("userId") userId: String)
@GET("profiles/search")
suspend fun searchUsers(@Query("q") query: String): List<ContactDto>
@GET("contacts/requests")
suspend fun getFriendRequests(): List<FriendRequestDto>
@POST("contacts/{friendshipId}/accept")
suspend fun acceptRequest(@Path("friendshipId") friendshipId: String): Any
@POST("contacts/{friendshipId}/decline")
suspend fun declineRequest(@Path("friendshipId") friendshipId: String): Any
}
data class FriendRequestDto(
val id: String,
val user: ContactDto,
val createdAt: String,
val isOutgoing: Boolean
)
data class ContactDto(
@com.google.gson.annotations.SerializedName("id")
val id: String,
@com.google.gson.annotations.SerializedName("userName")
val userName: String?,
@com.google.gson.annotations.SerializedName("username")
val username: String?,
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
@com.google.gson.annotations.SerializedName("avatarUrl")
val avatarUrl: String?,
@com.google.gson.annotations.SerializedName("avatar")
val avatar: String?,
@com.google.gson.annotations.SerializedName("isOnline")
val isOnline: Boolean,
@com.google.gson.annotations.SerializedName("lastSeen")
val lastSeen: String?,
@com.google.gson.annotations.SerializedName("friendshipId")
val friendshipId: String? = null
) {
val effectiveUsername: String get() = username ?: userName ?: "unknown"
val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar
}
data class AddContactRequest(
@com.google.gson.annotations.SerializedName("contactId")
val contactId: String
)
@@ -0,0 +1,74 @@
package contacts.data.repository
import contacts.data.remote.api.ContactApi
import contacts.data.remote.api.ContactDto
import contacts.domain.repository.ContactRepository
import javax.inject.Inject
class ContactRepositoryImpl @Inject constructor(
private val api: ContactApi
) : ContactRepository {
override suspend fun getContacts(): Result<List<ContactDto>> {
return try {
Result.success(api.getContacts())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addContact(userId: String): Result<ContactDto> {
return try {
val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId))
// Бэкенд возвращает статус или пустой ответ для запроса,
// так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или
// по-хорошему надо обновить доменную модель, но для начала вернем заглушку.
Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun removeContact(userId: String): Result<Unit> {
return try {
api.removeContact(userId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
return try {
Result.success(api.searchUsers(query))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
return try {
Result.success(api.getFriendRequests())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
return try {
api.acceptRequest(friendshipId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
return try {
api.declineRequest(friendshipId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,30 @@
package contacts.di
import contacts.data.remote.api.ContactApi
import contacts.data.repository.ContactRepositoryImpl
import contacts.domain.repository.ContactRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ContactModule {
@Provides
@Singleton
fun provideContactApi(retrofit: Retrofit): ContactApi {
return retrofit.create(ContactApi::class.java)
}
@Provides
@Singleton
fun provideContactRepository(
api: ContactApi
): ContactRepository {
return ContactRepositoryImpl(api)
}
}
@@ -0,0 +1,13 @@
package contacts.domain.repository
import contacts.data.remote.api.ContactDto
interface ContactRepository {
suspend fun getContacts(): Result<List<ContactDto>>
suspend fun addContact(userId: String): Result<ContactDto>
suspend fun removeContact(userId: String): Result<Unit>
suspend fun searchUsers(query: String): Result<List<ContactDto>>
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
suspend fun acceptRequest(friendshipId: String): Result<Unit>
suspend fun declineRequest(friendshipId: String): Result<Unit>
}
@@ -0,0 +1,253 @@
package contacts.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import contacts.data.remote.api.ContactDto
import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactListScreen(
contacts: List<contacts.data.remote.api.ContactDto>,
requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
isLoading: Boolean = false,
onContactClick: (String) -> Unit,
onSearchChange: (String) -> Unit,
onAddContact: (String) -> Unit,
onStartChat: (String) -> Unit,
onAcceptRequest: (String) -> Unit,
onDeclineRequest: (String) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf(
stringResource(R.string.all),
stringResource(R.string.online_tab)
)
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text(stringResource(R.string.contacts_title), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Поисковая строка
OutlinedTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
onSearchChange(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text(stringResource(R.string.search_hint)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
shape = SoftSquareShape
)
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val filteredContacts = if (searchQuery.isNotEmpty()) {
contacts
} else {
contacts.filter {
if (selectedTab == 1) it.isOnline else true
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Contact Requests Section
if (searchQuery.isEmpty() && requests.isNotEmpty()) {
item {
Text(
text = "Заявки в контакты",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(requests) { request ->
ContactRequestItem(
request = request,
onAccept = { onAcceptRequest(request.id) },
onDecline = { onDeclineRequest(request.id) }
)
}
item {
Divider(modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant)
}
}
if (filteredContacts.isEmpty() && requests.isEmpty()) {
item {
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = if (searchQuery.isNotEmpty()) "Пользователи не найдены" else "Список контактов пуст",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
items(filteredContacts) { contact ->
ContactItem(
contact = contact,
onClick = { onContactClick(contact.id) },
onAddClick = { onAddContact(contact.id) },
onChatClick = { onStartChat(contact.id) },
isSearchMode = searchQuery.isNotEmpty()
)
}
}
}
}
}
}
}
@Composable
fun ContactRequestItem(
request: contacts.data.remote.api.FriendRequestDto,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = request.user.effectiveAvatarUrl,
name = request.user.effectiveUsername,
size = 48.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = request.user.displayName ?: request.user.effectiveUsername,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (request.isOutgoing) "Исходящий запрос" else "Входящий запрос",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
if (!request.isOutgoing) {
IconButton(onClick = onAccept) {
Icon(Icons.Default.CheckCircle, contentDescription = "Accept", tint = Color(0xFF4CAF50))
}
}
IconButton(onClick = onDecline) {
Icon(Icons.Default.Cancel, contentDescription = "Decline", tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
fun ContactItem(
contact: contacts.data.remote.api.ContactDto,
onClick: () -> Unit,
onAddClick: () -> Unit,
onChatClick: () -> Unit,
isSearchMode: Boolean
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = contact.effectiveAvatarUrl,
name = contact.effectiveUsername,
size = 56.dp
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.displayName ?: contact.effectiveUsername,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
val statusText = if (contact.isOnline) {
stringResource(R.string.online)
} else {
stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
}
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
if (isSearchMode) {
IconButton(onClick = onAddClick) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = "Add Contact",
tint = MaterialTheme.colorScheme.primary
)
}
}
IconButton(onClick = onChatClick) {
Icon(
imageVector = Icons.Default.Chat,
contentDescription = "Start Chat",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@@ -0,0 +1,122 @@
package contacts.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.domain.repository.ChatRepository
import contacts.domain.repository.ContactRepository
import core.utils.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ContactListState(
val contacts: List<contacts.data.remote.api.ContactDto> = emptyList(),
val requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class ContactListViewModel @Inject constructor(
private val contactRepository: ContactRepository,
private val chatRepository: ChatRepository,
private val navigationManager: NavigationManager
) : ViewModel() {
private val _state = MutableStateFlow(ContactListState())
val state: StateFlow<ContactListState> = _state.asStateFlow()
init {
loadData()
}
fun loadData() {
loadContacts()
loadRequests()
}
fun loadContacts() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
contactRepository.getContacts()
.onSuccess { contacts ->
_state.update { it.copy(contacts = contacts, isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun loadRequests() {
viewModelScope.launch {
contactRepository.getFriendRequests()
.onSuccess { requests ->
_state.update { it.copy(requests = requests) }
}
}
}
fun onSearchChange(query: String) {
if (query.length < 3) {
if (query.isEmpty()) loadData()
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
contactRepository.searchUsers(query)
.onSuccess { users ->
_state.update { it.copy(contacts = users, isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun addContact(userId: String) {
viewModelScope.launch {
contactRepository.addContact(userId)
.onSuccess {
loadData()
}
.onFailure { e ->
_state.update { it.copy(error = e.message) }
}
}
}
fun acceptRequest(requestId: String) {
viewModelScope.launch {
contactRepository.acceptRequest(requestId)
.onSuccess { loadData() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
fun declineRequest(requestId: String) {
viewModelScope.launch {
contactRepository.declineRequest(requestId)
.onSuccess { loadData() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
fun startChat(userId: String) {
viewModelScope.launch {
try {
// В вебе мы ищем существующий чат или создаем новый.
// В мобилке мы для начала можем просто вызвать createPersonalChat.
// Бэкенд обычно возвращает существующий чат, если он уже есть.
val chat = chatRepository.createPersonalChat(userId)
navigationManager.navigateToChat(chat.id)
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
package core.di
import android.content.Context
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideContext(@ApplicationContext context: Context): Context {
return context
}
@Provides
@Singleton
fun provideGson(): Gson {
return Gson()
}
}
+53
View File
@@ -0,0 +1,53 @@
package core.di
import com.google.gson.GsonBuilder
import core.network.AuthInterceptor
import core.network.DynamicBaseUrlInterceptor
import core.network.ServerConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(300, TimeUnit.SECONDS)
.addInterceptor(logging)
.addInterceptor(dynamicBaseUrlInterceptor)
.addInterceptor(authInterceptor)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
val gson = com.google.gson.GsonBuilder()
.serializeNulls()
.create()
return Retrofit.Builder()
.baseUrl("https://api.placeholder.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
}
@@ -0,0 +1,57 @@
package core.domain.model
import com.google.gson.annotations.SerializedName
data class ServerConfigModel(
@SerializedName("stories") val stories: StoriesConfig = StoriesConfig(),
@SerializedName("messages") val messages: MessagesConfig = MessagesConfig(),
@SerializedName("chats") val chats: ChatsConfig = ChatsConfig(),
@SerializedName("webRtc") val webRtc: WebRtcConfig = WebRtcConfig()
) {
// Helper to keep the presentation layer simple
val features: FeaturesConfig
get() = FeaturesConfig(
stories = stories.enabled,
polls = messages.allowPolls,
calls = webRtc.enabled && webRtc.enableVoiceCalls,
groups = true // Static for now as chats config doesn't have a simple toggle
)
val limits: LimitsConfig
get() = LimitsConfig(
maxFileSize = messages.maxFileSize,
maxGroupMembers = chats.maxGroupParticipants
)
}
data class StoriesConfig(
@SerializedName("enabled") val enabled: Boolean = true
)
data class MessagesConfig(
@SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024,
@SerializedName("allowPolls") val allowPolls: Boolean = true
)
data class ChatsConfig(
@SerializedName("maxGroupParticipants") val maxGroupParticipants: Int = 200
)
data class WebRtcConfig(
@SerializedName("enabled") val enabled: Boolean = true,
@SerializedName("enableVoiceCalls") val enableVoiceCalls: Boolean = true,
@SerializedName("enableVideoCalls") val enableVideoCalls: Boolean = true
)
// DTOs to maintain compatibility with existing UI code if possible
data class FeaturesConfig(
val stories: Boolean,
val polls: Boolean,
val calls: Boolean,
val groups: Boolean
)
data class LimitsConfig(
val maxFileSize: Long,
val maxGroupMembers: Int
)
@@ -0,0 +1,79 @@
package core.network
import core.security.TokenManager
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.runBlocking
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager,
private val navigationManager: core.utils.NavigationManager,
private val authRepositoryProvider: javax.inject.Provider<auth.domain.repository.AuthRepository>
) : Interceptor {
@Volatile
private var isRefreshing = false
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenManager.getToken()
val request = chain.request().newBuilder().apply {
token?.let {
addHeader("Authorization", "Bearer $it")
}
}.build()
var response = chain.proceed(request)
if (response.code == 401) {
// Attempt to refresh token synchronously
val refreshedToken = refreshAuthToken()
if (refreshedToken != null) {
// Close the old response before retrying
response.close()
// Retry the original request with the new token
val retryRequest = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $refreshedToken")
.build()
response = chain.proceed(retryRequest)
} else {
// Refresh failed, logout
logoutUser()
}
}
return response
}
private fun refreshAuthToken(): String? {
if (isRefreshing) {
// Already refreshing, wait and return current token
return tokenManager.getToken()
}
return runBlocking {
isRefreshing = true
try {
val result = authRepositoryProvider.get().refreshToken()
if (result.isSuccess) {
result.getOrNull()?.token
} else {
null
}
} finally {
isRefreshing = false
}
}
}
private fun logoutUser() {
runBlocking {
authRepositoryProvider.get().logout()
}
navigationManager.logout()
}
}
@@ -0,0 +1,46 @@
package core.network
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DynamicBaseUrlInterceptor @Inject constructor(
private val serverConfig: ServerConfig
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val newBaseUrlString = serverConfig.getBaseUrl()
val newBaseUrl = newBaseUrlString.toHttpUrlOrNull() ?: return chain.proceed(originalRequest)
val oldUrl = originalRequest.url
// Формируем новый URL.
// Retrofit-пути у нас начинаются БЕЗ слеша (например "auth/login")
// Поэтому мы просто берем путь из настроек и добавляем к нему путь запроса.
val newUrl = newBaseUrl.newBuilder().apply {
val pathSegments = oldUrl.pathSegments
// Добавляем сегменты пути запроса к базовому пути из настроек
pathSegments.forEach { segment ->
if (segment.isNotEmpty()) {
addEncodedPathSegment(segment)
}
}
// Копируем параметры запроса (query params)
for (i in 0 until oldUrl.querySize) {
addEncodedQueryParameter(
oldUrl.queryParameterName(i),
oldUrl.queryParameterValue(i)
)
}
}.build()
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
}
}
@@ -0,0 +1,50 @@
package core.network
import android.content.Context
import com.google.gson.Gson
import core.domain.model.FeaturesConfig
import core.domain.model.ServerConfigModel
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerConfig @Inject constructor(
private val context: Context,
private val gson: Gson
) {
private val prefs = context.getSharedPreferences("server_config_prefs", Context.MODE_PRIVATE)
companion object {
private const val KEY_BASE_URL = "base_url"
private const val KEY_CONFIG_DATA = "server_config_data"
private const val DEFAULT_BASE_URL = "" // Пустой по умолчанию
}
fun getBaseUrl(): String {
return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL
}
fun setBaseUrl(url: String) {
val formattedUrl = if (url.endsWith("/")) url else "$url/"
prefs.edit().putString(KEY_BASE_URL, formattedUrl).apply()
}
fun saveServerConfig(config: ServerConfigModel) {
android.util.Log.d("ServerConfig", "Saving new config: $config")
prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).commit()
}
fun getServerConfig(): ServerConfigModel {
val json = prefs.getString(KEY_CONFIG_DATA, null) ?: return ServerConfigModel()
return try {
gson.fromJson(json, ServerConfigModel::class.java)
} catch (e: Exception) {
ServerConfigModel()
}
}
fun isFeatureEnabled(feature: (FeaturesConfig) -> Boolean): Boolean {
return feature(getServerConfig().features)
}
}
@@ -0,0 +1,28 @@
package core.notifications.data
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@Singleton
class ActiveChatTracker @Inject constructor() {
private val _currentChatId = MutableStateFlow<String?>(null)
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
private val _totalUnreadCount = MutableStateFlow(0)
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount.asStateFlow()
fun setChatId(chatId: String?) {
_currentChatId.value = chatId
}
fun setTotalUnreadCount(count: Int) {
_totalUnreadCount.value = count
}
fun incrementUnreadCount() {
_totalUnreadCount.value += 1
}
}
@@ -0,0 +1,58 @@
package core.notifications.data
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import auth.data.remote.api.AuthApi
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class KnotFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var authApi: AuthApi
@Inject
lateinit var activeChatTracker: ActiveChatTracker
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
override fun onNewToken(token: String) {
super.onNewToken(token)
// Отправляем новый токен на сервер
scope.launch {
try {
authApi.updatePushToken(token)
} catch (e: Exception) {
// Ошибка логируется или игнорируется (сервер может быть недоступен)
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
android.util.Log.d("FCM", "Message received: ${message.data}")
val title = message.notification?.title ?: message.data["title"]
val body = message.notification?.body ?: message.data["body"]
val type = message.data["type"] // "chat", "call", "story"
val chatId = message.data["chatId"]
val messageId = message.data["id"] ?: message.messageId
NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode(), activeChatTracker.totalUnreadCount.value)
}
private fun showNotification(title: String?, body: String?, type: String?) {
NotificationHelper.showNotification(this, title, body, type, totalCount = activeChatTracker.totalUnreadCount.value)
}
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
}
@@ -0,0 +1,57 @@
package core.notifications.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import ru.knot.messager.R
object NotificationHelper {
private const val CHANNEL_ID = "knot_notifications_channel"
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Knot Notifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Chat and system notifications"
}
notificationManager.createNotificationChannel(channel)
}
// Find our launcher activity via package manager to avoid static dependency on MainActivity
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("type", type)
putExtra("chatId", chatId)
} ?: return
val pendingIntent = PendingIntent.getActivity(
context, System.currentTimeMillis().toInt(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
)
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setNumber(totalCount)
.setContentIntent(pendingIntent)
notificationManager.notify(finalNotificationId, notificationBuilder.build())
}
}
@@ -0,0 +1,310 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun AppAudioPlayer(
url: String,
name: String? = null,
size: Long? = null,
isVoiceMessage: Boolean = false,
initialPlaybackSpeed: Float = core.utils.PlaybackManager.globalPlaybackSpeed.value,
autoPlay: Boolean = false,
onFinished: (Float) -> Unit = {},
modifier: Modifier = Modifier,
contentColor: Color = Color.White
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
// Update global speed when changed locally
LaunchedEffect(playbackSpeed) {
core.utils.PlaybackManager.globalPlaybackSpeed.value = playbackSpeed
}
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
playbackParameters = PlaybackParameters(playbackSpeed)
val savedPos = core.utils.PlaybackManager.getPosition(url)
seekTo(savedPos)
prepare()
}
}
// Обработка сигнала авто-запуска
LaunchedEffect(autoPlay) {
if (autoPlay) {
exoPlayer.play()
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) }
var duration by remember { mutableLongStateOf(0L) }
var layoutSize by remember { mutableStateOf(IntSize.Zero) }
// Глобальная синхронизация: если играет кто-то другой, ставим паузу
val globalPlayingUrl by core.utils.PlaybackManager.currentPlayingUrl
LaunchedEffect(globalPlayingUrl) {
if (globalPlayingUrl != url && isPlaying) {
exoPlayer.pause()
}
}
val fileName = remember(url, name) {
name ?: url.substringAfterLast("/")
}
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (playing) {
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
} else if (state == Player.STATE_ENDED) {
exoPlayer.pause() // Явно останавливаем, чтобы не было авто-реплея при seekTo
if (isVoiceMessage) {
exoPlayer.seekTo(0)
currentPosition = 0
core.utils.PlaybackManager.resetPosition(url)
}
onFinished(playbackSpeed)
}
}
}
exoPlayer.addListener(listener)
onDispose {
core.utils.PlaybackManager.savePosition(url, exoPlayer.currentPosition)
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
LaunchedEffect(isPlaying) {
if (isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
core.utils.PlaybackManager.savePosition(url, currentPosition)
delay(200)
}
}
}
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.padding(8.dp)
) {
// Header: Icon and Name
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isVoiceMessage) "Голосовое сообщение" else fileName,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Play/Pause Button
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color.White)
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color(0xFF3390EC),
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Waveform
Box(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.onSizeChanged { layoutSize = it }
.pointerInput(duration) {
detectTapGestures { offset ->
if (duration > 0 && layoutSize.width > 0) {
val pct = offset.x / layoutSize.width.toFloat()
val newPos = (pct * duration).toLong()
exoPlayer.seekTo(newPos)
currentPosition = newPos
}
}
},
contentAlignment = Alignment.CenterStart
) {
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
val barCount = 40
val barSpacing = 2.dp.toPx()
val barWidth = (this.size.width - (barCount - 1) * barSpacing) / barCount
val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f
val barHeights = listOf(
0.3f, 0.5f, 0.2f, 0.7f, 0.4f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f,
0.2f, 0.6f, 0.5f, 0.8f, 0.3f, 0.7f, 0.4f, 0.6f, 0.2f, 0.8f,
0.4f, 0.5f, 0.3f, 0.7f, 0.5f, 0.6f, 0.4f, 0.3f, 0.4f, 0.6f,
0.3f, 0.2f, 0.4f, 0.5f, 0.3f, 0.6f, 0.4f, 0.7f, 0.3f, 0.5f
)
for (i in 0 until barCount) {
val x = i * (barWidth + barSpacing)
val heightPct = barHeights[i % barHeights.size]
val barHeight = this.size.height * heightPct
val isActive = (i.toFloat() / barCount) <= progress
drawRoundRect(
color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f),
topLeft = androidx.compose.ui.geometry.Offset(x, (this.size.height - barHeight) / 2),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx())
)
}
}
}
Spacer(modifier = Modifier.height(2.dp))
// Footer: Time, Size, Download
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = formatDuration(if (isPlaying || currentPosition > 0) currentPosition else duration),
fontSize = 11.sp,
color = contentColor.copy(alpha = 0.7f)
)
if (!isVoiceMessage) {
size?.let {
Spacer(modifier = Modifier.width(12.dp))
val sizeStr = if (it > 1024 * 1024) String.format("%.1f MB", it / (1024.0 * 1024.0)) else "${it / 1024} KB"
Text(
text = sizeStr,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = contentColor.copy(alpha = 0.7f)
)
}
}
}
if (!isVoiceMessage) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).clickable {
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, url, fileName)
}
}
)
}
}
}
// Speed toggle (only for voice messages or if user wants)
if (isVoiceMessage || playbackSpeed > 1.0f) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
onClick = {
playbackSpeed = when (playbackSpeed) {
1.0f -> 1.5f
1.5f -> 2.0f
else -> 1.0f
}
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
},
color = Color.White.copy(alpha = 0.1f),
shape = CircleShape,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
}
}
}
private fun formatDuration(durationMs: Long): String {
if (durationMs <= 0) return "0:00"
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%d:%02d", minutes, seconds)
}
@@ -0,0 +1,85 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import androidx.compose.foundation.shape.RoundedCornerShape
import core.presentation.theme.SoftSquareShape
@Composable
fun AppAvatar(
url: String?,
name: String,
size: Dp = 48.dp,
modifier: Modifier = Modifier
) {
val isSavedMessages = remember(name) {
name.equals("Избранное", ignoreCase = true) || name.equals("Saved Messages", ignoreCase = true)
}
val initials = remember(name) {
if (isSavedMessages) "" else {
val words = name.trim().split("\\s+".toRegex())
if (words.size >= 2) {
(words[0].take(1) + words[1].take(1)).uppercase()
} else if (name.length >= 2) {
name.take(2).uppercase()
} else {
name.take(1).uppercase()
}
}
}
val avatarColor = Color(0xFF4AA6F3) // Premium Telegram Blue (Web)
val cornerRadius = remember(size) {
if (size <= 32.dp) (size.value * 0.25).dp else 12.dp
}
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(cornerRadius))
.background(avatarColor),
contentAlignment = Alignment.Center
) {
if (!url.isNullOrBlank()) {
AsyncImage(
model = url,
contentDescription = name,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop
)
} else if (isSavedMessages) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size((size.value * 0.5).dp)
)
} else {
Text(
text = initials,
color = Color.White,
fontSize = (size.value * 0.4).sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
}
}
}
@@ -0,0 +1,241 @@
package core.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import chats.domain.model.Media
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.launch
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun AppMediaLightbox(
mediaList: List<Media>,
initialIndex: Int = 0,
onClose: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
var canScroll by remember { mutableStateOf(true) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
pageSpacing = 16.dp,
beyondBoundsPageCount = 1,
userScrollEnabled = canScroll
) { page ->
val media = mediaList[page]
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (media.type.startsWith("video")) {
val isCurrentPage = pagerState.currentPage == page
AppVideoPlayer(
url = media.url,
modifier = Modifier.fillMaxSize(),
useController = true,
autoPlay = isCurrentPage // Только если страница активна
)
} else if (media.type.startsWith("image") || media.type.contains("gif")) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
// Reset zoom when page changes
LaunchedEffect(pagerState.currentPage) {
scale = 1f
offset = androidx.compose.ui.geometry.Offset.Zero
canScroll = true
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale > 1f) {
scale = 1f
offset = Offset.Zero
canScroll = true
} else {
scale = 3f
canScroll = false
}
}
)
}
.pointerInput(Unit) {
awaitEachGesture {
do {
val event = awaitPointerEvent()
val zoomActive = scale > 1f || event.changes.size > 1
if (zoomActive) {
// Если мы в режиме зума - поглощаем события
event.changes.forEach { it.consume() }
// Сами вычисляем трансформацию
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
scale = (scale * zoomChange).coerceIn(1f, 5f)
canScroll = scale <= 1f
if (scale > 1f) {
offset += panChange
} else {
offset = Offset.Zero
}
}
// Если zoomActive == false, мы НЕ вызываем consume(),
// и событие уходит наверх в HorizontalPager
} while (event.changes.any { it.pressed })
}
},
contentAlignment = Alignment.Center
) {
AsyncImage(
model = media.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(LocalContext.current),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
),
contentScale = ContentScale.Fit
)
}
}
else {
// Document / File placeholder in Lightbox
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = media.filename ?: "File",
color = Color.White,
style = MaterialTheme.typography.headlineSmall,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.url))
context.startActivity(intent)
} catch (e: Exception) {
// Handle error (no app to open)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("Открыть в приложении")
}
}
}
}
}
// Top Bar
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onClose,
modifier = Modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Close, contentDescription = null, tint = Color.White)
}
if (mediaList.isNotEmpty()) {
Text(
text = "${pagerState.currentPage + 1} / ${mediaList.size}",
color = Color.White,
style = MaterialTheme.typography.titleMedium
)
}
IconButton(
onClick = {
val currentMedia = mediaList[pagerState.currentPage]
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, currentMedia.url, currentMedia.filename)
}
},
modifier = Modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Download, contentDescription = null, tint = Color.White)
}
}
}
}
}
@@ -0,0 +1,204 @@
package core.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.media3.ui.AspectRatioFrameLayout
import kotlinx.coroutines.delay
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
@OptIn(UnstableApi::class)
@Composable
fun AppVideoPlayer(
url: String,
modifier: Modifier = Modifier,
useController: Boolean = true,
autoPlay: Boolean = true,
isMuted: Boolean = false,
onVideoFinished: () -> Unit = {}
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
volume = if (isMuted) 0f else 1f
prepare()
playWhenReady = autoPlay
}
}
var isPlaying by remember { mutableStateOf(autoPlay) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
var isControlsVisible by remember { mutableStateOf(true) }
var isDragging by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
} else if (state == Player.STATE_ENDED) {
onVideoFinished()
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
LaunchedEffect(isPlaying, isDragging) {
if (isPlaying && !isDragging) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
delay(500)
}
}
}
Box(modifier = modifier.background(Color.Black)) {
AndroidView(
factory = {
PlayerView(it).apply {
player = exoPlayer
this.useController = false // We use our own UI if needed
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setBackgroundColor(android.graphics.Color.BLACK)
}
},
modifier = Modifier.fillMaxSize().clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = useController // Only clickable if we have controls
) {
isControlsVisible = !isControlsVisible
}
)
AnimatedVisibility(
visible = isControlsVisible && useController,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
) {
// Center Controls
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
IconButton(
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Rounded.Replay10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(44.dp)
)
}
IconButton(
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(duration)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Rounded.Forward10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
}
// Bottom Controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp, start = 16.dp, end = 16.dp)
) {
Slider(
value = currentPosition.toFloat(),
onValueChange = {
isDragging = true
currentPosition = it.toLong()
},
onValueChangeFinished = {
exoPlayer.seekTo(currentPosition)
isDragging = false
},
valueRange = 0f..(duration.toFloat().coerceAtLeast(1f)),
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier.height(24.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${formatTime(currentPosition)} · ${formatTime(duration)}",
color = Color.White,
fontSize = 12.sp
)
IconButton(onClick = { /* Settings */ }) {
Icon(Icons.Rounded.Settings, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
}
}
}
}
}
}
private fun formatTime(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
@@ -0,0 +1,146 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.QuestionAnswer
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.knot.messager.R
@Composable
fun GlassNavigationBar(
currentRoute: String,
onNavigate: (String) -> Unit,
userAvatarUrl: String? = null,
username: String = ""
) {
// Контейнер с эффектом стекла
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.height(64.dp)
.clip(RoundedCornerShape(32.dp))
.background(Color(0xFF1C1C1E).copy(alpha = 0.95f))
.border(
0.5.dp,
Color.White.copy(alpha = 0.1f),
RoundedCornerShape(32.dp)
),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
NavItem(
icon = Icons.Default.QuestionAnswer,
label = stringResource(R.string.chats),
isSelected = currentRoute == "chat_list",
onClick = { onNavigate("chat_list") }
)
NavItem(
icon = Icons.Default.AccountCircle,
label = stringResource(R.string.contacts_tab),
isSelected = currentRoute == "contacts",
onClick = { onNavigate("contacts") }
)
NavItem(
icon = Icons.Default.Settings,
label = stringResource(R.string.settings),
isSelected = currentRoute == "settings",
onClick = { onNavigate("settings") }
)
// Профиль с аватаром
ProfileNavItem(
avatarUrl = userAvatarUrl,
username = username,
isSelected = currentRoute.startsWith("profile"),
onClick = { onNavigate("profile") }
)
}
}
}
@Composable
private fun RowScope.NavItem(
icon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
Column(
modifier = Modifier
.weight(1f)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = label,
tint = contentColor,
modifier = Modifier.size(26.dp)
)
Text(
text = label,
color = contentColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 2.dp)
)
}
}
@Composable
private fun RowScope.ProfileNavItem(
avatarUrl: String?,
username: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
Column(
modifier = Modifier
.weight(1f)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
AppAvatar(
url = avatarUrl,
name = username,
size = 26.dp
)
Text(
text = stringResource(R.string.profile_tab),
color = contentColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 2.dp)
)
}
}
@@ -0,0 +1,242 @@
package core.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
// Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh
Scaffold(
containerColor = Color(0xFF0F0F10),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back), tint = Color.White)
}
},
actions = {
IconButton(onClick = { viewModel.loadConfig() }) {
Icon(Icons.Default.Sync, contentDescription = "Refresh", tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFF0F0F10),
titleContentColor = Color.White
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
if (state.isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
color = Color(0xFF3390EC)
)
}
if (state.error != null) {
Text(
text = "Ошибка обновления: ${state.error}",
color = Color.Red,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Server Section
SettingsSection(title = stringResource(R.string.server_connection)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = stringResource(R.string.api_base_url),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3390EC)
)
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(
value = baseUrl,
onValueChange = { baseUrl = it },
modifier = Modifier.weight(1f),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedIndicatorColor = Color(0xFF3390EC),
unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f)
),
placeholder = {
Text("Например: http://192.168.1.100:5034/api/", color = Color.Gray.copy(alpha = 0.5f))
}
)
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC))
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Features Section
SettingsSection(title = stringResource(R.string.server_features)) {
Column {
SettingsToggleItem(
icon = Icons.Default.History,
label = stringResource(R.string.stories),
enabled = state.config.features.stories
)
SettingsToggleItem(
icon = Icons.Default.Poll,
label = stringResource(R.string.polls),
enabled = state.config.features.polls
)
SettingsToggleItem(
icon = Icons.Default.Call,
label = stringResource(R.string.calls),
enabled = state.config.features.calls
)
SettingsToggleItem(
icon = Icons.Default.Group,
label = stringResource(R.string.groups),
enabled = state.config.features.groups
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Limits Section
SettingsSection(title = stringResource(R.string.limits)) {
Column {
SettingsInfoItem(
icon = Icons.Default.Storage,
label = stringResource(R.string.max_file_size),
value = "${state.config.limits.maxFileSize / (1024 * 1024)} MB"
)
SettingsInfoItem(
icon = Icons.Default.People,
label = stringResource(R.string.max_group_members),
value = state.config.limits.maxGroupMembers.toString()
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Logout Button
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Default.Logout, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.log_out), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(80.dp)) // Space for bottom bar
}
}
}
@Composable
fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3390EC),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
)
Surface(
color = Color(0xFF1C1C1E),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth()
) {
content()
}
}
}
@Composable
fun SettingsToggleItem(icon: ImageVector, label: String, enabled: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = Color.White, fontSize = 16.sp)
}
Text(
text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
color = if (enabled) Color(0xFF3390EC) else Color(0xFFE53935),
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
@Composable
fun SettingsInfoItem(icon: ImageVector, label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = Color.White, fontSize = 16.sp)
}
Text(
text = value,
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
}
}
@@ -0,0 +1,81 @@
package core.presentation.settings
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import core.domain.model.ServerConfigModel
import core.network.ServerConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsState(
val baseUrl: String = "",
val config: ServerConfigModel = ServerConfigModel(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val serverConfig: ServerConfig,
private val authRepository: auth.domain.repository.AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
refreshState()
// loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён
}
private fun refreshState() {
_state.update {
it.copy(
baseUrl = serverConfig.getBaseUrl(),
config = serverConfig.getServerConfig()
)
}
}
fun loadConfig() {
// Не загружаем конфиг, если URL не введён
val currentUrl = serverConfig.getBaseUrl()
if (currentUrl.isBlank()) {
android.util.Log.d("SettingsViewModel", "Base URL is empty, skipping config load")
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = authRepository.fetchConfig()
Log.d("SettingsViewModel", "Fetch result: ${result.isSuccess}")
if (result.isSuccess) {
refreshState()
} else {
_state.update { it.copy(error = result.exceptionOrNull()?.message) }
}
_state.update { it.copy(isLoading = false) }
}
}
fun saveBaseUrl(url: String) {
val currentUrl = serverConfig.getBaseUrl()
// Если URL действительно изменился, очищаем токен и логируем
if (currentUrl != url) {
android.util.Log.d("SettingsViewModel", "Server URL changed: $currentUrl -> $url, clearing token")
viewModelScope.launch {
authRepository.logout()
}
}
serverConfig.setBaseUrl(url)
_state.update { it.copy(baseUrl = url) }
}
}
@@ -0,0 +1,41 @@
package core.presentation.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// Цвета из веб-версии (Dark Mode)
val DarkBackground = Color(0xFF0F0F10)
val SurfaceContainer = Color(0xFF161618)
val Primary = Color(0xFF6366F1) // Indigo/Primary из Tailwind
val PrimaryIndigo = Primary
val Indigo500 = Primary
val OnSurfaceVariant = Color(0xFF94A3B8)
private val DarkColors = darkColorScheme(
primary = Primary,
background = DarkBackground,
surface = SurfaceContainer,
onPrimary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onSurfaceVariant = OnSurfaceVariant
)
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
@Composable
fun KnotTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColors,
shapes = MaterialTheme.shapes.copy(
small = RoundedCornerShape(12.dp),
medium = SoftSquareShape,
large = RoundedCornerShape(32.dp)
),
content = content
)
}
@@ -0,0 +1,49 @@
package core.security
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenManager @Inject constructor(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String, userId: String? = null, refreshToken: String? = null) {
val editor = prefs.edit().putString("jwt_token", token)
if (userId != null) {
editor.putString("user_id", userId)
}
if (refreshToken != null) {
editor.putString("refresh_token", refreshToken)
}
editor.apply()
}
fun getToken(): String? {
return prefs.getString("jwt_token", null)
}
fun getRefreshToken(): String? {
return prefs.getString("refresh_token", null)
}
fun getUserId(): String? {
return prefs.getString("user_id", null)
}
fun deleteToken() {
prefs.edit().remove("jwt_token").remove("user_id").remove("refresh_token").apply()
}
}
+26
View File
@@ -0,0 +1,26 @@
package core.utils
import android.content.Context
import android.os.Build
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
object CoilUtils {
private var gifImageLoader: ImageLoader? = null
fun getGifImageLoader(context: Context): ImageLoader {
if (gifImageLoader == null) {
gifImageLoader = ImageLoader.Builder(context)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
}
return gifImageLoader!!
}
}

Some files were not shown because too many files have changed in this diff Show More