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