diff --git a/README.md b/README.md index e27f313..cba8f56 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Иконка ИКАР -Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия — `1.0.3+4`, вариант сборки — `ikar`. +Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников — `1.0.4+5` (не опубликована), вариант сборки — `ikar`. Опубликованный выпуск — `1.0.3+4`. Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Иконка перенесена из прежнего Икара. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии. @@ -18,6 +18,18 @@ Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников. +## Доработки из Komet + +Адаптированы изменения ветки `feature/FullStack` (`58576bc1`) и жесты из [PR №166](https://github.com/KometTeam/Komet/pull/166) (`10a194ed`). Ползунок качества видео получил полноценную область касания; качество и обрезка учитывают края системных жестов Android. Копирование, выделение и редактирование зашифрованных сообщений используют доступный расшифрованный текст. Недоступный текст не подменяется шифротекстом; локальное хранение и отправка сохраняют существующую схему шифрования. + +В настройках темы добавлена «Собственная тема»: выбор цветов, поворота и анимации градиента. Градиент сохраняется для аккаунта между запусками, отдельные фоны чатов продолжают иметь приоритет. Жесты назад, ответа, закрытия фото и скрытия клавиатуры согласованы между собой; короткий отменённый свайп возвращает фото, масштабирование не закрывает просмотр. + +«Невидимка» передаёт `interactive=false` в авторизованный ping и подключение SignalR, включая переподключения. Переключение настройки и уход приложения в фон обновляют активность сервера. Сервер учитывает отдельные соединения и сессии, сохраняет соединение для доставки сообщений и звонков, но не показывает невидимую сессию онлайн. Публичное время появления хранится отдельно от служебной активности сессии и не обновляется невидимыми запросами. Обновление схемы SQLite переносит прежнее время появления один раз. Видимая сессия на другом устройстве по-прежнему показывает аккаунт онлайн. + +Проверки: полный клиентский прогон — 1189 прошли, 3 пропущены; сервер — 150 прошли, без пропусков. Анализ `lib` и `test` не выявил ошибок и предупреждений, остались 42 информационных замечания стиля. Проверены выбор качества видео, сохранение темы, доступ к расшифрованному тексту, жесты, переключение невидимости, переподключение и сохранение публичной активности при обновлении схемы. Локальные отчёты: `artifacts/komet-full-flutter-tests.log`, `artifacts/komet-server-tests.log`, `artifacts/komet-implementation-analyze.log`. + +Эти изменения относятся к исходникам клиента и сервера. Для использования на телефоне нужны обновление сервера и сборка нового APK; текущий опубликованный выпуск их не содержит. Новый APK, развёртывание и проверка этих изменений на телефоне в этой задаче не выполнялись. + ## Размещение на Pi Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`. @@ -58,6 +70,14 @@ dotnet test ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj `tool/deploy_ikar.ps1` упаковывает опубликованный `linux-arm64` сервер и обновляет только новый Compose-проект на Pi. Модель Vosk в `ikar_server/asr/model.zip` проверяется по SHA-256 при сборке контейнера. Скрипты в `tool/` для проверок используют временные тестовые аккаунты и удаляют их по завершении. +## Меню расшифровки голосовых сообщений 1.0.4 (не опубликовано) + +Расшифровка сохранена и перенесена из маленькой кнопки «Т» в меню голосового сообщения. Нажатие на облако открывает меню с пунктом «Расшифровка голосового сообщения»: нажимается вся строка, её высота не меньше 48 логических пикселей и увеличивается при переносе текста или увеличенном шрифте. У обычных сообщений, файлов, видео, удалённых и ещё не отправленных записей этого пункта нет. Пересланные записи используют идентификаторы исходного голосового сообщения. + +Под записью отображается состояние распознавания, затем готовый текст; через меню его можно скрыть и показать снова без повторного запроса. Одновременные нажатия не создают повторные запросы. Ошибку можно повторить; результат, уже полученный через push, не затирается поздним ответом о постановке в очередь или сетевой ошибкой. + +Целевые тесты: `artifacts/voice-menu-tests.log`. Анализ изменённых UI-файлов и новых тестов: `artifacts/voice-menu-analyze-final.log`. Живая проверка тестовой записи подтвердила загрузку, запрос получателя, завершение распознавания через push, сохранение текста для обоих участников и запрет доступа постороннему: `artifacts/voice-transcription-live-verification.json`. Тестовые аккаунты удалены. Новая версия не публиковалась и не устанавливалась на телефон по указанию пользователя; нового APK нет. + ## Исправления видео 1.0.3 Серверный ответ на `VIDEO_PLAY` раньше содержал `external` и `hls`: стандартная JSON-сериализация меняла регистр ключей `EXTERNAL` и `HLS`, которые ожидает клиент. Теперь ключ `EXTERNAL` сохраняет точное написание, а исходный MP4 больше не выдаётся за HLS-плейлист. Выдача файла поддерживает HTTP byte ranges для перемотки. diff --git a/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs b/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs index 09d7c53..c07dcd0 100644 --- a/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs +++ b/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs @@ -55,6 +55,7 @@ public sealed class ChannelDtoMapperTests var owner = CreateUser("owner", "Owner"); owner.PhoneNumber = "+10000000001"; owner.AvatarStoragePath = "owner-avatar.png"; + owner.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(); owner.PrivacySettings = new UserPrivacySettings { UserId = owner.Id, @@ -89,6 +90,7 @@ public sealed class ChannelDtoMapperTests var owner = CreateUser("owner", "Owner"); owner.PhoneNumber = "+10000000001"; owner.AvatarStoragePath = "owner-avatar.png"; + owner.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(); owner.PrivacySettings = new UserPrivacySettings { UserId = owner.Id, diff --git a/ikar_server/src/Ikar.Server.Tests/PresenceTests.cs b/ikar_server/src/Ikar.Server.Tests/PresenceTests.cs new file mode 100644 index 0000000..a8fcef5 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/PresenceTests.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Hubs; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Tests; + +public sealed partial class QlyraCompatibilityControllerTests +{ + [Fact] + public async Task GhostPing_HidesOnlyItsSessionAndFreezesPublicActivity() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var previous = DateTimeOffset.UtcNow.AddMinutes(-10).ToUnixTimeMilliseconds(); + var user = new User { Username = "ghost", NormalizedUsername = "GHOST", DisplayName = "Ghost", PasswordHash = "test", PublicLastSeenUnixMs = previous }; + db.Users.Add(user); + await db.SaveChangesAsync(); + var session = Guid.NewGuid(); + var otherSession = Guid.NewGuid(); + var presence = new PresenceTracker(); + presence.Connect(user.Id, session, "first", true); + presence.Connect(user.Id, otherSession, "second", true); + var controller = CreateController(db, user.Id, presenceTracker: presence, sessionId: session); + using var ghost = JsonDocument.Parse("{\"interactive\":false}"); + Assert.IsType(await controller.Dispatch(1, new(ghost.RootElement), CancellationToken.None)); + Assert.True(presence.IsOnline(user.Id)); + Assert.Equal(previous, user.PublicLastSeenUnixMs); + presence.Disconnect(user.Id, "second"); + Assert.False(presence.IsOnline(user.Id)); + Assert.True(presence.IsConnected(user.Id)); + Assert.Equal(previous, user.ToDto(presence).LastSeenAt!.Value.ToUnixTimeMilliseconds()); + using var normal = JsonDocument.Parse("{\"interactive\":true}"); + Assert.IsType(await controller.Dispatch(1, new(normal.RootElement), CancellationToken.None)); + Assert.True(presence.IsOnline(user.Id)); + Assert.True(user.PublicLastSeenUnixMs > previous); + } + + [Fact] + public async Task GhostPresence_DoesNotExposeSecuritySessionActivity() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var visibleTime = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds(); + var user = new User { Username = "hidden", NormalizedUsername = "HIDDEN", DisplayName = "Hidden", PasswordHash = "test", PublicLastSeenUnixMs = visibleTime }; + user.Sessions.Add(new UserSession { TokenHash = "synthetic", LastSeenAt = DateTimeOffset.UtcNow }); + db.Users.Add(user); + await db.SaveChangesAsync(); + var presence = new PresenceTracker(); + presence.Connect(user.Id, Guid.NewGuid(), "invisible", false); + var dto = user.ToDto(presence); + Assert.False(dto.IsOnline); + Assert.Equal(visibleTime, dto.LastSeenAt!.Value.ToUnixTimeMilliseconds()); + Assert.True(presence.IsConnected(user.Id)); + } + + [Fact] + public async Task PublicActivityUpgrade_PreservesExistingTimestampAndIsIdempotent() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var seen = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero); + var user = new User { Username = "migrated", NormalizedUsername = "MIGRATED", DisplayName = "Migrated", PasswordHash = "test" }; + user.Sessions.Add(new UserSession { TokenHash = "synthetic-upgrade", LastSeenAt = seen }); + db.Users.Add(user); + await db.SaveChangesAsync(); + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Users DROP COLUMN PublicLastSeenUnixMs;"); + var schema = new DatabaseSchemaBootstrapper(db); + await schema.EnsureLatestAsync(); + db.ChangeTracker.Clear(); + var migrated = await db.Users.SingleAsync(); + Assert.Equal(seen.ToUnixTimeMilliseconds(), migrated.PublicLastSeenUnixMs); + migrated.PublicLastSeenUnixMs = seen.AddHours(-1).ToUnixTimeMilliseconds(); + await db.SaveChangesAsync(); + await schema.EnsureLatestAsync(); + db.ChangeTracker.Clear(); + Assert.Equal(seen.AddHours(-1).ToUnixTimeMilliseconds(), (await db.Users.SingleAsync()).PublicLastSeenUnixMs); + } +} + +public sealed class PresenceTrackerTests +{ + [Fact] + public void ReconnectAndLateDisconnect_DoNotRemoveTheNewConnection() + { + var user = Guid.NewGuid(); + var session = Guid.NewGuid(); + var presence = new PresenceTracker(); + presence.Connect(user, session, "old", false); + presence.Connect(user, session, "new", false); + presence.Disconnect(user, "old"); + Assert.True(presence.IsConnected(user)); + Assert.False(presence.IsOnline(user)); + presence.SetInteractive(user, session, true); + Assert.True(presence.IsOnline(user)); + presence.Disconnect(user, "old"); + Assert.True(presence.IsOnline(user)); + presence.Disconnect(user, "new"); + Assert.False(presence.IsConnected(user)); + } +} + +public sealed class MessengerHubPresenceTests +{ + [Theory] + [InlineData("false", false)] + [InlineData("FALSE", false)] + [InlineData("true", true)] + [InlineData("", true)] + public async Task SignalRConnection_RespectsActivityAndKeepsDeliveryGroup(string activity, bool visible) + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var previous = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeMilliseconds(); + var user = new User { Username = "hub", NormalizedUsername = "HUB", DisplayName = "Hub", PasswordHash = "test", PublicLastSeenUnixMs = previous }; + db.Users.Add(user); + await db.SaveChangesAsync(); + var presence = new PresenceTracker(); + var groups = new DeliveryGroups(); + var hub = new MessengerHub(presence, db, new CallSessionStore(), null!, Options.Create(new WebRtcOptions()), new QlyraClientIdMapper(db)) + { + Context = new ConnectionContext(user.Id, activity), + Groups = groups + }; + await hub.OnConnectedAsync(); + Assert.True(presence.IsConnected(user.Id)); + Assert.Equal(visible, presence.IsOnline(user.Id)); + Assert.Equal(visible, user.PublicLastSeenUnixMs > previous); + Assert.Contains(("presence-test", $"user:{user.Id:D}"), groups.Members); + await hub.OnDisconnectedAsync(null); + Assert.False(presence.IsConnected(user.Id)); + } + + private sealed class ConnectionContext : HubCallerContext + { + public ConnectionContext(Guid userId, string activity) + { + User = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("sid", Guid.NewGuid().ToString()) + ], "Test")); + var http = new DefaultHttpContext { User = User }; + http.Request.QueryString = new QueryString($"?interactive={activity}"); + Features.Set(new HttpFeature { HttpContext = http }); + } + + public override string ConnectionId => "presence-test"; + public override string? UserIdentifier => User.FindFirstValue(ClaimTypes.NameIdentifier); + public override ClaimsPrincipal User { get; } + public override IDictionary Items { get; } = new Dictionary(); + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override CancellationToken ConnectionAborted => CancellationToken.None; + public override void Abort() { } + } + + private sealed class HttpFeature : IHttpContextFeature + { + public HttpContext? HttpContext { get; set; } + } + + private sealed class DeliveryGroups : IGroupManager + { + public List<(string Connection, string Group)> Members { get; } = []; + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + Members.Add((connectionId, groupName)); + return Task.CompletedTask; + } + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs index 067f1db..d445f85 100644 --- a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs +++ b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs @@ -188,7 +188,8 @@ public sealed partial class QlyraCompatibilityControllerTests Username = "presence-user", NormalizedUsername = "PRESENCE-USER", DisplayName = "Presence User", - PasswordHash = "test" + PasswordHash = "test", + PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeMilliseconds() }; user.Sessions.Add(new UserSession { @@ -792,13 +793,15 @@ public sealed partial class QlyraCompatibilityControllerTests IEmailCodeSender? emailCodeSender = null, QlyraCallCompatibilityStore? qlyraCallStore = null, IPushNotificationSender? pushNotificationSender = null, - VideoPreviewService? videoPreviewService = null) + VideoPreviewService? videoPreviewService = null, + PresenceTracker? presenceTracker = null, + Guid? sessionId = null) { var controller = new QlyraCompatibilityController( dbContext, new TokenService(dbContext, Options.Create(new JwtOptions())), new DeviceAuthChallengeStore(), - new PresenceTracker(), + presenceTracker ?? new PresenceTracker(), new QlyraClientIdMapper(dbContext), uploadStore ?? ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()), new NoopHubContext(), @@ -814,7 +817,7 @@ public sealed partial class QlyraCompatibilityControllerTests HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( - [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim("sid", Guid.NewGuid().ToString())], + [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim("sid", (sessionId ?? Guid.NewGuid()).ToString())], "Test")) } }; diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs index a506fe8..68da02d 100644 --- a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs +++ b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs @@ -53,7 +53,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase { return opcode switch { - 1 => Ok(QlyraCompatibilityResponse.Ok(opcode, new { server = "ikar" })), + 1 => await PingAsync(opcode, request.Payload, cancellationToken), 17 => await RequestPhoneCodeAsync(opcode, request.Payload, cancellationToken), 18 => await VerifyPhoneCodeAsync(opcode, request.Payload, cancellationToken), 19 => await LoginAsync(opcode, cancellationToken), @@ -321,6 +321,23 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase })); } + private async Task PingAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + if (User.Identity?.IsAuthenticated == true) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Account is unavailable.")); + var interactive = ReadBool(payload, "interactive") ?? true; + presenceTracker.SetInteractive(user.Id, User.GetRequiredSessionId(), interactive); + if (interactive) + { + user.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { server = "ikar" })); + } + private async Task LoginAsync(int opcode, CancellationToken cancellationToken) { if (User.Identity?.IsAuthenticated != true) diff --git a/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs b/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs index f144ea0..dccf972 100644 --- a/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs +++ b/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs @@ -13,6 +13,7 @@ public sealed class User public bool IsBlocked { get; set; } public DateTimeOffset? BlockedAt { get; set; } public long? ScheduledDeletionUnixMs { get; set; } + public long? PublicLastSeenUnixMs { get; set; } public string? PhoneNumber { get; set; } public string? NormalizedPhoneNumber { get; set; } public string? Email { get; set; } diff --git a/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs b/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs index d0038e5..fca987b 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs @@ -12,10 +12,9 @@ public static class DtoMapper user.DisplayName, presenceTracker.IsOnline(user.Id), user.PhoneNumber, - user.Sessions - .OrderByDescending(x => x.LastSeenAt) - .Select(x => (DateTimeOffset?)x.LastSeenAt) - .FirstOrDefault(), + user.PublicLastSeenUnixMs is { } seen + ? DateTimeOffset.FromUnixTimeMilliseconds(seen) + : null, user.About, ResolveAvatarPath(user), user.IsBot); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs b/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs index f7082ed..0a72758 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs @@ -26,7 +26,14 @@ public sealed class MessengerHub( { var userId = Context.User!.GetRequiredUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId)); - presenceTracker.MarkOnline(userId); + var interactive = !string.Equals(Context.GetHttpContext()?.Request.Query["interactive"].ToString(), "false", StringComparison.OrdinalIgnoreCase); + presenceTracker.Connect(userId, Context.User!.GetRequiredSessionId(), Context.ConnectionId, interactive); + if (interactive) + { + var user = await dbContext.Users.SingleAsync(user => user.Id == userId, Context.ConnectionAborted); + user.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await dbContext.SaveChangesAsync(Context.ConnectionAborted); + } await CaptureClientVersionAsync(); await base.OnConnectedAsync(); } @@ -36,7 +43,7 @@ public sealed class MessengerHub( if (Context.User?.Identity?.IsAuthenticated == true) { var userId = Context.User.GetRequiredUserId(); - presenceTracker.MarkOffline(userId); + presenceTracker.Disconnect(userId, Context.ConnectionId); if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession)) { await NotifyCallEndedAsync(endedSession!); @@ -88,7 +95,7 @@ public sealed class MessengerHub( var caller = chat.Members.Single(member => member.UserId == callerId).User; var callee = chat.Members.Single(member => member.UserId != callerId).User; var calleeDevices = await LoadPushDevicesAsync(callee.Id); - var hasRealtimePresence = presenceTracker.IsOnline(callee.Id); + var hasRealtimePresence = presenceTracker.IsConnected(callee.Id); var hasPushDevices = calleeDevices.Count > 0; if (!hasRealtimePresence && !hasPushDevices) diff --git a/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs b/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs index ffc2881..b3a9c82 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs @@ -1,41 +1,56 @@ -using System.Collections.Concurrent; - namespace Ikar.Server.Infrastructure; public sealed class PresenceTracker { - private readonly ConcurrentDictionary _connections = new(); + private readonly object _gate = new(); + private readonly Dictionary> _connections = new(); + private sealed record Connection(Guid SessionId, bool Interactive); - public void MarkOnline(Guid userId) + public void Connect(Guid userId, Guid sessionId, string connectionId, bool interactive) { - _connections.AddOrUpdate(userId, 1, (_, current) => current + 1); + lock (_gate) + { + if (!_connections.TryGetValue(userId, out var connections)) + { + connections = new Dictionary(StringComparer.Ordinal); + _connections[userId] = connections; + } + connections[connectionId] = new Connection(sessionId, interactive); + } } - public void MarkOffline(Guid userId) + public void Disconnect(Guid userId, string connectionId) { - while (true) + lock (_gate) { - if (!_connections.TryGetValue(userId, out var current)) - { - return; - } + if (!_connections.TryGetValue(userId, out var connections)) return; + connections.Remove(connectionId); + if (connections.Count == 0) _connections.Remove(userId); + } + } - if (current <= 1) + public void SetInteractive(Guid userId, Guid sessionId, bool interactive) + { + lock (_gate) + { + if (!_connections.TryGetValue(userId, out var connections)) return; + foreach (var (id, connection) in connections.ToArray()) { - if (_connections.TryRemove(userId, out _)) - { - return; - } - - continue; - } - - if (_connections.TryUpdate(userId, current - 1, current)) - { - return; + if (connection.SessionId == sessionId) + connections[id] = connection with { Interactive = interactive }; } } } - public bool IsOnline(Guid userId) => _connections.ContainsKey(userId); + public bool IsConnected(Guid userId) + { + lock (_gate) return _connections.ContainsKey(userId); + } + + public bool IsOnline(Guid userId) + { + lock (_gate) + return _connections.TryGetValue(userId, out var connections) && + connections.Values.Any(connection => connection.Interactive); + } } diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs index c83bc8a..915998b 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs @@ -10,6 +10,15 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) { await EnableWriteAheadLoggingAsync(cancellationToken); await EnsureColumnAsync("Users", "ScheduledDeletionUnixMs", "INTEGER NULL", cancellationToken); + if (await EnsureColumnAsync("Users", "PublicLastSeenUnixMs", "INTEGER NULL", cancellationToken)) + { + await dbContext.Database.ExecuteSqlRawAsync(""" + UPDATE "Users" SET "PublicLastSeenUnixMs" = ( + SELECT CAST(strftime('%s', MAX("LastSeenAt")) AS INTEGER) * 1000 + FROM "UserSessions" WHERE "UserId" = "Users"."Id" + ); + """, cancellationToken); + } await EnsureMessageAttachmentsTableAsync(cancellationToken); await EnsurePushDevicesTableAsync(cancellationToken); await EnsureDeviceCredentialsTableAsync(cancellationToken); @@ -944,7 +953,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) cancellationToken); } - private async Task EnsureColumnAsync( + private async Task EnsureColumnAsync( string tableName, string columnName, string definition, @@ -967,7 +976,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) { if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) { - return; + return false; } } @@ -976,6 +985,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await using var alterCommand = connection.CreateCommand(); alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};"; await alterCommand.ExecuteNonQueryAsync(cancellationToken); + return true; } finally { diff --git a/lib/backend/api.dart b/lib/backend/api.dart index ffacea8..8f1b422 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../core/config/config.dart'; import '../core/config/countries.dart'; +import '../core/config/qlyra_settings.dart'; +import '../core/utils/app_foreground.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; import '../core/storage/token_storage.dart'; @@ -18,7 +20,10 @@ enum SessionState { disconnected, connecting, connected, online } /// Client for ikar_server. class Api { Api({IkarTransport Function(Uri)? transportFactory}) - : _transportFactory = transportFactory ?? IkarTransport.new; + : _transportFactory = transportFactory ?? IkarTransport.new { + QlyraSettings.ghostMode.addListener(_presenceChanged); + AppForeground.notifier.addListener(_presenceChanged); + } final IkarTransport Function(Uri) _transportFactory; final PacketDispatcher _dispatcher = PacketDispatcher(); @@ -35,6 +40,7 @@ class Api { Timer? _reconnect; Future Function()? _onReconnect; Future? _refreshing; + Future _presenceSync = Future.value(); bool _autoReconnect = false; bool _disposed = false; int _epoch = 0; @@ -62,6 +68,15 @@ class Api { Stream get handshakeSuccessStream => _handshakes.stream; Stream get errorStream => _errors.stream; Stream get pushStream => _dispatcher.pushStream; + bool get _interactive => + !QlyraSettings.ghostMode.value && AppForeground.value; + + void _presenceChanged() { + final transport = _transport; + if (transport != null) transport.interactive = _interactive; + if (_state == SessionState.online) unawaited(_checkConnection()); + } + void _setState(SessionState state) { _state = state; if (!_states.isClosed) _states.add(state); @@ -81,6 +96,7 @@ class Api { port: endpoint.port, ); final transport = _transportFactory(baseUri); + transport.interactive = _interactive; _transport = transport; final ping = await transport.request(Opcode.ping, {}); if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен'); @@ -117,11 +133,25 @@ class Api { } } - Future _checkConnection() async { + Future _checkConnection({bool? interactive}) { + final generation = _generation; + final pending = _presenceSync.then((_) async { + if (generation != _generation) return; + await _performConnectionCheck(interactive: interactive); + }); + _presenceSync = pending; + return pending; + } + + Future _performConnectionCheck({bool? interactive}) async { final transport = _transport; if (transport == null || _state != SessionState.online) return; try { - await sendRequest(Opcode.ping, {}, silent: true); + final active = + (interactive ?? AppForeground.value) && + !QlyraSettings.ghostMode.value; + transport.interactive = active; + await sendRequest(Opcode.ping, {'interactive': active}, silent: true); if (transport.token != null && !transport.eventsConnected) await transport.connectEvents(); } catch (_) { @@ -157,6 +187,13 @@ class Api { throw StateError('Сессия изменилась до отправки'); beforeSend?.call(); final body = Map.from(payload); + transport.interactive = _interactive; + if (opcode == Opcode.login || opcode == Opcode.ping) { + body['interactive'] = + (body['interactive'] as bool? ?? AppForeground.value) && + !QlyraSettings.ghostMode.value; + transport.interactive = body['interactive'] as bool; + } if (opcode == Opcode.authRequest) { final identity = await IkarDeviceKey.identity(body['phone'].toString()); _deviceId = identity['installationId']; @@ -306,7 +343,8 @@ class Api { unawaited(_checkConnection()); } - void sendPing({required bool interactive}) => unawaited(_checkConnection()); + void sendPing({required bool interactive}) => + unawaited(_checkConnection(interactive: interactive)); Future reconnectToEndpoint({ String? host, int? port, @@ -357,6 +395,8 @@ class Api { Future dispose() async { _disposed = true; + QlyraSettings.ghostMode.removeListener(_presenceChanged); + AppForeground.notifier.removeListener(_presenceChanged); await disconnect(); _dispatcher.dispose(); await _states.close(); diff --git a/lib/backend/ikar_transport.dart b/lib/backend/ikar_transport.dart index c665ad7..aa086ea 100644 --- a/lib/backend/ikar_transport.dart +++ b/lib/backend/ikar_transport.dart @@ -16,6 +16,7 @@ class IkarTransport { final Uri baseUri; final http.Client _client; String? token; + bool interactive = true; WebSocket? _socket; Timer? _keepAlive; final _events = StreamController>.broadcast(); @@ -72,7 +73,10 @@ class IkarTransport { .replace( scheme: baseUri.scheme == 'https' ? 'wss' : 'ws', port: baseUri.port, - queryParameters: {'access_token': token!}, + queryParameters: { + 'access_token': token!, + 'interactive': '$interactive', + }, ); final socket = await IkarNetwork.connectWebSocket( uri.toString(), diff --git a/lib/core/crypto/message_decryption_cache.dart b/lib/core/crypto/message_decryption_cache.dart index 7734174..e515f82 100644 --- a/lib/core/crypto/message_decryption_cache.dart +++ b/lib/core/crypto/message_decryption_cache.dart @@ -13,6 +13,7 @@ import 'e2ee_service.dart'; // #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет enum MessageDecryptionState { decrypted, wrongKey, unavailable } +// #***! результат по одному сообщению @immutable class MessageDecryption { final String? plaintext; @@ -32,6 +33,7 @@ class MessageDecryption { bool get isDecrypted => state == MessageDecryptionState.decrypted; } +// #***! кэш расшифровок, при смене ключа сбрасывается целиком class MessageDecryptionCache { MessageDecryptionCache._() { ChatEncryptionStore.instance.revision.addListener(clear); @@ -40,12 +42,15 @@ class MessageDecryptionCache { static final MessageDecryptionCache instance = MessageDecryptionCache._(); + // #***! тысяча последних, дальше вытесняем static const int _maxEntries = 1000; + int _generation = 0; final LinkedHashMap> _entries = LinkedHashMap(); - final Set _inFlight = {}; + final Map> _inFlight = {}; + // #***! пузырь подписывается сюда и перерисуется когда текст расшифруется ValueListenable listenableFor(String messageId) => _entryFor(messageId); @@ -60,36 +65,106 @@ class MessageDecryptionCache { } } + // #***! известный текст кладём сразу, например своё только что отправленное void seed(String messageId, String plaintext) { _entryFor(messageId).value = MessageDecryption.decrypted(plaintext); } + // #***! поменяли временный id на настоящий, переносим расшифровку void adopt(String fromMessageId, String toMessageId) { final value = _entries[fromMessageId]?.value; if (value != null) _entryFor(toMessageId).value = value; } + // #***! _inFlight чтоб не расшифровывать одно сообщение дважды void request({ required int accountId, required int chatId, required String messageId, required String cipherText, }) { - if (cipherText.isEmpty) return; - final e2ee = - E2eeService.instance.available && - KometCrypto.classifyText(cipherText) != TextClass.none; - if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) { - return; + final job = _begin(accountId, chatId, messageId, cipherText); + if (job != null) unawaited(job); + } + + Future resolve({ + required int accountId, + required int chatId, + required String messageId, + required String cipherText, + }) async { + try { + await _begin(accountId, chatId, messageId, cipherText); + } catch (_) { + return const MessageDecryption.unavailable(); } - if (_entryFor(messageId).value != null) return; - if (!_inFlight.add(messageId)) return; - _evictStale(messageId); - unawaited( - e2ee - ? _resolveSealed(accountId, chatId, messageId, cipherText) - : _resolve(accountId, chatId, messageId, cipherText), + return _entries[messageId]?.value; + } + + String? readableText(CachedMessage message) { + if (message.isControl) return null; + final decryption = _entries[message.id]?.value; + if (decryption == null) { + final text = message.selectableText; + if (message.e2ee != CachedMessage.e2eeNone || + (text != null && ChatCryptoService.instance.looksEncrypted(text))) { + return null; + } + return text; + } + return decryption.isDecrypted ? decryption.plaintext : null; + } + + Future resolveText(CachedMessage message) async { + final generation = _generation; + if (message.isControl) return null; + await resolve( + accountId: message.accountId, + chatId: message.chatId, + messageId: message.id, + cipherText: message.text ?? '', ); + if (_generation != generation) return null; + return readableText(message); + } + + Future? _begin( + int accountId, + int chatId, + String messageId, + String cipherText, + ) { + if (cipherText.isEmpty) return null; + final e2ee = + E2eeService.instance.isOn(accountId, chatId) || + (E2eeService.instance.available && + KometCrypto.classifyText(cipherText) != TextClass.none); + if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) { + return null; + } + if (_entryFor(messageId).value != null) return null; + final running = _inFlight[messageId]; + if (running != null) return running; + _evictStale(messageId); + final generation = _generation; + late final Future job; + job = + (e2ee + ? _resolveSealed(accountId, chatId, messageId, cipherText) + : _resolve(accountId, chatId, messageId, cipherText)) + .catchError((Object error) { + if (_generation == generation) { + _entryFor(messageId).value = + const MessageDecryption.unavailable(); + } + }) + .whenComplete(() { + if (identical(_inFlight[messageId], job)) { + _inFlight.remove(messageId); + } + }); + _inFlight[messageId] = job; + return job; } // #***! сквозное: открытый текст лежит в базе под локальным ключом @@ -99,51 +174,49 @@ class MessageDecryptionCache { String messageId, String cipherText, ) async { - try { - final row = await AppDatabase.loadMessage(accountId, chatId, messageId); - if (row == null) return; - final flag = row['e2ee'] as int? ?? 0; - final sealed = row['text_sealed']; - if (flag == CachedMessage.e2eeText && sealed is Uint8List) { - final text = await E2eeService.instance.openText( - accountId, - chatId, - sealed, + final generation = _generation; + final row = await AppDatabase.loadMessage(accountId, chatId, messageId); + if (_generation != generation) return; + if (row == null) return; + final flag = row['e2ee'] as int? ?? 0; + final sealed = row['text_sealed']; + if (flag == CachedMessage.e2eeText && sealed is Uint8List) { + final text = await E2eeService.instance.openText( + accountId, + chatId, + sealed, + ); + if (_generation != generation) return; + _entryFor(messageId).value = text == null + ? const MessageDecryption.unavailable() + : MessageDecryption.decrypted(text); + return; + } + if (flag == CachedMessage.e2eeFailed) { + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + return; + } + if (flag == CachedMessage.e2eeFile) { + _entryFor(messageId).value = const MessageDecryption.decrypted(''); + return; + } + if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) { + return; + } + switch (KometCrypto.classifyText(cipherText)) { + case TextClass.session: + _entryFor(messageId).value = const MessageDecryption.unavailable(); + case TextClass.offer: + case TextClass.answer: + _entryFor(messageId).value = MessageDecryption.decrypted( + cipherText.split('\n').first, ); - _entryFor(messageId).value = text == null - ? const MessageDecryption.unavailable() - : MessageDecryption.decrypted(text); - return; - } - if (flag == CachedMessage.e2eeFailed) { - _entryFor(messageId).value = const MessageDecryption.wrongKey(); - return; - } - if (flag == CachedMessage.e2eeFile) { - _entryFor(messageId).value = const MessageDecryption.decrypted(''); - return; - } - if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) { - return; - } - switch (KometCrypto.classifyText(cipherText)) { - case TextClass.session: - _entryFor(messageId).value = const MessageDecryption.unavailable(); - case TextClass.offer: - case TextClass.answer: - _entryFor(messageId).value = MessageDecryption.decrypted( - cipherText.split('\n').first, - ); - case TextClass.legacy: - if (ChatCryptoService.instance.isEnabled(accountId, chatId)) { - _inFlight.remove(messageId); - await _resolve(accountId, chatId, messageId, cipherText); - } - case TextClass.none: - break; - } - } finally { - _inFlight.remove(messageId); + case TextClass.legacy: + if (ChatCryptoService.instance.isEnabled(accountId, chatId)) { + await _resolve(accountId, chatId, messageId, cipherText); + } + case TextClass.none: + break; } } @@ -153,32 +226,32 @@ class MessageDecryptionCache { String messageId, String cipherText, ) async { - try { - final crypto = ChatCryptoService.instance; - final result = await crypto.decrypt(accountId, chatId, cipherText); - if (result.isOk) { - _entryFor(messageId).value = MessageDecryption.decrypted(result.text!); - return; - } - switch (result.failure) { - case CryptoFailure.wrongKey: + final generation = _generation; + final crypto = ChatCryptoService.instance; + final result = await crypto.decrypt(accountId, chatId, cipherText); + if (_generation != generation) return; + if (result.isOk) { + _entryFor(messageId).value = MessageDecryption.decrypted(result.text!); + return; + } + // #***! ключа нет показываем как неверный ключ только если текст правда похож на шифр + switch (result.failure) { + case CryptoFailure.wrongKey: + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + case CryptoFailure.noKey: + if (crypto.looksEncrypted(cipherText)) { _entryFor(messageId).value = const MessageDecryption.wrongKey(); - case CryptoFailure.noKey: - if (crypto.looksEncrypted(cipherText)) { - _entryFor(messageId).value = const MessageDecryption.wrongKey(); - } - case CryptoFailure.notEncrypted: - case CryptoFailure.malformed: - case CryptoFailure.unavailable: - case null: - break; - } - } finally { - _inFlight.remove(messageId); + } + case CryptoFailure.notEncrypted: + case CryptoFailure.malformed: + case CryptoFailure.unavailable: + case null: + break; } } void clear() { + _generation++; _entries.clear(); _inFlight.clear(); } diff --git a/lib/core/media/voice_transcription.dart b/lib/core/media/voice_transcription.dart index 492fa07..d05de6b 100644 --- a/lib/core/media/voice_transcription.dart +++ b/lib/core/media/voice_transcription.dart @@ -87,6 +87,7 @@ class VoiceTranscription { if (result.status != 1 && TranscriptionCache.get(id)?.status == 1) return; TranscriptionCache.put(id, result, expanded: true); } catch (_) { + if (TranscriptionCache.get(id)?.status == 1) return; TranscriptionCache.put( id, TranscriptionResult(status: -1), diff --git a/lib/frontend/screens/chats/chat/view/message_row_widgets.dart b/lib/frontend/screens/chats/chat/view/message_row_widgets.dart index 568a00e..4a36de8 100644 --- a/lib/frontend/screens/chats/chat/view/message_row_widgets.dart +++ b/lib/frontend/screens/chats/chat/view/message_row_widgets.dart @@ -14,10 +14,11 @@ import 'package:ikar/core/config/app_fonts.dart'; import 'package:ikar/core/config/app_frost.dart'; import 'package:ikar/core/config/app_message_actions_style.dart'; import 'package:ikar/core/utils/haptics.dart'; +import 'package:ikar/core/crypto/message_decryption_cache.dart'; import 'package:ikar/core/media/voice_transcription.dart'; import 'package:ikar/core/utils/text_format.dart'; import 'package:ikar/frontend/widgets/animated_text_swap.dart'; -import 'package:ikar/frontend/widgets/directional_drag_recognizer.dart'; +export 'package:ikar/frontend/widgets/swipe_to_reply.dart'; import 'package:ikar/frontend/widgets/liquid_glass.dart'; import 'package:ikar/frontend/widgets/message_actions_overlay.dart' show @@ -33,120 +34,6 @@ import 'package:ikar/main.dart' show animojiModule, messagesModule; import 'package:ikar/models/animoji.dart' show Animoji; import 'package:ikar/models/chat_reaction_settings.dart'; -class SwipeToReply extends StatefulWidget { - final Widget child; - final bool isMe; - final VoidCallback onReply; - - const SwipeToReply({ - super.key, - required this.child, - required this.isMe, - required this.onReply, - }); - - @override - State createState() => _SwipeToReplyState(); -} - -class _SwipeToReplyState extends State - with SingleTickerProviderStateMixin { - static const double _maxDrag = 72.0; - static const double _triggerThreshold = 56.0; - - late final AnimationController _springBack; - double _dragX = 0.0; - double _springFrom = 0.0; - bool _triggered = false; - - @override - void initState() { - super.initState(); - _springBack = - AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - )..addListener(() { - final t = Curves.easeOut.transform(_springBack.value); - setState(() => _dragX = _springFrom * (1 - t)); - }); - } - - @override - void dispose() { - _springBack.dispose(); - super.dispose(); - } - - void _onDragUpdate(DragUpdateDetails d) { - if (_springBack.isAnimating) _springBack.stop(); - var next = _dragX + d.delta.dx; - if (next > 0) next = 0; - if (next < -_maxDrag) next = -_maxDrag; - final wasTriggered = _triggered; - _triggered = next <= -_triggerThreshold; - if (_triggered && !wasTriggered) Haptics.medium(); - setState(() => _dragX = next); - } - - void _onDragEnd(DragEndDetails d) { - if (_triggered) widget.onReply(); - _settle(); - } - - void _onDragCancel() => _settle(); - - void _settle() { - _triggered = false; - _springFrom = _dragX; - _springBack.forward(from: 0); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); - return RawGestureDetector( - behavior: HitTestBehavior.opaque, - gestures: { - LeftwardDragRecognizer: - GestureRecognizerFactoryWithHandlers( - () => LeftwardDragRecognizer(debugOwner: this), - (instance) { - instance - ..onUpdate = _onDragUpdate - ..onEnd = _onDragEnd - ..onCancel = _onDragCancel; - }, - ), - }, - child: Stack( - alignment: Alignment.centerRight, - children: [ - Positioned( - right: 16, - child: Opacity( - opacity: progress, - child: Transform.scale( - scale: 0.6 + 0.4 * progress, - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon(Symbols.reply, size: 20, color: cs.primary), - ), - ), - ), - ), - Transform.translate(offset: Offset(_dragX, 0), child: widget.child), - ], - ), - ); - } -} - class PinnedMessageBanner extends StatelessWidget { final String? text; final bool isPreview; @@ -447,7 +334,7 @@ class _SelectableMessageRowState extends State { isMe: widget.isMe, bottomReservedSpace: widget.composerHeight?.value ?? 0, messageText: widget.message.text, - copyText: widget.message.selectableText, + copyText: MessageDecryptionCache.instance.readableText(widget.message), controller: controller, style: AppMessageActionsStyle.effective, interaction: MessageActionsInteraction.tap, @@ -582,7 +469,7 @@ class _SelectableMessageRowState extends State { isMe: widget.isMe, bottomReservedSpace: widget.composerHeight?.value ?? 0, messageText: widget.message.text, - copyText: widget.message.selectableText, + copyText: MessageDecryptionCache.instance.readableText(widget.message), controller: controller, style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index bc9400c..635c8f8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -51,6 +51,8 @@ import 'package:ikar/frontend/screens/chats/poll_create_screen.dart'; import 'package:ikar/frontend/widgets/animated_text_swap.dart'; import 'package:ikar/frontend/widgets/custom_notification.dart'; import '../../widgets/undo_notification.dart'; +import '../../widgets/scroll_keyboard_dismiss.dart'; +import '../../widgets/swipe_to_reply.dart'; import '../../../core/security/app_lock.dart'; import 'package:ikar/frontend/widgets/chat_menu_overlay.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -145,7 +147,6 @@ import '../../widgets/sticker_pack_sheet.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_route.dart'; -import '../../widgets/directional_drag_recognizer.dart'; import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/schedule_time_picker.dart'; import '../../widgets/chat_wallpaper_sheet.dart'; @@ -581,7 +582,7 @@ class _ChatScreenState extends State set _historyKickedOff(bool v) => _chatController.historyKickedOff = v; final GlobalKey _messageListKey = GlobalKey(); - _ChatMessageList? _messageListWidget; + Widget? _messageListWidget; final Set _deletingIds = {}; static const double _avgMessageHeight = 72.0; @@ -2681,7 +2682,10 @@ class _ChatScreenState extends State } void _startTextSelection(CachedMessage message, Offset globalPosition) { - if (message.isControl || message.selectableText == null) return; + if (message.isControl || + MessageDecryptionCache.instance.readableText(message) == null) { + return; + } _textSelectionDrag.value = null; _textSelection.value = (id: message.id, pos: globalPosition); } @@ -2707,7 +2711,9 @@ class _ChatScreenState extends State List _copyableSelection(Set ids) => [ for (final m in _messages) - if (ids.contains(m.id) && (m.selectableText ?? '').isNotEmpty) m, + if (ids.contains(m.id) && + (MessageDecryptionCache.instance.readableText(m) ?? '').isNotEmpty) + m, ]; CachedMessage? _singleEditable(Set ids) { @@ -2719,7 +2725,11 @@ class _ChatScreenState extends State void _copySelected(List messages) { if (messages.isEmpty) return; - final text = messages.map((m) => m.selectableText!).join('\n\n'); + final text = messages + .map((m) => MessageDecryptionCache.instance.readableText(m) ?? '') + .where((text) => text.isNotEmpty) + .join('\n\n'); + if (text.isEmpty) return; Clipboard.setData(ClipboardData(text: text)); Haptics.tap(); showCustomNotification( @@ -3130,6 +3140,14 @@ class _ChatScreenState extends State } Future _startEditMessage(CachedMessage message) async { + final originalText = await MessageDecryptionCache.instance.resolveText( + message, + ); + if (!mounted) return; + if (originalText == null) { + showCustomNotification(context, 'Не удалось расшифровать сообщение'); + return; + } final cs = Theme.of(context).colorScheme; final content = @@ -3143,7 +3161,7 @@ class _ChatScreenState extends State borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (sheetContext) => _EditMessageSheet( - text: message.text ?? '', + text: originalText, formatRanges: message.formatRanges, contextMenuBuilder: _formatContextMenu, ), @@ -3158,8 +3176,7 @@ class _ChatScreenState extends State final oldElements = serializeFormatElements( message.formatRanges.where((r) => composerFormats.contains(r.format)), ); - if (newText == (message.text ?? '') && - _sameElements(elements, oldElements)) { + if (newText == originalText && _sameElements(elements, oldElements)) { return; } @@ -5990,8 +6007,10 @@ class _ChatScreenState extends State ); } - Widget _buildMessagesList() => - _messageListWidget ??= _ChatMessageList(this, key: _messageListKey); + Widget _buildMessagesList() => _messageListWidget ??= ScrollKeyboardDismiss( + onDismiss: _messageFocusNode.unfocus, + child: _ChatMessageList(this, key: _messageListKey), + ); EdgeInsets _messagesListPadding(BuildContext context) { if (AppChatChrome.current.value == ChatChromeStyle.color) { @@ -6199,8 +6218,7 @@ class _ChatScreenState extends State final swipeable = (message.isControl || isChannel || !_canReply) ? pressable - : _SwipeToReply( - isMe: isMe, + : SwipeToReply( onReply: () => _startReply(message), child: pressable, ); @@ -6312,119 +6330,6 @@ class _ChatScreenState extends State ); } -class _SwipeToReply extends StatefulWidget { - final Widget child; - final bool isMe; - final VoidCallback onReply; - - const _SwipeToReply({ - required this.child, - required this.isMe, - required this.onReply, - }); - - @override - State<_SwipeToReply> createState() => _SwipeToReplyState(); -} - -class _SwipeToReplyState extends State<_SwipeToReply> - with SingleTickerProviderStateMixin { - static const double _maxDrag = 72.0; - static const double _triggerThreshold = 56.0; - - late final AnimationController _springBack; - double _dragX = 0.0; - double _springFrom = 0.0; - bool _triggered = false; - - @override - void initState() { - super.initState(); - _springBack = - AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - )..addListener(() { - final t = Curves.easeOut.transform(_springBack.value); - setState(() => _dragX = _springFrom * (1 - t)); - }); - } - - @override - void dispose() { - _springBack.dispose(); - super.dispose(); - } - - void _onDragUpdate(DragUpdateDetails d) { - if (_springBack.isAnimating) _springBack.stop(); - var next = _dragX + d.delta.dx; - if (next > 0) next = 0; - if (next < -_maxDrag) next = -_maxDrag; - final wasTriggered = _triggered; - _triggered = next <= -_triggerThreshold; - if (_triggered && !wasTriggered) Haptics.medium(); - setState(() => _dragX = next); - } - - void _onDragEnd(DragEndDetails d) { - if (_triggered) widget.onReply(); - _settle(); - } - - void _onDragCancel() => _settle(); - - void _settle() { - _triggered = false; - _springFrom = _dragX; - _springBack.forward(from: 0); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); - return RawGestureDetector( - behavior: HitTestBehavior.opaque, - gestures: { - LeftwardDragRecognizer: - GestureRecognizerFactoryWithHandlers( - () => LeftwardDragRecognizer(debugOwner: this), - (instance) { - instance - ..onUpdate = _onDragUpdate - ..onEnd = _onDragEnd - ..onCancel = _onDragCancel; - }, - ), - }, - child: Stack( - alignment: Alignment.centerRight, - children: [ - Positioned( - right: 16, - child: Opacity( - opacity: progress, - child: Transform.scale( - scale: 0.6 + 0.4 * progress, - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon(Symbols.reply, size: 20, color: cs.primary), - ), - ), - ), - ), - Transform.translate(offset: Offset(_dragX, 0), child: widget.child), - ], - ), - ); - } -} - class _PinnedMessageBanner extends StatelessWidget { final String? text; final bool isPreview; diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart index e3ef765..7bd3600 100644 --- a/lib/frontend/screens/profile/theme_settings_screen.dart +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -6,12 +8,17 @@ import '../../widgets/connection_status.dart'; import '../../../core/config/app_amoled.dart'; import '../../../core/config/app_theme_mode.dart'; import '../../../core/config/app_theme_schedule.dart'; +import '../../../core/config/app_wallpaper_tint.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/chat_wallpaper_store.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/mesh_gradient_background.dart'; import '../../widgets/settings_radio_tile.dart'; import '../../widgets/settings_card.dart'; +import 'custom_gradient_editor_screen.dart'; class ThemeSettingsScreen extends StatelessWidget { const ThemeSettingsScreen({super.key}); @@ -45,9 +52,14 @@ class ThemeSettingsScreen extends StatelessWidget { } } -class _ThemeModeCard extends StatelessWidget { +class _ThemeModeCard extends StatefulWidget { const _ThemeModeCard(); + @override + State<_ThemeModeCard> createState() => _ThemeModeCardState(); +} + +class _ThemeModeCardState extends State<_ThemeModeCard> { static const _items = [ (mode: AppThemeMode.system, icon: Symbols.brightness_auto), (mode: AppThemeMode.light, icon: Symbols.light_mode), @@ -55,6 +67,72 @@ class _ThemeModeCard extends StatelessWidget { (mode: AppThemeMode.schedule, icon: Symbols.schedule), ]; + int _accountId = 0; + ChatWallpaper? _wallpaper; + bool _ready = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + await ChatWallpaperStore.instance.load(); + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + setState(() { + _accountId = profile?.id ?? 0; + final wallpaper = ChatWallpaperStore.instance.get( + _accountId, + kGlobalWallpaperChatId, + ); + _wallpaper = wallpaper?.isGradient == true ? wallpaper : null; + _ready = true; + }); + } + + Future _enableCustom() async { + Haptics.selection(); + if (_wallpaper == null && _accountId != 0) { + final wallpaper = await ChatWallpaperStore.instance.setGradient( + _accountId, + kGlobalWallpaperChatId, + const [Color(0xFF000000)], + ); + if (mounted) setState(() => _wallpaper = wallpaper); + } + await AppWallpaperTint.save(true); + } + + Future _openEditor() async { + if (_accountId == 0) return; + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CustomGradientEditorScreen( + initialColors: _wallpaper?.gradientColors, + initialAnimated: _wallpaper?.gradientAnimated ?? false, + initialRotation: _wallpaper?.gradientRotation ?? 0, + ), + ), + ); + if (result == null || !mounted) return; + final activeProfile = await AppDatabase.loadActiveProfile(); + if (!mounted || activeProfile?.id != _accountId) return; + final wallpaper = await ChatWallpaperStore.instance.setGradient( + _accountId, + kGlobalWallpaperChatId, + result.colors, + animated: result.animated, + rotation: result.rotation, + ); + if (!AppWallpaperTint.current.value) { + await AppWallpaperTint.save(true); + } + if (!mounted) return; + setState(() => _wallpaper = wallpaper); + } + String _labelFor(AppLocalizations l10n, AppThemeMode mode) { switch (mode) { case AppThemeMode.system: @@ -72,6 +150,7 @@ class _ThemeModeCard extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; + final colors = _wallpaper?.gradientColors; return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), child: Column( @@ -91,24 +170,72 @@ class _ThemeModeCard extends StatelessWidget { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: AppThemeModeConfig.current, - builder: (context, current, _) { + ListenableBuilder( + listenable: Listenable.merge([ + AppThemeModeConfig.current, + AppWallpaperTint.current, + ]), + builder: (context, _) { + final current = AppThemeModeConfig.current.value; + final customSelected = AppWallpaperTint.current.value; return Column( children: [ for (final item in _items) _ModeTile( icon: item.icon, label: _labelFor(l10n, item.mode), - selected: current == item.mode, + selected: !customSelected && current == item.mode, onTap: (position) { - if (current == item.mode) return; + if (!customSelected && current == item.mode) return; Haptics.selection(); + if (customSelected) { + unawaited(AppWallpaperTint.save(false)); + } QlyraApp.stateOf( context, )?.applyThemeModeWithReveal(item.mode, position); }, ), + _ModeTile( + icon: Symbols.palette, + label: l10n.themeSettingsCustomTitle, + selected: customSelected, + onTap: (_) { + if (!_ready || _accountId == 0) return; + if (customSelected) return; + unawaited(_enableCustom()); + }, + ), + if (customSelected) + Padding( + padding: const EdgeInsets.only(left: 36), + child: SettingsNavTile( + isLast: true, + onTap: _ready ? _openEditor : null, + leading: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 32, + height: 32, + child: colors != null && colors.isNotEmpty + ? MeshGradientBackground( + colors: colors, + animate: false, + rotation: _wallpaper?.gradientRotation ?? 0, + ) + : ColoredBox( + color: cs.surfaceContainerHighest, + child: Icon( + Symbols.palette, + color: cs.onSurface, + size: 18, + ), + ), + ), + ), + label: l10n.themeSettingsCustomizeAction, + ), + ), ], ); }, diff --git a/lib/frontend/widgets/attachment/photo_hero.dart b/lib/frontend/widgets/attachment/photo_hero.dart index 0cd2b0a..68d353d 100644 --- a/lib/frontend/widgets/attachment/photo_hero.dart +++ b/lib/frontend/widgets/attachment/photo_hero.dart @@ -68,6 +68,7 @@ class PhotoHeroController { class PhotoHeroRoute extends PageRouteBuilder { PhotoHeroRoute({required this.hero, required WidgetBuilder builder}) : super( + opaque: false, transitionDuration: const Duration(milliseconds: 320), reverseTransitionDuration: const Duration(milliseconds: 280), pageBuilder: (context, animation, secondaryAnimation) => @@ -301,7 +302,7 @@ class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> { } return Stack( children: [ - widget.child, + FadeTransition(opacity: widget.animation, child: widget.child), Positioned.fill( child: IgnorePointer( child: AnimatedBuilder( diff --git a/lib/frontend/widgets/attachment/video_editor.dart b/lib/frontend/widgets/attachment/video_editor.dart index 6be0e0a..7973a7c 100644 --- a/lib/frontend/widgets/attachment/video_editor.dart +++ b/lib/frontend/widgets/attachment/video_editor.dart @@ -402,6 +402,7 @@ class _VideoQualityEditorState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final out = widget.geometry.naturalOutput; + final inset = _QualitySlider.insetFor(context); return Scaffold( backgroundColor: Colors.black, appBar: AppBar( @@ -450,22 +451,29 @@ class _VideoQualityEditorState extends State { mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: EdgeInsets.fromLTRB(inset, 8, inset, 0), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - l10n.videoEditorQualityLow, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, + Expanded( + child: Text( + l10n.videoEditorQualityLow, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), ), ), - Text( - l10n.videoEditorQualityHigh, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.videoEditorQualityHigh, + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), ), ), ], @@ -474,6 +482,7 @@ class _VideoQualityEditorState extends State { _QualitySlider( count: widget.options.length, index: _index, + inset: inset, onChanged: (value) => setState(() => _index = value), ), Row( @@ -519,23 +528,35 @@ class _VideoQualityEditorState extends State { class _QualitySlider extends StatelessWidget { final int count; final int index; + final double inset; final ValueChanged onChanged; const _QualitySlider({ required this.count, required this.index, + required this.inset, required this.onChanged, }); + static const double _minInset = 16.0; + static const double _height = 48.0; + + // #***! концы дорожки уводим из зоны системных жестов, иначе крайние + // значения на телефоне с жестовой навигацией нечем зацепить + static double insetFor(BuildContext context) { + final gesture = MediaQuery.of(context).systemGestureInsets; + return math.max(_minInset, math.max(gesture.left, gesture.right) + 8); + } + @override Widget build(BuildContext context) { final accent = MediaAccent.of(context); return SizedBox( - height: 40, + height: _height, child: LayoutBuilder( builder: (context, constraints) { - const inset = 16.0; - final span = math.max(1.0, constraints.maxWidth - inset * 2); + final width = constraints.maxWidth; + final span = math.max(1.0, width - inset * 2); void pick(double dx) { if (count <= 1) return; final t = ((dx - inset) / span).clamp(0.0, 1.0); @@ -547,7 +568,10 @@ class _QualitySlider extends StatelessWidget { behavior: HitTestBehavior.opaque, onTapDown: (d) => pick(d.localPosition.dx), onHorizontalDragUpdate: (d) => pick(d.localPosition.dx), + // #***! без явного размера CustomPaint схлопывается в ноль ширины, + // дорожка рисуется мимо и по ней нечем попасть child: CustomPaint( + size: Size(width, _height), painter: _QualitySliderPainter( count: count, index: index, diff --git a/lib/frontend/widgets/attachment/video_preview_screen.dart b/lib/frontend/widgets/attachment/video_preview_screen.dart index 1b9e8fe..a01744a 100644 --- a/lib/frontend/widgets/attachment/video_preview_screen.dart +++ b/lib/frontend/widgets/attachment/video_preview_screen.dart @@ -595,12 +595,20 @@ class _VideoPreviewScreenState extends State { ); } + static const double _barInset = 8; + static const double _trimMinInset = 16; + Widget _bottomBar() { final controller = _controller; + final gesture = MediaQuery.of(context).systemGestureInsets; + final trimInset = math.max( + _trimMinInset, + math.max(gesture.left, gesture.right) + 8 - _barInset, + ); return SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + padding: const EdgeInsets.fromLTRB(_barInset, 0, _barInset, 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -616,14 +624,17 @@ class _VideoPreviewScreenState extends State { if (widget.editable && controller != null && controller.value.isInitialized) - TrimBar( - frames: _strip, - controller: controller, - duration: _edit.sourceDuration, - start: _edit.start, - end: _edit.end, - onScrub: _onScrub, - onTrim: _onTrim, + Padding( + padding: EdgeInsets.symmetric(horizontal: trimInset), + child: TrimBar( + frames: _strip, + controller: controller, + duration: _edit.sourceDuration, + start: _edit.start, + end: _edit.end, + onScrub: _onScrub, + onTrim: _onTrim, + ), ), const SizedBox(height: 10), _captionField(), diff --git a/lib/frontend/widgets/directional_drag_recognizer.dart b/lib/frontend/widgets/directional_drag_recognizer.dart index e84d655..93a657d 100644 --- a/lib/frontend/widgets/directional_drag_recognizer.dart +++ b/lib/frontend/widgets/directional_drag_recognizer.dart @@ -3,44 +3,43 @@ import 'package:flutter/gestures.dart'; class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer { DirectionalDragRecognizer({ required this.direction, - this.minAcceptDistance = 20.0, - this.minAcceptVelocity, + this.minAcceptDistance = kTouchSlop, super.debugOwner, }) { onlyAcceptDragOnThreshold = true; + dragStartBehavior = DragStartBehavior.down; } final double direction; final double minAcceptDistance; - final double? minAcceptVelocity; + bool Function()? enabled; final Map _initialPositions = {}; - final Map _velocityTrackers = {}; - final Map _currentDeltaX = {}; + final Map _currentDeltas = {}; + bool _directionLocked = false; + + @override + bool isPointerAllowed(PointerEvent event) => + (enabled?.call() ?? true) && super.isPointerAllowed(event); @override void addAllowedPointer(PointerDownEvent event) { _initialPositions[event.pointer] = event.position; - final tracker = VelocityTracker.withKind(event.kind); - tracker.addPosition(event.timeStamp, event.localPosition); - _velocityTrackers[event.pointer] = tracker; super.addAllowedPointer(event); } @override void handleEvent(PointerEvent event) { if (event is PointerMoveEvent) { - _velocityTrackers[event.pointer]?.addPosition( - event.timeStamp, - event.localPosition, - ); final initial = _initialPositions[event.pointer]; if (initial != null) { - final dx = (event.position.dx - initial.dx) * direction; - _currentDeltaX[event.pointer] = dx; - if (dx < -kTouchSlop) { - stopTrackingPointer(event.pointer); - _cleanup(event.pointer); + final delta = event.position - initial; + _currentDeltas[event.pointer] = delta; + if (!_directionLocked && + (delta.dx * direction < -kTouchSlop || + (delta.dy.abs() > kTouchSlop && + delta.dy.abs() > delta.dx.abs()))) { + resolvePointer(event.pointer, GestureDisposition.rejected); return; } } @@ -59,30 +58,23 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer { )) { return false; } - double maxDx = 0; - for (final dx in _currentDeltaX.values) { - if (dx > maxDx) maxDx = dx; - } - if (maxDx < minAcceptDistance) return false; - - final minVelocity = minAcceptVelocity; - if (minVelocity == null) return true; - for (final tracker in _velocityTrackers.values) { - final vx = tracker.getVelocity().pixelsPerSecond.dx * direction; - if (vx >= minVelocity) return true; - } - return false; + _directionLocked = _currentDeltas.values.any( + (delta) => + delta.dx * direction >= minAcceptDistance && + delta.dx.abs() > delta.dy.abs(), + ); + return _directionLocked; } void _cleanup(int pointer) { _initialPositions.remove(pointer); - _velocityTrackers.remove(pointer); - _currentDeltaX.remove(pointer); + _currentDeltas.remove(pointer); } @override void didStopTrackingLastPointer(int pointer) { _cleanup(pointer); + _directionLocked = false; super.didStopTrackingLastPointer(pointer); } @@ -94,8 +86,7 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer { } class RightwardDragRecognizer extends DirectionalDragRecognizer { - RightwardDragRecognizer({super.debugOwner}) - : super(direction: 1, minAcceptVelocity: 700); + RightwardDragRecognizer({super.debugOwner}) : super(direction: 1); } class LeftwardDragRecognizer extends DirectionalDragRecognizer { diff --git a/lib/frontend/widgets/keyboard_dismissal.dart b/lib/frontend/widgets/keyboard_dismissal.dart index ec0751e..b66c5c1 100644 --- a/lib/frontend/widgets/keyboard_dismissal.dart +++ b/lib/frontend/widgets/keyboard_dismissal.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import '../../core/config/ios_release.dart'; +import 'scroll_keyboard_dismiss.dart'; bool _focusIsText() { final context = FocusManager.instance.primaryFocus?.context; @@ -69,6 +70,9 @@ class _KeyboardDismissalState extends State { if (notification.dragDetails == null) return false; if (notification.metrics.axis != Axis.vertical) return false; final source = notification.context; + if (source?.findAncestorWidgetOfExactType() != null) { + return false; + } if (source != null && (source.findAncestorWidgetOfExactType() != null || source.findAncestorWidgetOfExactType() != diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 41706f9..5c16c96 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -79,6 +79,19 @@ class PhotoViewerActions { viewAllMedia == null; } +class PhotoViewerRoute extends PageRouteBuilder { + PhotoViewerRoute({required WidgetBuilder builder}) + : super( + opaque: false, + transitionDuration: const Duration(milliseconds: 220), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, animation, secondaryAnimation) => + builder(context), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ); +} + class _ViewerMedia { final String id; final MessageAttachment attachment; @@ -199,7 +212,8 @@ class PhotoViewerScreen extends StatefulWidget { State createState() => _PhotoViewerScreenState(); } -class _PhotoViewerScreenState extends State { +class _PhotoViewerScreenState extends State + with SingleTickerProviderStateMixin { static const int _prefetchThreshold = 3; static const int _maxCachedVideoPlayers = 5; static const double _compactHeight = 480; @@ -217,9 +231,17 @@ class _PhotoViewerScreenState extends State { final Map>> _videoSourceLoads = {}; final TransformationController _heroTransform = TransformationController(); final Map _pageTransforms = {}; - int _pointers = 0; bool _zoomed = false; bool _swipeEnabled = true; + final Set _pointers = {}; + late final AnimationController _dismissReturn; + Offset _dismissOffset = Offset.zero; + Offset _returnFrom = Offset.zero; + Offset? _pointerOrigin; + VelocityTracker? _velocityTracker; + bool _dismissAllowed = false; + bool _draggingToDismiss = false; + bool _closing = false; bool _feedLoaded = false; bool _feedFailed = false; bool _loadingMore = false; @@ -231,6 +253,10 @@ class _PhotoViewerScreenState extends State { @override void initState() { super.initState(); + _dismissReturn = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + )..addListener(_animateDismissReturn); _heroTransform.addListener(_syncHero); _heroTransform.addListener(_syncZoom); _items = _localItems(); @@ -272,14 +298,8 @@ class _PhotoViewerScreenState extends State { _syncSwipe(); } - void _updatePointers(int delta) { - final next = _pointers + delta; - _pointers = next < 0 ? 0 : next; - _syncSwipe(); - } - void _syncSwipe() { - final enabled = _pointers < 2 && !_zoomed; + final enabled = _pointers.length < 2 && !_zoomed; if (enabled == _swipeEnabled) return; setState(() => _swipeEnabled = enabled); } @@ -287,6 +307,7 @@ class _PhotoViewerScreenState extends State { @override void dispose() { _controller.dispose(); + _dismissReturn.dispose(); _heroTransform.dispose(); for (final transform in _pageTransforms.values) { transform.dispose(); @@ -297,6 +318,99 @@ class _PhotoViewerScreenState extends State { super.dispose(); } + bool get _canDismiss { + final route = ModalRoute.of(context); + return !_closing && + !_dismissReturn.isAnimating && + route?.isCurrent == true && + route?.popDisposition != RoutePopDisposition.doNotPop && + Navigator.of(context).canPop() && + (!_controller.hasClients || + !_controller.position.isScrollingNotifier.value) && + (_current.isVideo || + _transformFor(_current.id).value.getMaxScaleOnAxis() <= 1.01); + } + + void _onMediaPointerDown(PointerDownEvent event) { + _pointers.add(event.pointer); + _syncSwipe(); + if (_pointers.length != 1) { + _dismissAllowed = false; + _restoreDismiss(); + return; + } + _dismissAllowed = event.kind != PointerDeviceKind.mouse && _canDismiss; + _pointerOrigin = event.position; + _velocityTracker = VelocityTracker.withKind(event.kind) + ..addPosition(event.timeStamp, event.position); + } + + void _onMediaPointerMove(PointerMoveEvent event) { + if (!_dismissAllowed || _pointers.length != 1) return; + _velocityTracker?.addPosition(event.timeStamp, event.position); + final delta = event.position - _pointerOrigin!; + if (!_draggingToDismiss) { + if (delta.distance < kTouchSlop) return; + if (delta.dx.abs() >= delta.dy.abs()) { + _dismissAllowed = false; + return; + } + if (!_current.isVideo && + _transformFor(_current.id).value.getMaxScaleOnAxis() > 1.01) { + _dismissAllowed = false; + return; + } + _draggingToDismiss = true; + } + setState(() => _dismissOffset = Offset(delta.dx * 0.35, delta.dy)); + } + + void _onMediaPointerUp(PointerUpEvent event) { + _pointers.remove(event.pointer); + _syncSwipe(); + if (!_draggingToDismiss || !_dismissAllowed) return; + _dismissAllowed = false; + _draggingToDismiss = false; + _velocityTracker?.addPosition(event.timeStamp, event.position); + final velocity = _velocityTracker?.getVelocity().pixelsPerSecond.dy ?? 0; + final distance = _dismissOffset.dy; + final threshold = (MediaQuery.sizeOf(context).height * 0.18).clamp(96, 180); + final fling = + distance.abs() >= 32 && + velocity.abs() >= 900 && + velocity.sign == distance.sign; + final route = ModalRoute.of(context); + if ((distance.abs() >= threshold || fling) && + route?.isCurrent == true && + route?.popDisposition != RoutePopDisposition.doNotPop) { + _closing = true; + Navigator.of(context).pop(); + } else { + _restoreDismiss(); + } + } + + void _onMediaPointerCancel(PointerCancelEvent event) { + _pointers.remove(event.pointer); + _syncSwipe(); + _dismissAllowed = false; + _restoreDismiss(); + } + + void _restoreDismiss() { + _draggingToDismiss = false; + if (_closing || _dismissOffset == Offset.zero) return; + _returnFrom = _dismissOffset; + _dismissReturn.forward(from: 0); + } + + void _animateDismissReturn() { + final value = Curves.easeOutCubic.transform(_dismissReturn.value); + setState( + () => _dismissOffset = Offset.lerp(_returnFrom, Offset.zero, value)!, + ); + } + AvatarFeed? get _avatarFeed => widget.avatars; bool get _isAvatars => _avatarFeed != null; @@ -932,9 +1046,12 @@ class _PhotoViewerScreenState extends State { final media = MediaQuery.of(context); final padding = media.padding; final compact = media.size.height < _compactHeight; + final dismissProgress = + (_dismissOffset.dy.abs() / (MediaQuery.sizeOf(context).height * 0.55)) + .clamp(0.0, 1.0); return Scaffold( - backgroundColor: Colors.black, + backgroundColor: Colors.black.withValues(alpha: 1 - dismissProgress), body: CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.arrowLeft): () => @@ -946,51 +1063,70 @@ class _PhotoViewerScreenState extends State { autofocus: true, child: Stack( children: [ - Positioned.fill(child: _buildPager()), + Positioned.fill( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onMediaPointerDown, + onPointerMove: _onMediaPointerMove, + onPointerUp: _onMediaPointerUp, + onPointerCancel: _onMediaPointerCancel, + child: Transform.translate( + key: const ValueKey('media-dismiss-transform'), + offset: _dismissOffset, + child: Transform.scale( + scale: 1 - 0.12 * dismissProgress, + child: _buildPager(), + ), + ), + ), + ), Positioned.fill( child: IgnorePointer( - ignoring: !_chromeVisible, + ignoring: !_chromeVisible || _dismissOffset != Offset.zero, child: AnimatedOpacity( opacity: _chromeVisible ? 1 : 0, duration: const Duration(milliseconds: 220), curve: Curves.easeOut, - child: Stack( - children: [ - if (_canStep(_leftStep)) - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.only(left: padding.left), - child: _arrow( - Symbols.chevron_left, - () => _step(_leftStep), + child: Opacity( + opacity: 1 - dismissProgress, + child: Stack( + children: [ + if (_canStep(_leftStep)) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only(left: padding.left), + child: _arrow( + Symbols.chevron_left, + () => _step(_leftStep), + ), ), ), - ), - if (_canStep(-_leftStep)) - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.only(right: padding.right), - child: _arrow( - Symbols.chevron_right, - () => _step(-_leftStep), + if (_canStep(-_leftStep)) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.only(right: padding.right), + child: _arrow( + Symbols.chevron_right, + () => _step(-_leftStep), + ), ), ), + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildTopBar(padding, compact: compact), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: _buildTopBar(padding, compact: compact), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _buildBottomBar(padding, compact: compact), - ), - ], + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomBar(padding, compact: compact), + ), + ], + ), ), ), ), @@ -1010,23 +1146,18 @@ class _PhotoViewerScreenState extends State { gestureSettings: DeviceGestureSettings(touchSlop: swipeSlop), ); - return Listener( - onPointerDown: (_) => _updatePointers(1), - onPointerUp: (_) => _updatePointers(-1), - onPointerCancel: (_) => _updatePointers(-1), - child: MediaQuery( - data: swipeMedia, - child: PageView.builder( - key: ValueKey(_pager), - controller: _controller, - reverse: !_isAvatars, - physics: _swipeEnabled ? null : const NeverScrollableScrollPhysics(), - itemCount: _items.length, - onPageChanged: _onPageChanged, - itemBuilder: (_, i) => MediaQuery( - data: _zoomed ? media : swipeMedia, - child: _buildPage(i), - ), + return MediaQuery( + data: swipeMedia, + child: PageView.builder( + key: ValueKey(_pager), + controller: _controller, + reverse: !_isAvatars, + physics: _swipeEnabled ? null : const NeverScrollableScrollPhysics(), + itemCount: _items.length, + onPageChanged: _onPageChanged, + itemBuilder: (_, i) => MediaQuery( + data: _zoomed ? media : swipeMedia, + child: _buildPage(i), ), ), ); diff --git a/lib/frontend/widgets/scroll_keyboard_dismiss.dart b/lib/frontend/widgets/scroll_keyboard_dismiss.dart new file mode 100644 index 0000000..74c2802 --- /dev/null +++ b/lib/frontend/widgets/scroll_keyboard_dismiss.dart @@ -0,0 +1,72 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +class ScrollKeyboardDismiss extends StatefulWidget { + const ScrollKeyboardDismiss({ + super.key, + required this.onDismiss, + required this.child, + }); + + final VoidCallback onDismiss; + final Widget child; + + @override + State createState() => _ScrollKeyboardDismissState(); +} + +class _ScrollKeyboardDismissState extends State { + static const _minimumSpeed = 300.0; + static const _minimumDistance = 18.0; + VelocityTracker? _tracker; + Offset? _origin; + bool _dismissed = false; + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification is ScrollStartNotification) { + final details = notification.dragDetails; + _tracker = details == null + ? null + : VelocityTracker.withKind(details.kind ?? PointerDeviceKind.touch); + _origin = details?.globalPosition; + _dismissed = false; + if (details?.sourceTimeStamp case final time?) { + _tracker?.addPosition(time, details!.globalPosition); + } + } else if (notification is ScrollEndNotification) { + _tracker = null; + _origin = null; + } else if (!_dismissed) { + final details = switch (notification) { + ScrollUpdateNotification() => notification.dragDetails, + OverscrollNotification() => notification.dragDetails, + _ => null, + }; + final time = details?.sourceTimeStamp; + final tracker = _tracker; + final origin = _origin; + if (details != null && + time != null && + tracker != null && + origin != null) { + tracker.addPosition(time, details.globalPosition); + if ((details.globalPosition.dy - origin.dy).abs() >= _minimumDistance && + tracker.getVelocity().pixelsPerSecond.dy.abs() >= _minimumSpeed) { + _dismissed = true; + widget.onDismiss(); + } + } + } + return false; + } + + @override + Widget build(BuildContext context) => + NotificationListener( + onNotification: _onScroll, + child: widget.child, + ); +} diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index c0002c5..26e06b7 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -30,19 +30,36 @@ class SwipeRoute extends PageRoute { @override Duration get reverseTransitionDuration => const Duration(milliseconds: 400); + @override + DelegatedTransitionBuilder? get delegatedTransition => + (context, animation, secondaryAnimation, allowSnapshotting, child) => + CupertinoPageTransition( + primaryRouteAnimation: kAlwaysCompleteAnimation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: Navigator.of(context).userGestureInProgress, + child: child ?? const SizedBox.shrink(), + ); + @override bool canTransitionTo(TransitionRoute nextRoute) { + if (nextRoute is PageRoute && nextRoute.fullscreenDialog) return false; return nextRoute is SwipeRoute || - nextRoute is CupertinoRouteTransitionMixin; + nextRoute is CupertinoRouteTransitionMixin || + (nextRoute is ModalRoute && nextRoute.delegatedTransition != null); } @override - bool get popGestureInProgress => _gestureController != null; + bool canTransitionFrom(TransitionRoute previousRoute) => + previousRoute is PageRoute && !fullscreenDialog; + + @override + bool get popGestureInProgress => navigator?.userGestureInProgress ?? false; _SwipeBackController? _gestureController; @override bool get popGestureEnabled { + if (fullscreenDialog || !isCurrent) return false; if (isFirst) return false; if (willHandlePopInternally) return false; if (popDisposition == RoutePopDisposition.doNotPop) return false; @@ -56,6 +73,9 @@ class SwipeRoute extends PageRoute { final gesture = _SwipeBackController( navigator: navigator!, controller: controller!, + isActive: () => isActive, + isCurrent: () => isCurrent, + canPop: () => popDisposition != RoutePopDisposition.doNotPop, ); _gestureController = gesture; gesture._onEnd = () { @@ -63,9 +83,16 @@ class SwipeRoute extends PageRoute { _gestureController = null; } }; + navigator!.didStartUserGesture(); return gesture; } + @override + void dispose() { + _gestureController?.dispose(); + super.dispose(); + } + @override Widget buildPage( BuildContext context, @@ -134,6 +161,7 @@ class _SwipeBackGestureDetectorState if (!widget.enabledCallback()) return; _width = context.size?.width ?? MediaQuery.of(context).size.width; if (_width <= 0) _width = 1.0; + FocusManager.instance.primaryFocus?.unfocus(); _backController = widget.onStartPopGesture(); } @@ -149,38 +177,53 @@ class _SwipeBackGestureDetectorState } void _handleCancel() { - _backController?.dragEnd(0.0); + _backController?.dragEnd(0.0, cancelled: true); _backController = null; } @override Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - RightwardDragRecognizer: - GestureRecognizerFactoryWithHandlers( - () => RightwardDragRecognizer(debugOwner: this), - (instance) { - instance - ..onStart = _handleStart - ..onUpdate = _handleUpdate - ..onEnd = _handleEnd - ..onCancel = _handleCancel; - }, - ), - }, - child: widget.child, + return Listener( + onPointerCancel: (_) => _handleCancel(), + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..enabled = widget.enabledCallback + ..onStart = _handleStart + ..onUpdate = _handleUpdate + ..onEnd = _handleEnd + ..onCancel = _handleCancel; + }, + ), + }, + child: widget.child, + ), ); } } class _SwipeBackController { - _SwipeBackController({required this.navigator, required this.controller}); + _SwipeBackController({ + required this.navigator, + required this.controller, + required this.isActive, + required this.isCurrent, + required this.canPop, + }); final NavigatorState navigator; final AnimationController controller; + final ValueGetter isActive; + final ValueGetter isCurrent; + final ValueGetter canPop; VoidCallback? _onEnd; + AnimationStatusListener? _statusListener; + bool _ended = false; static const double _kMinFlingVelocity = 1.0; @@ -188,11 +231,15 @@ class _SwipeBackController { controller.value -= delta; } - void dragEnd(double velocity) { - const animationCurve = Curves.fastLinearToSlowEaseIn; + void dragEnd(double velocity, {bool cancelled = false}) { + const animationCurve = Curves.fastEaseInToSlowEaseOut; final bool animateForward; - if (velocity.abs() >= _kMinFlingVelocity) { + if (!isCurrent()) { + animateForward = isActive(); + } else if (cancelled || !canPop()) { + animateForward = true; + } else if (velocity.abs() >= _kMinFlingVelocity) { animateForward = velocity <= 0; } else { animateForward = controller.value > 0.5; @@ -209,9 +256,12 @@ class _SwipeBackController { curve: animationCurve, ); } else { - navigator.pop(); + if (isCurrent()) navigator.pop(); if (controller.isAnimating) { - final backMs = lerpDouble(0, 800, controller.value)!.floor(); + final backMs = math.min( + lerpDouble(0, 800, controller.value)!.floor(), + 300, + ); controller.animateBack( 0.0, duration: Duration(milliseconds: backMs), @@ -221,14 +271,35 @@ class _SwipeBackController { } if (controller.isAnimating) { - late AnimationStatusListener statusCb; - statusCb = (status) { - _onEnd?.call(); - controller.removeStatusListener(statusCb); + _statusListener = (status) { + if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + _finish(); + } }; - controller.addStatusListener(statusCb); + controller.addStatusListener(_statusListener!); } else { - _onEnd?.call(); + _finish(); } } + + void _finish() { + if (_ended) return; + _ended = true; + final listener = _statusListener; + if (listener != null) controller.removeStatusListener(listener); + _statusListener = null; + _onEnd?.call(); + if (navigator.mounted) navigator.didStopUserGesture(); + } + + void dispose() { + if (_ended) return; + _ended = true; + final listener = _statusListener; + if (listener != null) controller.removeStatusListener(listener); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (navigator.mounted) navigator.didStopUserGesture(); + }); + } } diff --git a/lib/frontend/widgets/swipe_to_reply.dart b/lib/frontend/widgets/swipe_to_reply.dart new file mode 100644 index 0000000..014bdee --- /dev/null +++ b/lib/frontend/widgets/swipe_to_reply.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/utils/haptics.dart'; +import 'directional_drag_recognizer.dart'; + +class SwipeToReply extends StatefulWidget { + const SwipeToReply({super.key, required this.child, required this.onReply}); + + final Widget child; + final VoidCallback onReply; + + @override + State createState() => _SwipeToReplyState(); +} + +class _SwipeToReplyState extends State + with SingleTickerProviderStateMixin { + static const double _triggerDistance = 56; + static const double _resistanceLength = 32; + static final SpringDescription _spring = SpringDescription.withDampingRatio( + mass: 1, + stiffness: 500, + ratio: 1, + ); + + late final AnimationController _offset; + double _distance = 0; + bool _triggered = false; + bool _hapticSent = false; + + @override + void initState() { + super.initState(); + _offset = AnimationController.unbounded(vsync: this); + } + + @override + void dispose() { + _offset.dispose(); + super.dispose(); + } + + void _onDragStart(DragStartDetails details) { + _offset.stop(); + final visibleDistance = (-_offset.value).clamp(0.0, double.infinity); + final extra = (visibleDistance - _triggerDistance).clamp( + 0.0, + _resistanceLength - 0.01, + ); + _distance = extra == 0 + ? visibleDistance + : _triggerDistance + extra / (1 - extra / _resistanceLength); + _triggered = false; + _hapticSent = false; + } + + void _onDragUpdate(DragUpdateDetails details) { + _distance = (_distance - details.delta.dx).clamp(0.0, double.infinity); + final extra = (_distance - _triggerDistance).clamp(0.0, double.infinity); + _offset.value = extra == 0 + ? -_distance + : -(_triggerDistance + extra / (1 + extra / _resistanceLength)); + _triggered = _distance >= _triggerDistance; + if (_triggered && !_hapticSent) { + _hapticSent = true; + Haptics.medium(); + } + } + + void _onDragEnd(DragEndDetails details) { + final shouldReply = _triggered; + _settle(details.velocity.pixelsPerSecond.dx); + if (shouldReply) widget.onReply(); + } + + void _settle([double velocity = 0]) { + _triggered = false; + final extra = (_distance - _triggerDistance).clamp(0.0, double.infinity); + final resistance = 1 + extra / _resistanceLength; + _offset.animateWith( + SpringSimulation( + _spring, + _offset.value, + 0, + velocity.clamp(-1500.0, 1500.0) / (resistance * resistance), + tolerance: const Tolerance(distance: 0.1, velocity: 0.1), + snapToEnd: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Listener( + onPointerCancel: (_) => _settle(), + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + LeftwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LeftwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _settle; + }, + ), + }, + child: AnimatedBuilder( + animation: _offset, + child: RepaintBoundary(child: widget.child), + builder: (context, child) { + final offset = _offset.value.clamp(-double.infinity, 0.0); + final progress = (-offset / _triggerDistance).clamp(0.0, 1.0); + return Stack( + alignment: Alignment.centerRight, + children: [ + Positioned( + right: 16, + child: Opacity( + opacity: progress, + child: Transform.scale( + scale: 0.6 + 0.4 * progress, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon(Symbols.reply, size: 20, color: cs.primary), + ), + ), + ), + ), + Transform.translate(offset: Offset(offset, 0), child: child), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 77bfacb..ff561b2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3362,7 +3362,7 @@ class AppLocalizationsRu extends AppLocalizations { String get msgActionsCopyLink => 'Скопировать ссылку'; @override - String get themeSettingsCustomTitle => 'Своя'; + String get themeSettingsCustomTitle => 'Собственная тема'; @override String get callKometDetectedNotification => diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ca72c46..07ca507 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1203,7 +1203,7 @@ "e2eeExportedAndDisabled": "Перенос создан. На этом устройстве шифрование выключено: импортируйте файл на новом и включите там.", "codeErrorInvalid": "Неверный код", "msgActionsCopyLink": "Скопировать ссылку", - "themeSettingsCustomTitle": "Своя", + "themeSettingsCustomTitle": "Собственная тема", "callKometDetectedNotification": "Этот человек использует Komet! :3", "callTooltipKometHub": "Komet", "webPushOpenSite": "Открыть push.komet.pw", diff --git a/test/chat_swipe_gestures_test.dart b/test/chat_swipe_gestures_test.dart new file mode 100644 index 0000000..141d169 --- /dev/null +++ b/test/chat_swipe_gestures_test.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ikar/core/utils/haptics.dart'; +import 'package:ikar/frontend/widgets/swipe_route.dart'; +import 'package:ikar/frontend/widgets/swipe_to_reply.dart'; +import 'package:ikar/frontend/widgets/scroll_keyboard_dismiss.dart'; + +const _backgroundKey = ValueKey('background'); +const _pageKey = ValueKey('page'); +const _messageKey = ValueKey('message'); + +class _Harness { + final navigatorKey = GlobalKey(); + final focus = FocusNode(); + final scroll = ScrollController(); + int replies = 0; + + Future pump(WidgetTester tester, {bool canPop = true}) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + navigatorKey: navigatorKey, + home: const Scaffold(body: SizedBox.expand(key: _backgroundKey)), + ), + ); + navigatorKey.currentState!.push( + SwipeRoute( + builder: (_) => PopScope( + canPop: canPop, + child: Scaffold( + key: _pageKey, + body: Column( + children: [ + Expanded( + child: ScrollKeyboardDismiss( + onDismiss: focus.unfocus, + child: CustomScrollView( + controller: scroll, + reverse: true, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.manual, + slivers: [ + SliverList.builder( + itemCount: 30, + itemBuilder: (_, index) => SwipeToReply( + onReply: () => replies++, + child: SizedBox( + key: index == 2 ? _messageKey : null, + height: 100, + width: double.infinity, + child: Text('Synthetic message $index'), + ), + ), + ), + ], + ), + ), + ), + TextField(focusNode: focus), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + focus.dispose(); + scroll.dispose(); + } +} + +void main() { + setUp(() => Haptics.enabled = false); + tearDown(() => Haptics.enabled = true); + + testWidgets('slow back drag follows the finger and dismisses the keyboard', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.showKeyboard(find.byType(TextField)); + expect(harness.focus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + final start = tester.getCenter(find.byKey(_messageKey)); + final gesture = await tester.startGesture(start); + await gesture.moveBy( + const Offset(40, 0), + timeStamp: const Duration(milliseconds: 300), + ); + await tester.pump(); + + expect(harness.navigatorKey.currentState!.userGestureInProgress, isTrue); + expect(harness.focus.hasFocus, isFalse); + expect(tester.testTextInput.isVisible, isFalse); + expect(tester.getTopLeft(find.byKey(_pageKey)).dx, closeTo(40, 0.1)); + + await gesture.moveBy( + const Offset(80, 0), + timeStamp: const Duration(milliseconds: 600), + ); + await tester.pump(); + final width = tester.getSize(find.byKey(_pageKey)).width; + expect(tester.getTopLeft(find.byKey(_pageKey)).dx, closeTo(120, 0.1)); + expect( + tester.getTopLeft(find.byKey(_backgroundKey)).dx, + closeTo(-(width - 120) / 3, 0.1), + ); + + await gesture.up(timeStamp: const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); + expect(find.byKey(_pageKey), findsOneWidget); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + expect(tester.getTopLeft(find.byKey(_pageKey)).dx, 0); + await harness.dispose(tester); + }); + + testWidgets('a slow back drag past half the screen pops once', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + final y = tester.getCenter(find.byKey(_messageKey)).dy; + final gesture = await tester.startGesture(Offset(100, y)); + for (var step = 1; step <= 11; step++) { + await gesture.moveBy( + const Offset(40, 0), + timeStamp: Duration(milliseconds: step * 160), + ); + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(timeStamp: const Duration(milliseconds: 2000)); + await tester.pumpAndSettle(); + expect(find.byKey(_pageKey), findsNothing); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + expect(harness.replies, 0); + await harness.dispose(tester); + }); + + testWidgets('a cancelled back gesture restores the page past halfway', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + final y = tester.getCenter(find.byKey(_messageKey)).dy; + final gesture = await tester.startGesture(Offset(100, y)); + await gesture.moveBy( + const Offset(450, 0), + timeStamp: const Duration(milliseconds: 700), + ); + await tester.pump(); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(find.byKey(_pageKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(_pageKey)).dx, 0); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + await harness.dispose(tester); + }); + + testWidgets('an interrupted route releases its active back gesture', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + await gesture.moveBy( + const Offset(60, 0), + timeStamp: const Duration(milliseconds: 200), + ); + await tester.pump(); + harness.navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(_pageKey), findsNothing); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + expect(tester.takeException(), isNull); + await harness.dispose(tester); + }); + + testWidgets( + 'reply uses the initial movement and leaves back navigation idle', + (tester) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.showKeyboard(find.byType(TextField)); + final initialLeft = tester.getTopLeft(find.byKey(_messageKey)).dx; + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + await gesture.moveBy( + const Offset(-60, 0), + timeStamp: const Duration(milliseconds: 300), + ); + await tester.pump(); + expect( + tester.getTopLeft(find.byKey(_messageKey)).dx, + lessThan(initialLeft - 56), + ); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + expect(harness.focus.hasFocus, isTrue); + expect(harness.replies, 0); + await gesture.up(); + await tester.pumpAndSettle(); + expect(harness.replies, 1); + expect(tester.getTopLeft(find.byKey(_messageKey)).dx, initialLeft); + await harness.dispose(tester); + }, + ); + + testWidgets('reply resistance is gradual and retracting cancels the reply', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + final firstOffset = tester.getTopLeft(find.byKey(_messageKey)).dx; + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + final nextOffset = tester.getTopLeft(find.byKey(_messageKey)).dx; + expect(nextOffset, lessThan(firstOffset)); + expect(firstOffset - nextOffset, lessThan(20)); + await gesture.moveBy(const Offset(180, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(harness.replies, 0); + expect(tester.getTopLeft(find.byKey(_messageKey)).dx, 0); + await harness.dispose(tester); + }); + + testWidgets('pointer cancellation never commits a reply', (tester) async { + final harness = _Harness(); + await harness.pump(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(harness.replies, 0); + expect(tester.getTopLeft(find.byKey(_messageKey)).dx, 0); + await harness.dispose(tester); + }); + + testWidgets( + 'vertical scrolling dismisses focus without replying or popping', + (tester) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.showKeyboard(find.byType(TextField)); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + for (var step = 1; step <= 8; step++) { + await gesture.moveBy( + const Offset(1, 14), + timeStamp: Duration(milliseconds: step * 28), + ); + await tester.pump(const Duration(milliseconds: 28)); + } + await gesture.up(timeStamp: const Duration(milliseconds: 230)); + await tester.pumpAndSettle(); + expect(harness.scroll.offset, greaterThan(0)); + expect(harness.focus.hasFocus, isFalse); + expect(harness.replies, 0); + expect(find.byKey(_pageKey), findsOneWidget); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + await harness.dispose(tester); + }, + ); + + testWidgets('slow vertical scrolling keeps the keyboard open', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.showKeyboard(find.byType(TextField)); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + for (var step = 1; step <= 10; step++) { + await gesture.moveBy( + const Offset(0, 10), + timeStamp: Duration(milliseconds: step * 140), + ); + await tester.pump(const Duration(milliseconds: 140)); + expect(harness.focus.hasFocus, isTrue); + } + await gesture.up(timeStamp: const Duration(milliseconds: 1500)); + await tester.pumpAndSettle(); + expect(harness.scroll.offset, greaterThan(0)); + expect(tester.testTextInput.isVisible, isTrue); + expect(harness.replies, 0); + await harness.dispose(tester); + }); + + testWidgets('accelerating a slow scroll dismisses the keyboard', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.showKeyboard(find.byType(TextField)); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(_messageKey)), + ); + for (var step = 1; step <= 5; step++) { + await gesture.moveBy( + const Offset(0, 10), + timeStamp: Duration(milliseconds: step * 140), + ); + await tester.pump(const Duration(milliseconds: 140)); + } + expect(harness.focus.hasFocus, isTrue); + for (var step = 1; step <= 8; step++) { + await gesture.moveBy( + const Offset(0, 12), + timeStamp: Duration(milliseconds: 700 + step * 20), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(harness.focus.hasFocus, isFalse); + await gesture.up(timeStamp: const Duration(milliseconds: 880)); + await tester.pumpAndSettle(); + await harness.dispose(tester); + }); + + testWidgets('programmatic scrolling keeps the keyboard open', (tester) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.showKeyboard(find.byType(TextField)); + harness.scroll.jumpTo(400); + await tester.pumpAndSettle(); + expect(harness.focus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + await harness.dispose(tester); + }); + + testWidgets('a blocked pop does not dismiss the composer or move the page', ( + tester, + ) async { + final harness = _Harness(); + await harness.pump(tester, canPop: false); + await tester.showKeyboard(find.byType(TextField)); + await tester.drag(find.byKey(_messageKey), const Offset(250, 0)); + await tester.pumpAndSettle(); + expect(harness.focus.hasFocus, isTrue); + expect(tester.getTopLeft(find.byKey(_pageKey)).dx, 0); + expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse); + await harness.dispose(tester); + }); +} diff --git a/test/custom_theme_screen_test.dart b/test/custom_theme_screen_test.dart new file mode 100644 index 0000000..bf11821 --- /dev/null +++ b/test/custom_theme_screen_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:ikar/core/config/app_wallpaper_tint.dart'; +import 'package:ikar/core/storage/app_database.dart'; +import 'package:ikar/core/storage/chat_wallpaper_store.dart'; +import 'package:ikar/core/utils/haptics.dart'; +import 'package:ikar/frontend/screens/profile/custom_gradient_editor_screen.dart'; +import 'package:ikar/frontend/screens/profile/theme_settings_screen.dart'; +import 'package:ikar/l10n/app_localizations.dart'; +import 'package:ikar/main.dart' show api; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Paths extends PathProviderPlatform { + _Paths(this.path); + final String path; + @override + Future getApplicationSupportPath() async => path; + @override + Future getApplicationDocumentsPath() async => path; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( + 'custom theme edits the global gradient and keeps chat overrides', + (tester) async { + final previousPaths = PathProviderPlatform.instance; + late Directory dir; + await tester.runAsync(() async { + api.state; + dir = await Directory.systemTemp.createTemp('ikar-theme-test-'); + PathProviderPlatform.instance = _Paths(dir.path); + SharedPreferences.setMockInitialValues({}); + Haptics.enabled = false; + await AppWallpaperTint.load(); + await AppDatabase.init(); + await AppDatabase.saveProfile(ProfileData.stub(42)); + await AppDatabase.setActiveAccount(42); + await ChatWallpaperStore.instance.load(); + await ChatWallpaperStore.instance.setTheme( + 42, + 7, + 'synthetic-chat-theme', + ); + }); + addTearDown(() async { + await api.dispose(); + await AppDatabase.close(); + PathProviderPlatform.instance = previousPaths; + Haptics.enabled = true; + await dir.delete(recursive: true); + }); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const ThemeSettingsScreen(), + ), + ); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Собственная тема')); + await tester.pumpAndSettle(); + expect(AppWallpaperTint.current.value, isTrue); + expect(find.text('Настроить'), findsOneWidget); + await tester.tap(find.text('Настроить')); + await tester.pumpAndSettle(); + expect(find.byType(CustomGradientEditorScreen), findsOneWidget); + final editorContext = tester.element( + find.byType(CustomGradientEditorScreen), + ); + Navigator.of(editorContext).pop( + const CustomGradientResult( + colors: [Colors.red, Colors.blue], + animated: false, + rotation: 0.7, + ), + ); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pumpAndSettle(); + final wallpaper = ChatWallpaperStore.instance.get( + 42, + kGlobalWallpaperChatId, + )!; + expect(wallpaper.gradientColors!.map((color) => color.toARGB32()), [ + Colors.red.toARGB32(), + Colors.blue.toARGB32(), + ]); + expect(wallpaper.gradientRotation, 0.7); + expect( + ChatWallpaperStore.instance.get(42, 7)!.themeId, + 'synthetic-chat-theme', + ); + final restored = ChatWallpaperStore.forTesting(); + await tester.runAsync(restored.load); + expect( + restored + .get(42, kGlobalWallpaperChatId)! + .gradientColors! + .map((color) => color.toARGB32()), + [Colors.red.toARGB32(), Colors.blue.toARGB32()], + ); + await tester.tap(find.text('Системная')); + await tester.pumpAndSettle(); + expect(AppWallpaperTint.current.value, isFalse); + expect(restored.get(42, kGlobalWallpaperChatId), isNotNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +} diff --git a/test/e2ee_integration_test.dart b/test/e2ee_integration_test.dart index 5741b09..6ebbe22 100644 --- a/test/e2ee_integration_test.dart +++ b/test/e2ee_integration_test.dart @@ -12,6 +12,7 @@ import 'package:ikar/backend/modules/outgoing_text.dart'; import 'package:ikar/backend/modules/outbox_queue.dart'; import 'package:ikar/core/crypto/e2ee_service.dart'; import 'package:ikar/core/crypto/encrypted_photo.dart'; +import 'package:ikar/core/crypto/message_decryption_cache.dart'; import 'package:ikar/core/storage/app_database.dart'; import 'package:ikar/core/utils/media_cache.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -153,6 +154,42 @@ void main() { await dir.delete(recursive: true); }); + test( + 'copy and edit resolve the same sealed text without exposing ciphertext', + () async { + await handshake(); + const plaintext = 'synthetic message for editing'; + final wire = await service.encryptText(42, 7, plaintext); + final incoming = CachedMessage( + id: '501', + accountId: 84, + chatId: 7, + senderId: 42, + text: wire, + time: 501, + ); + final decoded = await service.inspect( + incoming, + commit: (message) => AppDatabase.saveMessages([message.toDbRow()]), + ); + final cache = MessageDecryptionCache.instance; + cache.clear(); + expect(cache.readableText(decoded), isNull); + final results = await Future.wait([ + cache.resolveText(decoded), + cache.resolveText(decoded), + ]); + expect(results, [plaintext, plaintext]); + expect(cache.readableText(decoded), plaintext); + cache.clear(); + final pending = cache.resolveText(decoded); + cache.clear(); + expect(await pending, isNull); + expect(cache.readableText(decoded), isNull); + expect((await AppDatabase.loadMessage(84, 7, '501'))!['text'], wire); + }, + ); + test( 'history establishes a session; fingerprints match; restart preserves decryption', () async { diff --git a/test/ikar_presence_test.dart b/test/ikar_presence_test.dart new file mode 100644 index 0000000..f9935ba --- /dev/null +++ b/test/ikar_presence_test.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:ikar/backend/api.dart'; +import 'package:ikar/backend/ikar_transport.dart'; +import 'package:ikar/core/config/qlyra_settings.dart'; +import 'package:ikar/core/protocol/opcode_map.dart'; +import 'package:ikar/core/utils/app_foreground.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Transport extends IkarTransport { + _Transport( + super.uri, + List requests, { + Future Function(Map)? onRequest, + }) : super( + client: MockClient((request) async { + final payload = jsonDecode(request.body)['payload'] as Map; + requests.add(payload); + await onRequest?.call(payload); + return http.Response(jsonEncode({'cmd': 1, 'payload': {}}), 200); + }), + ); + bool connected = false; + final eventActivity = []; + @override + bool get eventsConnected => connected; + @override + Future connectEvents() async { + eventActivity.add(interactive); + connected = true; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + SharedPreferences.setMockInitialValues({}); + QlyraSettings.ghostMode.value = false; + AppForeground.update(foreground: true); + }); + test('explicit inactive ping reaches the server', () async { + final requests = []; + final api = Api(transportFactory: (uri) => _Transport(uri, requests)); + addTearDown(api.dispose); + await api.connect(); + api.sendPing(interactive: false); + await Future.delayed(const Duration(milliseconds: 20)); + expect(requests.last['interactive'], false); + }); + test('ghost mode survives login and event reconnection', () async { + QlyraSettings.ghostMode.value = true; + final requests = []; + late _Transport transport; + final api = Api( + transportFactory: (uri) => transport = _Transport(uri, requests), + ); + addTearDown(api.dispose); + await api.connect(); + await api.sendRequest(Opcode.login, { + 'token': 'synthetic', + 'interactive': true, + }); + expect(requests.last['interactive'], false); + expect(transport.eventActivity, [false]); + transport.connected = false; + api.wakeUp(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(transport.eventActivity, [false, false]); + expect(requests.last['interactive'], false); + QlyraSettings.ghostMode.value = false; + await Future.delayed(const Duration(milliseconds: 20)); + expect(requests.last['interactive'], true); + AppForeground.update(foreground: false); + await Future.delayed(const Duration(milliseconds: 20)); + expect(requests.last['interactive'], false); + }); + test('a slow active ping cannot override newly enabled ghost mode', () async { + final requests = []; + final started = Completer(); + final release = Completer(); + final hidden = Completer(); + final api = Api( + transportFactory: (uri) => _Transport( + uri, + requests, + onRequest: (payload) async { + if (payload['interactive'] == true && !started.isCompleted) { + started.complete(); + await release.future; + } + if (payload['interactive'] == false && !hidden.isCompleted) { + hidden.complete(); + } + }, + ), + ); + addTearDown(api.dispose); + await api.connect(); + api.sendPing(interactive: true); + await started.future; + QlyraSettings.ghostMode.value = true; + await Future.delayed(Duration.zero); + expect( + requests.where((payload) => payload.containsKey('interactive')), + hasLength(1), + ); + release.complete(); + await hidden.future.timeout(const Duration(seconds: 2)); + expect(requests.last['interactive'], false); + }); +} diff --git a/test/photo_viewer_test.dart b/test/photo_viewer_test.dart index 1bf9367..bd01736 100644 --- a/test/photo_viewer_test.dart +++ b/test/photo_viewer_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ikar/backend/modules/messages.dart'; +import 'package:ikar/frontend/widgets/attachment/photo_hero.dart'; import 'package:ikar/frontend/widgets/photo_viewer.dart'; import 'package:ikar/l10n/app_localizations.dart'; import 'package:ikar/models/attachment.dart'; @@ -41,7 +42,215 @@ Future _pumpViewer( await tester.pump(); } +Future _pushViewer( + WidgetTester tester, { + PhotoHeroController? hero, +}) async { + Widget viewer(BuildContext context) => PhotoViewerScreen( + photos: const [ + PhotoAttachment(baseUrl: 'https://example.test/first.jpg'), + PhotoAttachment(baseUrl: 'https://example.test/second.jpg'), + ], + hero: hero, + ); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + backgroundColor: Colors.green, + body: TextButton( + onPressed: () => Navigator.of(context).push( + hero == null + ? PhotoViewerRoute(builder: viewer) + : PhotoHeroRoute(hero: hero, builder: viewer), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); +} + +Offset _dismissOffset(WidgetTester tester) { + final transform = tester.widget( + find.byKey(const ValueKey('media-dismiss-transform')), + ); + return Offset(transform.transform[12], transform.transform[13]); +} + void main() { + testWidgets('drag dismissal flies back from the dragged photo position', ( + tester, + ) async { + final image = (await tester.runAsync( + () => createTestImage(width: 20, height: 10), + ))!; + addTearDown(image.dispose); + final hero = PhotoHeroController( + origin: () => const Rect.fromLTWH(20, 60, 80, 80), + image: RawImageProvider(image), + ); + await _pushViewer(tester, hero: hero); + final gesture = await tester.startGesture(const Offset(400, 260)); + await gesture.moveBy( + const Offset(0, 140), + timeStamp: const Duration(milliseconds: 400), + ); + await tester.pump(); + final draggedPhoto = inscribeRect(const Size(20, 10), hero.areaRect!); + await gesture.up(timeStamp: const Duration(milliseconds: 500)); + await tester.pump(); + + final flight = find.byWidgetPredicate( + (widget) => widget is Image && widget.image is RawImageProvider, + ); + expect(flight, findsOneWidget); + expect(tester.getRect(flight).top, closeTo(draggedPhoto.top, 1)); + await tester.pump(const Duration(milliseconds: 320)); + expect(find.byType(PhotoViewerScreen), findsNothing); + }); + + testWidgets( + 'vertical drag follows the finger, fades the background and closes', + (tester) async { + await _pushViewer(tester); + final gesture = await tester.startGesture(const Offset(400, 260)); + await gesture.moveBy( + const Offset(12, 140), + timeStamp: const Duration(milliseconds: 400), + ); + await tester.pump(); + + expect(_dismissOffset(tester).dy, 140); + final scaffold = tester.widget( + find.descendant( + of: find.byType(PhotoViewerScreen), + matching: find.byType(Scaffold), + ), + ); + expect(scaffold.backgroundColor!.a, lessThan(1)); + + await gesture.up(timeStamp: const Duration(milliseconds: 500)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(PhotoViewerScreen), findsNothing); + }, + ); + + testWidgets('short slow drag and cancelled drag restore the photo', ( + tester, + ) async { + await _pushViewer(tester); + var gesture = await tester.startGesture(const Offset(400, 260)); + await gesture.moveBy( + const Offset(0, 50), + timeStamp: const Duration(milliseconds: 400), + ); + await gesture.up(timeStamp: const Duration(milliseconds: 700)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(_dismissOffset(tester), Offset.zero); + expect(find.byType(PhotoViewerScreen), findsOneWidget); + + gesture = await tester.startGesture(const Offset(400, 260)); + await gesture.moveBy( + const Offset(0, -150), + timeStamp: const Duration(milliseconds: 400), + ); + await tester.pump(); + expect(_dismissOffset(tester).dy, -150); + await gesture.cancel(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(_dismissOffset(tester), Offset.zero); + expect(find.byType(PhotoViewerScreen), findsOneWidget); + }); + + testWidgets('a short fast vertical fling dismisses the photo', ( + tester, + ) async { + await _pushViewer(tester); + await tester.flingFrom(const Offset(400, 260), const Offset(0, -70), 1500); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(PhotoViewerScreen), findsNothing); + }); + + testWidgets('horizontal swipe changes photos without dismissing', ( + tester, + ) async { + await _pushViewer(tester); + await tester.timedDragFrom( + const Offset(650, 260), + const Offset(-500, 0), + const Duration(milliseconds: 600), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_dismissOffset(tester), Offset.zero); + expect(find.byIcon(Symbols.chevron_left), findsOneWidget); + expect(find.byIcon(Symbols.chevron_right), findsNothing); + }); + + testWidgets('zoomed photos pan vertically without dismissing', ( + tester, + ) async { + await _pushViewer(tester); + final viewer = tester.widget( + find.byType(InteractiveViewer).first, + ); + viewer.transformationController!.value = Matrix4.diagonal3Values(2, 2, 1); + await tester.pump(); + await tester.dragFrom(const Offset(400, 260), const Offset(0, 160)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_dismissOffset(tester), Offset.zero); + expect( + viewer.transformationController!.value.getMaxScaleOnAxis(), + greaterThan(1), + ); + }); + + testWidgets('a second finger cancels dismissal and preserves pinch zoom', ( + tester, + ) async { + await _pushViewer(tester); + final first = await tester.startGesture(const Offset(340, 260), pointer: 1); + await first.moveBy(const Offset(0, 40)); + await tester.pump(); + expect(_dismissOffset(tester).dy, 40); + final second = await tester.startGesture( + const Offset(460, 300), + pointer: 2, + ); + await first.moveTo(const Offset(280, 300)); + await second.moveTo(const Offset(520, 300)); + await tester.pump(); + await first.up(); + await second.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_dismissOffset(tester), Offset.zero); + final viewer = tester.widget( + find.byType(InteractiveViewer).first, + ); + expect( + viewer.transformationController!.value.getMaxScaleOnAxis(), + greaterThan(1), + ); + }); + testWidgets('shows who sent the photo and when', (tester) async { await _pumpViewer(tester, message: _message()); diff --git a/test/video_quality_gesture_test.dart b/test/video_quality_gesture_test.dart new file mode 100644 index 0000000..d6d2504 --- /dev/null +++ b/test/video_quality_gesture_test.dart @@ -0,0 +1,82 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ikar/frontend/widgets/attachment/editor_common.dart'; +import 'package:ikar/frontend/widgets/attachment/video_edit.dart'; +import 'package:ikar/frontend/widgets/attachment/video_editor.dart'; +import 'package:ikar/l10n/app_localizations.dart'; + +void main() { + testWidgets( + 'quality endpoints remain reachable outside Android gesture edges', + (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawColor(Colors.blue, BlendMode.src); + final picture = recorder.endRecording(); + final image = picture.toImageSync(32, 32); + addTearDown(image.dispose); + addTearDown(picture.dispose); + int? selected; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + systemGestureInsets: const EdgeInsets.only(left: 60, right: 70), + ), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoQualityEditor( + frame: image, + geometry: VideoGeometry.resolve( + null, + const Size(720, 1280), + ), + adjust: ColorAdjust(), + options: const [360, 480, 720], + selected: 480, + fps: 30, + duration: const Duration(seconds: 2), + onPreview: (result) async => selected = result, + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + final slider = find.byWidgetPredicate( + (widget) => + widget is GestureDetector && + widget.onHorizontalDragUpdate != null && + widget.child is CustomPaint, + ); + expect(slider, findsOneWidget); + final bounds = tester.getRect(slider); + expect(bounds.width, 360); + expect(bounds.height, greaterThanOrEqualTo(48)); + await tester.tapAt(Offset(bounds.right - 80, bounds.center.dy)); + await tester.pump(); + await tester.tap(find.text('ГОТОВО')); + await tester.pumpAndSettle(); + expect(selected, 720); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/voice_transcription_test.dart b/test/voice_transcription_test.dart index 87a7aff..c81c5a8 100644 --- a/test/voice_transcription_test.dart +++ b/test/voice_transcription_test.dart @@ -152,4 +152,22 @@ void main() { expect(TranscriptionCache.get('101')?.text, 'Готово'); expect(TranscriptionCache.get('101')?.status, 1); }); + + test('a late request error cannot erase a completed push result', () async { + final target = VoiceTranscriptionTarget.fromMessage(_message())!; + final response = Completer(); + final pending = VoiceTranscription.toggle( + target, + (_, _, _) => response.future, + ); + TranscriptionCache.put( + '101', + TranscriptionResult(status: 1, text: 'Готово'), + expanded: true, + ); + response.completeError(StateError('connection lost after push')); + await pending; + expect(TranscriptionCache.get('101')?.text, 'Готово'); + expect(TranscriptionCache.get('101')?.status, 1); + }); }