44 Commits

Author SHA1 Message Date
Халимов Рустам
a426b63b7d Получение и отправка текста, статусы сообщения 2026-05-15 14:59:54 +03:00
Халимов Рустам
4d4bc8edd1 Разделение навигации и реактивности 2026-05-15 11:00:38 +03:00
Халимов Рустам
40589dbb75 Повторный вход в аккаунт, очистка кэша 2026-05-15 00:10:34 +03:00
Халимов Рустам
0b013def2e Отображение чатов 2026-05-14 22:56:42 +03:00
Халимов Рустам
ccffc5a53c Настройки, авторизация 2026-05-14 12:06:34 +03:00
Халимов Рустам
e8161d23d4 Приложение на флаторе, настройки 2026-05-13 17:21:37 +03:00
Халимов Рустам
89e325556c Удалил к херам нативную версию 2026-05-13 13:49:46 +03:00
Халимов Рустам
df4feeeee5 Правки 2026-05-13 13:48:34 +03:00
Халимов Рустам
00ed4b5959 Фиксы 2026-05-08 23:22:07 +03:00
Халимов Рустам
a0b50b57b0 Вторая часть по кэшу 2026-05-08 22:48:56 +03:00
Халимов Рустам
d4c41b333a compose 2026-05-07 23:34:28 +03:00
Халимов Рустам
9daa786cfb Заготовка 2026-05-07 23:27:37 +03:00
Халимов Рустам
2d2e4b685e Восстановлены голосовые 2026-04-30 10:33:15 +03:00
Халимов Рустам
46c22300e9 Почти плавный скрол чата 2026-04-28 00:16:52 +03:00
Халимов Рустам
d9a20e40b0 Сборка перед кэшированием 2026-04-23 01:31:51 +03:00
Халимов Рустам
945134f029 Нормальные токены 2026-04-19 22:55:38 +03:00
Халимов Рустам
f6400ce3ac Merge branch 'main' into android 2026-04-19 22:31:31 +03:00
Халимов Рустам
8c8ef55b58 Merge branch 'main' into android 2026-04-19 21:24:40 +03:00
Халимов Рустам
629fddfca0 Реакции, но с багом 2026-04-18 00:41:05 +03:00
Халимов Рустам
b68f68a1f2 Анимация, список реакций 2026-04-17 23:51:46 +03:00
Халимов Рустам
754e9e8ad0 Меню для сообщений 2026-04-17 23:44:11 +03:00
Халимов Рустам
ed7521a563 Правка ответов, локализация, поддержка медиа 2026-04-17 22:07:33 +03:00
Халимов Рустам
bef30c2c86 Ответы 2026-04-17 21:59:53 +03:00
Халимов Рустам
8c00d1376d Событие печати, смена адреса, иконки 2026-04-17 16:23:52 +03:00
Халимов Рустам
cea3f4d669 Профиль, редактирование без аватара 2026-04-16 15:41:22 +03:00
Халимов Рустам
8409c51842 Пуши 2026-04-15 02:40:52 +03:00
Халимов Рустам
9560a9235f Сохранение состояния при сворачивании 2026-04-15 02:19:03 +03:00
Халимов Рустам
f17edfc0da Зум и пролистывание 2026-04-15 02:12:43 +03:00
Халимов Рустам
c6bebec599 Кнопка вниз 2026-04-15 02:01:46 +03:00
Халимов Рустам
5a71e5bbfa Подгрузка старых сообщений 2026-04-15 01:55:23 +03:00
Халимов Рустам
5b0133d55e Прокрутка чата 2026-04-15 01:35:22 +03:00
Халимов Рустам
dcb733ac01 Нормальный чат 2026-04-15 01:30:48 +03:00
Халимов Рустам
487cb1b12b Рабочие счетчики и переработка чата 2026-04-15 01:14:55 +03:00
Халимов Рустам
58fdf1aca1 Чат, вложения 2026-04-14 21:53:44 +03:00
Халимов Рустам
118f8b8971 Прочтение 2026-04-14 15:09:44 +03:00
Халимов Рустам
8165b74e43 Отправка гиф 2026-04-14 13:55:01 +03:00
Халимов Рустам
8ce4bc714f Гиф 2026-04-14 13:46:17 +03:00
Халимов Рустам
2d0bc0d75c Чат, правки 2026-04-14 12:52:03 +03:00
Халимов Рустам
d7e75797ef Ответы, аудио, голосовые 2026-04-14 12:29:32 +03:00
Халимов Рустам
58bbdae26c Настройки 2026-04-14 11:46:55 +03:00
Халимов Рустам
3310a3c4a4 Правка чата 2026-04-14 11:31:20 +03:00
Халимов Рустам
d8b0d86534 Чат 2026-04-14 10:48:07 +03:00
Халимов Рустам
dc051fa9ae Авторизация, нерабочий чат 2026-04-14 01:41:51 +03:00
Халимов Рустам
1fb1be47dd Приложение 2026-04-14 01:15:54 +03:00
144 changed files with 30616 additions and 75 deletions

10
.gitignore vendored
View File

@@ -87,3 +87,13 @@ postgres_data/
tmp/
*.txt
# Android / Kotlin Mobile
client-mobile/.gradle/
client-mobile/.idea/
client-mobile/.run/
client-mobile/build/
client-mobile/.cxx/
client-mobile/local.properties
client-mobile/*.iml
client-mobile/.kotlin/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

20
client-mobile/.metadata Normal file
View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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