Compare commits
44 Commits
main
...
android_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a426b63b7d | ||
|
|
4d4bc8edd1 | ||
|
|
40589dbb75 | ||
|
|
0b013def2e | ||
|
|
ccffc5a53c | ||
|
|
e8161d23d4 | ||
|
|
89e325556c | ||
|
|
df4feeeee5 | ||
|
|
00ed4b5959 | ||
|
|
a0b50b57b0 | ||
|
|
d4c41b333a | ||
|
|
9daa786cfb | ||
|
|
2d2e4b685e | ||
|
|
46c22300e9 | ||
|
|
d9a20e40b0 | ||
|
|
945134f029 | ||
|
|
f6400ce3ac | ||
|
|
8c8ef55b58 | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -87,3 +87,13 @@ postgres_data/
|
|||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
*.txt
|
*.txt
|
||||||
|
|
||||||
|
# Android / Kotlin Mobile
|
||||||
|
client-mobile/.gradle/
|
||||||
|
client-mobile/.idea/
|
||||||
|
client-mobile/.run/
|
||||||
|
client-mobile/build/
|
||||||
|
client-mobile/.cxx/
|
||||||
|
client-mobile/local.properties
|
||||||
|
client-mobile/*.iml
|
||||||
|
client-mobile/.kotlin/
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ using FluentAssertions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Messaging.Domain;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.Chats.Create;
|
using Knot.Modules.Conversations.Application.Chats.Create;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.UnitTests.Chats;
|
namespace Knot.Modules.Conversations.UnitTests.Chats;
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ using FluentAssertions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Messaging.Domain;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.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.Chats.GetChats;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using NSubstitute;
|
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.Application.Chats.GetOrCreateFavorites;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Messaging.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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\Messaging\Knot.Modules.Messaging.csproj" />
|
||||||
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
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.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.Conversations.Application.Messages.Send;
|
||||||
using Knot.Modules.Settings.Application.Settings.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Modules.Settings.Application.Settings.DTOs;
|
using Knot.Contracts.Settings.Application.DTOs;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.UnitTests.Messages;
|
namespace Knot.Modules.Conversations.UnitTests.Messages;
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
|
|||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
private readonly IMessagesSettings _messagesSettings;
|
private readonly IMessagesSettings _messagesSettings;
|
||||||
|
private readonly IIdempotencyStore _idempotencyStore;
|
||||||
|
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||||
private readonly SendMessageCommandHandler _handler;
|
private readonly SendMessageCommandHandler _handler;
|
||||||
|
|
||||||
public SendMessageCommandHandlerTests()
|
public SendMessageCommandHandlerTests()
|
||||||
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
|
|||||||
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
|
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
|
||||||
_mediator = Substitute.For<IMediator>();
|
_mediator = Substitute.For<IMediator>();
|
||||||
_messagesSettings = Substitute.For<IMessagesSettings>();
|
_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);
|
_messagesSettings.Current.Returns(config);
|
||||||
|
|
||||||
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
|
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Хранилище для обеспечения идемпотентности операций.
|
||||||
|
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
|
||||||
|
/// </summary>
|
||||||
|
public interface IIdempotencyStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
|
||||||
|
/// </summary>
|
||||||
|
Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
TimeSpan? expiration = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -32,11 +32,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
|||||||
using Knot.Modules.WebRtc;
|
using Knot.Modules.WebRtc;
|
||||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||||
using Knot.Shared.Infrastructure;
|
using Knot.Shared.Infrastructure;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ public sealed record SendMessageCommand(
|
|||||||
DateTime? PollExpiresAt = null,
|
DateTime? PollExpiresAt = null,
|
||||||
string? CallType = null,
|
string? CallType = null,
|
||||||
string? CallStatus = null,
|
string? CallStatus = null,
|
||||||
int? Duration = null) : ICommand<Guid>;
|
int? Duration = null,
|
||||||
|
string? IdempotencyKey = null) : ICommand<Guid>;
|
||||||
|
|
||||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||||
{
|
{
|
||||||
@@ -39,64 +41,74 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly MediatR.IMediator _mediator;
|
private readonly MediatR.IMediator _mediator;
|
||||||
private readonly IMessagesSettings _messagesSettings;
|
private readonly IMessagesSettings _messagesSettings;
|
||||||
|
private readonly IIdempotencyStore _idempotencyStore;
|
||||||
|
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||||
|
|
||||||
public SendMessageCommandHandler(
|
public SendMessageCommandHandler(
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
MediatR.IMediator mediator,
|
MediatR.IMediator mediator,
|
||||||
IMessagesSettings messagesSettings)
|
IMessagesSettings messagesSettings,
|
||||||
|
IIdempotencyStore idempotencyStore,
|
||||||
|
ILogger<SendMessageCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
_messagesSettings = messagesSettings;
|
_messagesSettings = messagesSettings;
|
||||||
|
_idempotencyStore = idempotencyStore;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
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);
|
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||||
if (chat is null)
|
if (chat is null)
|
||||||
{
|
{
|
||||||
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
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))
|
if (!chat.Members.Any(m => m.UserId == request.SenderId))
|
||||||
{
|
{
|
||||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
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;
|
Message message;
|
||||||
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
||||||
{
|
{
|
||||||
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
||||||
|
|
||||||
|
|
||||||
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
||||||
message = new StoryMessage(
|
message = new StoryMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
|
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
|
|
||||||
request.StoryId ?? Guid.Empty,
|
request.StoryId ?? Guid.Empty,
|
||||||
|
|
||||||
request.StoryMediaUrl ?? string.Empty,
|
request.StoryMediaUrl ?? string.Empty,
|
||||||
request.StoryMediaType,
|
request.StoryMediaType,
|
||||||
|
|
||||||
request.Content,
|
request.Content,
|
||||||
|
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
|
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
|
||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
else if (request.Attachments != null && request.Attachments.Any())
|
else if (request.Attachments != null && request.Attachments.Any())
|
||||||
@@ -106,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
var firstAtt = request.Attachments.First();
|
var firstAtt = request.Attachments.First();
|
||||||
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||||
|
|
||||||
|
|
||||||
message = new MediaMessage(
|
message = new MediaMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
|
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
parsedType,
|
parsedType,
|
||||||
|
|
||||||
request.Content,
|
request.Content,
|
||||||
|
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
|
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
|
||||||
false);
|
false);
|
||||||
|
|
||||||
|
|
||||||
foreach (var att in request.Attachments)
|
foreach (var att in request.Attachments)
|
||||||
{
|
{
|
||||||
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
|
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(
|
message = new TextMessage(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
|
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
|
|
||||||
request.Content ?? string.Empty,
|
request.Content ?? string.Empty,
|
||||||
|
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
|
||||||
request.Quote,
|
request.Quote,
|
||||||
|
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
|
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
|
||||||
false);
|
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();
|
chat.IncrementSequenceId();
|
||||||
message.SetSequenceId(chat.LastMessageSequenceId);
|
message.SetSequenceId(chat.LastMessageSequenceId);
|
||||||
|
|
||||||
@@ -193,19 +188,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||||
senderMember.UpdateDeliveredCursor(message.Id);
|
senderMember.UpdateDeliveredCursor(message.Id);
|
||||||
|
|
||||||
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
// 5. Сохранение
|
||||||
_messageRepository.Add(message);
|
_messageRepository.Add(message);
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await _mediator.Publish(new MessageSentDomainEvent(
|
await _mediator.Publish(new MessageSentDomainEvent(
|
||||||
message.Id,
|
message.Id,
|
||||||
|
|
||||||
message.ChatId,
|
message.ChatId,
|
||||||
|
|
||||||
message.SenderId,
|
message.SenderId,
|
||||||
|
|
||||||
message.Content),
|
message.Content),
|
||||||
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return Result.Success(message.Id);
|
return Result.Success(message.Id);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
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;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Services;
|
using Knot.Modules.Conversations.Infrastructure.Services;
|
||||||
@@ -8,7 +10,6 @@ using Knot.Shared.Kernel;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations;
|
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.Application.Abstractions.IUserStatusService, UserStatusService>();
|
||||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
||||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
||||||
|
|
||||||
|
services.AddMemoryCache();
|
||||||
|
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Реализация хранилища идемпотентности на основе IMemoryCache.
|
||||||
|
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||||
|
|
||||||
|
public MemoryCacheIdempotencyStore(IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
TimeSpan? expiration = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null)
|
||||||
|
{
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _semaphore.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Double-check после получения блокировки
|
||||||
|
if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null)
|
||||||
|
{
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = await factory(cancellationToken);
|
||||||
|
|
||||||
|
var options = new MemoryCacheEntryOptions()
|
||||||
|
.SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24))
|
||||||
|
.SetPriority(CacheItemPriority.Normal);
|
||||||
|
|
||||||
|
_cache.Set(key, value, options);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_semaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||||
|
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -55,11 +55,14 @@ public static class MessagesEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
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 =>
|
var attachments = request.Attachments?.Select(a =>
|
||||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
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(
|
var command = new SendMessageCommand(
|
||||||
chatId,
|
chatId,
|
||||||
userContext.UserId,
|
userContext.UserId,
|
||||||
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
|
|||||||
attachments,
|
attachments,
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
request.Quote,
|
request.Quote,
|
||||||
request.ForwardedFromId);
|
request.ForwardedFromId,
|
||||||
|
IdempotencyKey: idempotencyKey.ToString());
|
||||||
|
|
||||||
var result = await sender.Send(command, ct);
|
var result = await sender.Send(command, ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||||
|
|||||||
44
client-mobile/.gitignore
vendored
Normal file
44
client-mobile/.gitignore
vendored
Normal 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
20
client-mobile/.metadata
Normal 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
|
||||||
125
client-mobile/GETTING_STARTED.md
Normal file
125
client-mobile/GETTING_STARTED.md
Normal 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
51
client-mobile/README.md
Normal 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
|
||||||
8
client-mobile/analysis_options.yaml
Normal file
8
client-mobile/analysis_options.yaml
Normal 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
7
client-mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
69
client-mobile/android/app/build.gradle
Normal file
69
client-mobile/android/app/build.gradle
Normal 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 {}
|
||||||
28
client-mobile/android/app/src/main/AndroidManifest.xml
Normal file
28
client-mobile/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.messenger_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#2481CC" />
|
||||||
|
</shape>
|
||||||
9
client-mobile/android/app/src/main/res/values/styles.xml
Normal file
9
client-mobile/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
25
client-mobile/android/build.gradle
Normal file
25
client-mobile/android/build.gradle
Normal 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
|
||||||
|
}
|
||||||
3
client-mobile/android/gradle.properties
Normal file
3
client-mobile/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
5
client-mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
client-mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
25
client-mobile/android/settings.gradle
Normal file
25
client-mobile/android/settings.gradle
Normal 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"
|
||||||
14
client-mobile/ios/Flutter/Generated.xcconfig
Normal file
14
client-mobile/ios/Flutter/Generated.xcconfig
Normal 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
|
||||||
32
client-mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
32
client-mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal 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 --")
|
||||||
5
client-mobile/ios/Flutter/ephemeral/flutter_lldbinit
Normal file
5
client-mobile/ios/Flutter/ephemeral/flutter_lldbinit
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#
|
||||||
|
# Generated file, do not edit.
|
||||||
|
#
|
||||||
|
|
||||||
|
command script import --relative-to-command-file flutter_lldb_helper.py
|
||||||
13
client-mobile/ios/Flutter/flutter_export_environment.sh
Normal file
13
client-mobile/ios/Flutter/flutter_export_environment.sh
Normal 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"
|
||||||
13
client-mobile/ios/Runner/AppDelegate.swift
Normal file
13
client-mobile/ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
client-mobile/ios/Runner/GeneratedPluginRegistrant.h
Normal file
19
client-mobile/ios/Runner/GeneratedPluginRegistrant.h
Normal 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 */
|
||||||
28
client-mobile/ios/Runner/GeneratedPluginRegistrant.m
Normal file
28
client-mobile/ios/Runner/GeneratedPluginRegistrant.m
Normal 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
6
client-mobile/l10n.yaml
Normal 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
|
||||||
93
client-mobile/l10n/app_en.arb
Normal file
93
client-mobile/l10n/app_en.arb
Normal 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"
|
||||||
|
}
|
||||||
93
client-mobile/l10n/app_ru.arb
Normal file
93
client-mobile/l10n/app_ru.arb
Normal 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": "Сообщение"
|
||||||
|
}
|
||||||
85
client-mobile/lib/app.dart
Normal file
85
client-mobile/lib/app.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
client-mobile/lib/core/constants/app_constants.dart
Normal file
25
client-mobile/lib/core/constants/app_constants.dart
Normal 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
|
||||||
|
}
|
||||||
9
client-mobile/lib/core/constants/storage_keys.dart
Normal file
9
client-mobile/lib/core/constants/storage_keys.dart
Normal 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';
|
||||||
|
}
|
||||||
22
client-mobile/lib/core/database/database_service.dart
Normal file
22
client-mobile/lib/core/database/database_service.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
16
client-mobile/lib/core/errors/errors.dart
Normal file
16
client-mobile/lib/core/errors/errors.dart
Normal 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;
|
||||||
|
}
|
||||||
1814
client-mobile/lib/core/errors/errors.freezed.dart
Normal file
1814
client-mobile/lib/core/errors/errors.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
47
client-mobile/lib/core/errors/result.dart
Normal file
47
client-mobile/lib/core/errors/result.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
client-mobile/lib/core/network/api_client.dart
Normal file
135
client-mobile/lib/core/network/api_client.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
client-mobile/lib/core/network/signalr_service.dart
Normal file
190
client-mobile/lib/core/network/signalr_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
client-mobile/lib/core/theme/app_theme.dart
Normal file
134
client-mobile/lib/core/theme/app_theme.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
client-mobile/lib/features/auth/domain/entities/user.dart
Normal file
19
client-mobile/lib/features/auth/domain/entities/user.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
30
client-mobile/lib/features/auth/domain/entities/user.g.dart
Normal file
30
client-mobile/lib/features/auth/domain/entities/user.g.dart
Normal 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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
13
client-mobile/lib/features/auth/domain/usecases/login.dart
Normal file
13
client-mobile/lib/features/auth/domain/usecases/login.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
124
client-mobile/lib/features/chat/data/models/chat_model.dart
Normal file
124
client-mobile/lib/features/chat/data/models/chat_model.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3226
client-mobile/lib/features/chat/data/models/chat_model.g.dart
Normal file
3226
client-mobile/lib/features/chat/data/models/chat_model.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2224
client-mobile/lib/features/chat/data/models/message_model.g.dart
Normal file
2224
client-mobile/lib/features/chat/data/models/message_model.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
}
|
||||||
21
client-mobile/lib/features/chat/domain/entities/chat.dart
Normal file
21
client-mobile/lib/features/chat/domain/entities/chat.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'user.dart';
|
||||||
|
import 'message.dart';
|
||||||
|
|
||||||
|
part 'chat.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class Chat with _$Chat {
|
||||||
|
const factory Chat({
|
||||||
|
required String id,
|
||||||
|
required String type,
|
||||||
|
required String title,
|
||||||
|
String? avatarUrl,
|
||||||
|
List<User>? participants,
|
||||||
|
@Default(null) Message? lastMessage,
|
||||||
|
DateTime? lastMessageTime,
|
||||||
|
@Default(0) int unreadCount,
|
||||||
|
@Default(false) bool isMuted,
|
||||||
|
@Default(false) bool isPinned,
|
||||||
|
}) = _Chat;
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
// 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 'chat.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 _$Chat {
|
||||||
|
String get id => throw _privateConstructorUsedError;
|
||||||
|
String get type => throw _privateConstructorUsedError;
|
||||||
|
String get title => throw _privateConstructorUsedError;
|
||||||
|
String? get avatarUrl => throw _privateConstructorUsedError;
|
||||||
|
List<User>? get participants => throw _privateConstructorUsedError;
|
||||||
|
Message? get lastMessage => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get lastMessageTime => throw _privateConstructorUsedError;
|
||||||
|
int get unreadCount => throw _privateConstructorUsedError;
|
||||||
|
bool get isMuted => throw _privateConstructorUsedError;
|
||||||
|
bool get isPinned => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$ChatCopyWith<Chat> get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $ChatCopyWith<$Res> {
|
||||||
|
factory $ChatCopyWith(Chat value, $Res Function(Chat) then) =
|
||||||
|
_$ChatCopyWithImpl<$Res, Chat>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String id,
|
||||||
|
String type,
|
||||||
|
String title,
|
||||||
|
String? avatarUrl,
|
||||||
|
List<User>? participants,
|
||||||
|
Message? lastMessage,
|
||||||
|
DateTime? lastMessageTime,
|
||||||
|
int unreadCount,
|
||||||
|
bool isMuted,
|
||||||
|
bool isPinned});
|
||||||
|
|
||||||
|
$MessageCopyWith<$Res>? get lastMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$ChatCopyWithImpl<$Res, $Val extends Chat>
|
||||||
|
implements $ChatCopyWith<$Res> {
|
||||||
|
_$ChatCopyWithImpl(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? type = null,
|
||||||
|
Object? title = null,
|
||||||
|
Object? avatarUrl = freezed,
|
||||||
|
Object? participants = freezed,
|
||||||
|
Object? lastMessage = freezed,
|
||||||
|
Object? lastMessageTime = freezed,
|
||||||
|
Object? unreadCount = null,
|
||||||
|
Object? isMuted = null,
|
||||||
|
Object? isPinned = null,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
type: null == type
|
||||||
|
? _value.type
|
||||||
|
: type // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
title: null == title
|
||||||
|
? _value.title
|
||||||
|
: title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
avatarUrl: freezed == avatarUrl
|
||||||
|
? _value.avatarUrl
|
||||||
|
: avatarUrl // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
participants: freezed == participants
|
||||||
|
? _value.participants
|
||||||
|
: participants // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<User>?,
|
||||||
|
lastMessage: freezed == lastMessage
|
||||||
|
? _value.lastMessage
|
||||||
|
: lastMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Message?,
|
||||||
|
lastMessageTime: freezed == lastMessageTime
|
||||||
|
? _value.lastMessageTime
|
||||||
|
: lastMessageTime // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
unreadCount: null == unreadCount
|
||||||
|
? _value.unreadCount
|
||||||
|
: unreadCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
isMuted: null == isMuted
|
||||||
|
? _value.isMuted
|
||||||
|
: isMuted // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
isPinned: null == isPinned
|
||||||
|
? _value.isPinned
|
||||||
|
: isPinned // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
) as $Val);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$MessageCopyWith<$Res>? get lastMessage {
|
||||||
|
if (_value.lastMessage == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $MessageCopyWith<$Res>(_value.lastMessage!, (value) {
|
||||||
|
return _then(_value.copyWith(lastMessage: value) as $Val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$ChatImplCopyWith<$Res> implements $ChatCopyWith<$Res> {
|
||||||
|
factory _$$ChatImplCopyWith(
|
||||||
|
_$ChatImpl value, $Res Function(_$ChatImpl) then) =
|
||||||
|
__$$ChatImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String id,
|
||||||
|
String type,
|
||||||
|
String title,
|
||||||
|
String? avatarUrl,
|
||||||
|
List<User>? participants,
|
||||||
|
Message? lastMessage,
|
||||||
|
DateTime? lastMessageTime,
|
||||||
|
int unreadCount,
|
||||||
|
bool isMuted,
|
||||||
|
bool isPinned});
|
||||||
|
|
||||||
|
@override
|
||||||
|
$MessageCopyWith<$Res>? get lastMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$ChatImplCopyWithImpl<$Res>
|
||||||
|
extends _$ChatCopyWithImpl<$Res, _$ChatImpl>
|
||||||
|
implements _$$ChatImplCopyWith<$Res> {
|
||||||
|
__$$ChatImplCopyWithImpl(_$ChatImpl _value, $Res Function(_$ChatImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? type = null,
|
||||||
|
Object? title = null,
|
||||||
|
Object? avatarUrl = freezed,
|
||||||
|
Object? participants = freezed,
|
||||||
|
Object? lastMessage = freezed,
|
||||||
|
Object? lastMessageTime = freezed,
|
||||||
|
Object? unreadCount = null,
|
||||||
|
Object? isMuted = null,
|
||||||
|
Object? isPinned = null,
|
||||||
|
}) {
|
||||||
|
return _then(_$ChatImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
type: null == type
|
||||||
|
? _value.type
|
||||||
|
: type // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
title: null == title
|
||||||
|
? _value.title
|
||||||
|
: title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
avatarUrl: freezed == avatarUrl
|
||||||
|
? _value.avatarUrl
|
||||||
|
: avatarUrl // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
participants: freezed == participants
|
||||||
|
? _value._participants
|
||||||
|
: participants // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<User>?,
|
||||||
|
lastMessage: freezed == lastMessage
|
||||||
|
? _value.lastMessage
|
||||||
|
: lastMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Message?,
|
||||||
|
lastMessageTime: freezed == lastMessageTime
|
||||||
|
? _value.lastMessageTime
|
||||||
|
: lastMessageTime // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
unreadCount: null == unreadCount
|
||||||
|
? _value.unreadCount
|
||||||
|
: unreadCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
isMuted: null == isMuted
|
||||||
|
? _value.isMuted
|
||||||
|
: isMuted // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
isPinned: null == isPinned
|
||||||
|
? _value.isPinned
|
||||||
|
: isPinned // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$ChatImpl implements _Chat {
|
||||||
|
const _$ChatImpl(
|
||||||
|
{required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.title,
|
||||||
|
this.avatarUrl,
|
||||||
|
final List<User>? participants,
|
||||||
|
this.lastMessage = null,
|
||||||
|
this.lastMessageTime,
|
||||||
|
this.unreadCount = 0,
|
||||||
|
this.isMuted = false,
|
||||||
|
this.isPinned = false})
|
||||||
|
: _participants = participants;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
@override
|
||||||
|
final String type;
|
||||||
|
@override
|
||||||
|
final String title;
|
||||||
|
@override
|
||||||
|
final String? avatarUrl;
|
||||||
|
final List<User>? _participants;
|
||||||
|
@override
|
||||||
|
List<User>? get participants {
|
||||||
|
final value = _participants;
|
||||||
|
if (value == null) return null;
|
||||||
|
if (_participants is EqualUnmodifiableListView) return _participants;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final Message? lastMessage;
|
||||||
|
@override
|
||||||
|
final DateTime? lastMessageTime;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final int unreadCount;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool isMuted;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool isPinned;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Chat(id: $id, type: $type, title: $title, avatarUrl: $avatarUrl, participants: $participants, lastMessage: $lastMessage, lastMessageTime: $lastMessageTime, unreadCount: $unreadCount, isMuted: $isMuted, isPinned: $isPinned)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$ChatImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
(identical(other.type, type) || other.type == type) &&
|
||||||
|
(identical(other.title, title) || other.title == title) &&
|
||||||
|
(identical(other.avatarUrl, avatarUrl) ||
|
||||||
|
other.avatarUrl == avatarUrl) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other._participants, _participants) &&
|
||||||
|
(identical(other.lastMessage, lastMessage) ||
|
||||||
|
other.lastMessage == lastMessage) &&
|
||||||
|
(identical(other.lastMessageTime, lastMessageTime) ||
|
||||||
|
other.lastMessageTime == lastMessageTime) &&
|
||||||
|
(identical(other.unreadCount, unreadCount) ||
|
||||||
|
other.unreadCount == unreadCount) &&
|
||||||
|
(identical(other.isMuted, isMuted) || other.isMuted == isMuted) &&
|
||||||
|
(identical(other.isPinned, isPinned) ||
|
||||||
|
other.isPinned == isPinned));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
avatarUrl,
|
||||||
|
const DeepCollectionEquality().hash(_participants),
|
||||||
|
lastMessage,
|
||||||
|
lastMessageTime,
|
||||||
|
unreadCount,
|
||||||
|
isMuted,
|
||||||
|
isPinned);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$ChatImplCopyWith<_$ChatImpl> get copyWith =>
|
||||||
|
__$$ChatImplCopyWithImpl<_$ChatImpl>(this, _$identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Chat implements Chat {
|
||||||
|
const factory _Chat(
|
||||||
|
{required final String id,
|
||||||
|
required final String type,
|
||||||
|
required final String title,
|
||||||
|
final String? avatarUrl,
|
||||||
|
final List<User>? participants,
|
||||||
|
final Message? lastMessage,
|
||||||
|
final DateTime? lastMessageTime,
|
||||||
|
final int unreadCount,
|
||||||
|
final bool isMuted,
|
||||||
|
final bool isPinned}) = _$ChatImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id;
|
||||||
|
@override
|
||||||
|
String get type;
|
||||||
|
@override
|
||||||
|
String get title;
|
||||||
|
@override
|
||||||
|
String? get avatarUrl;
|
||||||
|
@override
|
||||||
|
List<User>? get participants;
|
||||||
|
@override
|
||||||
|
Message? get lastMessage;
|
||||||
|
@override
|
||||||
|
DateTime? get lastMessageTime;
|
||||||
|
@override
|
||||||
|
int get unreadCount;
|
||||||
|
@override
|
||||||
|
bool get isMuted;
|
||||||
|
@override
|
||||||
|
bool get isPinned;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$ChatImplCopyWith<_$ChatImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
22
client-mobile/lib/features/chat/domain/entities/message.dart
Normal file
22
client-mobile/lib/features/chat/domain/entities/message.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'message.freezed.dart';
|
||||||
|
// part 'message.g.dart';
|
||||||
|
|
||||||
|
enum MessageStatus { sending, delivered, read, error }
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class Message with _$Message {
|
||||||
|
const factory Message({
|
||||||
|
required String id,
|
||||||
|
required String chatId,
|
||||||
|
required String senderId,
|
||||||
|
required String content,
|
||||||
|
required String messageType,
|
||||||
|
required int sequenceId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
@Default(MessageStatus.delivered) MessageStatus status,
|
||||||
|
Map<String, dynamic>? media,
|
||||||
|
}) = _Message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
// 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 'message.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 _$Message {
|
||||||
|
String get id => throw _privateConstructorUsedError;
|
||||||
|
String get chatId => throw _privateConstructorUsedError;
|
||||||
|
String get senderId => throw _privateConstructorUsedError;
|
||||||
|
String get content => throw _privateConstructorUsedError;
|
||||||
|
String get messageType => throw _privateConstructorUsedError;
|
||||||
|
int get sequenceId => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get createdAt => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get updatedAt => throw _privateConstructorUsedError;
|
||||||
|
MessageStatus get status => throw _privateConstructorUsedError;
|
||||||
|
Map<String, dynamic>? get media => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$MessageCopyWith<Message> get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $MessageCopyWith<$Res> {
|
||||||
|
factory $MessageCopyWith(Message value, $Res Function(Message) then) =
|
||||||
|
_$MessageCopyWithImpl<$Res, Message>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String id,
|
||||||
|
String chatId,
|
||||||
|
String senderId,
|
||||||
|
String content,
|
||||||
|
String messageType,
|
||||||
|
int sequenceId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
MessageStatus status,
|
||||||
|
Map<String, dynamic>? media});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$MessageCopyWithImpl<$Res, $Val extends Message>
|
||||||
|
implements $MessageCopyWith<$Res> {
|
||||||
|
_$MessageCopyWithImpl(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? chatId = null,
|
||||||
|
Object? senderId = null,
|
||||||
|
Object? content = null,
|
||||||
|
Object? messageType = null,
|
||||||
|
Object? sequenceId = null,
|
||||||
|
Object? createdAt = freezed,
|
||||||
|
Object? updatedAt = freezed,
|
||||||
|
Object? status = null,
|
||||||
|
Object? media = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
chatId: null == chatId
|
||||||
|
? _value.chatId
|
||||||
|
: chatId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
senderId: null == senderId
|
||||||
|
? _value.senderId
|
||||||
|
: senderId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
content: null == content
|
||||||
|
? _value.content
|
||||||
|
: content // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
messageType: null == messageType
|
||||||
|
? _value.messageType
|
||||||
|
: messageType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
sequenceId: null == sequenceId
|
||||||
|
? _value.sequenceId
|
||||||
|
: sequenceId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
createdAt: freezed == createdAt
|
||||||
|
? _value.createdAt
|
||||||
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
updatedAt: freezed == updatedAt
|
||||||
|
? _value.updatedAt
|
||||||
|
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
status: null == status
|
||||||
|
? _value.status
|
||||||
|
: status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as MessageStatus,
|
||||||
|
media: freezed == media
|
||||||
|
? _value.media
|
||||||
|
: media // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>?,
|
||||||
|
) as $Val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$MessageImplCopyWith<$Res> implements $MessageCopyWith<$Res> {
|
||||||
|
factory _$$MessageImplCopyWith(
|
||||||
|
_$MessageImpl value, $Res Function(_$MessageImpl) then) =
|
||||||
|
__$$MessageImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String id,
|
||||||
|
String chatId,
|
||||||
|
String senderId,
|
||||||
|
String content,
|
||||||
|
String messageType,
|
||||||
|
int sequenceId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
MessageStatus status,
|
||||||
|
Map<String, dynamic>? media});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$MessageImplCopyWithImpl<$Res>
|
||||||
|
extends _$MessageCopyWithImpl<$Res, _$MessageImpl>
|
||||||
|
implements _$$MessageImplCopyWith<$Res> {
|
||||||
|
__$$MessageImplCopyWithImpl(
|
||||||
|
_$MessageImpl _value, $Res Function(_$MessageImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? chatId = null,
|
||||||
|
Object? senderId = null,
|
||||||
|
Object? content = null,
|
||||||
|
Object? messageType = null,
|
||||||
|
Object? sequenceId = null,
|
||||||
|
Object? createdAt = freezed,
|
||||||
|
Object? updatedAt = freezed,
|
||||||
|
Object? status = null,
|
||||||
|
Object? media = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$MessageImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
chatId: null == chatId
|
||||||
|
? _value.chatId
|
||||||
|
: chatId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
senderId: null == senderId
|
||||||
|
? _value.senderId
|
||||||
|
: senderId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
content: null == content
|
||||||
|
? _value.content
|
||||||
|
: content // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
messageType: null == messageType
|
||||||
|
? _value.messageType
|
||||||
|
: messageType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
sequenceId: null == sequenceId
|
||||||
|
? _value.sequenceId
|
||||||
|
: sequenceId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
createdAt: freezed == createdAt
|
||||||
|
? _value.createdAt
|
||||||
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
updatedAt: freezed == updatedAt
|
||||||
|
? _value.updatedAt
|
||||||
|
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
status: null == status
|
||||||
|
? _value.status
|
||||||
|
: status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as MessageStatus,
|
||||||
|
media: freezed == media
|
||||||
|
? _value._media
|
||||||
|
: media // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$MessageImpl implements _Message {
|
||||||
|
const _$MessageImpl(
|
||||||
|
{required this.id,
|
||||||
|
required this.chatId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.content,
|
||||||
|
required this.messageType,
|
||||||
|
required this.sequenceId,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
this.status = MessageStatus.delivered,
|
||||||
|
final Map<String, dynamic>? media})
|
||||||
|
: _media = media;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
@override
|
||||||
|
final String chatId;
|
||||||
|
@override
|
||||||
|
final String senderId;
|
||||||
|
@override
|
||||||
|
final String content;
|
||||||
|
@override
|
||||||
|
final String messageType;
|
||||||
|
@override
|
||||||
|
final int sequenceId;
|
||||||
|
@override
|
||||||
|
final DateTime? createdAt;
|
||||||
|
@override
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final MessageStatus status;
|
||||||
|
final Map<String, dynamic>? _media;
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? get media {
|
||||||
|
final value = _media;
|
||||||
|
if (value == null) return null;
|
||||||
|
if (_media is EqualUnmodifiableMapView) return _media;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableMapView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Message(id: $id, chatId: $chatId, senderId: $senderId, content: $content, messageType: $messageType, sequenceId: $sequenceId, createdAt: $createdAt, updatedAt: $updatedAt, status: $status, media: $media)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$MessageImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
(identical(other.chatId, chatId) || other.chatId == chatId) &&
|
||||||
|
(identical(other.senderId, senderId) ||
|
||||||
|
other.senderId == senderId) &&
|
||||||
|
(identical(other.content, content) || other.content == content) &&
|
||||||
|
(identical(other.messageType, messageType) ||
|
||||||
|
other.messageType == messageType) &&
|
||||||
|
(identical(other.sequenceId, sequenceId) ||
|
||||||
|
other.sequenceId == sequenceId) &&
|
||||||
|
(identical(other.createdAt, createdAt) ||
|
||||||
|
other.createdAt == createdAt) &&
|
||||||
|
(identical(other.updatedAt, updatedAt) ||
|
||||||
|
other.updatedAt == updatedAt) &&
|
||||||
|
(identical(other.status, status) || other.status == status) &&
|
||||||
|
const DeepCollectionEquality().equals(other._media, _media));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
id,
|
||||||
|
chatId,
|
||||||
|
senderId,
|
||||||
|
content,
|
||||||
|
messageType,
|
||||||
|
sequenceId,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
status,
|
||||||
|
const DeepCollectionEquality().hash(_media));
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$MessageImplCopyWith<_$MessageImpl> get copyWith =>
|
||||||
|
__$$MessageImplCopyWithImpl<_$MessageImpl>(this, _$identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Message implements Message {
|
||||||
|
const factory _Message(
|
||||||
|
{required final String id,
|
||||||
|
required final String chatId,
|
||||||
|
required final String senderId,
|
||||||
|
required final String content,
|
||||||
|
required final String messageType,
|
||||||
|
required final int sequenceId,
|
||||||
|
final DateTime? createdAt,
|
||||||
|
final DateTime? updatedAt,
|
||||||
|
final MessageStatus status,
|
||||||
|
final Map<String, dynamic>? media}) = _$MessageImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id;
|
||||||
|
@override
|
||||||
|
String get chatId;
|
||||||
|
@override
|
||||||
|
String get senderId;
|
||||||
|
@override
|
||||||
|
String get content;
|
||||||
|
@override
|
||||||
|
String get messageType;
|
||||||
|
@override
|
||||||
|
int get sequenceId;
|
||||||
|
@override
|
||||||
|
DateTime? get createdAt;
|
||||||
|
@override
|
||||||
|
DateTime? get updatedAt;
|
||||||
|
@override
|
||||||
|
MessageStatus get status;
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? get media;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$MessageImplCopyWith<_$MessageImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
18
client-mobile/lib/features/chat/domain/entities/user.dart
Normal file
18
client-mobile/lib/features/chat/domain/entities/user.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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 name,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? role, // 'admin', 'member'
|
||||||
|
bool? isOnline,
|
||||||
|
DateTime? lastSeen,
|
||||||
|
}) = _User;
|
||||||
|
|
||||||
|
// factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// 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');
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$User {
|
||||||
|
String get id => throw _privateConstructorUsedError;
|
||||||
|
String get name => throw _privateConstructorUsedError;
|
||||||
|
String? get avatarUrl => throw _privateConstructorUsedError;
|
||||||
|
String? get role => throw _privateConstructorUsedError; // 'admin', 'member'
|
||||||
|
bool? get isOnline => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get lastSeen => 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 name,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? role,
|
||||||
|
bool? isOnline,
|
||||||
|
DateTime? lastSeen});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @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? name = null,
|
||||||
|
Object? avatarUrl = freezed,
|
||||||
|
Object? role = freezed,
|
||||||
|
Object? isOnline = freezed,
|
||||||
|
Object? lastSeen = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // 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?,
|
||||||
|
role: freezed == role
|
||||||
|
? _value.role
|
||||||
|
: role // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
isOnline: freezed == isOnline
|
||||||
|
? _value.isOnline
|
||||||
|
: isOnline // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool?,
|
||||||
|
lastSeen: freezed == lastSeen
|
||||||
|
? _value.lastSeen
|
||||||
|
: lastSeen // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
) 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 name,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? role,
|
||||||
|
bool? isOnline,
|
||||||
|
DateTime? lastSeen});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @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? name = null,
|
||||||
|
Object? avatarUrl = freezed,
|
||||||
|
Object? role = freezed,
|
||||||
|
Object? isOnline = freezed,
|
||||||
|
Object? lastSeen = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$UserImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // 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?,
|
||||||
|
role: freezed == role
|
||||||
|
? _value.role
|
||||||
|
: role // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
isOnline: freezed == isOnline
|
||||||
|
? _value.isOnline
|
||||||
|
: isOnline // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool?,
|
||||||
|
lastSeen: freezed == lastSeen
|
||||||
|
? _value.lastSeen
|
||||||
|
: lastSeen // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$UserImpl implements _User {
|
||||||
|
const _$UserImpl(
|
||||||
|
{required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.role,
|
||||||
|
this.isOnline,
|
||||||
|
this.lastSeen});
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
@override
|
||||||
|
final String name;
|
||||||
|
@override
|
||||||
|
final String? avatarUrl;
|
||||||
|
@override
|
||||||
|
final String? role;
|
||||||
|
// 'admin', 'member'
|
||||||
|
@override
|
||||||
|
final bool? isOnline;
|
||||||
|
@override
|
||||||
|
final DateTime? lastSeen;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'User(id: $id, name: $name, avatarUrl: $avatarUrl, role: $role, isOnline: $isOnline, lastSeen: $lastSeen)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$UserImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
(identical(other.name, name) || other.name == name) &&
|
||||||
|
(identical(other.avatarUrl, avatarUrl) ||
|
||||||
|
other.avatarUrl == avatarUrl) &&
|
||||||
|
(identical(other.role, role) || other.role == role) &&
|
||||||
|
(identical(other.isOnline, isOnline) ||
|
||||||
|
other.isOnline == isOnline) &&
|
||||||
|
(identical(other.lastSeen, lastSeen) ||
|
||||||
|
other.lastSeen == lastSeen));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(runtimeType, id, name, avatarUrl, role, isOnline, lastSeen);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
|
||||||
|
__$$UserImplCopyWithImpl<_$UserImpl>(this, _$identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _User implements User {
|
||||||
|
const factory _User(
|
||||||
|
{required final String id,
|
||||||
|
required final String name,
|
||||||
|
final String? avatarUrl,
|
||||||
|
final String? role,
|
||||||
|
final bool? isOnline,
|
||||||
|
final DateTime? lastSeen}) = _$UserImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id;
|
||||||
|
@override
|
||||||
|
String get name;
|
||||||
|
@override
|
||||||
|
String? get avatarUrl;
|
||||||
|
@override
|
||||||
|
String? get role;
|
||||||
|
@override // 'admin', 'member'
|
||||||
|
bool? get isOnline;
|
||||||
|
@override
|
||||||
|
DateTime? get lastSeen;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import '../../../../core/errors/result.dart';
|
||||||
|
import '../entities/chat.dart';
|
||||||
|
import '../entities/message.dart';
|
||||||
|
|
||||||
|
abstract class ChatRepository {
|
||||||
|
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});
|
||||||
|
Future<Result<List<Message>>> getMessages(String chatId, {String? cursor});
|
||||||
|
Future<Result<void>> togglePin(String chatId);
|
||||||
|
Future<Result<void>> clearChat(String chatId);
|
||||||
|
Future<Result<void>> clearCache();
|
||||||
|
|
||||||
|
Stream<Message> get messageStream;
|
||||||
|
Stream<Map<String, dynamic>> get typingStream;
|
||||||
|
Stream<Map<String, dynamic>> get statusStream;
|
||||||
|
Stream<Map<String, dynamic>> get readReceiptStream;
|
||||||
|
Future<void> sendTypingStatus(String chatId, bool isTyping);
|
||||||
|
Future<void> markAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId);
|
||||||
|
Future<void> initSignalR();
|
||||||
|
Future<void> disposeSignalR();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '../../../../core/errors/result.dart';
|
||||||
|
import '../repositories/chat_repository.dart';
|
||||||
|
import '../entities/chat.dart';
|
||||||
|
|
||||||
|
class GetChatsUseCase {
|
||||||
|
final ChatRepository _repository;
|
||||||
|
|
||||||
|
GetChatsUseCase(this._repository);
|
||||||
|
|
||||||
|
Future<Result<List<Chat>>> execute() {
|
||||||
|
return _repository.getChats();
|
||||||
|
}
|
||||||
|
}
|
||||||
515
client-mobile/lib/features/chat/presentation/bloc/chat_bloc.dart
Normal file
515
client-mobile/lib/features/chat/presentation/bloc/chat_bloc.dart
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../domain/entities/chat.dart';
|
||||||
|
import '../../domain/entities/message.dart';
|
||||||
|
import '../../domain/repositories/chat_repository.dart';
|
||||||
|
import 'chat_event.dart';
|
||||||
|
import 'chat_state.dart';
|
||||||
|
|
||||||
|
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||||
|
final ChatRepository chatRepository;
|
||||||
|
StreamSubscription? _messageSubscription;
|
||||||
|
StreamSubscription? _typingSubscription;
|
||||||
|
StreamSubscription? _statusSubscription;
|
||||||
|
StreamSubscription? _readReceiptSubscription;
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
Timer? _retryTimer;
|
||||||
|
String? _currentUserId;
|
||||||
|
final Set<String> _sendingIds = {};
|
||||||
|
|
||||||
|
ChatBloc({required this.chatRepository}) : super(const ChatState.initial()) {
|
||||||
|
on<ChatEventStarted>(_onStarted);
|
||||||
|
on<ChatEventChatsLoaded>(_onChatsLoaded);
|
||||||
|
on<ChatEventChatSelected>(_onChatSelected);
|
||||||
|
on<ChatEventMessagesRequested>(_onMessagesRequested);
|
||||||
|
on<ChatEventMessageReceived>(_onMessageReceived);
|
||||||
|
on<ChatEventMessageSent>(_onMessageSent);
|
||||||
|
on<ChatEventFavoritesRequested>(_onFavoritesRequested);
|
||||||
|
on<ChatEventTypingUpdated>(_onTypingUpdated);
|
||||||
|
on<ChatEventUserStatusChanged>(_onUserStatusChanged);
|
||||||
|
on<ChatEventRefreshPresence>(_onRefreshPresence);
|
||||||
|
on<ChatEventSendTypingStatus>(_onSendTypingStatus);
|
||||||
|
on<ChatEventMarkAsRead>(_onMarkAsRead);
|
||||||
|
on<ChatEventReadReceiptReceived>(_onReadReceiptReceived);
|
||||||
|
on<ChatEventMessageStatusUpdated>(_onMessageStatusUpdated);
|
||||||
|
on<ChatEventMessageReplaced>(_onMessageReplaced);
|
||||||
|
on<ChatEventCacheCleared>(_onCacheCleared);
|
||||||
|
|
||||||
|
_messageSubscription = chatRepository.messageStream.listen((message) {
|
||||||
|
add(ChatEvent.messageReceived(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
_typingSubscription = chatRepository.typingStream.listen((data) {
|
||||||
|
add(ChatEvent.typingUpdated(data['chatId'], data['userId'], data['isTyping']));
|
||||||
|
});
|
||||||
|
|
||||||
|
_statusSubscription = chatRepository.statusStream.listen((data) {
|
||||||
|
add(ChatEvent.userStatusChanged(
|
||||||
|
data['userId'],
|
||||||
|
data['isOnline'],
|
||||||
|
lastSeen: data['lastSeen'] != null ? DateTime.tryParse(data['lastSeen'].toString()) : null,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
_readReceiptSubscription = chatRepository.readReceiptStream.listen((data) {
|
||||||
|
add(ChatEvent.readReceiptReceived(
|
||||||
|
chatId: data['chatId'],
|
||||||
|
userId: data['userId'],
|
||||||
|
lastReadMessageId: data['lastReadMessageId'],
|
||||||
|
lastReadSequenceId: int.tryParse(data['lastReadSequenceId'].toString()) ?? 0,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh presence status every minute to update "X minutes ago"
|
||||||
|
_refreshTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||||
|
add(const ChatEvent.refreshPresence());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry sending messages every 10 seconds
|
||||||
|
_retryTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||||
|
_retryPendingMessages();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStarted(ChatEventStarted event, Emitter<ChatState> emit) async {
|
||||||
|
_currentUserId = event.userId;
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] ChatBloc._onStarted for user: ${event.userId}');
|
||||||
|
// Start SignalR in background
|
||||||
|
chatRepository.initSignalR().catchError((e) {
|
||||||
|
// Log or handle SignalR init error
|
||||||
|
});
|
||||||
|
// Immediately trigger chats loading
|
||||||
|
add(ChatEvent.chatsLoaded(currentUserId: event.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onChatsLoaded(ChatEventChatsLoaded event, Emitter<ChatState> emit) async {
|
||||||
|
emit(const ChatState.loading());
|
||||||
|
final result = await chatRepository.getChats(currentUserId: event.currentUserId);
|
||||||
|
result.fold(
|
||||||
|
(error) => emit(ChatState.error(error.message ?? 'Failed to load chats')),
|
||||||
|
(chats) => emit(ChatState.chatsLoaded(chats)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onChatSelected(ChatEventChatSelected event, Emitter<ChatState> emit) async {
|
||||||
|
final currentChatId = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.chat.id,
|
||||||
|
messagesLoaded: (s) => s.chat.id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentChatId == event.chatId) {
|
||||||
|
// Already in this chat, just refresh messages
|
||||||
|
final messagesResult = await chatRepository.getMessages(event.chatId);
|
||||||
|
messagesResult.fold(
|
||||||
|
(error) => null,
|
||||||
|
(messages) {
|
||||||
|
final currentChat = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.chat,
|
||||||
|
messagesLoaded: (s) => s.chat,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (currentChat != null) {
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, messages, typingUsers: state.typingUsers));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(ChatState.loading(typingUsers: state.typingUsers));
|
||||||
|
final chatResult = await chatRepository.getChatById(event.chatId);
|
||||||
|
final messagesResult = await chatRepository.getMessages(event.chatId);
|
||||||
|
|
||||||
|
chatResult.fold(
|
||||||
|
(error) => emit(ChatState.error(error.message ?? 'Failed to load chat', typingUsers: state.typingUsers)),
|
||||||
|
(chat) {
|
||||||
|
messagesResult.fold(
|
||||||
|
(error) => emit(ChatState.chatSelected(chat, [], typingUsers: state.typingUsers)),
|
||||||
|
(messages) => emit(ChatState.chatSelected(chat, messages, typingUsers: state.typingUsers)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessagesRequested(ChatEventMessagesRequested event, Emitter<ChatState> emit) async {
|
||||||
|
final currentChat = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.chat,
|
||||||
|
messagesLoaded: (s) => s.chat,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only fetch if it's the current chat or we are in chats list
|
||||||
|
final result = await chatRepository.getMessages(event.chatId);
|
||||||
|
result.fold(
|
||||||
|
(error) => null, // Don't emit error state for background updates to avoid UI flickering
|
||||||
|
(messages) {
|
||||||
|
if (currentChat != null && currentChat.id == event.chatId) {
|
||||||
|
// If we are in the chat, update messages and switch to messagesLoaded (stable state)
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, messages, typingUsers: state.typingUsers));
|
||||||
|
}
|
||||||
|
// If we are in the list, the list will update via local cache anyway or on next fetch
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessageReceived(ChatEventMessageReceived event, Emitter<ChatState> emit) async {
|
||||||
|
final message = event.message;
|
||||||
|
final currentChat = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.chat,
|
||||||
|
messagesLoaded: (s) => s.chat,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentChat != null && currentChat.id == message.chatId) {
|
||||||
|
final List<Message> currentMessages = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.messages,
|
||||||
|
messagesLoaded: (s) => s.messages,
|
||||||
|
orElse: () => [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<Message> mutableList = List<Message>.from(currentMessages);
|
||||||
|
|
||||||
|
// Check if this is a message from me that should replace a temp one
|
||||||
|
if (message.senderId == _currentUserId) {
|
||||||
|
// Look for a temp message with same content and type
|
||||||
|
final tempIdx = mutableList.indexWhere((m) =>
|
||||||
|
m.id.startsWith('temp_') &&
|
||||||
|
m.messageType.toLowerCase() == message.messageType.toLowerCase() &&
|
||||||
|
(m.content.trim() == message.content.trim() || (m.content.isEmpty && message.content.isEmpty)));
|
||||||
|
|
||||||
|
if (tempIdx != -1) {
|
||||||
|
// Replace temp with real one
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] Smart replacement (SignalR): replacing ${mutableList[tempIdx].id} with ${message.id} (Type: ${message.messageType})');
|
||||||
|
mutableList[tempIdx] = message;
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no temp message found, check if we already have this message by ID (prevent double add)
|
||||||
|
if (mutableList.any((m) => m.id == message.id)) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] Self-message already in list, skipping: ${message.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] Self-message received but no matching temp found: ${message.id} (Content: "${message.content}", Type: ${message.messageType})');
|
||||||
|
} else {
|
||||||
|
// From someone else, check for duplicates
|
||||||
|
if (mutableList.any((m) => m.id == message.id)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedMessages = mutableList..insert(0, message);
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also trigger chats refresh if we are in chatsLoaded state to update last message/unread
|
||||||
|
state.maybeMap(
|
||||||
|
chatsLoaded: (_) => add(ChatEvent.chatsLoaded(currentUserId: currentChat?.participants?.firstWhere((p) => p.id != '').id)), // userId logic might need to be better
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessageSent(ChatEventMessageSent event, Emitter<ChatState> emit) async {
|
||||||
|
final currentChat = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.chat,
|
||||||
|
messagesLoaded: (s) => s.chat,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentChat == null) return;
|
||||||
|
final userId = _currentUserId ?? '';
|
||||||
|
|
||||||
|
final List<Message> currentMessages = state.maybeMap(
|
||||||
|
chatSelected: (s) => s.messages,
|
||||||
|
messagesLoaded: (s) => s.messages,
|
||||||
|
orElse: () => [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create optimistic "sending" message
|
||||||
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final tempMessage = Message(
|
||||||
|
id: tempId,
|
||||||
|
chatId: event.chatId,
|
||||||
|
senderId: userId,
|
||||||
|
content: event.content,
|
||||||
|
messageType: 'text',
|
||||||
|
sequenceId: -1, // Temporary
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
status: MessageStatus.sending,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedMessages = List<Message>.from(currentMessages)..insert(0, tempMessage);
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers));
|
||||||
|
|
||||||
|
final result = await chatRepository.sendMessage(event.chatId, event.content);
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
(error) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] sendMessage failed for $tempId: ${error.toString()}');
|
||||||
|
final isNetworkError = error.maybeMap(
|
||||||
|
network: (_) => true,
|
||||||
|
orElse: () => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNetworkError) {
|
||||||
|
// Keep as sending, retry timer will pick it up
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Message> currentList = state.maybeMap(
|
||||||
|
messagesLoaded: (s) => s.messages,
|
||||||
|
orElse: () => updatedMessages,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<Message> mutableList = List<Message>.from(currentList);
|
||||||
|
final idx = mutableList.indexWhere((m) => m.id == tempId);
|
||||||
|
if (idx != -1) {
|
||||||
|
mutableList[idx] = mutableList[idx].copyWith(status: MessageStatus.error);
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(sentMessage) {
|
||||||
|
// Replace temp message with the actual one from server
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] sendMessage success for $tempId, replacing with ${sentMessage.id}');
|
||||||
|
final List<Message> currentList = state.maybeMap(
|
||||||
|
messagesLoaded: (s) => s.messages,
|
||||||
|
orElse: () => updatedMessages,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<Message> mutableList = List<Message>.from(currentList);
|
||||||
|
final idx = mutableList.indexWhere((m) => m.id == tempId);
|
||||||
|
if (idx != -1) {
|
||||||
|
mutableList[idx] = sentMessage;
|
||||||
|
emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onFavoritesRequested(ChatEventFavoritesRequested event, Emitter<ChatState> emit) async {
|
||||||
|
emit(ChatState.loading(typingUsers: state.typingUsers));
|
||||||
|
final result = await chatRepository.getOrCreateFavorites();
|
||||||
|
result.fold(
|
||||||
|
(error) => emit(ChatState.error(error.message ?? 'Failed to open Favorites', typingUsers: state.typingUsers)),
|
||||||
|
(chat) => add(ChatEvent.chatSelected(chat.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTypingUpdated(ChatEventTypingUpdated event, Emitter<ChatState> emit) async {
|
||||||
|
final Map<String, Set<String>> newTypingUsers = Map.from(state.typingUsers);
|
||||||
|
final Set<String> chatTypingUsers = Set.from(newTypingUsers[event.chatId] ?? {});
|
||||||
|
|
||||||
|
if (event.isTyping) {
|
||||||
|
chatTypingUsers.add(event.userId);
|
||||||
|
} else {
|
||||||
|
chatTypingUsers.remove(event.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatTypingUsers.isEmpty) {
|
||||||
|
newTypingUsers.remove(event.chatId);
|
||||||
|
} else {
|
||||||
|
newTypingUsers[event.chatId] = chatTypingUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.maybeMap(
|
||||||
|
chatSelected: (s) {
|
||||||
|
// If it's the current chat, transition to stable state
|
||||||
|
if (s.chat.id == event.chatId) {
|
||||||
|
emit(ChatState.messagesLoaded(s.chat, s.messages, typingUsers: newTypingUsers));
|
||||||
|
} else {
|
||||||
|
emit(s.copyWith(typingUsers: newTypingUsers));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
messagesLoaded: (s) => emit(s.copyWith(typingUsers: newTypingUsers)),
|
||||||
|
chatsLoaded: (s) => emit(s.copyWith(typingUsers: newTypingUsers)),
|
||||||
|
orElse: () => emit(state.copyWith(typingUsers: newTypingUsers)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onUserStatusChanged(ChatEventUserStatusChanged event, Emitter<ChatState> emit) async {
|
||||||
|
state.mapOrNull(
|
||||||
|
chatsLoaded: (s) {
|
||||||
|
final updatedChats = s.chats.map((chat) {
|
||||||
|
final participants = chat.participants;
|
||||||
|
if (participants != null && participants.any((p) => p.id == event.userId)) {
|
||||||
|
final updatedParticipants = participants.map((p) {
|
||||||
|
if (p.id == event.userId) {
|
||||||
|
return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}).toList();
|
||||||
|
return chat.copyWith(participants: updatedParticipants);
|
||||||
|
}
|
||||||
|
return chat;
|
||||||
|
}).toList();
|
||||||
|
emit(s.copyWith(chats: updatedChats));
|
||||||
|
},
|
||||||
|
chatSelected: (s) {
|
||||||
|
if (s.chat.participants?.any((p) => p.id == event.userId) ?? false) {
|
||||||
|
final updatedParticipants = s.chat.participants!.map((p) {
|
||||||
|
if (p.id == event.userId) {
|
||||||
|
return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}).toList();
|
||||||
|
final updatedChat = s.chat.copyWith(participants: updatedParticipants);
|
||||||
|
emit(s.copyWith(chat: updatedChat));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
messagesLoaded: (s) {
|
||||||
|
if (s.chat.participants?.any((p) => p.id == event.userId) ?? false) {
|
||||||
|
final updatedParticipants = s.chat.participants!.map((p) {
|
||||||
|
if (p.id == event.userId) {
|
||||||
|
return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}).toList();
|
||||||
|
final updatedChat = s.chat.copyWith(participants: updatedParticipants);
|
||||||
|
emit(s.copyWith(chat: updatedChat));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRefreshPresence(ChatEventRefreshPresence event, Emitter<ChatState> emit) async {
|
||||||
|
// Re-emit the current state with a new timestamp to trigger UI rebuild
|
||||||
|
state.mapOrNull(
|
||||||
|
initial: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
|
||||||
|
loading: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
|
||||||
|
chatsLoaded: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
|
||||||
|
chatSelected: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
|
||||||
|
messagesLoaded: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
|
||||||
|
error: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSendTypingStatus(ChatEventSendTypingStatus event, Emitter<ChatState> emit) async {
|
||||||
|
await chatRepository.sendTypingStatus(event.chatId, event.isTyping);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMarkAsRead(ChatEventMarkAsRead event, Emitter<ChatState> emit) async {
|
||||||
|
await chatRepository.markAsRead(event.chatId, event.messageId, event.sequenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onReadReceiptReceived(ChatEventReadReceiptReceived event, Emitter<ChatState> emit) async {
|
||||||
|
state.mapOrNull(
|
||||||
|
messagesLoaded: (s) {
|
||||||
|
if (s.chat.id == event.chatId) {
|
||||||
|
final updatedMessages = s.messages.map((m) {
|
||||||
|
if (m.sequenceId <= event.lastReadSequenceId && m.status != MessageStatus.read) {
|
||||||
|
return m.copyWith(status: MessageStatus.read);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
emit(s.copyWith(messages: updatedMessages));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chatSelected: (s) {
|
||||||
|
if (s.chat.id == event.chatId) {
|
||||||
|
final updatedMessages = s.messages.map((m) {
|
||||||
|
if (m.sequenceId <= event.lastReadSequenceId && m.status != MessageStatus.read) {
|
||||||
|
return m.copyWith(status: MessageStatus.read);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
emit(s.copyWith(messages: updatedMessages));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCacheCleared(ChatEventCacheCleared event, Emitter<ChatState> emit) async {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] ChatBloc._onCacheCleared START');
|
||||||
|
await chatRepository.clearCache();
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[DEBUG] ChatBloc._onCacheCleared END');
|
||||||
|
emit(const ChatState.initial());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retryPendingMessages() {
|
||||||
|
state.mapOrNull(
|
||||||
|
messagesLoaded: (s) {
|
||||||
|
final pending = s.messages.where((m) => m.status == MessageStatus.sending && m.id.startsWith('temp_')).toList();
|
||||||
|
for (final msg in pending) {
|
||||||
|
if (!_sendingIds.contains(msg.id)) {
|
||||||
|
_retrySendMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _retrySendMessage(Message tempMessage) async {
|
||||||
|
_sendingIds.add(tempMessage.id);
|
||||||
|
final result = await chatRepository.sendMessage(tempMessage.chatId, tempMessage.content);
|
||||||
|
_sendingIds.remove(tempMessage.id);
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
(error) {
|
||||||
|
final isNetworkError = error.maybeMap(
|
||||||
|
network: (_) => true,
|
||||||
|
orElse: () => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isNetworkError) {
|
||||||
|
_updateMessageStatus(tempMessage.id, MessageStatus.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(sentMessage) {
|
||||||
|
_replaceTempMessage(tempMessage.id, sentMessage);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMessageStatus(String id, MessageStatus status) {
|
||||||
|
add(ChatEvent.messageStatusUpdated(id, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceTempMessage(String tempId, Message realMessage) {
|
||||||
|
add(ChatEvent.messageReplaced(tempId, realMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessageStatusUpdated(ChatEventMessageStatusUpdated event, Emitter<ChatState> emit) async {
|
||||||
|
state.mapOrNull(
|
||||||
|
messagesLoaded: (s) {
|
||||||
|
final List<Message> mutableList = List<Message>.from(s.messages);
|
||||||
|
final idx = mutableList.indexWhere((m) => m.id == event.messageId);
|
||||||
|
if (idx != -1) {
|
||||||
|
mutableList[idx] = mutableList[idx].copyWith(status: event.status);
|
||||||
|
emit(s.copyWith(messages: mutableList));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessageReplaced(ChatEventMessageReplaced event, Emitter<ChatState> emit) async {
|
||||||
|
state.mapOrNull(
|
||||||
|
messagesLoaded: (s) {
|
||||||
|
final List<Message> mutableList = List<Message>.from(s.messages);
|
||||||
|
final idx = mutableList.indexWhere((m) => m.id == event.tempId);
|
||||||
|
if (idx != -1) {
|
||||||
|
mutableList[idx] = event.realMessage;
|
||||||
|
emit(s.copyWith(messages: mutableList));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_messageSubscription?.cancel();
|
||||||
|
_typingSubscription?.cancel();
|
||||||
|
_statusSubscription?.cancel();
|
||||||
|
_readReceiptSubscription?.cancel();
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
chatRepository.disposeSignalR();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../domain/entities/message.dart';
|
||||||
|
|
||||||
|
part 'chat_event.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class ChatEvent with _$ChatEvent {
|
||||||
|
const factory ChatEvent.started({String? userId}) = ChatEventStarted;
|
||||||
|
const factory ChatEvent.chatsLoaded({String? currentUserId}) = ChatEventChatsLoaded;
|
||||||
|
const factory ChatEvent.chatSelected(String chatId) = ChatEventChatSelected;
|
||||||
|
const factory ChatEvent.messagesRequested(String chatId, {String? cursor}) = ChatEventMessagesRequested;
|
||||||
|
const factory ChatEvent.messageReceived(Message message) = ChatEventMessageReceived;
|
||||||
|
const factory ChatEvent.messageSent(String chatId, String content) = ChatEventMessageSent;
|
||||||
|
const factory ChatEvent.favoritesRequested() = ChatEventFavoritesRequested;
|
||||||
|
const factory ChatEvent.typingUpdated(String chatId, String userId, bool isTyping) = ChatEventTypingUpdated;
|
||||||
|
const factory ChatEvent.userStatusChanged(String userId, bool isOnline, {DateTime? lastSeen}) = ChatEventUserStatusChanged;
|
||||||
|
const factory ChatEvent.refreshPresence() = ChatEventRefreshPresence;
|
||||||
|
const factory ChatEvent.sendTypingStatus(String chatId, bool isTyping) = ChatEventSendTypingStatus;
|
||||||
|
const factory ChatEvent.markAsRead(String chatId, String messageId, int sequenceId) = ChatEventMarkAsRead;
|
||||||
|
const factory ChatEvent.readReceiptReceived({
|
||||||
|
required String chatId,
|
||||||
|
required String userId,
|
||||||
|
required String lastReadMessageId,
|
||||||
|
required int lastReadSequenceId,
|
||||||
|
}) = ChatEventReadReceiptReceived;
|
||||||
|
const factory ChatEvent.messageStatusUpdated(String messageId, MessageStatus status) = ChatEventMessageStatusUpdated;
|
||||||
|
const factory ChatEvent.messageReplaced(String tempId, Message realMessage) = ChatEventMessageReplaced;
|
||||||
|
const factory ChatEvent.cacheCleared() = ChatEventCacheCleared;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../domain/entities/chat.dart';
|
||||||
|
import '../../domain/entities/message.dart';
|
||||||
|
|
||||||
|
part 'chat_state.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class ChatState with _$ChatState {
|
||||||
|
const factory ChatState.initial({@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatInitial;
|
||||||
|
const factory ChatState.loading({@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatLoading;
|
||||||
|
const factory ChatState.chatsLoaded(List<Chat> chats, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = _ChatsLoaded;
|
||||||
|
const factory ChatState.chatSelected(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = _ChatSelected;
|
||||||
|
const factory ChatState.messagesLoaded(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = _MessagesLoaded;
|
||||||
|
const factory ChatState.error(String message, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatError;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:messenger_app/l10n/app_localizations.dart';
|
||||||
|
import '../bloc/chat_bloc.dart';
|
||||||
|
import '../bloc/chat_event.dart';
|
||||||
|
import '../bloc/chat_state.dart';
|
||||||
|
import '../../domain/entities/chat.dart';
|
||||||
|
import '../../domain/entities/message.dart';
|
||||||
|
|
||||||
|
class ChatDetailPage extends StatefulWidget {
|
||||||
|
final Chat chat;
|
||||||
|
final String currentUserId;
|
||||||
|
|
||||||
|
const ChatDetailPage({
|
||||||
|
super.key,
|
||||||
|
required this.chat,
|
||||||
|
required this.currentUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||||
|
final _messageController = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
centerTitle: false,
|
||||||
|
title: BlocBuilder<ChatBloc, ChatState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final currentChat = state.maybeWhen(
|
||||||
|
chatSelected: (chat, messages, typingUsers, lastUpdate) => chat,
|
||||||
|
messagesLoaded: (chat, messages, typingUsers, lastUpdate) => chat,
|
||||||
|
orElse: () => widget.chat,
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(currentChat.type == 'favorites' ? AppLocalizations.of(context)!.favorites : currentChat.title),
|
||||||
|
if (currentChat.type != 'favorites')
|
||||||
|
_buildPresenceStatus(currentChat, state),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<ChatBloc, ChatState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return state.maybeWhen(
|
||||||
|
chatSelected: (chat, messages, typingUsers, lastUpdate) => _buildMessageList(messages),
|
||||||
|
messagesLoaded: (chat, messages, typingUsers, lastUpdate) => _buildMessageList(messages),
|
||||||
|
loading: (typingUsers, lastUpdate) => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (msg, typingUsers, lastUpdate) => Center(child: Text('Ошибка: $msg')),
|
||||||
|
orElse: () => const Center(child: Text('Начните общение')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildMessageInput(widget.chat),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPresenceStatus(Chat chat, ChatState state) {
|
||||||
|
// Check typing status first
|
||||||
|
final typingInChat = state.typingUsers[chat.id];
|
||||||
|
final otherMemberTyping = typingInChat?.any((id) => id != widget.currentUserId) ?? false;
|
||||||
|
|
||||||
|
if (otherMemberTyping) {
|
||||||
|
return const Text(
|
||||||
|
'печатает...',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.normal, fontStyle: FontStyle.italic),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final participants = chat.participants;
|
||||||
|
if (participants == null || participants.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final otherMember = participants.firstWhere(
|
||||||
|
(m) => m.id != widget.currentUserId,
|
||||||
|
orElse: () => participants[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final locale = Localizations.localeOf(context).languageCode;
|
||||||
|
|
||||||
|
if (otherMember.isOnline == true) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
l10n.online,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.normal),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (otherMember.lastSeen != null) {
|
||||||
|
return Text(
|
||||||
|
_formatLastSeen(otherMember.lastSeen!, l10n, locale),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.normal),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLastSeen(DateTime utcDate, AppLocalizations l10n, String locale) {
|
||||||
|
final lastSeen = utcDate.toLocal();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(lastSeen);
|
||||||
|
final timeStr = DateFormat.Hm(locale).format(lastSeen);
|
||||||
|
|
||||||
|
// Case 1: Same day (within 24h of NOW, but specifically checking calendar day is better for "yesterday")
|
||||||
|
// Let's use difference for "relative" and calendar days for "yesterday/day before"
|
||||||
|
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final seenDay = DateTime(lastSeen.year, lastSeen.month, lastSeen.day);
|
||||||
|
final dayDiff = today.difference(seenDay).inDays;
|
||||||
|
|
||||||
|
if (dayDiff == 0) {
|
||||||
|
// Same calendar day
|
||||||
|
if (difference.inHours > 0) {
|
||||||
|
return l10n.lastSeenRelative(difference.inHours, difference.inMinutes % 60);
|
||||||
|
} else if (difference.inMinutes > 0) {
|
||||||
|
return l10n.lastSeenRelativeMinutes(difference.inMinutes);
|
||||||
|
} else {
|
||||||
|
return l10n.lastSeenJustNow;
|
||||||
|
}
|
||||||
|
} else if (dayDiff == 1) {
|
||||||
|
return l10n.lastSeenYesterday(timeStr);
|
||||||
|
} else if (dayDiff == 2) {
|
||||||
|
return l10n.lastSeenDayBeforeYesterday(timeStr);
|
||||||
|
} else {
|
||||||
|
final dateStr = DateFormat.MMMMd(locale).format(lastSeen);
|
||||||
|
return l10n.lastSeen(dateStr, timeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date, String locale) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final localDate = date.toLocal();
|
||||||
|
if (localDate.year == now.year) {
|
||||||
|
return DateFormat.MMMMd(locale).format(localDate);
|
||||||
|
}
|
||||||
|
return DateFormat.yMMMMd(locale).format(localDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageList(List<Message> messages) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final locale = Localizations.localeOf(context).languageCode;
|
||||||
|
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
return Center(child: Text(l10n.noMessages));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as read logic
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final unreadFromOthers = messages.where((m) =>
|
||||||
|
m.senderId != widget.currentUserId &&
|
||||||
|
m.status != MessageStatus.read
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
if (unreadFromOthers.isNotEmpty) {
|
||||||
|
// Find the message with the highest sequenceId to mark everything up to it as read
|
||||||
|
final latest = unreadFromOthers.reduce((a, b) => a.sequenceId > b.sequenceId ? a : b);
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.markAsRead(latest.chatId, latest.id, latest.sequenceId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<dynamic> items = [];
|
||||||
|
for (int i = 0; i < messages.length; i++) {
|
||||||
|
final message = messages[i];
|
||||||
|
items.add(message);
|
||||||
|
|
||||||
|
if (message.createdAt != null) {
|
||||||
|
final localCreatedAt = message.createdAt!.toLocal();
|
||||||
|
final currentDay = DateTime(localCreatedAt.year, localCreatedAt.month, localCreatedAt.day);
|
||||||
|
|
||||||
|
if (i == messages.length - 1) {
|
||||||
|
items.add(currentDay);
|
||||||
|
} else {
|
||||||
|
final nextMessage = messages[i + 1];
|
||||||
|
if (nextMessage.createdAt != null) {
|
||||||
|
final nextLocalCreatedAt = nextMessage.createdAt!.toLocal();
|
||||||
|
final nextDay = DateTime(nextLocalCreatedAt.year, nextLocalCreatedAt.month, nextLocalCreatedAt.day);
|
||||||
|
if (currentDay != nextDay) {
|
||||||
|
items.add(currentDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
reverse: true,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
|
||||||
|
if (item is DateTime) {
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatDate(item, locale),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.black54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = item as Message;
|
||||||
|
final isMe = message.senderId == widget.currentUserId;
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 8),
|
||||||
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? const Color(0xFFE3F2FD) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16).copyWith(
|
||||||
|
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(16),
|
||||||
|
bottomLeft: !isMe ? const Radius.circular(2) : const Radius.circular(16),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
message.content,
|
||||||
|
style: const TextStyle(color: Colors.black87, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (message.createdAt != null)
|
||||||
|
Text(
|
||||||
|
DateFormat.Hm(locale).format(message.createdAt!.toLocal()),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.black45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildStatusIcon(message),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIcon(Message message) {
|
||||||
|
switch (message.status) {
|
||||||
|
case MessageStatus.sending:
|
||||||
|
return const Icon(Icons.access_time, size: 12, color: Colors.black45);
|
||||||
|
case MessageStatus.delivered:
|
||||||
|
return const Icon(Icons.check, size: 12, color: Colors.black45);
|
||||||
|
case MessageStatus.read:
|
||||||
|
return const Icon(Icons.done_all, size: 12, color: Colors.blue);
|
||||||
|
case MessageStatus.error:
|
||||||
|
return const Icon(Icons.error_outline, size: 12, color: Colors.red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageInput(Chat chat) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
offset: const Offset(0, -1),
|
||||||
|
blurRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(icon: const Icon(Icons.add), onPressed: () {}),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: AppLocalizations.of(context)!.messageHint,
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.sendTypingStatus(chat.id, value.isNotEmpty));
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _sendMessage(chat.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: () => _sendMessage(chat.id),
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage(String chatId) {
|
||||||
|
final content = _messageController.text.trim();
|
||||||
|
if (content.isNotEmpty) {
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.messageSent(chatId, content));
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.sendTypingStatus(chatId, false));
|
||||||
|
_messageController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:messenger_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||||
|
import 'package:messenger_app/features/chat/presentation/bloc/chat_event.dart';
|
||||||
|
import 'package:messenger_app/features/chat/presentation/bloc/chat_state.dart';
|
||||||
|
import 'package:messenger_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||||
|
import 'package:messenger_app/features/auth/presentation/bloc/auth_state.dart';
|
||||||
|
import 'package:messenger_app/features/chat/domain/entities/chat.dart';
|
||||||
|
import 'package:messenger_app/l10n/app_localizations.dart';
|
||||||
|
import 'chat_detail_page.dart';
|
||||||
|
|
||||||
|
class ChatsPage extends StatefulWidget {
|
||||||
|
const ChatsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatsPage> createState() => _ChatsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatsPageState extends State<ChatsPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
final userId = authState.maybeWhen(
|
||||||
|
authenticated: (id) => id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.chatsLoaded(currentUserId: userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isNavigating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.chats),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bookmark_border),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<ChatBloc>().add(const ChatEvent.favoritesRequested());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocConsumer<ChatBloc, ChatState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
state.whenOrNull(
|
||||||
|
chatSelected: (chat, messages, typingUsers, lastUpdate) {
|
||||||
|
if (_isNavigating) return;
|
||||||
|
|
||||||
|
// Only navigate if we are currently on the ChatsPage
|
||||||
|
final isCurrent = ModalRoute.of(context)?.isCurrent ?? false;
|
||||||
|
if (!isCurrent) return;
|
||||||
|
|
||||||
|
_isNavigating = true;
|
||||||
|
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
final userId = authState.maybeWhen(
|
||||||
|
authenticated: (id) => id,
|
||||||
|
orElse: () => '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: 'chat_detail'),
|
||||||
|
builder: (_) => ChatDetailPage(
|
||||||
|
chat: chat,
|
||||||
|
currentUserId: userId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((_) {
|
||||||
|
_isNavigating = false;
|
||||||
|
// Reload chats when returning from detail page
|
||||||
|
if (context.mounted) {
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.chatsLoaded(currentUserId: userId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return state.maybeWhen(
|
||||||
|
loading: (typingUsers, lastUpdate) => const Center(child: CircularProgressIndicator()),
|
||||||
|
chatsLoaded: (chats, typingUsers, lastUpdate) => _buildChatList(chats, l10n),
|
||||||
|
error: (message, typingUsers, lastUpdate) => Center(child: Text('${l10n.error}: $message')),
|
||||||
|
orElse: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Open contact list to create chat
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatList(List<Chat> chats, AppLocalizations l10n) {
|
||||||
|
if (chats.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(l10n.noChats),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
final userId = authState.maybeWhen(
|
||||||
|
authenticated: (id) => id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.chatsLoaded(currentUserId: userId));
|
||||||
|
},
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: chats.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final chat = chats[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundImage: chat.avatarUrl != null ? NetworkImage(chat.avatarUrl!) : null,
|
||||||
|
child: chat.avatarUrl == null
|
||||||
|
? Text(chat.title.isNotEmpty ? chat.title[0].toUpperCase() : '?')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
chat.type == 'favorites' ? l10n.favorites : chat.title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
chat.lastMessage?.content ?? l10n.noChats,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (chat.lastMessageTime != null)
|
||||||
|
Text(
|
||||||
|
DateFormat.Hm().format(chat.lastMessageTime!.toLocal()),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (chat.unreadCount > 0)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
chat.unreadCount.toString(),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
context.read<ChatBloc>().add(ChatEvent.chatSelected(chat.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'profile.freezed.dart';
|
||||||
|
// part 'profile.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class Profile with _$Profile {
|
||||||
|
const factory Profile({
|
||||||
|
required String id,
|
||||||
|
required String userId,
|
||||||
|
String? bio,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? phoneNumber,
|
||||||
|
String? email,
|
||||||
|
DateTime? lastSeen,
|
||||||
|
bool? isOnline,
|
||||||
|
String? status,
|
||||||
|
}) = _Profile;
|
||||||
|
|
||||||
|
// factory Profile.fromJson(Map<String, dynamic> json) => _$ProfileFromJson(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// 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 'profile.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 _$Profile {
|
||||||
|
String get id => throw _privateConstructorUsedError;
|
||||||
|
String get userId => throw _privateConstructorUsedError;
|
||||||
|
String? get bio => throw _privateConstructorUsedError;
|
||||||
|
String? get avatarUrl => throw _privateConstructorUsedError;
|
||||||
|
String? get phoneNumber => throw _privateConstructorUsedError;
|
||||||
|
String? get email => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get lastSeen => throw _privateConstructorUsedError;
|
||||||
|
bool? get isOnline => throw _privateConstructorUsedError;
|
||||||
|
String? get status => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$ProfileCopyWith<Profile> get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $ProfileCopyWith<$Res> {
|
||||||
|
factory $ProfileCopyWith(Profile value, $Res Function(Profile) then) =
|
||||||
|
_$ProfileCopyWithImpl<$Res, Profile>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String id,
|
||||||
|
String userId,
|
||||||
|
String? bio,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? phoneNumber,
|
||||||
|
String? email,
|
||||||
|
DateTime? lastSeen,
|
||||||
|
bool? isOnline,
|
||||||
|
String? status});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$ProfileCopyWithImpl<$Res, $Val extends Profile>
|
||||||
|
implements $ProfileCopyWith<$Res> {
|
||||||
|
_$ProfileCopyWithImpl(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? userId = null,
|
||||||
|
Object? bio = freezed,
|
||||||
|
Object? avatarUrl = freezed,
|
||||||
|
Object? phoneNumber = freezed,
|
||||||
|
Object? email = freezed,
|
||||||
|
Object? lastSeen = freezed,
|
||||||
|
Object? isOnline = freezed,
|
||||||
|
Object? status = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
userId: null == userId
|
||||||
|
? _value.userId
|
||||||
|
: userId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
bio: freezed == bio
|
||||||
|
? _value.bio
|
||||||
|
: bio // 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?,
|
||||||
|
email: freezed == email
|
||||||
|
? _value.email
|
||||||
|
: email // 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?,
|
||||||
|
status: freezed == status
|
||||||
|
? _value.status
|
||||||
|
: status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
) as $Val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$ProfileImplCopyWith<$Res> implements $ProfileCopyWith<$Res> {
|
||||||
|
factory _$$ProfileImplCopyWith(
|
||||||
|
_$ProfileImpl value, $Res Function(_$ProfileImpl) then) =
|
||||||
|
__$$ProfileImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String id,
|
||||||
|
String userId,
|
||||||
|
String? bio,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? phoneNumber,
|
||||||
|
String? email,
|
||||||
|
DateTime? lastSeen,
|
||||||
|
bool? isOnline,
|
||||||
|
String? status});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$ProfileImplCopyWithImpl<$Res>
|
||||||
|
extends _$ProfileCopyWithImpl<$Res, _$ProfileImpl>
|
||||||
|
implements _$$ProfileImplCopyWith<$Res> {
|
||||||
|
__$$ProfileImplCopyWithImpl(
|
||||||
|
_$ProfileImpl _value, $Res Function(_$ProfileImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? userId = null,
|
||||||
|
Object? bio = freezed,
|
||||||
|
Object? avatarUrl = freezed,
|
||||||
|
Object? phoneNumber = freezed,
|
||||||
|
Object? email = freezed,
|
||||||
|
Object? lastSeen = freezed,
|
||||||
|
Object? isOnline = freezed,
|
||||||
|
Object? status = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$ProfileImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
userId: null == userId
|
||||||
|
? _value.userId
|
||||||
|
: userId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
bio: freezed == bio
|
||||||
|
? _value.bio
|
||||||
|
: bio // 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?,
|
||||||
|
email: freezed == email
|
||||||
|
? _value.email
|
||||||
|
: email // 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?,
|
||||||
|
status: freezed == status
|
||||||
|
? _value.status
|
||||||
|
: status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$ProfileImpl implements _Profile {
|
||||||
|
const _$ProfileImpl(
|
||||||
|
{required this.id,
|
||||||
|
required this.userId,
|
||||||
|
this.bio,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.phoneNumber,
|
||||||
|
this.email,
|
||||||
|
this.lastSeen,
|
||||||
|
this.isOnline,
|
||||||
|
this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
@override
|
||||||
|
final String userId;
|
||||||
|
@override
|
||||||
|
final String? bio;
|
||||||
|
@override
|
||||||
|
final String? avatarUrl;
|
||||||
|
@override
|
||||||
|
final String? phoneNumber;
|
||||||
|
@override
|
||||||
|
final String? email;
|
||||||
|
@override
|
||||||
|
final DateTime? lastSeen;
|
||||||
|
@override
|
||||||
|
final bool? isOnline;
|
||||||
|
@override
|
||||||
|
final String? status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Profile(id: $id, userId: $userId, bio: $bio, avatarUrl: $avatarUrl, phoneNumber: $phoneNumber, email: $email, lastSeen: $lastSeen, isOnline: $isOnline, status: $status)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$ProfileImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
(identical(other.userId, userId) || other.userId == userId) &&
|
||||||
|
(identical(other.bio, bio) || other.bio == bio) &&
|
||||||
|
(identical(other.avatarUrl, avatarUrl) ||
|
||||||
|
other.avatarUrl == avatarUrl) &&
|
||||||
|
(identical(other.phoneNumber, phoneNumber) ||
|
||||||
|
other.phoneNumber == phoneNumber) &&
|
||||||
|
(identical(other.email, email) || other.email == email) &&
|
||||||
|
(identical(other.lastSeen, lastSeen) ||
|
||||||
|
other.lastSeen == lastSeen) &&
|
||||||
|
(identical(other.isOnline, isOnline) ||
|
||||||
|
other.isOnline == isOnline) &&
|
||||||
|
(identical(other.status, status) || other.status == status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType, id, userId, bio, avatarUrl,
|
||||||
|
phoneNumber, email, lastSeen, isOnline, status);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$ProfileImplCopyWith<_$ProfileImpl> get copyWith =>
|
||||||
|
__$$ProfileImplCopyWithImpl<_$ProfileImpl>(this, _$identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Profile implements Profile {
|
||||||
|
const factory _Profile(
|
||||||
|
{required final String id,
|
||||||
|
required final String userId,
|
||||||
|
final String? bio,
|
||||||
|
final String? avatarUrl,
|
||||||
|
final String? phoneNumber,
|
||||||
|
final String? email,
|
||||||
|
final DateTime? lastSeen,
|
||||||
|
final bool? isOnline,
|
||||||
|
final String? status}) = _$ProfileImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id;
|
||||||
|
@override
|
||||||
|
String get userId;
|
||||||
|
@override
|
||||||
|
String? get bio;
|
||||||
|
@override
|
||||||
|
String? get avatarUrl;
|
||||||
|
@override
|
||||||
|
String? get phoneNumber;
|
||||||
|
@override
|
||||||
|
String? get email;
|
||||||
|
@override
|
||||||
|
DateTime? get lastSeen;
|
||||||
|
@override
|
||||||
|
bool? get isOnline;
|
||||||
|
@override
|
||||||
|
String? get status;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$ProfileImplCopyWith<_$ProfileImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import '../../../../core/errors/result.dart';
|
||||||
|
import '../entities/profile.dart';
|
||||||
|
|
||||||
|
abstract class ProfileRepository {
|
||||||
|
Future<Result<Profile>> getProfile(String userId);
|
||||||
|
Future<Result<Profile>> updateProfile(Map<String, dynamic> data);
|
||||||
|
Future<Result<void>> uploadAvatar(String imagePath);
|
||||||
|
Future<Result<void>> updateStatus(String status);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user