47 Commits

Author SHA1 Message Date
Халимов Рустам
a426b63b7d Получение и отправка текста, статусы сообщения 2026-05-15 14:59:54 +03:00
Халимов Рустам
4d4bc8edd1 Разделение навигации и реактивности 2026-05-15 11:00:38 +03:00
Халимов Рустам
40589dbb75 Повторный вход в аккаунт, очистка кэша 2026-05-15 00:10:34 +03:00
Халимов Рустам
0b013def2e Отображение чатов 2026-05-14 22:56:42 +03:00
Халимов Рустам
ccffc5a53c Настройки, авторизация 2026-05-14 12:06:34 +03:00
Халимов Рустам
e8161d23d4 Приложение на флаторе, настройки 2026-05-13 17:21:37 +03:00
Халимов Рустам
89e325556c Удалил к херам нативную версию 2026-05-13 13:49:46 +03:00
Халимов Рустам
df4feeeee5 Правки 2026-05-13 13:48:34 +03:00
Халимов Рустам
00ed4b5959 Фиксы 2026-05-08 23:22:07 +03:00
Халимов Рустам
a0b50b57b0 Вторая часть по кэшу 2026-05-08 22:48:56 +03:00
Халимов Рустам
d4c41b333a compose 2026-05-07 23:34:28 +03:00
Халимов Рустам
9daa786cfb Заготовка 2026-05-07 23:27:37 +03:00
Халимов Рустам
2d2e4b685e Восстановлены голосовые 2026-04-30 10:33:15 +03:00
Халимов Рустам
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
167 changed files with 31620 additions and 704 deletions

10
.gitignore vendored
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/

View File

@@ -7,9 +7,8 @@ using FluentAssertions;
using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.Create;
namespace Knot.Modules.Conversations.UnitTests.Chats;

View File

@@ -7,9 +7,10 @@ using FluentAssertions;
using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Chats.GetChats;
using Knot.Modules.Conversations.Application.DTOs;

View File

@@ -1,9 +1,8 @@
using FluentAssertions;
using NSubstitute;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Xunit;

View File

@@ -23,10 +23,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,15 +5,17 @@ using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using MediatR;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Settings.Application.Settings.Abstractions;
using Knot.Modules.Settings.Application.Settings.DTOs;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
namespace Knot.Modules.Conversations.UnitTests.Messages;
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<SendMessageCommandHandler> _logger;
private readonly SendMessageCommandHandler _handler;
public SendMessageCommandHandlerTests()
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
_mediator = Substitute.For<IMediator>();
_messagesSettings = Substitute.For<IMessagesSettings>();
_idempotencyStore = Substitute.For<IIdempotencyStore>();
_logger = Substitute.For<ILogger<SendMessageCommandHandler>>();
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig();
var config = new MessagesConfig();
_messagesSettings.Current.Returns(config);
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
}
[Fact]

View File

@@ -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);
}

View File

@@ -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");
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,17 @@
namespace Knot.Contracts.Conversations.Application.Abstractions;
/// <summary>
/// Хранилище для обеспечения идемпотентности операций.
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
/// </summary>
public interface IIdempotencyStore
{
/// <summary>
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
/// </summary>
Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default);
}

View File

@@ -32,11 +32,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
using Knot.Modules.WebRtc;
using Knot.Modules.WebRtc.Presentation.Endpoints;
using Knot.Shared.Infrastructure;
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MediatR;

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"
}
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> 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
});
}
}

View File

@@ -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>;

View File

@@ -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
});
}
}

View File

@@ -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>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </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. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
var user = User.Create(
request.Username,
passwordHash,
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email,
request.Bio);
// 4. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> - <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20> 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
});
}
}

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
};
}
}

View File

@@ -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[]

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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();
}

View File

@@ -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;
@@ -114,7 +114,8 @@ 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)
));
}

View File

@@ -1,9 +1,10 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -30,7 +31,8 @@ public sealed record SendMessageCommand(
DateTime? PollExpiresAt = null,
string? CallType = null,
string? CallStatus = null,
int? Duration = null) : ICommand<Guid>;
int? Duration = null,
string? IdempotencyKey = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -39,64 +41,74 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator,
IMessagesSettings messagesSettings)
IMessagesSettings messagesSettings,
IIdempotencyStore idempotencyStore,
ILogger<SendMessageCommandHandler> logger)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
_messagesSettings = messagesSettings;
_idempotencyStore = idempotencyStore;
_logger = logger;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
var key = $"send_msg:{request.ChatId}:{request.IdempotencyKey}";
return await _idempotencyStore.GetOrCreateAsync(
key,
factory: ct => ExecuteAsync(request, ct),
cancellationToken: cancellationToken);
}
return await ExecuteAsync(request, cancellationToken);
}
private async Task<Result<Guid>> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. Проверка существования чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
}
// 2. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// 2. Проверка, является ли отправитель участником чата
if (!chat.Members.Any(m => m.UserId == request.SenderId))
{
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
}
// 3. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// 3. Создание сообщения
Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
request.StoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else if (request.Attachments != null && request.Attachments.Any())
@@ -106,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
foreach (var att in request.Attachments)
{
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
@@ -167,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
{
message = new TextMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
// 4. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> High-Water Mark
// 4. Обновление High-Water Mark
chat.IncrementSequenceId();
message.SetSequenceId(chat.LastMessageSequenceId);
@@ -193,19 +188,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// 5. Сохранение
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id);

View File

@@ -1,6 +1,8 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Idempotency;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Infrastructure.Services;
@@ -8,7 +10,6 @@ using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Knot.Contracts.Messaging.Application.Abstractions;
namespace Knot.Modules.Conversations;
@@ -42,7 +43,10 @@ public static class DependencyInjection
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
services.AddMemoryCache();
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
return services;
}
}

View File

@@ -0,0 +1,54 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
/// <summary>
/// Реализация хранилища идемпотентности на основе IMemoryCache.
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
/// </summary>
public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore
{
private readonly IMemoryCache _cache;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public MemoryCacheIdempotencyStore(IMemoryCache cache)
{
_cache = cache;
}
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null)
{
return cachedValue;
}
await _semaphore.WaitAsync(cancellationToken);
try
{
// Double-check после получения блокировки
if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null)
{
return cachedValue;
}
var value = await factory(cancellationToken);
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24))
.SetPriority(CacheItemPriority.Normal);
_cache.Set(key, value, options);
return value;
}
finally
{
_semaphore.Release();
}
}
}

View File

@@ -37,6 +37,7 @@
<ItemGroup>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -55,11 +55,14 @@ public static class MessagesEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
// Получаем idempotency ключ из заголовка
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
var command = new SendMessageCommand(
chatId,
userContext.UserId,
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
request.ForwardedFromId,
IdempotencyKey: idempotencyKey.ToString());
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);

44
client-mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

20
client-mobile/.metadata Normal file
View File

@@ -0,0 +1,20 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
- platform: android
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb

View File

@@ -0,0 +1,125 @@
# Инструкция по запуску проекта
## Предварительные требования
1. **Flutter SDK** (версия 3.0.0 или выше)
- Установите Flutter: https://docs.flutter.dev/get-started/install
- Проверьте установку: `flutter doctor`
2. **Android Studio** или **VS Code** с Flutter плагином
3. **Android SDK** (для сборки под Android)
- MinSDK: 21
- TargetSDK: 34
- CompileSDK: 34
## Установка зависимостей
```bash
cd client-mobile
flutter pub get
```
## Генерация кода
Проект использует code generation для:
- Freezed (immutable модели)
- AutoRoute (навигация)
- Isar (база данных)
```bash
dart run build_runner build --delete-conflicting-outputs
```
Для автоматической генерации при изменениях:
```bash
dart run build_runner watch --delete-conflicting-outputs
```
## Запуск приложения
### Android
```bash
flutter run
```
### Сборка релиза
```bash
flutter build apk --release
```
## Структура проекта
```
lib/
├── core/ # Общие компоненты
│ ├── constants/ # Константы приложения
│ ├── errors/ # Обработка ошибок
│ ├── network/ # Сетевой клиент (Dio)
│ └── theme/ # Темы оформления
├── features/ # Функциональные модули
│ ├── auth/ # Аутентификация
│ │ ├── data/ # Data layer
│ │ ├── domain/ # Domain layer
│ │ └── presentation/ # UI layer (BLoC, страницы)
│ ├── chat/ # Чаты
│ ├── profile/ # Профиль
│ └── settings/ # Настройки
└── internal/ # Внутренняя конфигурация
├── di/ # Dependency Injection (GetIt)
└── router/ # Навигация
```
## Архитектура
Проект следует принципам **Clean Architecture**:
- **Domain Layer**: Бизнес-логика, entities, use cases, repository interfaces
- **Data Layer**: Реализации репозиториев, datasources, модели
- **Presentation Layer**: UI, BLoC, страницы, виджеты
## State Management
Используется **flutter_bloc** для управления состоянием:
- Каждый feature имеет свой BLoC
- События и состояния генерируются через Freezed
- DI через GetIt
## Навигация
Используется **auto_route** для декларативной навигации.
## База данных
Используется **Isar** - быстрая NoSQL база данных для Flutter.
## Сетевые запросы
- **Dio** для REST API запросов
- **SignalR** для real-time обновлений
## Тестирование
```bash
flutter test
```
## Полезные команды
```bash
# Анализ кода
flutter analyze
# Форматирование
dart format .
# Очистка
flutter clean
# Проверка зависимостей
flutter pub outdated
```
## Контакты
Для вопросов и предложений обращайтесь к команде разработки.

51
client-mobile/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Messenger App
A Telegram-like mobile messenger application built with Flutter using Clean Architecture.
## Architecture
This project follows Clean Architecture principles with the following structure:
```
lib/
├── core/ # Core utilities, network, errors, theme
├── features/ # Feature modules (auth, chat, profile, settings)
│ └── [feature]/
│ ├── data/ # Data layer (repositories, datasources)
│ ├── domain/ # Domain layer (entities, usecases, repository interfaces)
│ └── presentation/ # UI layer (bloc, pages, widgets)
└── internal/ # App configuration, DI, routing
```
## Dependencies
- **State Management**: flutter_bloc, bloc
- **Architecture & DI**: get_it, injectable, freezed_annotation
- **Network & Real-time**: dio, signalr_netcore
- **Navigation**: auto_route
- **Database**: isar, isar_flutter_libs
## Setup
1. Install Flutter dependencies:
```bash
flutter pub get
```
2. Generate code:
```bash
dart run build_runner build --delete-conflicting-outputs
```
3. Run the app:
```bash
flutter run
```
## Features
- Authentication (Login/Register)
- Real-time Chat
- Contacts
- Profile Management
- Settings

View File

@@ -0,0 +1,8 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_declarations: true
avoid_print: false
prefer_single_quotes: true

7
client-mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java

View File

@@ -0,0 +1,69 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.example.messenger_app"
compileSdkVersion 36
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.example.messenger_app"
minSdkVersion flutter.minSdkVersion
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
buildTypes {
release {
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {}

View File

@@ -0,0 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Messenger"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.example.messenger_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2481CC" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
afterEvaluate { project ->
if (project.hasProperty("android")) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

View File

@@ -0,0 +1,14 @@
// This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=C:\src\flutter
FLUTTER_APPLICATION_PATH=E:\GIT\forkmessager\client-mobile
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib\main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -0,0 +1,32 @@
#
# Generated file, do not edit.
#
import lldb
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
base = frame.register["x0"].GetValueAsAddress()
page_len = frame.register["x1"].GetValueAsUnsigned()
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
# first page to see if handled it correctly. This makes diagnosing
# misconfiguration (e.g. missing breakpoint) easier.
data = bytearray(page_len)
data[0:8] = b'IHELPED!'
error = lldb.SBError()
frame.GetThread().GetProcess().WriteMemory(base, data, error)
if not error.Success():
print(f'Failed to write into {base}[+{page_len}]', error)
return
def __lldb_init_module(debugger: lldb.SBDebugger, _):
target = debugger.GetDummyTarget()
# Caveat: must use BreakpointCreateByRegEx here and not
# BreakpointCreateByName. For some reasons callback function does not
# get carried over from dummy target for the later.
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
bp.SetAutoContinue(True)
print("-- LLDB integration loaded --")

View File

@@ -0,0 +1,5 @@
#
# Generated file, do not edit.
#
command script import --relative-to-command-file flutter_lldb_helper.py

View File

@@ -0,0 +1,13 @@
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=C:\src\flutter"
export "FLUTTER_APPLICATION_PATH=E:\GIT\forkmessager\client-mobile"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib\main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

View File

@@ -0,0 +1,13 @@
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */

View File

@@ -0,0 +1,28 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
#if __has_include(<isar_flutter_libs/IsarFlutterLibsPlugin.h>)
#import <isar_flutter_libs/IsarFlutterLibsPlugin.h>
#else
@import isar_flutter_libs;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@import shared_preferences_foundation;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[IsarFlutterLibsPlugin registerWithRegistrar:[registry registrarForPlugin:@"IsarFlutterLibsPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
}
@end

6
client-mobile/l10n.yaml Normal file
View File

@@ -0,0 +1,6 @@
arb-dir: l10n
template-arb-file: app_ru.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
synthetic-package: false

View File

@@ -0,0 +1,93 @@
{
"@@locale": "en",
"appTitle": "Messenger",
"chats": "Chats",
"favorites": "Favorites",
"contacts": "Contacts",
"settings": "Settings",
"login": "Login",
"username": "Login",
"password": "Password",
"name": "Name",
"loginButton": "Sign In",
"register": "Register",
"noAccount": "No account? Sign up",
"logout": "Sign Out",
"serverSettings": "Server Settings",
"apiUrl": "API URL",
"apiUrlHint": "https://api.example.com",
"save": "Save",
"serverConfig": "Server Configuration",
"system": "System",
"stories": "Stories",
"chatsModule": "Chats",
"messages": "Messages",
"calls": "Calls",
"klipy": "Klipy",
"import": "Import",
"federation": "Federation",
"domainUrl": "Domain",
"registrationStatus": "Registration",
"enabled": "Enabled",
"disabled": "Disabled",
"language": "Language",
"russian": "Русский",
"english": "English",
"notifications": "Notifications",
"privacy": "Privacy",
"soundsAndVibration": "Sounds & Vibration",
"dataAndStorage": "Data & Storage",
"aboutApp": "About App",
"help": "Help",
"version": "Version",
"loading": "Loading...",
"error": "Error",
"retry": "Retry",
"noChats": "No chats yet",
"inDevelopment": "In development",
"user": "User",
"edit": "Edit",
"serverConnectionError": "Server connection error",
"connected": "Connected",
"unknownError": "Unknown error",
"networkError": "Network error",
"serverError": "Server error",
"parsingError": "Data parsing error",
"voice": "Voice Calls",
"video": "Video Calls",
"lifetime": "Lifetime",
"groups": "Groups",
"maxParticipants": "Max participants",
"hours": "h",
"dailyLimit": "Daily Limit",
"historyLimit": "History Limit",
"maxFileSize": "Max File Size",
"noLimit": "No Limit",
"allowForwarding": "Forwarding",
"allowReactions": "Reactions",
"allowReplies": "Replies",
"allowQuoting": "Quoting",
"allowMessageDeletion": "Deletion",
"forbidCopying": "Forbid Copying",
"allowLinks": "Links",
"allowPolls": "Polls",
"allowPinning": "Pins",
"allowMedia": "Media",
"description": "Description",
"server": "Server",
"notSpecified": "not specified",
"settingsSaved": "Settings saved",
"enterUsername": "Enter login",
"invalidUsername": "Login must be at least 3 characters and without Cyrillic",
"loginFailed": "Login failed. Check your login and password",
"registrationFailed": "Registration failed",
"online": "online",
"lastSeen": "last seen {date} at {time}",
"lastSeenRelative": "last seen {hours}h {minutes}m ago",
"lastSeenRelativeMinutes": "last seen {minutes}m ago",
"lastSeenJustNow": "last seen just now",
"lastSeenYesterday": "last seen yesterday at {time}",
"lastSeenDayBeforeYesterday": "last seen day before yesterday at {time}",
"noMessages": "No messages yet",
"messageHint": "Message"
}

View File

@@ -0,0 +1,93 @@
{
"@@locale": "ru",
"appTitle": "Мессенджер",
"chats": "Чаты",
"favorites": "Избранное",
"contacts": "Контакты",
"settings": "Настройки",
"login": "Вход",
"username": "Логин",
"password": "Пароль",
"name": "Имя",
"loginButton": "Войти",
"register": "Зарегистрироваться",
"noAccount": "Нет аккаунта? Зарегистрироваться",
"logout": "Выйти из аккаунта",
"serverSettings": "Настройки сервера",
"apiUrl": "Адрес API",
"apiUrlHint": "https://api.example.com",
"save": "Сохранить",
"serverConfig": "Конфигурация сервера",
"system": "Система",
"stories": "Истории",
"chatsModule": "Чаты",
"messages": "Сообщения",
"calls": "Звонки",
"klipy": "Клипы",
"import": "Импорт",
"federation": "Федерация",
"domainUrl": "Домен",
"registrationStatus": "Регистрация",
"enabled": "Включено",
"disabled": "Отключено",
"language": "Язык",
"russian": "Русский",
"english": "English",
"notifications": "Уведомления",
"privacy": "Конфиденциальность",
"soundsAndVibration": "Звуки и вибрация",
"dataAndStorage": "Данные и память",
"aboutApp": "О приложении",
"help": "Помощь",
"version": "Версия",
"loading": "Загрузка...",
"error": "Ошибка",
"retry": "Повторить",
"noChats": "У вас пока нет чатов",
"inDevelopment": "Функция в разработке",
"user": "Пользователь",
"edit": "Редактировать",
"serverConnectionError": "Ошибка подключения к серверу",
"connected": "Подключено",
"unknownError": "Неизвестная ошибка",
"networkError": "Ошибка сети",
"serverError": "Ошибка сервера",
"parsingError": "Ошибка обработки данных",
"voice": "Голосовые вызовы",
"video": "Видеовызовы",
"lifetime": "Время жизни",
"groups": "Группы",
"maxParticipants": "Макс. участников",
"hours": "ч.",
"dailyLimit": "Дневной лимит",
"historyLimit": "Лимит истории",
"maxFileSize": "Макс. файл",
"noLimit": "Без лимита",
"allowForwarding": "Пересылка",
"allowReactions": "Реакции",
"allowReplies": "Ответы",
"allowQuoting": "Цитирование",
"allowMessageDeletion": "Удаление",
"forbidCopying": "Запрет копирования",
"allowLinks": "Ссылки",
"allowPolls": "Опросы",
"allowPinning": "Закрепы",
"allowMedia": "Медиа",
"description": "Описание",
"server": "Сервер",
"notSpecified": "не указан",
"settingsSaved": "Настройки сохранены",
"enterUsername": "Введите логин",
"invalidUsername": "Логин должен быть от 3 символов и без кириллицы",
"loginFailed": "Ошибка входа. Проверьте логин и пароль",
"registrationFailed": "Ошибка регистрации",
"online": "в сети",
"lastSeen": "был(а) в сети {date} в {time}",
"lastSeenRelative": "был(а) {hours} ч. {minutes} мин. назад",
"lastSeenRelativeMinutes": "был(а) {minutes} мин. назад",
"lastSeenJustNow": "был(а) только что",
"lastSeenYesterday": "был(а) вчера в {time}",
"lastSeenDayBeforeYesterday": "был(а) позавчера в {time}",
"noMessages": "Сообщений пока нет",
"messageHint": "Сообщение"
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'core/theme/app_theme.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/chat/presentation/bloc/chat_bloc.dart';
import 'features/chat/presentation/bloc/chat_event.dart';
import 'internal/di/injection_container.dart' as di;
import 'features/settings/presentation/bloc/settings_bloc.dart';
import 'features/settings/presentation/bloc/settings_event.dart';
import 'features/settings/presentation/bloc/settings_state.dart';
import 'l10n/app_localizations.dart';
import 'internal/router/main_screen.dart';
import 'features/auth/presentation/pages/login_page.dart';
import 'features/auth/presentation/bloc/auth_state.dart';
class MessengerApp extends StatelessWidget {
const MessengerApp({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>(
create: (_) => di.sl<AuthBloc>()..add(const AuthEvent.authChecked()),
),
BlocProvider<ChatBloc>(
create: (_) => di.sl<ChatBloc>()..add(const ChatEvent.started()),
),
BlocProvider<SettingsBloc>(
create: (_) => di.sl<SettingsBloc>()..add(const SettingsEvent.started()),
),
],
child: BlocBuilder<SettingsBloc, SettingsState>(
builder: (context, state) {
final locale = state.maybeWhen(
loaded: (_, __, languageCode, ___, ____) => Locale(languageCode),
orElse: () => const Locale('ru'),
);
return MaterialApp(
title: 'Messenger',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
locale: locale,
supportedLocales: const [
Locale('ru'),
Locale('en'),
],
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: BlocListener<AuthBloc, AuthState>(
listener: (context, authState) {
authState.maybeWhen(
authenticated: (userId) {
context.read<ChatBloc>().add(ChatEvent.started(userId: userId));
},
unauthenticated: () {
context.read<ChatBloc>().add(const ChatEvent.cacheCleared());
},
orElse: () {},
);
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
return authState.maybeWhen(
authenticated: (_) => const MainScreen(),
orElse: () => const LoginPage(),
);
},
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,25 @@
class AppConstants {
AppConstants._();
// API
static const String apiBaseUrl = 'https://api.messenger.app';
static const int apiTimeout = 30;
// Storage
static const String sharedPrefsName = 'messenger_prefs';
static const String tokenKey = 'auth_token';
static const String refreshTokenKey = 'refresh_token';
static const String userIdKey = 'user_id';
// Pagination
static const int pageSize = 20;
static const int defaultPageSize = 50;
// Chat
static const int maxMessageLength = 4096;
static const int typingIndicatorTimeout = 3000;
// Media
static const int maxImageSize = 10 * 1024 * 1024; // 10MB
static const int maxVideoSize = 100 * 1024 * 1024; // 100MB
}

View File

@@ -0,0 +1,9 @@
class StorageKeys {
StorageKeys._();
static const String apiUrl = 'api_url';
static const String languageCode = 'language_code';
static const String serverConfig = 'server_config';
static const String authToken = 'auth_token';
static const String refreshToken = 'refresh_token';
}

View File

@@ -0,0 +1,22 @@
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import '../../features/chat/data/models/chat_model.dart';
import '../../features/chat/data/models/message_model.dart';
class DatabaseService {
late Isar _isar;
Isar get isar => _isar;
Future<void> init() async {
final dir = await getApplicationDocumentsDirectory();
_isar = await Isar.open(
[ChatModelSchema, MessageModelSchema],
directory: dir.path,
);
}
Future<void> clearAll() async {
await _isar.writeTxn(() => _isar.clear());
}
}

View File

@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'errors.freezed.dart';
@freezed
class AppError with _$AppError {
const factory AppError.unknown({String? message}) = UnknownError;
const factory AppError.network({String? message}) = NetworkError;
const factory AppError.unauthorized({String? message}) = UnauthorizedError;
const factory AppError.forbidden({String? message}) = ForbiddenError;
const factory AppError.notFound({String? message}) = NotFoundError;
const factory AppError.server({int? statusCode, String? message}) = ServerError;
const factory AppError.database({String? message}) = DatabaseError;
const factory AppError.validation({String? message, Map<String, String>? fieldErrors}) = ValidationError;
const factory AppError.parsing({String? message}) = ParsingError;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import 'errors.dart';
/// A generic class for handling success/failure results
class Result<T> {
final T? data;
final AppError? error;
const Result.success(this.data) : error = null;
const Result.failure(this.error) : data = null;
bool get isSuccess => error == null;
bool get isFailure => error != null;
R when<R>({
required R Function(T data) onSuccess,
required R Function(AppError error) onFailure,
}) {
if (isSuccess) {
return onSuccess(data as T);
}
return onFailure(error!);
}
R fold<R>(
R Function(AppError error) onFailure,
R Function(T data) onSuccess,
) {
if (isSuccess) {
return onSuccess(data as T);
}
return onFailure(error!);
}
Result<R> map<R>(R Function(T data) transform) {
if (isSuccess) {
return Result.success(transform(data as T));
}
return Result.failure(error!);
}
T getOrThrow() {
if (isFailure) {
throw error!;
}
return data as T;
}
}

View File

@@ -0,0 +1,135 @@
import 'package:dio/dio.dart';
import '../errors/errors.dart';
import '../errors/result.dart';
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<Result<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
Future<Result<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
Future<Result<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
Future<Result<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return Result.success(response.data as T);
} on DioException catch (e) {
return Result.failure(_handleDioError(e));
} catch (e) {
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
}
}
AppError _handleDioError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const AppError.network(message: 'Connection timeout');
case DioExceptionType.connectionError:
return const AppError.network(message: 'No internet connection');
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
switch (statusCode) {
case 401:
return const AppError.unauthorized();
case 403:
return const AppError.forbidden();
case 404:
return const AppError.notFound();
case 422:
return AppError.validation(fieldErrors: _extractValidationErrors(error.response?.data));
case int s when s >= 500:
return AppError.server(statusCode: statusCode, message: 'Server error');
default:
return AppError.server(statusCode: statusCode, message: 'Request failed');
}
case DioExceptionType.cancel:
return const AppError.unknown(message: 'Request cancelled');
default:
return const AppError.unknown();
}
}
Map<String, String>? _extractValidationErrors(dynamic data) {
if (data is Map<String, dynamic> && data.containsKey('errors')) {
final errors = data['errors'] as Map<String, dynamic>;
return errors.map((key, value) {
if (value is List && value.isNotEmpty) {
return MapEntry(key, value.first.toString());
}
return MapEntry(key, 'Invalid');
});
}
return null;
}
}

View File

@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:signalr_netcore/signalr_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/chat/domain/entities/message.dart';
class SignalRService {
final SharedPreferences prefs;
HubConnection? _hubConnection;
final _messageController = StreamController<Message>.broadcast();
final _typingController = StreamController<Map<String, dynamic>>.broadcast();
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
final _readReceiptController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Message> get messages => _messageController.stream;
Stream<Map<String, dynamic>> get typingUpdates => _typingController.stream;
Stream<Map<String, dynamic>> get statusUpdates => _statusController.stream;
Stream<Map<String, dynamic>> get readReceipts => _readReceiptController.stream;
SignalRService(this.prefs);
Future<void> init() async {
if (_hubConnection?.state == HubConnectionState.Connected ||
_hubConnection?.state == HubConnectionState.Connecting) {
// ignore: avoid_print
print('[SignalR] Connection already active or connecting. Skipping init.');
return;
}
if (_hubConnection != null) {
await stop();
}
final apiUrl = prefs.getString('api_url') ?? 'https://api.messenger.app';
final token = prefs.getString('access_token');
if (token == null) return;
final hubUrl = '${apiUrl.replaceAll(RegExp(r'/+$'), '')}/hubs/chat';
// ignore: avoid_print
print('[SignalR] Initializing connection to: $hubUrl');
_hubConnection = HubConnectionBuilder()
.withUrl(hubUrl, options: HttpConnectionOptions(
accessTokenFactory: () async => token,
))
.withAutomaticReconnect()
.build();
_hubConnection?.onclose(({error}) {
// ignore: avoid_print
print('[SignalR] Connection closed. Error: $error');
});
_hubConnection?.onreconnecting(({error}) {
// ignore: avoid_print
print('[SignalR] Reconnecting... Error: $error');
});
_hubConnection?.onreconnected(({connectionId}) {
// ignore: avoid_print
print('[SignalR] Reconnected! ID: $connectionId');
});
// Event names verified from backend source code (MessageNotifier.cs)
_hubConnection?.on('new_message', _handleReceiveMessage);
_hubConnection?.on('new_message_received', _handleReceiveMessage); // Optional fallback
_hubConnection?.on('user_typing', _handleUserTyping);
_hubConnection?.on('user_stopped_typing', _handleUserStoppedTyping);
_hubConnection?.on('user_online', (args) {
// ignore: avoid_print
print('[SignalR] user_online: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_statusController.add({'userId': data['userId'] ?? data['UserId'], 'isOnline': true});
}
});
_hubConnection?.on('user_offline', (args) {
// ignore: avoid_print
print('[SignalR] user_offline: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_statusController.add({
'userId': data['userId'] ?? data['UserId'],
'isOnline': false,
'lastSeen': data['lastSeen'] ?? data['LastSeen']
});
}
});
_hubConnection?.on('messages_read', (args) {
// ignore: avoid_print
print('[SignalR] messages_read: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_readReceiptController.add({
'chatId': data['chatId'] ?? data['ChatId'],
'userId': data['userId'] ?? data['UserId'],
'lastReadMessageId': data['lastReadMessageId'] ?? data['LastReadMessageId'],
'lastReadSequenceId': data['lastReadSequenceId'] ?? data['LastReadSequenceId'],
});
}
});
try {
// ignore: avoid_print
print('[SignalR] Starting connection...');
await _hubConnection?.start();
// ignore: avoid_print
print('[SignalR] Connection started successfully. State: ${_hubConnection?.state}');
} catch (e) {
// ignore: avoid_print
print('[SignalR] Connection failed: $e');
}
}
Future<void> markMessagesAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId) async {
// ignore: avoid_print
print('[SignalR] markMessagesAsRead: chatId=$chatId, msgId=$lastReadMessageId, seqId=$lastReadSequenceId');
await _hubConnection?.invoke('read_messages', args: [
{
'ChatId': chatId,
'LastReadMessageId': lastReadMessageId,
'LastReadSequenceId': lastReadSequenceId,
}
]);
}
void _handleUserTyping(List<dynamic>? arguments) {
// ignore: avoid_print
print('[SignalR] _handleUserTyping: $arguments');
if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>;
_typingController.add({'chatId': data['ChatId'] ?? data['chatId'], 'userId': data['UserId'] ?? data['userId'], 'isTyping': true});
}
}
void _handleUserStoppedTyping(List<dynamic>? arguments) {
// ignore: avoid_print
print('[SignalR] _handleUserStoppedTyping: $arguments');
if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>;
_typingController.add({'chatId': data['ChatId'] ?? data['chatId'], 'userId': data['UserId'] ?? data['userId'], 'isTyping': false});
}
}
Future<void> sendTypingStatus(String chatId, bool isTyping) async {
if (isTyping) {
await _hubConnection?.invoke('typing_start', args: [chatId]);
} else {
await _hubConnection?.invoke('typing_stop', args: [chatId]);
}
}
void _handleReceiveMessage(List<dynamic>? arguments) {
// ignore: avoid_print
print('[SignalR] _handleReceiveMessage: $arguments');
if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>;
final message = Message(
id: (data['id'] ?? data['Id'] ?? '').toString(),
chatId: (data['chatId'] ?? data['ChatId'] ?? '').toString(),
senderId: (data['senderId'] ?? data['SenderId'] ?? data['userId'] ?? data['UserId'] ?? '').toString(),
content: data['content'] ?? data['Content'] ?? '',
messageType: (data['type'] ?? data['Type'] ?? 'text').toString().toLowerCase(),
sequenceId: int.tryParse((data['sequenceId'] ?? data['SequenceId'] ?? '0').toString()) ?? 0,
createdAt: _parseDateTime(data['createdAt'] ?? data['CreatedAt']),
updatedAt: _parseDateTime(data['updatedAt'] ?? data['UpdatedAt']),
status: (data['isRead'] ?? data['IsRead'] ?? false) ? MessageStatus.read : MessageStatus.delivered,
media: (data['media'] ?? data['Media']) is Map<String, dynamic>
? (data['media'] ?? data['Media']) as Map<String, dynamic>
: null,
);
_messageController.add(message);
}
}
DateTime? _parseDateTime(String? dateStr) {
if (dateStr == null) return null;
String normalized = dateStr;
if (!normalized.endsWith('Z') && !normalized.contains('+')) {
normalized += 'Z';
}
return DateTime.parse(normalized).toLocal();
}
Future<void> stop() async {
await _hubConnection?.stop();
_hubConnection = null;
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppTheme {
AppTheme._();
static const _primaryColor = Color(0xFF2481CC);
static const _primaryContainerColor = Color(0xFFD3E4FD);
static const _secondaryColor = Color(0xFF5F6AC4);
static const _tertiaryColor = Color(0xFFFFD700);
static const _lightSurfaceColor = Color(0xFFFFFBFE);
static const _darkSurfaceColor = Color(0xFF1C1B1F);
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: const ColorScheme.light(
primary: _primaryColor,
primaryContainer: _primaryContainerColor,
secondary: _secondaryColor,
tertiary: _tertiaryColor,
surface: _lightSurfaceColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: Colors.black87,
error: Color(0xFFBA1A1A),
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: _primaryColor,
unselectedItemColor: Colors.grey,
elevation: 8,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
primary: _primaryColor,
primaryContainer: _primaryContainerColor,
secondary: _secondaryColor,
tertiary: _tertiaryColor,
surface: _darkSurfaceColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: const Color(0xFFE6E1E5),
error: Color(0xFFFFB4AB),
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF1F1F1F),
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: Color(0xFF1F1F1F),
selectedItemColor: _primaryColor,
unselectedItemColor: Colors.grey,
elevation: 8,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2D2D2D),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}

View File

@@ -0,0 +1,11 @@
abstract class AuthLocalDataSource {
Future<String?> getToken();
String? getTokenSync();
Future<void> saveToken(String token);
Future<String?> getRefreshToken();
Future<void> saveRefreshToken(String token);
Future<void> removeToken();
Future<String?> getUserId();
Future<void> saveUserId(String userId);
Future<void> removeUserId();
}

View File

@@ -0,0 +1,58 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'auth_local_datasource.dart';
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
final SharedPreferences sharedPreferences;
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';
AuthLocalDataSourceImpl(this.sharedPreferences);
@override
Future<String?> getToken() async {
return sharedPreferences.getString(_accessTokenKey);
}
@override
String? getTokenSync() {
return sharedPreferences.getString(_accessTokenKey);
}
Future<String?> getRefreshToken() async {
return sharedPreferences.getString(_refreshTokenKey);
}
@override
Future<void> saveToken(String token) async {
// ignore: avoid_print
print('[DEBUG] Saving new access token (starts with: ${token.substring(0, 10)}...)');
await sharedPreferences.setString(_accessTokenKey, token);
}
Future<void> saveRefreshToken(String token) async {
await sharedPreferences.setString(_refreshTokenKey, token);
}
@override
Future<void> removeToken() async {
await sharedPreferences.remove(_accessTokenKey);
await sharedPreferences.remove(_refreshTokenKey);
}
@override
Future<String?> getUserId() async {
return sharedPreferences.getString(_userIdKey);
}
@override
Future<void> saveUserId(String userId) async {
await sharedPreferences.setString(_userIdKey, userId);
}
@override
Future<void> removeUserId() async {
await sharedPreferences.remove(_userIdKey);
}
}

View File

@@ -0,0 +1,11 @@
import '../../../../core/errors/result.dart';
import '../../domain/entities/user.dart';
import '../models/auth_response.dart';
abstract class AuthRemoteDataSource {
Future<Result<AuthResponse>> login(String username, String password);
Future<Result<AuthResponse>> register(String username, String password, String name);
Future<Result<void>> logout();
Future<Result<User>> getCurrentUser();
Future<Result<void>> refreshToken();
}

View File

@@ -0,0 +1,87 @@
import '../../../../core/errors/errors.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/errors/result.dart';
import '../../domain/entities/user.dart';
import '../models/auth_response.dart';
import 'auth_remote_datasource.dart';
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final ApiClient apiClient;
AuthRemoteDataSourceImpl(this.apiClient);
@override
Future<Result<AuthResponse>> login(String username, String password) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/auth/login',
data: {
'username': username,
'password': password,
},
);
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(AuthResponse.fromJson(data));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Invalid server response format: $e'));
}
},
);
}
@override
Future<Result<AuthResponse>> register(String username, String password, String name) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/auth/register',
data: {
'username': username,
'password': password,
'displayName': name,
},
);
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(AuthResponse.fromJson(data));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Invalid server response format: $e'));
}
},
);
}
@override
Future<Result<void>> logout() async {
return await apiClient.post('/api/auth/logout');
}
@override
Future<Result<User>> getCurrentUser() async {
final result = await apiClient.get<Map<String, dynamic>>('/api/auth/me');
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(User(
id: data['userId'] as String,
email: data['username'] as String,
name: data['displayName'] as String,
avatarUrl: data['avatar'] as String?,
));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Invalid user data format: $e'));
}
},
);
}
@override
Future<Result<void>> refreshToken() async {
return await apiClient.post('/api/auth/refresh');
}
}

View File

@@ -0,0 +1,30 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/user.dart';
part 'auth_response.freezed.dart';
part 'auth_response.g.dart';
@freezed
class AuthResponse with _$AuthResponse {
const factory AuthResponse({
required String accessToken,
required String refreshToken,
required String userId,
required String username,
required String displayName,
}) = _AuthResponse;
const AuthResponse._();
factory AuthResponse.fromJson(Map<String, dynamic> json) =>
_$AuthResponseFromJson(json);
// Helper to convert to domain User
User toDomainUser() {
return User(
id: userId,
email: username, // Использование логина как email в сущности User
name: displayName,
);
}
}

View File

@@ -0,0 +1,242 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'auth_response.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
AuthResponse _$AuthResponseFromJson(Map<String, dynamic> json) {
return _AuthResponse.fromJson(json);
}
/// @nodoc
mixin _$AuthResponse {
String get accessToken => throw _privateConstructorUsedError;
String get refreshToken => throw _privateConstructorUsedError;
String get userId => throw _privateConstructorUsedError;
String get username => throw _privateConstructorUsedError;
String get displayName => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$AuthResponseCopyWith<AuthResponse> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AuthResponseCopyWith<$Res> {
factory $AuthResponseCopyWith(
AuthResponse value, $Res Function(AuthResponse) then) =
_$AuthResponseCopyWithImpl<$Res, AuthResponse>;
@useResult
$Res call(
{String accessToken,
String refreshToken,
String userId,
String username,
String displayName});
}
/// @nodoc
class _$AuthResponseCopyWithImpl<$Res, $Val extends AuthResponse>
implements $AuthResponseCopyWith<$Res> {
_$AuthResponseCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? accessToken = null,
Object? refreshToken = null,
Object? userId = null,
Object? username = null,
Object? displayName = null,
}) {
return _then(_value.copyWith(
accessToken: null == accessToken
? _value.accessToken
: accessToken // ignore: cast_nullable_to_non_nullable
as String,
refreshToken: null == refreshToken
? _value.refreshToken
: refreshToken // ignore: cast_nullable_to_non_nullable
as String,
userId: null == userId
? _value.userId
: userId // ignore: cast_nullable_to_non_nullable
as String,
username: null == username
? _value.username
: username // ignore: cast_nullable_to_non_nullable
as String,
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$AuthResponseImplCopyWith<$Res>
implements $AuthResponseCopyWith<$Res> {
factory _$$AuthResponseImplCopyWith(
_$AuthResponseImpl value, $Res Function(_$AuthResponseImpl) then) =
__$$AuthResponseImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String accessToken,
String refreshToken,
String userId,
String username,
String displayName});
}
/// @nodoc
class __$$AuthResponseImplCopyWithImpl<$Res>
extends _$AuthResponseCopyWithImpl<$Res, _$AuthResponseImpl>
implements _$$AuthResponseImplCopyWith<$Res> {
__$$AuthResponseImplCopyWithImpl(
_$AuthResponseImpl _value, $Res Function(_$AuthResponseImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? accessToken = null,
Object? refreshToken = null,
Object? userId = null,
Object? username = null,
Object? displayName = null,
}) {
return _then(_$AuthResponseImpl(
accessToken: null == accessToken
? _value.accessToken
: accessToken // ignore: cast_nullable_to_non_nullable
as String,
refreshToken: null == refreshToken
? _value.refreshToken
: refreshToken // ignore: cast_nullable_to_non_nullable
as String,
userId: null == userId
? _value.userId
: userId // ignore: cast_nullable_to_non_nullable
as String,
username: null == username
? _value.username
: username // ignore: cast_nullable_to_non_nullable
as String,
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$AuthResponseImpl extends _AuthResponse {
const _$AuthResponseImpl(
{required this.accessToken,
required this.refreshToken,
required this.userId,
required this.username,
required this.displayName})
: super._();
factory _$AuthResponseImpl.fromJson(Map<String, dynamic> json) =>
_$$AuthResponseImplFromJson(json);
@override
final String accessToken;
@override
final String refreshToken;
@override
final String userId;
@override
final String username;
@override
final String displayName;
@override
String toString() {
return 'AuthResponse(accessToken: $accessToken, refreshToken: $refreshToken, userId: $userId, username: $username, displayName: $displayName)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AuthResponseImpl &&
(identical(other.accessToken, accessToken) ||
other.accessToken == accessToken) &&
(identical(other.refreshToken, refreshToken) ||
other.refreshToken == refreshToken) &&
(identical(other.userId, userId) || other.userId == userId) &&
(identical(other.username, username) ||
other.username == username) &&
(identical(other.displayName, displayName) ||
other.displayName == displayName));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType, accessToken, refreshToken, userId, username, displayName);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$AuthResponseImplCopyWith<_$AuthResponseImpl> get copyWith =>
__$$AuthResponseImplCopyWithImpl<_$AuthResponseImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$AuthResponseImplToJson(
this,
);
}
}
abstract class _AuthResponse extends AuthResponse {
const factory _AuthResponse(
{required final String accessToken,
required final String refreshToken,
required final String userId,
required final String username,
required final String displayName}) = _$AuthResponseImpl;
const _AuthResponse._() : super._();
factory _AuthResponse.fromJson(Map<String, dynamic> json) =
_$AuthResponseImpl.fromJson;
@override
String get accessToken;
@override
String get refreshToken;
@override
String get userId;
@override
String get username;
@override
String get displayName;
@override
@JsonKey(ignore: true)
_$$AuthResponseImplCopyWith<_$AuthResponseImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$AuthResponseImpl _$$AuthResponseImplFromJson(Map<String, dynamic> json) =>
_$AuthResponseImpl(
accessToken: json['accessToken'] as String,
refreshToken: json['refreshToken'] as String,
userId: json['userId'] as String,
username: json['username'] as String,
displayName: json['displayName'] as String,
);
Map<String, dynamic> _$$AuthResponseImplToJson(_$AuthResponseImpl instance) =>
<String, dynamic>{
'accessToken': instance.accessToken,
'refreshToken': instance.refreshToken,
'userId': instance.userId,
'username': instance.username,
'displayName': instance.displayName,
};

View File

@@ -0,0 +1,79 @@
import '../../../../core/errors/result.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_local_datasource.dart';
import '../datasources/auth_remote_datasource.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Result<User>> login(String username, String password) async {
final result = await remoteDataSource.login(username, password);
return result.fold(
(error) => Result.failure(error),
(response) async {
await localDataSource.saveToken(response.accessToken);
await localDataSource.saveRefreshToken(response.refreshToken);
await localDataSource.saveUserId(response.userId);
return Result.success(response.toDomainUser());
},
);
}
@override
Future<Result<User>> register(String username, String password, String name) async {
final result = await remoteDataSource.register(username, password, name);
return result.fold(
(error) => Result.failure(error),
(response) async {
await localDataSource.saveToken(response.accessToken);
await localDataSource.saveRefreshToken(response.refreshToken);
await localDataSource.saveUserId(response.userId);
return Result.success(response.toDomainUser());
},
);
}
@override
Future<Result<void>> logout() async {
await remoteDataSource.logout();
await localDataSource.removeToken();
await localDataSource.removeUserId();
return const Result.success(null);
}
@override
Future<Result<User>> getCurrentUser() async {
final result = await remoteDataSource.getCurrentUser();
if (result.isFailure) {
// If remote fails (e.g. offline), try to return a dummy user with stored ID
// to keep the app in authenticated state if we have a token
final userId = await localDataSource.getUserId();
if (userId != null) {
return Result.success(User(id: userId, name: 'User', email: ''));
}
}
return result;
}
@override
Future<Result<void>> refreshToken() async {
return await remoteDataSource.refreshToken();
}
@override
bool get isLoggedIn {
// This is problematic because it's synchronous but SharedPreferences is async.
// However, in our DI we wait for SharedPreferences.getInstance().
// We can use a synchronous check if we expose it or just use the fact that
// AuthBloc calls authChecked which is async.
return localDataSource.getTokenSync() != null;
}
}

View File

@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String email,
required String name,
String? avatarUrl,
String? phoneNumber,
DateTime? lastSeen,
bool? isOnline,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

View File

@@ -0,0 +1,275 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'user.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
User _$UserFromJson(Map<String, dynamic> json) {
return _User.fromJson(json);
}
/// @nodoc
mixin _$User {
String get id => throw _privateConstructorUsedError;
String get email => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String? get avatarUrl => throw _privateConstructorUsedError;
String? get phoneNumber => throw _privateConstructorUsedError;
DateTime? get lastSeen => throw _privateConstructorUsedError;
bool? get isOnline => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$UserCopyWith<User> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UserCopyWith<$Res> {
factory $UserCopyWith(User value, $Res Function(User) then) =
_$UserCopyWithImpl<$Res, User>;
@useResult
$Res call(
{String id,
String email,
String name,
String? avatarUrl,
String? phoneNumber,
DateTime? lastSeen,
bool? isOnline});
}
/// @nodoc
class _$UserCopyWithImpl<$Res, $Val extends User>
implements $UserCopyWith<$Res> {
_$UserCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? email = null,
Object? name = null,
Object? avatarUrl = freezed,
Object? phoneNumber = freezed,
Object? lastSeen = freezed,
Object? isOnline = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
avatarUrl: freezed == avatarUrl
? _value.avatarUrl
: avatarUrl // ignore: cast_nullable_to_non_nullable
as String?,
phoneNumber: freezed == phoneNumber
? _value.phoneNumber
: phoneNumber // ignore: cast_nullable_to_non_nullable
as String?,
lastSeen: freezed == lastSeen
? _value.lastSeen
: lastSeen // ignore: cast_nullable_to_non_nullable
as DateTime?,
isOnline: freezed == isOnline
? _value.isOnline
: isOnline // ignore: cast_nullable_to_non_nullable
as bool?,
) as $Val);
}
}
/// @nodoc
abstract class _$$UserImplCopyWith<$Res> implements $UserCopyWith<$Res> {
factory _$$UserImplCopyWith(
_$UserImpl value, $Res Function(_$UserImpl) then) =
__$$UserImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
String email,
String name,
String? avatarUrl,
String? phoneNumber,
DateTime? lastSeen,
bool? isOnline});
}
/// @nodoc
class __$$UserImplCopyWithImpl<$Res>
extends _$UserCopyWithImpl<$Res, _$UserImpl>
implements _$$UserImplCopyWith<$Res> {
__$$UserImplCopyWithImpl(_$UserImpl _value, $Res Function(_$UserImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? email = null,
Object? name = null,
Object? avatarUrl = freezed,
Object? phoneNumber = freezed,
Object? lastSeen = freezed,
Object? isOnline = freezed,
}) {
return _then(_$UserImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
avatarUrl: freezed == avatarUrl
? _value.avatarUrl
: avatarUrl // ignore: cast_nullable_to_non_nullable
as String?,
phoneNumber: freezed == phoneNumber
? _value.phoneNumber
: phoneNumber // ignore: cast_nullable_to_non_nullable
as String?,
lastSeen: freezed == lastSeen
? _value.lastSeen
: lastSeen // ignore: cast_nullable_to_non_nullable
as DateTime?,
isOnline: freezed == isOnline
? _value.isOnline
: isOnline // ignore: cast_nullable_to_non_nullable
as bool?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$UserImpl implements _User {
const _$UserImpl(
{required this.id,
required this.email,
required this.name,
this.avatarUrl,
this.phoneNumber,
this.lastSeen,
this.isOnline});
factory _$UserImpl.fromJson(Map<String, dynamic> json) =>
_$$UserImplFromJson(json);
@override
final String id;
@override
final String email;
@override
final String name;
@override
final String? avatarUrl;
@override
final String? phoneNumber;
@override
final DateTime? lastSeen;
@override
final bool? isOnline;
@override
String toString() {
return 'User(id: $id, email: $email, name: $name, avatarUrl: $avatarUrl, phoneNumber: $phoneNumber, lastSeen: $lastSeen, isOnline: $isOnline)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UserImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.email, email) || other.email == email) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.avatarUrl, avatarUrl) ||
other.avatarUrl == avatarUrl) &&
(identical(other.phoneNumber, phoneNumber) ||
other.phoneNumber == phoneNumber) &&
(identical(other.lastSeen, lastSeen) ||
other.lastSeen == lastSeen) &&
(identical(other.isOnline, isOnline) ||
other.isOnline == isOnline));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType, id, email, name, avatarUrl, phoneNumber, lastSeen, isOnline);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
__$$UserImplCopyWithImpl<_$UserImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UserImplToJson(
this,
);
}
}
abstract class _User implements User {
const factory _User(
{required final String id,
required final String email,
required final String name,
final String? avatarUrl,
final String? phoneNumber,
final DateTime? lastSeen,
final bool? isOnline}) = _$UserImpl;
factory _User.fromJson(Map<String, dynamic> json) = _$UserImpl.fromJson;
@override
String get id;
@override
String get email;
@override
String get name;
@override
String? get avatarUrl;
@override
String? get phoneNumber;
@override
DateTime? get lastSeen;
@override
bool? get isOnline;
@override
@JsonKey(ignore: true)
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UserImpl _$$UserImplFromJson(Map<String, dynamic> json) => _$UserImpl(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String,
avatarUrl: json['avatarUrl'] as String?,
phoneNumber: json['phoneNumber'] as String?,
lastSeen: json['lastSeen'] == null
? null
: DateTime.parse(json['lastSeen'] as String),
isOnline: json['isOnline'] as bool?,
);
Map<String, dynamic> _$$UserImplToJson(_$UserImpl instance) =>
<String, dynamic>{
'id': instance.id,
'email': instance.email,
'name': instance.name,
'avatarUrl': instance.avatarUrl,
'phoneNumber': instance.phoneNumber,
'lastSeen': instance.lastSeen?.toIso8601String(),
'isOnline': instance.isOnline,
};

View File

@@ -0,0 +1,11 @@
import '../../../../core/errors/result.dart';
import '../entities/user.dart';
abstract class AuthRepository {
Future<Result<User>> login(String username, String password);
Future<Result<User>> register(String username, String password, String name);
Future<Result<void>> logout();
Future<Result<User>> getCurrentUser();
Future<Result<void>> refreshToken();
bool get isLoggedIn;
}

View File

@@ -0,0 +1,13 @@
import '../../../../core/errors/result.dart';
import '../repositories/auth_repository.dart';
import '../entities/user.dart';
class LoginUseCase {
final AuthRepository _repository;
LoginUseCase(this._repository);
Future<Result<User>> execute(String email, String password) {
return _repository.login(email, password);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/repositories/auth_repository.dart';
import 'auth_event.dart';
import 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository authRepository;
AuthBloc({required this.authRepository}) : super(const AuthState.initial()) {
on<AuthEventStarted>(_onStarted);
on<AuthEventLoginRequested>(_onLoginRequested);
on<AuthEventRegisterRequested>(_onRegisterRequested);
on<AuthEventLogoutRequested>(_onLogoutRequested);
on<AuthEventAuthChecked>(_onAuthChecked);
on<AuthEventUsernameChanged>(_onUsernameChanged);
on<AuthEventPasswordChanged>(_onPasswordChanged);
on<AuthEventNameChanged>(_onNameChanged);
}
Future<void> _onStarted(AuthEventStarted event, Emitter<AuthState> emit) async {
add(const AuthEvent.authChecked());
}
Future<void> _onLoginRequested(AuthEventLoginRequested event, Emitter<AuthState> emit) async {
emit(const AuthState.loading());
final result = await authRepository.login(event.username, event.password);
result.fold(
(error) => emit(AuthState.error('loginFailed')),
(user) => emit(AuthState.authenticated(user.id)),
);
}
Future<void> _onRegisterRequested(AuthEventRegisterRequested event, Emitter<AuthState> emit) async {
emit(const AuthState.loading());
final result = await authRepository.register(event.username, event.password, event.name);
result.fold(
(error) => emit(AuthState.error('registrationFailed')),
(user) => emit(AuthState.authenticated(user.id)),
);
}
Future<void> _onLogoutRequested(AuthEventLogoutRequested event, Emitter<AuthState> emit) async {
emit(const AuthState.loading());
await authRepository.logout();
emit(const AuthState.unauthenticated());
}
Future<void> _onAuthChecked(AuthEventAuthChecked event, Emitter<AuthState> emit) async {
final result = await authRepository.getCurrentUser();
result.fold(
(error) => emit(const AuthState.unauthenticated()),
(user) => emit(AuthState.authenticated(user.id)),
);
}
void _onUsernameChanged(AuthEventUsernameChanged event, Emitter<AuthState> emit) {}
void _onPasswordChanged(AuthEventPasswordChanged event, Emitter<AuthState> emit) {}
void _onNameChanged(AuthEventNameChanged event, Emitter<AuthState> emit) {}
}

View File

@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_event.freezed.dart';
@freezed
class AuthEvent with _$AuthEvent {
const factory AuthEvent.started() = AuthEventStarted;
const factory AuthEvent.loginRequested(String username, String password) = AuthEventLoginRequested;
const factory AuthEvent.registerRequested(String username, String password, String name) = AuthEventRegisterRequested;
const factory AuthEvent.logoutRequested() = AuthEventLogoutRequested;
const factory AuthEvent.authChecked() = AuthEventAuthChecked;
const factory AuthEvent.usernameChanged(String username) = AuthEventUsernameChanged;
const factory AuthEvent.passwordChanged(String password) = AuthEventPasswordChanged;
const factory AuthEvent.nameChanged(String name) = AuthEventNameChanged;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = AuthInitial;
const factory AuthState.loading() = AuthLoading;
const factory AuthState.authenticated(String userId) = AuthAuthenticated;
const factory AuthState.unauthenticated() = AuthUnauthenticated;
const factory AuthState.error(String message) = AuthError;
}

View File

@@ -0,0 +1,757 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'auth_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
/// @nodoc
mixin _$AuthState {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(String userId) authenticated,
required TResult Function() unauthenticated,
required TResult Function(String message) error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(String userId)? authenticated,
TResult? Function()? unauthenticated,
TResult? Function(String message)? error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(String userId)? authenticated,
TResult Function()? unauthenticated,
TResult Function(String message)? error,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(AuthInitial value) initial,
required TResult Function(AuthLoading value) loading,
required TResult Function(AuthAuthenticated value) authenticated,
required TResult Function(AuthUnauthenticated value) unauthenticated,
required TResult Function(AuthError value) error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(AuthInitial value)? initial,
TResult? Function(AuthLoading value)? loading,
TResult? Function(AuthAuthenticated value)? authenticated,
TResult? Function(AuthUnauthenticated value)? unauthenticated,
TResult? Function(AuthError value)? error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(AuthInitial value)? initial,
TResult Function(AuthLoading value)? loading,
TResult Function(AuthAuthenticated value)? authenticated,
TResult Function(AuthUnauthenticated value)? unauthenticated,
TResult Function(AuthError value)? error,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AuthStateCopyWith<$Res> {
factory $AuthStateCopyWith(AuthState value, $Res Function(AuthState) then) =
_$AuthStateCopyWithImpl<$Res, AuthState>;
}
/// @nodoc
class _$AuthStateCopyWithImpl<$Res, $Val extends AuthState>
implements $AuthStateCopyWith<$Res> {
_$AuthStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
}
/// @nodoc
abstract class _$$AuthInitialImplCopyWith<$Res> {
factory _$$AuthInitialImplCopyWith(
_$AuthInitialImpl value, $Res Function(_$AuthInitialImpl) then) =
__$$AuthInitialImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$AuthInitialImplCopyWithImpl<$Res>
extends _$AuthStateCopyWithImpl<$Res, _$AuthInitialImpl>
implements _$$AuthInitialImplCopyWith<$Res> {
__$$AuthInitialImplCopyWithImpl(
_$AuthInitialImpl _value, $Res Function(_$AuthInitialImpl) _then)
: super(_value, _then);
}
/// @nodoc
class _$AuthInitialImpl implements AuthInitial {
const _$AuthInitialImpl();
@override
String toString() {
return 'AuthState.initial()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _$AuthInitialImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(String userId) authenticated,
required TResult Function() unauthenticated,
required TResult Function(String message) error,
}) {
return initial();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(String userId)? authenticated,
TResult? Function()? unauthenticated,
TResult? Function(String message)? error,
}) {
return initial?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(String userId)? authenticated,
TResult Function()? unauthenticated,
TResult Function(String message)? error,
required TResult orElse(),
}) {
if (initial != null) {
return initial();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(AuthInitial value) initial,
required TResult Function(AuthLoading value) loading,
required TResult Function(AuthAuthenticated value) authenticated,
required TResult Function(AuthUnauthenticated value) unauthenticated,
required TResult Function(AuthError value) error,
}) {
return initial(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(AuthInitial value)? initial,
TResult? Function(AuthLoading value)? loading,
TResult? Function(AuthAuthenticated value)? authenticated,
TResult? Function(AuthUnauthenticated value)? unauthenticated,
TResult? Function(AuthError value)? error,
}) {
return initial?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(AuthInitial value)? initial,
TResult Function(AuthLoading value)? loading,
TResult Function(AuthAuthenticated value)? authenticated,
TResult Function(AuthUnauthenticated value)? unauthenticated,
TResult Function(AuthError value)? error,
required TResult orElse(),
}) {
if (initial != null) {
return initial(this);
}
return orElse();
}
}
abstract class AuthInitial implements AuthState {
const factory AuthInitial() = _$AuthInitialImpl;
}
/// @nodoc
abstract class _$$AuthLoadingImplCopyWith<$Res> {
factory _$$AuthLoadingImplCopyWith(
_$AuthLoadingImpl value, $Res Function(_$AuthLoadingImpl) then) =
__$$AuthLoadingImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$AuthLoadingImplCopyWithImpl<$Res>
extends _$AuthStateCopyWithImpl<$Res, _$AuthLoadingImpl>
implements _$$AuthLoadingImplCopyWith<$Res> {
__$$AuthLoadingImplCopyWithImpl(
_$AuthLoadingImpl _value, $Res Function(_$AuthLoadingImpl) _then)
: super(_value, _then);
}
/// @nodoc
class _$AuthLoadingImpl implements AuthLoading {
const _$AuthLoadingImpl();
@override
String toString() {
return 'AuthState.loading()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _$AuthLoadingImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(String userId) authenticated,
required TResult Function() unauthenticated,
required TResult Function(String message) error,
}) {
return loading();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(String userId)? authenticated,
TResult? Function()? unauthenticated,
TResult? Function(String message)? error,
}) {
return loading?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(String userId)? authenticated,
TResult Function()? unauthenticated,
TResult Function(String message)? error,
required TResult orElse(),
}) {
if (loading != null) {
return loading();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(AuthInitial value) initial,
required TResult Function(AuthLoading value) loading,
required TResult Function(AuthAuthenticated value) authenticated,
required TResult Function(AuthUnauthenticated value) unauthenticated,
required TResult Function(AuthError value) error,
}) {
return loading(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(AuthInitial value)? initial,
TResult? Function(AuthLoading value)? loading,
TResult? Function(AuthAuthenticated value)? authenticated,
TResult? Function(AuthUnauthenticated value)? unauthenticated,
TResult? Function(AuthError value)? error,
}) {
return loading?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(AuthInitial value)? initial,
TResult Function(AuthLoading value)? loading,
TResult Function(AuthAuthenticated value)? authenticated,
TResult Function(AuthUnauthenticated value)? unauthenticated,
TResult Function(AuthError value)? error,
required TResult orElse(),
}) {
if (loading != null) {
return loading(this);
}
return orElse();
}
}
abstract class AuthLoading implements AuthState {
const factory AuthLoading() = _$AuthLoadingImpl;
}
/// @nodoc
abstract class _$$AuthAuthenticatedImplCopyWith<$Res> {
factory _$$AuthAuthenticatedImplCopyWith(_$AuthAuthenticatedImpl value,
$Res Function(_$AuthAuthenticatedImpl) then) =
__$$AuthAuthenticatedImplCopyWithImpl<$Res>;
@useResult
$Res call({String userId});
}
/// @nodoc
class __$$AuthAuthenticatedImplCopyWithImpl<$Res>
extends _$AuthStateCopyWithImpl<$Res, _$AuthAuthenticatedImpl>
implements _$$AuthAuthenticatedImplCopyWith<$Res> {
__$$AuthAuthenticatedImplCopyWithImpl(_$AuthAuthenticatedImpl _value,
$Res Function(_$AuthAuthenticatedImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? userId = null,
}) {
return _then(_$AuthAuthenticatedImpl(
null == userId
? _value.userId
: userId // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class _$AuthAuthenticatedImpl implements AuthAuthenticated {
const _$AuthAuthenticatedImpl(this.userId);
@override
final String userId;
@override
String toString() {
return 'AuthState.authenticated(userId: $userId)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AuthAuthenticatedImpl &&
(identical(other.userId, userId) || other.userId == userId));
}
@override
int get hashCode => Object.hash(runtimeType, userId);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$AuthAuthenticatedImplCopyWith<_$AuthAuthenticatedImpl> get copyWith =>
__$$AuthAuthenticatedImplCopyWithImpl<_$AuthAuthenticatedImpl>(
this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(String userId) authenticated,
required TResult Function() unauthenticated,
required TResult Function(String message) error,
}) {
return authenticated(userId);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(String userId)? authenticated,
TResult? Function()? unauthenticated,
TResult? Function(String message)? error,
}) {
return authenticated?.call(userId);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(String userId)? authenticated,
TResult Function()? unauthenticated,
TResult Function(String message)? error,
required TResult orElse(),
}) {
if (authenticated != null) {
return authenticated(userId);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(AuthInitial value) initial,
required TResult Function(AuthLoading value) loading,
required TResult Function(AuthAuthenticated value) authenticated,
required TResult Function(AuthUnauthenticated value) unauthenticated,
required TResult Function(AuthError value) error,
}) {
return authenticated(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(AuthInitial value)? initial,
TResult? Function(AuthLoading value)? loading,
TResult? Function(AuthAuthenticated value)? authenticated,
TResult? Function(AuthUnauthenticated value)? unauthenticated,
TResult? Function(AuthError value)? error,
}) {
return authenticated?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(AuthInitial value)? initial,
TResult Function(AuthLoading value)? loading,
TResult Function(AuthAuthenticated value)? authenticated,
TResult Function(AuthUnauthenticated value)? unauthenticated,
TResult Function(AuthError value)? error,
required TResult orElse(),
}) {
if (authenticated != null) {
return authenticated(this);
}
return orElse();
}
}
abstract class AuthAuthenticated implements AuthState {
const factory AuthAuthenticated(final String userId) =
_$AuthAuthenticatedImpl;
String get userId;
@JsonKey(ignore: true)
_$$AuthAuthenticatedImplCopyWith<_$AuthAuthenticatedImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$AuthUnauthenticatedImplCopyWith<$Res> {
factory _$$AuthUnauthenticatedImplCopyWith(_$AuthUnauthenticatedImpl value,
$Res Function(_$AuthUnauthenticatedImpl) then) =
__$$AuthUnauthenticatedImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$AuthUnauthenticatedImplCopyWithImpl<$Res>
extends _$AuthStateCopyWithImpl<$Res, _$AuthUnauthenticatedImpl>
implements _$$AuthUnauthenticatedImplCopyWith<$Res> {
__$$AuthUnauthenticatedImplCopyWithImpl(_$AuthUnauthenticatedImpl _value,
$Res Function(_$AuthUnauthenticatedImpl) _then)
: super(_value, _then);
}
/// @nodoc
class _$AuthUnauthenticatedImpl implements AuthUnauthenticated {
const _$AuthUnauthenticatedImpl();
@override
String toString() {
return 'AuthState.unauthenticated()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AuthUnauthenticatedImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(String userId) authenticated,
required TResult Function() unauthenticated,
required TResult Function(String message) error,
}) {
return unauthenticated();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(String userId)? authenticated,
TResult? Function()? unauthenticated,
TResult? Function(String message)? error,
}) {
return unauthenticated?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(String userId)? authenticated,
TResult Function()? unauthenticated,
TResult Function(String message)? error,
required TResult orElse(),
}) {
if (unauthenticated != null) {
return unauthenticated();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(AuthInitial value) initial,
required TResult Function(AuthLoading value) loading,
required TResult Function(AuthAuthenticated value) authenticated,
required TResult Function(AuthUnauthenticated value) unauthenticated,
required TResult Function(AuthError value) error,
}) {
return unauthenticated(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(AuthInitial value)? initial,
TResult? Function(AuthLoading value)? loading,
TResult? Function(AuthAuthenticated value)? authenticated,
TResult? Function(AuthUnauthenticated value)? unauthenticated,
TResult? Function(AuthError value)? error,
}) {
return unauthenticated?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(AuthInitial value)? initial,
TResult Function(AuthLoading value)? loading,
TResult Function(AuthAuthenticated value)? authenticated,
TResult Function(AuthUnauthenticated value)? unauthenticated,
TResult Function(AuthError value)? error,
required TResult orElse(),
}) {
if (unauthenticated != null) {
return unauthenticated(this);
}
return orElse();
}
}
abstract class AuthUnauthenticated implements AuthState {
const factory AuthUnauthenticated() = _$AuthUnauthenticatedImpl;
}
/// @nodoc
abstract class _$$AuthErrorImplCopyWith<$Res> {
factory _$$AuthErrorImplCopyWith(
_$AuthErrorImpl value, $Res Function(_$AuthErrorImpl) then) =
__$$AuthErrorImplCopyWithImpl<$Res>;
@useResult
$Res call({String message});
}
/// @nodoc
class __$$AuthErrorImplCopyWithImpl<$Res>
extends _$AuthStateCopyWithImpl<$Res, _$AuthErrorImpl>
implements _$$AuthErrorImplCopyWith<$Res> {
__$$AuthErrorImplCopyWithImpl(
_$AuthErrorImpl _value, $Res Function(_$AuthErrorImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? message = null,
}) {
return _then(_$AuthErrorImpl(
null == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class _$AuthErrorImpl implements AuthError {
const _$AuthErrorImpl(this.message);
@override
final String message;
@override
String toString() {
return 'AuthState.error(message: $message)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AuthErrorImpl &&
(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType, message);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$AuthErrorImplCopyWith<_$AuthErrorImpl> get copyWith =>
__$$AuthErrorImplCopyWithImpl<_$AuthErrorImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(String userId) authenticated,
required TResult Function() unauthenticated,
required TResult Function(String message) error,
}) {
return error(message);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(String userId)? authenticated,
TResult? Function()? unauthenticated,
TResult? Function(String message)? error,
}) {
return error?.call(message);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(String userId)? authenticated,
TResult Function()? unauthenticated,
TResult Function(String message)? error,
required TResult orElse(),
}) {
if (error != null) {
return error(message);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(AuthInitial value) initial,
required TResult Function(AuthLoading value) loading,
required TResult Function(AuthAuthenticated value) authenticated,
required TResult Function(AuthUnauthenticated value) unauthenticated,
required TResult Function(AuthError value) error,
}) {
return error(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(AuthInitial value)? initial,
TResult? Function(AuthLoading value)? loading,
TResult? Function(AuthAuthenticated value)? authenticated,
TResult? Function(AuthUnauthenticated value)? unauthenticated,
TResult? Function(AuthError value)? error,
}) {
return error?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(AuthInitial value)? initial,
TResult Function(AuthLoading value)? loading,
TResult Function(AuthAuthenticated value)? authenticated,
TResult Function(AuthUnauthenticated value)? unauthenticated,
TResult Function(AuthError value)? error,
required TResult orElse(),
}) {
if (error != null) {
return error(this);
}
return orElse();
}
}
abstract class AuthError implements AuthState {
const factory AuthError(final String message) = _$AuthErrorImpl;
String get message;
@JsonKey(ignore: true)
_$$AuthErrorImplCopyWith<_$AuthErrorImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:messenger_app/features/auth/presentation/bloc/auth_bloc.dart';
import 'package:messenger_app/features/auth/presentation/bloc/auth_event.dart';
import 'package:messenger_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:messenger_app/features/settings/presentation/bloc/settings_bloc.dart';
import 'package:messenger_app/features/settings/presentation/bloc/settings_state.dart';
import 'package:messenger_app/features/settings/presentation/pages/settings_page.dart';
import 'package:messenger_app/l10n/app_localizations.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.login),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsPage()),
);
},
),
],
),
body: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
state.whenOrNull(
authenticated: (userId) {
// Navigation handled in app.dart home
},
error: (message) {
final errorMessage = message == 'loginFailed'
? l10n.loginFailed
: message == 'registrationFailed'
? l10n.registrationFailed
: message;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorMessage)),
);
},
);
},
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.chat_bubble_outline,
size: 80,
color: Color(0xFF2481CC),
),
const SizedBox(height: 16),
BlocBuilder<SettingsBloc, SettingsState>(
builder: (context, state) {
final apiUrl = state.when(
initial: () => '',
loading: () => '',
loaded: (url, _, __, ___, ____) => url,
error: (_) => '',
);
return Column(
children: [
Text(
l10n.server,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
Text(
apiUrl.isEmpty ? l10n.notSpecified : apiUrl,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
);
},
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: l10n.username,
prefixIcon: const Icon(Icons.person_outline),
),
keyboardType: TextInputType.text,
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.enterUsername;
}
// Регулярка для проверки отсутствия кириллицы и минимум 3 символа
if (value.length < 3 || RegExp(r'[а-яА-ЯёЁ]').hasMatch(value)) {
return l10n.invalidUsername;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock_outlined),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Введите пароль';
}
if (value.length < 6) {
return 'Пароль должен быть не менее 6 символов';
}
return null;
},
),
const SizedBox(height: 32),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
if (authState is AuthLoading) {
return const Center(child: CircularProgressIndicator());
}
return ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
context.read<AuthBloc>().add(
AuthEvent.loginRequested(
_emailController.text,
_passwordController.text,
),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(l10n.loginButton, style: const TextStyle(fontSize: 16)),
),
);
},
),
const SizedBox(height: 16),
BlocBuilder<SettingsBloc, SettingsState>(
builder: (context, settingsState) {
final isRegistrationEnabled = settingsState.when(
initial: () => false,
loading: () => false,
loaded: (_, config, __, ___, status) =>
status == ConnectionStatus.connected && (config?.system.enableRegistration ?? false),
error: (_) => false,
);
return TextButton(
onPressed: isRegistrationEnabled ? () {
// Navigate to register page
} : null,
child: Text(l10n.noAccount),
);
},
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import '../../../../core/errors/result.dart';
import '../../domain/entities/chat.dart';
import '../../domain/entities/message.dart';
abstract class ChatLocalDataSource {
Future<Result<List<Chat>>> getCachedChats();
Future<Result<void>> cacheChats(List<Chat> chats);
Future<Result<Chat>> getChatFromCache(String chatId);
Future<Result<void>> cacheChat(Chat chat);
Future<Result<List<Message>>> getMessagesFromCache(String chatId);
Future<Result<void>> cacheMessages(String chatId, List<Message> messages);
Future<Result<void>> clearCache();
}

View File

@@ -0,0 +1,106 @@
import 'package:isar/isar.dart';
import '../../../../core/errors/result.dart';
import '../../../../core/errors/errors.dart';
import '../../domain/entities/chat.dart';
import '../../domain/entities/message.dart';
import '../models/chat_model.dart';
import '../models/message_model.dart';
import 'chat_local_datasource.dart';
class ChatLocalDataSourceImpl implements ChatLocalDataSource {
final Isar isar;
ChatLocalDataSourceImpl(this.isar);
@override
Future<Result<List<Chat>>> getCachedChats() async {
try {
final chatModels = await isar.chatModels.where().sortByLastMessageTimeDesc().findAll();
return Result.success(chatModels.map((m) => m.toEntity()).toList());
} catch (e) {
return Result.failure(AppError.database(message: e.toString()));
}
}
@override
Future<Result<void>> cacheChats(List<Chat> chats) async {
try {
final models = chats.map((c) => ChatModel.fromEntity(c)).toList();
await isar.writeTxn(() async {
await isar.chatModels.putAll(models);
});
return const Result.success(null);
} catch (e) {
return Result.failure(AppError.database(message: e.toString()));
}
}
@override
Future<Result<Chat>> getChatFromCache(String chatId) async {
try {
final model = await isar.chatModels.filter().idEqualTo(chatId).findFirst();
if (model != null) {
return Result.success(model.toEntity());
}
return const Result.failure(AppError.notFound(message: 'Chat not found in cache'));
} catch (e) {
return Result.failure(AppError.database(message: e.toString()));
}
}
@override
Future<Result<void>> cacheChat(Chat chat) async {
try {
final model = ChatModel.fromEntity(chat);
await isar.writeTxn(() async {
await isar.chatModels.put(model);
});
return const Result.success(null);
} catch (e) {
return Result.failure(AppError.database(message: e.toString()));
}
}
@override
Future<Result<List<Message>>> getMessagesFromCache(String chatId) async {
try {
final messageModels = await isar.messageModels
.filter()
.chatIdEqualTo(chatId)
.sortByCreatedAtDesc()
.findAll();
return Result.success(messageModels.map((m) => m.toEntity()).toList());
} catch (e) {
return Result.failure(AppError.database(message: e.toString()));
}
}
@override
Future<Result<void>> cacheMessages(String chatId, List<Message> messages) async {
try {
final models = messages.map((m) => MessageModel.fromEntity(m)).toList();
await isar.writeTxn(() async {
await isar.messageModels.putAll(models);
});
return const Result.success(null);
} catch (e) {
return Result.failure(AppError.database(message: e.toString()));
}
}
@override
Future<Result<void>> clearCache() async {
// ignore: avoid_print
print('[DEBUG] ChatLocalDataSource.clearCache calling isar.clear()');
try {
await isar.writeTxn(() => isar.clear());
// ignore: avoid_print
print('[DEBUG] ChatLocalDataSource.clearCache SUCCESS');
return const Result.success(null);
} catch (e) {
// ignore: avoid_print
print('[DEBUG] ChatLocalDataSource.clearCache ERROR: $e');
return Result.failure(AppError.database(message: e.toString()));
}
}
}

View File

@@ -0,0 +1,15 @@
import '../../../../core/errors/result.dart';
import '../../domain/entities/chat.dart';
import '../../domain/entities/message.dart';
abstract class ChatRemoteDataSource {
Future<Result<List<Chat>>> getChats({String? currentUserId});
Future<Result<Chat>> getChatById(String chatId);
Future<Result<Chat>> createPersonalChat(String userId);
Future<Result<Chat>> createGroupChat(String name, List<String> memberIds);
Future<Result<Chat>> getOrCreateFavorites();
Future<Result<Message>> sendMessage(String chatId, String content, {String? type, List<Map<String, dynamic>>? attachments});
Future<Result<List<Message>>> getMessages(String chatId, {String? cursor});
Future<Result<void>> togglePin(String chatId);
Future<Result<void>> clearChat(String chatId);
}

View File

@@ -0,0 +1,214 @@
import '../../../../core/network/api_client.dart';
import '../../../../core/errors/result.dart';
import '../../../../core/errors/errors.dart';
import '../../domain/entities/chat.dart';
import '../../domain/entities/message.dart';
import '../../domain/entities/user.dart';
import 'chat_remote_datasource.dart';
class ChatRemoteDataSourceImpl implements ChatRemoteDataSource {
final ApiClient apiClient;
ChatRemoteDataSourceImpl(this.apiClient);
@override
Future<Result<List<Chat>>> getChats({String? currentUserId}) async {
final result = await apiClient.get<List<dynamic>>('/api/chats');
return result.fold(
(error) => Result.failure(error),
(data) {
try {
final chats = data.map((json) => _mapChatJson(json, currentUserId: currentUserId)).toList();
// Sort: favorites first, then by last message time or creation time
chats.sort((a, b) {
if (a.type == 'favorites') return -1;
if (b.type == 'favorites') return 1;
final timeA = a.lastMessageTime ?? DateTime.fromMillisecondsSinceEpoch(0);
final timeB = b.lastMessageTime ?? DateTime.fromMillisecondsSinceEpoch(0);
return timeB.compareTo(timeA);
});
return Result.success(chats);
} catch (e) {
return Result.failure(AppError.parsing(message: 'Failed to parse chats: $e'));
}
},
);
}
@override
Future<Result<Chat>> getChatById(String chatId) async {
final result = await apiClient.get<Map<String, dynamic>>('/api/chats/$chatId');
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(_mapChatJson(data));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Failed to parse chat: $e'));
}
},
);
}
@override
Future<Result<Chat>> createPersonalChat(String userId) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/chats/personal',
data: {'userId': userId},
);
return result.fold(
(error) => Result.failure(error),
(data) => Result.success(_mapChatJson(data)),
);
}
@override
Future<Result<Chat>> createGroupChat(String name, List<String> memberIds) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/chats/group',
data: {
'name': name,
'memberIds': memberIds,
},
);
return result.fold(
(error) => Result.failure(error),
(data) => Result.success(_mapChatJson(data)),
);
}
@override
Future<Result<Chat>> getOrCreateFavorites() async {
final result = await apiClient.post<Map<String, dynamic>>('/api/chats/favorites');
return result.fold(
(error) => Result.failure(error),
(data) => Result.success(_mapChatJson(data)),
);
}
@override
Future<Result<Message>> sendMessage(String chatId, String content, {String? type, List<Map<String, dynamic>>? attachments}) async {
final result = await apiClient.post<Map<String, dynamic>>(
'/api/messages/chat/$chatId',
data: {
'content': content,
'type': type ?? 'Text',
'attachments': attachments,
},
);
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(_mapMessageJson(data));
} catch (e) {
return Result.failure(AppError.parsing(message: 'Failed to parse message: $e'));
}
},
);
}
@override
Future<Result<List<Message>>> getMessages(String chatId, {String? cursor}) async {
final result = await apiClient.get<List<dynamic>>(
'/api/messages/chat/$chatId',
queryParameters: cursor != null ? {'cursor': cursor} : null,
);
return result.fold(
(error) => Result.failure(error),
(data) {
try {
return Result.success(data.map((json) => _mapMessageJson(json)).toList());
} catch (e) {
return Result.failure(AppError.parsing(message: 'Failed to parse messages: $e'));
}
},
);
}
@override
Future<Result<void>> togglePin(String chatId) async {
return await apiClient.post('/api/chats/$chatId/pin');
}
@override
Future<Result<void>> clearChat(String chatId) async {
return await apiClient.post('/api/chats/$chatId/clear');
}
Chat _mapChatJson(Map<String, dynamic> json, {String? currentUserId}) {
final membersData = json['members'] ?? json['participants'];
final members = (membersData as List<dynamic>?)?.map((m) {
final userData = m is Map<String, dynamic> ? (m['user'] ?? m) : m;
return User(
id: (m is Map ? m['userId'] : null) ?? (userData?['id'] ?? ''),
name: userData?['displayName'] ?? (userData?['name'] ?? ''),
avatarUrl: userData?['avatar'] ?? userData?['avatarUrl'],
isOnline: userData?['isOnline'] ?? userData?['IsOnline'] ?? false,
lastSeen: _parseDateTime(userData?['lastSeen'] ?? userData?['LastSeen']),
);
}).toList();
dynamic lastMsgRaw = json['lastMessage'];
if (lastMsgRaw is List && lastMsgRaw.isNotEmpty) {
lastMsgRaw = lastMsgRaw.first;
}
final lastMsgJson = lastMsgRaw ?? (json['messages'] as List?)?.firstOrNull;
final type = json['type']?.toString().toLowerCase() ?? 'personal';
String title = json['name'] ?? '';
if (title.isEmpty && type == 'personal' && members != null && members.isNotEmpty) {
// If personal chat and no title, use the OTHER member's name
try {
final otherMember = members.firstWhere(
(m) => m.id != currentUserId && m.name.isNotEmpty,
orElse: () => members.firstWhere((m) => m.id != currentUserId, orElse: () => members[0]),
);
title = otherMember.name;
} catch (_) {
title = members[0].name;
}
}
return Chat(
id: json['id'],
type: type,
title: title,
avatarUrl: json['avatar'],
participants: members,
unreadCount: json['unreadCount'] ?? 0,
isPinned: json['isPinned'] ?? false,
lastMessage: lastMsgJson != null && lastMsgJson is Map<String, dynamic>
? _mapMessageJson(lastMsgJson)
: null,
lastMessageTime: lastMsgJson != null && lastMsgJson is Map<String, dynamic>
? _parseDateTime(lastMsgJson['createdAt'])
: null,
);
}
Message _mapMessageJson(Map<String, dynamic> json) {
return Message(
id: json['id'] ?? '',
chatId: json['chatId'] ?? '',
senderId: json['userId'] ?? (json['senderId'] ?? ''),
content: json['content'] ?? '',
messageType: json['type']?.toString().toLowerCase() ?? 'text',
sequenceId: int.tryParse(json['sequenceId']?.toString() ?? '0') ?? 0,
createdAt: _parseDateTime(json['createdAt']),
updatedAt: _parseDateTime(json['updatedAt']),
status: (json['isRead'] ?? false) ? MessageStatus.read : MessageStatus.delivered,
media: json['media'] is Map<String, dynamic> ? json['media'] as Map<String, dynamic> : null,
);
}
DateTime? _parseDateTime(String? dateStr) {
if (dateStr == null) return null;
// If it doesn't have a timezone indicator, assume it's UTC
String normalized = dateStr;
if (!normalized.endsWith('Z') && !normalized.contains('+')) {
normalized += 'Z';
}
return DateTime.parse(normalized).toLocal();
}
}

View File

@@ -0,0 +1,124 @@
import 'package:isar/isar.dart';
import '../../domain/entities/chat.dart';
import '../../domain/entities/user.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart';
part 'chat_model.g.dart';
@collection
class ChatModel {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String id;
late String type;
late String title;
String? avatarUrl;
late List<UserModel> participants;
LastMessageModel? lastMessage;
DateTime? lastMessageTime;
int unreadCount = 0;
bool isMuted = false;
bool isPinned = false;
Chat toEntity() {
return Chat(
id: id,
type: type,
title: title,
avatarUrl: avatarUrl,
participants: participants.map((u) => u.toEntity()).toList(),
lastMessage: lastMessage?.toEntity(),
lastMessageTime: lastMessageTime,
unreadCount: unreadCount,
isMuted: isMuted,
isPinned: isPinned,
);
}
static ChatModel fromEntity(Chat chat) {
return ChatModel()
..id = chat.id
..type = chat.type
..title = chat.title
..avatarUrl = chat.avatarUrl
..participants = chat.participants?.map((u) => UserModel.fromEntity(u)).toList() ?? []
..lastMessage = chat.lastMessage != null ? LastMessageModel.fromEntity(chat.lastMessage!) : null
..lastMessageTime = chat.lastMessageTime
..unreadCount = chat.unreadCount
..isMuted = chat.isMuted
..isPinned = chat.isPinned;
}
}
@embedded
class LastMessageModel {
late String id;
late String content;
late String messageType;
late int sequenceId;
DateTime? createdAt;
@Enumerated(EnumType.name)
MessageStatus status = MessageStatus.delivered;
Message toEntity() {
return Message(
id: id,
chatId: '', // Не критично для превью в списке чатов
senderId: '',
content: content,
messageType: messageType,
sequenceId: sequenceId,
createdAt: createdAt,
status: status,
);
}
static LastMessageModel fromEntity(Message message) {
return LastMessageModel()
..id = message.id
..content = message.content
..messageType = message.messageType
..sequenceId = message.sequenceId
..createdAt = message.createdAt
..status = message.status;
}
}
@embedded
class UserModel {
late String id;
late String name;
String? avatarUrl;
String? role;
DateTime? lastSeen;
bool? isOnline;
User toEntity() {
return User(
id: id,
name: name,
avatarUrl: avatarUrl,
role: role,
lastSeen: lastSeen,
isOnline: isOnline,
);
}
static UserModel fromEntity(User user) {
return UserModel()
..id = user.id
..name = user.name
..avatarUrl = user.avatarUrl
..role = user.role
..lastSeen = user.lastSeen
..isOnline = user.isOnline;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import 'package:isar/isar.dart';
import '../../domain/entities/message.dart';
part 'message_model.g.dart';
@collection
class MessageModel {
Id isarId = Isar.autoIncrement;
@Index(unique: true, replace: true)
late String id;
@Index()
late String chatId;
late String senderId;
late String content;
late String messageType;
late int sequenceId;
@Index()
DateTime? createdAt;
DateTime? updatedAt;
@Enumerated(EnumType.name)
MessageStatus status = MessageStatus.delivered;
// For simplicity, store media as a JSON string or ignore for now
String? mediaJson;
Message toEntity() {
return Message(
id: id,
chatId: chatId,
senderId: senderId,
content: content,
messageType: messageType,
sequenceId: sequenceId,
createdAt: createdAt,
updatedAt: updatedAt,
status: status,
);
}
static MessageModel fromEntity(Message message) {
return MessageModel()
..id = message.id
..chatId = message.chatId
..senderId = message.senderId
..content = message.content
..messageType = message.messageType
..sequenceId = message.sequenceId
..createdAt = message.createdAt
..updatedAt = message.updatedAt
..status = message.status;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,164 @@
import 'dart:async';
import '../../../../core/errors/result.dart';
import '../../../../core/network/signalr_service.dart';
import '../../domain/entities/chat.dart';
import '../../domain/entities/message.dart';
import '../../domain/repositories/chat_repository.dart';
import '../datasources/chat_local_datasource.dart';
import '../datasources/chat_remote_datasource.dart';
class ChatRepositoryImpl implements ChatRepository {
final ChatRemoteDataSource remoteDataSource;
final ChatLocalDataSource localDataSource;
final SignalRService signalRService;
ChatRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.signalRService,
});
@override
Stream<Message> get messageStream => signalRService.messages;
@override
Stream<Map<String, dynamic>> get typingStream => signalRService.typingUpdates;
@override
Stream<Map<String, dynamic>> get statusStream => signalRService.statusUpdates;
@override
Stream<Map<String, dynamic>> get readReceiptStream => signalRService.readReceipts;
@override
Future<void> sendTypingStatus(String chatId, bool isTyping) => signalRService.sendTypingStatus(chatId, isTyping);
@override
Future<void> markAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId) =>
signalRService.markMessagesAsRead(chatId, lastReadMessageId, lastReadSequenceId);
@override
Future<void> initSignalR() => signalRService.init();
@override
Future<void> disposeSignalR() => signalRService.stop();
@override
Future<Result<List<Chat>>> getChats({String? currentUserId}) async {
// ignore: avoid_print
print('[DEBUG] ChatRepository.getChats for user: $currentUserId');
// 1. Return cached chats immediately if available
final cachedResult = await localDataSource.getCachedChats();
// 2. Fetch from remote and update cache
final remoteResult = await remoteDataSource.getChats(currentUserId: currentUserId);
if (remoteResult.isSuccess) {
final chats = remoteResult.data!;
// ignore: avoid_print
print('[DEBUG] Remote getChats success: ${chats.length} chats');
await localDataSource.cacheChats(chats);
return Result.success(chats);
}
// ignore: avoid_print
print('[DEBUG] Remote getChats failed or empty');
// If remote fails, return cached or error
return cachedResult;
}
@override
Future<Result<Chat>> getChatById(String chatId) async {
final remoteResult = await remoteDataSource.getChatById(chatId);
if (remoteResult.isSuccess) {
await localDataSource.cacheChat(remoteResult.data!);
return remoteResult;
}
return localDataSource.getChatFromCache(chatId);
}
@override
Future<Result<Chat>> createPersonalChat(String userId) async {
final result = await remoteDataSource.createPersonalChat(userId);
if (result.isSuccess) {
await localDataSource.cacheChat(result.data!);
}
return result;
}
@override
Future<Result<Chat>> createGroupChat(String name, List<String> memberIds) async {
final result = await remoteDataSource.createGroupChat(name, memberIds);
if (result.isSuccess) {
await localDataSource.cacheChat(result.data!);
}
return result;
}
@override
Future<Result<Chat>> getOrCreateFavorites() async {
final result = await remoteDataSource.getOrCreateFavorites();
if (result.isSuccess) {
await localDataSource.cacheChat(result.data!);
}
return result;
}
@override
Future<Result<Message>> sendMessage(String chatId, String content, {String? type}) async {
final result = await remoteDataSource.sendMessage(chatId, content, type: type);
if (result.isSuccess) {
// We don't cache individual messages here usually,
// let SignalR ReceiveMessage handle it or update list
final messagesResult = await getMessages(chatId);
if (messagesResult.isSuccess) {
await localDataSource.cacheMessages(chatId, messagesResult.data!);
}
}
return result;
}
@override
Future<Result<List<Message>>> getMessages(String chatId, {String? cursor}) async {
// ignore: avoid_print
print('[DEBUG] ChatRepository.getMessages for chat: $chatId');
if (cursor == null) {
final cached = await localDataSource.getMessagesFromCache(chatId);
final remote = await remoteDataSource.getMessages(chatId);
if (remote.isSuccess) {
// ignore: avoid_print
print('[DEBUG] Remote getMessages success: ${remote.data!.length} messages');
await localDataSource.cacheMessages(chatId, remote.data!);
return remote;
}
// ignore: avoid_print
print('[DEBUG] Remote getMessages failed: ${remote.error?.message}');
return cached;
} else {
return remoteDataSource.getMessages(chatId, cursor: cursor);
}
}
@override
Future<Result<void>> togglePin(String chatId) async {
final result = await remoteDataSource.togglePin(chatId);
if (result.isSuccess) {
final chat = await getChatById(chatId);
if (chat.isSuccess) {
await localDataSource.cacheChat(chat.data!);
}
}
return result;
}
@override
Future<Result<void>> clearChat(String chatId) async {
final result = await remoteDataSource.clearChat(chatId);
if (result.isSuccess) {
await localDataSource.cacheMessages(chatId, []);
}
return result;
}
@override
Future<Result<void>> clearCache() => localDataSource.clearCache();
}

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