Port Komet UI improvements and implement IKAR ghost mode
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
This commit is contained in:
sevenhill
2026-10-07 22:07:05 +03:00
parent 69ea5091eb
commit 1ef2b7420e
35 changed files with 2210 additions and 524 deletions
+21 -1
View File
@@ -2,7 +2,7 @@
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР"> <img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
Самостоятельный 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` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии. Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Иконка перенесена из прежнего Икара. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
@@ -18,6 +18,18 @@
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников. Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; 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 ## Размещение на Pi
Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`. Внешний диск: `/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/` для проверок используют временные тестовые аккаунты и удаляют их по завершении. `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 ## Исправления видео 1.0.3
Серверный ответ на `VIDEO_PLAY` раньше содержал `external` и `hls`: стандартная JSON-сериализация меняла регистр ключей `EXTERNAL` и `HLS`, которые ожидает клиент. Теперь ключ `EXTERNAL` сохраняет точное написание, а исходный MP4 больше не выдаётся за HLS-плейлист. Выдача файла поддерживает HTTP byte ranges для перемотки. Серверный ответ на `VIDEO_PLAY` раньше содержал `external` и `hls`: стандартная JSON-сериализация меняла регистр ключей `EXTERNAL` и `HLS`, которые ожидает клиент. Теперь ключ `EXTERNAL` сохраняет точное написание, а исходный MP4 больше не выдаётся за HLS-плейлист. Выдача файла поддерживает HTTP byte ranges для перемотки.
@@ -55,6 +55,7 @@ public sealed class ChannelDtoMapperTests
var owner = CreateUser("owner", "Owner"); var owner = CreateUser("owner", "Owner");
owner.PhoneNumber = "+10000000001"; owner.PhoneNumber = "+10000000001";
owner.AvatarStoragePath = "owner-avatar.png"; owner.AvatarStoragePath = "owner-avatar.png";
owner.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds();
owner.PrivacySettings = new UserPrivacySettings owner.PrivacySettings = new UserPrivacySettings
{ {
UserId = owner.Id, UserId = owner.Id,
@@ -89,6 +90,7 @@ public sealed class ChannelDtoMapperTests
var owner = CreateUser("owner", "Owner"); var owner = CreateUser("owner", "Owner");
owner.PhoneNumber = "+10000000001"; owner.PhoneNumber = "+10000000001";
owner.AvatarStoragePath = "owner-avatar.png"; owner.AvatarStoragePath = "owner-avatar.png";
owner.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds();
owner.PrivacySettings = new UserPrivacySettings owner.PrivacySettings = new UserPrivacySettings
{ {
UserId = owner.Id, UserId = owner.Id,
@@ -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<OkObjectResult>(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<OkObjectResult>(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<IHttpContextFeature>(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<object, object?> Items { get; } = new Dictionary<object, object?>();
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;
}
}
@@ -188,7 +188,8 @@ public sealed partial class QlyraCompatibilityControllerTests
Username = "presence-user", Username = "presence-user",
NormalizedUsername = "PRESENCE-USER", NormalizedUsername = "PRESENCE-USER",
DisplayName = "Presence User", DisplayName = "Presence User",
PasswordHash = "test" PasswordHash = "test",
PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeMilliseconds()
}; };
user.Sessions.Add(new UserSession user.Sessions.Add(new UserSession
{ {
@@ -792,13 +793,15 @@ public sealed partial class QlyraCompatibilityControllerTests
IEmailCodeSender? emailCodeSender = null, IEmailCodeSender? emailCodeSender = null,
QlyraCallCompatibilityStore? qlyraCallStore = null, QlyraCallCompatibilityStore? qlyraCallStore = null,
IPushNotificationSender? pushNotificationSender = null, IPushNotificationSender? pushNotificationSender = null,
VideoPreviewService? videoPreviewService = null) VideoPreviewService? videoPreviewService = null,
PresenceTracker? presenceTracker = null,
Guid? sessionId = null)
{ {
var controller = new QlyraCompatibilityController( var controller = new QlyraCompatibilityController(
dbContext, dbContext,
new TokenService(dbContext, Options.Create(new JwtOptions())), new TokenService(dbContext, Options.Create(new JwtOptions())),
new DeviceAuthChallengeStore(), new DeviceAuthChallengeStore(),
new PresenceTracker(), presenceTracker ?? new PresenceTracker(),
new QlyraClientIdMapper(dbContext), new QlyraClientIdMapper(dbContext),
uploadStore ?? ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()), uploadStore ?? ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()),
new NoopHubContext<MessengerHub>(), new NoopHubContext<MessengerHub>(),
@@ -814,7 +817,7 @@ public sealed partial class QlyraCompatibilityControllerTests
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
{ {
User = new ClaimsPrincipal(new ClaimsIdentity( 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")) "Test"))
} }
}; };
@@ -53,7 +53,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
{ {
return opcode switch 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), 17 => await RequestPhoneCodeAsync(opcode, request.Payload, cancellationToken),
18 => await VerifyPhoneCodeAsync(opcode, request.Payload, cancellationToken), 18 => await VerifyPhoneCodeAsync(opcode, request.Payload, cancellationToken),
19 => await LoginAsync(opcode, cancellationToken), 19 => await LoginAsync(opcode, cancellationToken),
@@ -321,6 +321,23 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
})); }));
} }
private async Task<IActionResult> 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<IActionResult> LoginAsync(int opcode, CancellationToken cancellationToken) private async Task<IActionResult> LoginAsync(int opcode, CancellationToken cancellationToken)
{ {
if (User.Identity?.IsAuthenticated != true) if (User.Identity?.IsAuthenticated != true)
@@ -13,6 +13,7 @@ public sealed class User
public bool IsBlocked { get; set; } public bool IsBlocked { get; set; }
public DateTimeOffset? BlockedAt { get; set; } public DateTimeOffset? BlockedAt { get; set; }
public long? ScheduledDeletionUnixMs { get; set; } public long? ScheduledDeletionUnixMs { get; set; }
public long? PublicLastSeenUnixMs { get; set; }
public string? PhoneNumber { get; set; } public string? PhoneNumber { get; set; }
public string? NormalizedPhoneNumber { get; set; } public string? NormalizedPhoneNumber { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
@@ -12,10 +12,9 @@ public static class DtoMapper
user.DisplayName, user.DisplayName,
presenceTracker.IsOnline(user.Id), presenceTracker.IsOnline(user.Id),
user.PhoneNumber, user.PhoneNumber,
user.Sessions user.PublicLastSeenUnixMs is { } seen
.OrderByDescending(x => x.LastSeenAt) ? DateTimeOffset.FromUnixTimeMilliseconds(seen)
.Select(x => (DateTimeOffset?)x.LastSeenAt) : null,
.FirstOrDefault(),
user.About, user.About,
ResolveAvatarPath(user), ResolveAvatarPath(user),
user.IsBot); user.IsBot);
@@ -26,7 +26,14 @@ public sealed class MessengerHub(
{ {
var userId = Context.User!.GetRequiredUserId(); var userId = Context.User!.GetRequiredUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId)); 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 CaptureClientVersionAsync();
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
@@ -36,7 +43,7 @@ public sealed class MessengerHub(
if (Context.User?.Identity?.IsAuthenticated == true) if (Context.User?.Identity?.IsAuthenticated == true)
{ {
var userId = Context.User.GetRequiredUserId(); var userId = Context.User.GetRequiredUserId();
presenceTracker.MarkOffline(userId); presenceTracker.Disconnect(userId, Context.ConnectionId);
if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession)) if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession))
{ {
await NotifyCallEndedAsync(endedSession!); await NotifyCallEndedAsync(endedSession!);
@@ -88,7 +95,7 @@ public sealed class MessengerHub(
var caller = chat.Members.Single(member => member.UserId == callerId).User; var caller = chat.Members.Single(member => member.UserId == callerId).User;
var callee = 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 calleeDevices = await LoadPushDevicesAsync(callee.Id);
var hasRealtimePresence = presenceTracker.IsOnline(callee.Id); var hasRealtimePresence = presenceTracker.IsConnected(callee.Id);
var hasPushDevices = calleeDevices.Count > 0; var hasPushDevices = calleeDevices.Count > 0;
if (!hasRealtimePresence && !hasPushDevices) if (!hasRealtimePresence && !hasPushDevices)
@@ -1,41 +1,56 @@
using System.Collections.Concurrent;
namespace Ikar.Server.Infrastructure; namespace Ikar.Server.Infrastructure;
public sealed class PresenceTracker public sealed class PresenceTracker
{ {
private readonly ConcurrentDictionary<Guid, int> _connections = new(); private readonly object _gate = new();
private readonly Dictionary<Guid, Dictionary<string, Connection>> _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<string, Connection>(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)) if (!_connections.TryGetValue(userId, out var connections)) return;
{ connections.Remove(connectionId);
return; if (connections.Count == 0) _connections.Remove(userId);
}
} }
if (current <= 1) public void SetInteractive(Guid userId, Guid sessionId, bool interactive)
{ {
if (_connections.TryRemove(userId, out _)) lock (_gate)
{ {
return; if (!_connections.TryGetValue(userId, out var connections)) return;
} foreach (var (id, connection) in connections.ToArray())
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);
}
} }
@@ -10,6 +10,15 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
{ {
await EnableWriteAheadLoggingAsync(cancellationToken); await EnableWriteAheadLoggingAsync(cancellationToken);
await EnsureColumnAsync("Users", "ScheduledDeletionUnixMs", "INTEGER NULL", 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 EnsureMessageAttachmentsTableAsync(cancellationToken);
await EnsurePushDevicesTableAsync(cancellationToken); await EnsurePushDevicesTableAsync(cancellationToken);
await EnsureDeviceCredentialsTableAsync(cancellationToken); await EnsureDeviceCredentialsTableAsync(cancellationToken);
@@ -944,7 +953,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
cancellationToken); cancellationToken);
} }
private async Task EnsureColumnAsync( private async Task<bool> EnsureColumnAsync(
string tableName, string tableName,
string columnName, string columnName,
string definition, string definition,
@@ -967,7 +976,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
{ {
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) 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(); await using var alterCommand = connection.CreateCommand();
alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};"; alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};";
await alterCommand.ExecuteNonQueryAsync(cancellationToken); await alterCommand.ExecuteNonQueryAsync(cancellationToken);
return true;
} }
finally finally
{ {
+44 -4
View File
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../core/config/config.dart'; import '../core/config/config.dart';
import '../core/config/countries.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/opcode_map.dart';
import '../core/protocol/packet.dart'; import '../core/protocol/packet.dart';
import '../core/storage/token_storage.dart'; import '../core/storage/token_storage.dart';
@@ -18,7 +20,10 @@ enum SessionState { disconnected, connecting, connected, online }
/// Client for ikar_server. /// Client for ikar_server.
class Api { class Api {
Api({IkarTransport Function(Uri)? transportFactory}) 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 IkarTransport Function(Uri) _transportFactory;
final PacketDispatcher _dispatcher = PacketDispatcher(); final PacketDispatcher _dispatcher = PacketDispatcher();
@@ -35,6 +40,7 @@ class Api {
Timer? _reconnect; Timer? _reconnect;
Future<void> Function()? _onReconnect; Future<void> Function()? _onReconnect;
Future<bool>? _refreshing; Future<bool>? _refreshing;
Future<void> _presenceSync = Future<void>.value();
bool _autoReconnect = false; bool _autoReconnect = false;
bool _disposed = false; bool _disposed = false;
int _epoch = 0; int _epoch = 0;
@@ -62,6 +68,15 @@ class Api {
Stream<String> get handshakeSuccessStream => _handshakes.stream; Stream<String> get handshakeSuccessStream => _handshakes.stream;
Stream<String> get errorStream => _errors.stream; Stream<String> get errorStream => _errors.stream;
Stream<Packet> get pushStream => _dispatcher.pushStream; Stream<Packet> 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) { void _setState(SessionState state) {
_state = state; _state = state;
if (!_states.isClosed) _states.add(state); if (!_states.isClosed) _states.add(state);
@@ -81,6 +96,7 @@ class Api {
port: endpoint.port, port: endpoint.port,
); );
final transport = _transportFactory(baseUri); final transport = _transportFactory(baseUri);
transport.interactive = _interactive;
_transport = transport; _transport = transport;
final ping = await transport.request(Opcode.ping, {}); final ping = await transport.request(Opcode.ping, {});
if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен'); if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен');
@@ -117,11 +133,25 @@ class Api {
} }
} }
Future<void> _checkConnection() async { Future<void> _checkConnection({bool? interactive}) {
final generation = _generation;
final pending = _presenceSync.then((_) async {
if (generation != _generation) return;
await _performConnectionCheck(interactive: interactive);
});
_presenceSync = pending;
return pending;
}
Future<void> _performConnectionCheck({bool? interactive}) async {
final transport = _transport; final transport = _transport;
if (transport == null || _state != SessionState.online) return; if (transport == null || _state != SessionState.online) return;
try { 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) if (transport.token != null && !transport.eventsConnected)
await transport.connectEvents(); await transport.connectEvents();
} catch (_) { } catch (_) {
@@ -157,6 +187,13 @@ class Api {
throw StateError('Сессия изменилась до отправки'); throw StateError('Сессия изменилась до отправки');
beforeSend?.call(); beforeSend?.call();
final body = Map<dynamic, dynamic>.from(payload); final body = Map<dynamic, dynamic>.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) { if (opcode == Opcode.authRequest) {
final identity = await IkarDeviceKey.identity(body['phone'].toString()); final identity = await IkarDeviceKey.identity(body['phone'].toString());
_deviceId = identity['installationId']; _deviceId = identity['installationId'];
@@ -306,7 +343,8 @@ class Api {
unawaited(_checkConnection()); unawaited(_checkConnection());
} }
void sendPing({required bool interactive}) => unawaited(_checkConnection()); void sendPing({required bool interactive}) =>
unawaited(_checkConnection(interactive: interactive));
Future<bool> reconnectToEndpoint({ Future<bool> reconnectToEndpoint({
String? host, String? host,
int? port, int? port,
@@ -357,6 +395,8 @@ class Api {
Future<void> dispose() async { Future<void> dispose() async {
_disposed = true; _disposed = true;
QlyraSettings.ghostMode.removeListener(_presenceChanged);
AppForeground.notifier.removeListener(_presenceChanged);
await disconnect(); await disconnect();
_dispatcher.dispose(); _dispatcher.dispose();
await _states.close(); await _states.close();
+5 -1
View File
@@ -16,6 +16,7 @@ class IkarTransport {
final Uri baseUri; final Uri baseUri;
final http.Client _client; final http.Client _client;
String? token; String? token;
bool interactive = true;
WebSocket? _socket; WebSocket? _socket;
Timer? _keepAlive; Timer? _keepAlive;
final _events = StreamController<Map<String, dynamic>>.broadcast(); final _events = StreamController<Map<String, dynamic>>.broadcast();
@@ -72,7 +73,10 @@ class IkarTransport {
.replace( .replace(
scheme: baseUri.scheme == 'https' ? 'wss' : 'ws', scheme: baseUri.scheme == 'https' ? 'wss' : 'ws',
port: baseUri.port, port: baseUri.port,
queryParameters: {'access_token': token!}, queryParameters: {
'access_token': token!,
'interactive': '$interactive',
},
); );
final socket = await IkarNetwork.connectWebSocket( final socket = await IkarNetwork.connectWebSocket(
uri.toString(), uri.toString(),
+96 -23
View File
@@ -13,6 +13,7 @@ import 'e2ee_service.dart';
// #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет // #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет
enum MessageDecryptionState { decrypted, wrongKey, unavailable } enum MessageDecryptionState { decrypted, wrongKey, unavailable }
// #***! результат по одному сообщению
@immutable @immutable
class MessageDecryption { class MessageDecryption {
final String? plaintext; final String? plaintext;
@@ -32,6 +33,7 @@ class MessageDecryption {
bool get isDecrypted => state == MessageDecryptionState.decrypted; bool get isDecrypted => state == MessageDecryptionState.decrypted;
} }
// #***! кэш расшифровок, при смене ключа сбрасывается целиком
class MessageDecryptionCache { class MessageDecryptionCache {
MessageDecryptionCache._() { MessageDecryptionCache._() {
ChatEncryptionStore.instance.revision.addListener(clear); ChatEncryptionStore.instance.revision.addListener(clear);
@@ -40,12 +42,15 @@ class MessageDecryptionCache {
static final MessageDecryptionCache instance = MessageDecryptionCache._(); static final MessageDecryptionCache instance = MessageDecryptionCache._();
// #***! тысяча последних, дальше вытесняем
static const int _maxEntries = 1000; static const int _maxEntries = 1000;
int _generation = 0;
final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries = final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries =
LinkedHashMap(); LinkedHashMap();
final Set<String> _inFlight = {}; final Map<String, Future<void>> _inFlight = {};
// #***! пузырь подписывается сюда и перерисуется когда текст расшифруется
ValueListenable<MessageDecryption?> listenableFor(String messageId) => ValueListenable<MessageDecryption?> listenableFor(String messageId) =>
_entryFor(messageId); _entryFor(messageId);
@@ -60,36 +65,106 @@ class MessageDecryptionCache {
} }
} }
// #***! известный текст кладём сразу, например своё только что отправленное
void seed(String messageId, String plaintext) { void seed(String messageId, String plaintext) {
_entryFor(messageId).value = MessageDecryption.decrypted(plaintext); _entryFor(messageId).value = MessageDecryption.decrypted(plaintext);
} }
// #***! поменяли временный id на настоящий, переносим расшифровку
void adopt(String fromMessageId, String toMessageId) { void adopt(String fromMessageId, String toMessageId) {
final value = _entries[fromMessageId]?.value; final value = _entries[fromMessageId]?.value;
if (value != null) _entryFor(toMessageId).value = value; if (value != null) _entryFor(toMessageId).value = value;
} }
// #***! _inFlight чтоб не расшифровывать одно сообщение дважды
void request({ void request({
required int accountId, required int accountId,
required int chatId, required int chatId,
required String messageId, required String messageId,
required String cipherText, required String cipherText,
}) { }) {
if (cipherText.isEmpty) return; final job = _begin(accountId, chatId, messageId, cipherText);
final e2ee = if (job != null) unawaited(job);
E2eeService.instance.available &&
KometCrypto.classifyText(cipherText) != TextClass.none;
if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) {
return;
} }
if (_entryFor(messageId).value != null) return;
if (!_inFlight.add(messageId)) return; Future<MessageDecryption?> resolve({
_evictStale(messageId); required int accountId,
unawaited( required int chatId,
e2ee required String messageId,
? _resolveSealed(accountId, chatId, messageId, cipherText) required String cipherText,
: _resolve(accountId, chatId, messageId, cipherText), }) async {
try {
await _begin(accountId, chatId, messageId, cipherText);
} catch (_) {
return const MessageDecryption.unavailable();
}
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<String?> 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<void>? _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<void> 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,8 +174,9 @@ class MessageDecryptionCache {
String messageId, String messageId,
String cipherText, String cipherText,
) async { ) async {
try { final generation = _generation;
final row = await AppDatabase.loadMessage(accountId, chatId, messageId); final row = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (_generation != generation) return;
if (row == null) return; if (row == null) return;
final flag = row['e2ee'] as int? ?? 0; final flag = row['e2ee'] as int? ?? 0;
final sealed = row['text_sealed']; final sealed = row['text_sealed'];
@@ -110,6 +186,7 @@ class MessageDecryptionCache {
chatId, chatId,
sealed, sealed,
); );
if (_generation != generation) return;
_entryFor(messageId).value = text == null _entryFor(messageId).value = text == null
? const MessageDecryption.unavailable() ? const MessageDecryption.unavailable()
: MessageDecryption.decrypted(text); : MessageDecryption.decrypted(text);
@@ -136,15 +213,11 @@ class MessageDecryptionCache {
); );
case TextClass.legacy: case TextClass.legacy:
if (ChatCryptoService.instance.isEnabled(accountId, chatId)) { if (ChatCryptoService.instance.isEnabled(accountId, chatId)) {
_inFlight.remove(messageId);
await _resolve(accountId, chatId, messageId, cipherText); await _resolve(accountId, chatId, messageId, cipherText);
} }
case TextClass.none: case TextClass.none:
break; break;
} }
} finally {
_inFlight.remove(messageId);
}
} }
Future<void> _resolve( Future<void> _resolve(
@@ -153,13 +226,15 @@ class MessageDecryptionCache {
String messageId, String messageId,
String cipherText, String cipherText,
) async { ) async {
try { final generation = _generation;
final crypto = ChatCryptoService.instance; final crypto = ChatCryptoService.instance;
final result = await crypto.decrypt(accountId, chatId, cipherText); final result = await crypto.decrypt(accountId, chatId, cipherText);
if (_generation != generation) return;
if (result.isOk) { if (result.isOk) {
_entryFor(messageId).value = MessageDecryption.decrypted(result.text!); _entryFor(messageId).value = MessageDecryption.decrypted(result.text!);
return; return;
} }
// #***! ключа нет показываем как неверный ключ только если текст правда похож на шифр
switch (result.failure) { switch (result.failure) {
case CryptoFailure.wrongKey: case CryptoFailure.wrongKey:
_entryFor(messageId).value = const MessageDecryption.wrongKey(); _entryFor(messageId).value = const MessageDecryption.wrongKey();
@@ -173,12 +248,10 @@ class MessageDecryptionCache {
case null: case null:
break; break;
} }
} finally {
_inFlight.remove(messageId);
}
} }
void clear() { void clear() {
_generation++;
_entries.clear(); _entries.clear();
_inFlight.clear(); _inFlight.clear();
} }
+1
View File
@@ -87,6 +87,7 @@ class VoiceTranscription {
if (result.status != 1 && TranscriptionCache.get(id)?.status == 1) return; if (result.status != 1 && TranscriptionCache.get(id)?.status == 1) return;
TranscriptionCache.put(id, result, expanded: true); TranscriptionCache.put(id, result, expanded: true);
} catch (_) { } catch (_) {
if (TranscriptionCache.get(id)?.status == 1) return;
TranscriptionCache.put( TranscriptionCache.put(
id, id,
TranscriptionResult(status: -1), TranscriptionResult(status: -1),
@@ -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_frost.dart';
import 'package:ikar/core/config/app_message_actions_style.dart'; import 'package:ikar/core/config/app_message_actions_style.dart';
import 'package:ikar/core/utils/haptics.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/media/voice_transcription.dart';
import 'package:ikar/core/utils/text_format.dart'; import 'package:ikar/core/utils/text_format.dart';
import 'package:ikar/frontend/widgets/animated_text_swap.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/liquid_glass.dart';
import 'package:ikar/frontend/widgets/message_actions_overlay.dart' import 'package:ikar/frontend/widgets/message_actions_overlay.dart'
show 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/animoji.dart' show Animoji;
import 'package:ikar/models/chat_reaction_settings.dart'; 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<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: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => 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 { class PinnedMessageBanner extends StatelessWidget {
final String? text; final String? text;
final bool isPreview; final bool isPreview;
@@ -447,7 +334,7 @@ class _SelectableMessageRowState extends State<SelectableMessageRow> {
isMe: widget.isMe, isMe: widget.isMe,
bottomReservedSpace: widget.composerHeight?.value ?? 0, bottomReservedSpace: widget.composerHeight?.value ?? 0,
messageText: widget.message.text, messageText: widget.message.text,
copyText: widget.message.selectableText, copyText: MessageDecryptionCache.instance.readableText(widget.message),
controller: controller, controller: controller,
style: AppMessageActionsStyle.effective, style: AppMessageActionsStyle.effective,
interaction: MessageActionsInteraction.tap, interaction: MessageActionsInteraction.tap,
@@ -582,7 +469,7 @@ class _SelectableMessageRowState extends State<SelectableMessageRow> {
isMe: widget.isMe, isMe: widget.isMe,
bottomReservedSpace: widget.composerHeight?.value ?? 0, bottomReservedSpace: widget.composerHeight?.value ?? 0,
messageText: widget.message.text, messageText: widget.message.text,
copyText: widget.message.selectableText, copyText: MessageDecryptionCache.instance.readableText(widget.message),
controller: controller, controller: controller,
style: MessageActionsStyle.list, style: MessageActionsStyle.list,
interaction: MessageActionsInteraction.click, interaction: MessageActionsInteraction.click,
+30 -125
View File
@@ -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/animated_text_swap.dart';
import 'package:ikar/frontend/widgets/custom_notification.dart'; import 'package:ikar/frontend/widgets/custom_notification.dart';
import '../../widgets/undo_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 '../../../core/security/app_lock.dart';
import 'package:ikar/frontend/widgets/chat_menu_overlay.dart'; import 'package:ikar/frontend/widgets/chat_menu_overlay.dart';
import 'package:material_symbols_icons/symbols.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/small_spinner.dart';
import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_to_pop.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
import '../../widgets/directional_drag_recognizer.dart';
import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/schedule_time_picker.dart'; import '../../widgets/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart'; import '../../widgets/chat_wallpaper_sheet.dart';
@@ -581,7 +582,7 @@ class _ChatScreenState extends State<ChatScreen>
set _historyKickedOff(bool v) => _chatController.historyKickedOff = v; set _historyKickedOff(bool v) => _chatController.historyKickedOff = v;
final GlobalKey _messageListKey = GlobalKey(); final GlobalKey _messageListKey = GlobalKey();
_ChatMessageList? _messageListWidget; Widget? _messageListWidget;
final Set<String> _deletingIds = {}; final Set<String> _deletingIds = {};
static const double _avgMessageHeight = 72.0; static const double _avgMessageHeight = 72.0;
@@ -2681,7 +2682,10 @@ class _ChatScreenState extends State<ChatScreen>
} }
void _startTextSelection(CachedMessage message, Offset globalPosition) { 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; _textSelectionDrag.value = null;
_textSelection.value = (id: message.id, pos: globalPosition); _textSelection.value = (id: message.id, pos: globalPosition);
} }
@@ -2707,7 +2711,9 @@ class _ChatScreenState extends State<ChatScreen>
List<CachedMessage> _copyableSelection(Set<String> ids) => [ List<CachedMessage> _copyableSelection(Set<String> ids) => [
for (final m in _messages) 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<String> ids) { CachedMessage? _singleEditable(Set<String> ids) {
@@ -2719,7 +2725,11 @@ class _ChatScreenState extends State<ChatScreen>
void _copySelected(List<CachedMessage> messages) { void _copySelected(List<CachedMessage> messages) {
if (messages.isEmpty) return; 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)); Clipboard.setData(ClipboardData(text: text));
Haptics.tap(); Haptics.tap();
showCustomNotification( showCustomNotification(
@@ -3130,6 +3140,14 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<void> _startEditMessage(CachedMessage message) async { Future<void> _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 cs = Theme.of(context).colorScheme;
final content = final content =
@@ -3143,7 +3161,7 @@ class _ChatScreenState extends State<ChatScreen>
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
), ),
builder: (sheetContext) => _EditMessageSheet( builder: (sheetContext) => _EditMessageSheet(
text: message.text ?? '', text: originalText,
formatRanges: message.formatRanges, formatRanges: message.formatRanges,
contextMenuBuilder: _formatContextMenu, contextMenuBuilder: _formatContextMenu,
), ),
@@ -3158,8 +3176,7 @@ class _ChatScreenState extends State<ChatScreen>
final oldElements = serializeFormatElements( final oldElements = serializeFormatElements(
message.formatRanges.where((r) => composerFormats.contains(r.format)), message.formatRanges.where((r) => composerFormats.contains(r.format)),
); );
if (newText == (message.text ?? '') && if (newText == originalText && _sameElements(elements, oldElements)) {
_sameElements(elements, oldElements)) {
return; return;
} }
@@ -5990,8 +6007,10 @@ class _ChatScreenState extends State<ChatScreen>
); );
} }
Widget _buildMessagesList() => Widget _buildMessagesList() => _messageListWidget ??= ScrollKeyboardDismiss(
_messageListWidget ??= _ChatMessageList(this, key: _messageListKey); onDismiss: _messageFocusNode.unfocus,
child: _ChatMessageList(this, key: _messageListKey),
);
EdgeInsets _messagesListPadding(BuildContext context) { EdgeInsets _messagesListPadding(BuildContext context) {
if (AppChatChrome.current.value == ChatChromeStyle.color) { if (AppChatChrome.current.value == ChatChromeStyle.color) {
@@ -6199,8 +6218,7 @@ class _ChatScreenState extends State<ChatScreen>
final swipeable = final swipeable =
(message.isControl || isChannel || !_canReply) (message.isControl || isChannel || !_canReply)
? pressable ? pressable
: _SwipeToReply( : SwipeToReply(
isMe: isMe,
onReply: () => _startReply(message), onReply: () => _startReply(message),
child: pressable, child: pressable,
); );
@@ -6312,119 +6330,6 @@ class _ChatScreenState extends State<ChatScreen>
); );
} }
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: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => 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 { class _PinnedMessageBanner extends StatelessWidget {
final String? text; final String? text;
final bool isPreview; final bool isPreview;
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.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_amoled.dart';
import '../../../core/config/app_theme_mode.dart'; import '../../../core/config/app_theme_mode.dart';
import '../../../core/config/app_theme_schedule.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 '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/mesh_gradient_background.dart';
import '../../widgets/settings_radio_tile.dart'; import '../../widgets/settings_radio_tile.dart';
import '../../widgets/settings_card.dart'; import '../../widgets/settings_card.dart';
import 'custom_gradient_editor_screen.dart';
class ThemeSettingsScreen extends StatelessWidget { class ThemeSettingsScreen extends StatelessWidget {
const ThemeSettingsScreen({super.key}); const ThemeSettingsScreen({super.key});
@@ -45,9 +52,14 @@ class ThemeSettingsScreen extends StatelessWidget {
} }
} }
class _ThemeModeCard extends StatelessWidget { class _ThemeModeCard extends StatefulWidget {
const _ThemeModeCard(); const _ThemeModeCard();
@override
State<_ThemeModeCard> createState() => _ThemeModeCardState();
}
class _ThemeModeCardState extends State<_ThemeModeCard> {
static const _items = [ static const _items = [
(mode: AppThemeMode.system, icon: Symbols.brightness_auto), (mode: AppThemeMode.system, icon: Symbols.brightness_auto),
(mode: AppThemeMode.light, icon: Symbols.light_mode), (mode: AppThemeMode.light, icon: Symbols.light_mode),
@@ -55,6 +67,72 @@ class _ThemeModeCard extends StatelessWidget {
(mode: AppThemeMode.schedule, icon: Symbols.schedule), (mode: AppThemeMode.schedule, icon: Symbols.schedule),
]; ];
int _accountId = 0;
ChatWallpaper? _wallpaper;
bool _ready = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _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<void> _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<void> _openEditor() async {
if (_accountId == 0) return;
final result = await Navigator.of(context).push<CustomGradientResult>(
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) { String _labelFor(AppLocalizations l10n, AppThemeMode mode) {
switch (mode) { switch (mode) {
case AppThemeMode.system: case AppThemeMode.system:
@@ -72,6 +150,7 @@ class _ThemeModeCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final colors = _wallpaper?.gradientColors;
return SettingsPanel( return SettingsPanel(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), padding: const EdgeInsets.fromLTRB(20, 18, 20, 12),
child: Column( child: Column(
@@ -91,24 +170,72 @@ class _ThemeModeCard extends StatelessWidget {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ValueListenableBuilder<AppThemeMode>( ListenableBuilder(
valueListenable: AppThemeModeConfig.current, listenable: Listenable.merge([
builder: (context, current, _) { AppThemeModeConfig.current,
AppWallpaperTint.current,
]),
builder: (context, _) {
final current = AppThemeModeConfig.current.value;
final customSelected = AppWallpaperTint.current.value;
return Column( return Column(
children: [ children: [
for (final item in _items) for (final item in _items)
_ModeTile( _ModeTile(
icon: item.icon, icon: item.icon,
label: _labelFor(l10n, item.mode), label: _labelFor(l10n, item.mode),
selected: current == item.mode, selected: !customSelected && current == item.mode,
onTap: (position) { onTap: (position) {
if (current == item.mode) return; if (!customSelected && current == item.mode) return;
Haptics.selection(); Haptics.selection();
if (customSelected) {
unawaited(AppWallpaperTint.save(false));
}
QlyraApp.stateOf( QlyraApp.stateOf(
context, context,
)?.applyThemeModeWithReveal(item.mode, position); )?.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,
),
),
], ],
); );
}, },
@@ -68,6 +68,7 @@ class PhotoHeroController {
class PhotoHeroRoute<T> extends PageRouteBuilder<T> { class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
PhotoHeroRoute({required this.hero, required WidgetBuilder builder}) PhotoHeroRoute({required this.hero, required WidgetBuilder builder})
: super( : super(
opaque: false,
transitionDuration: const Duration(milliseconds: 320), transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 280), reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, animation, secondaryAnimation) => pageBuilder: (context, animation, secondaryAnimation) =>
@@ -301,7 +302,7 @@ class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> {
} }
return Stack( return Stack(
children: [ children: [
widget.child, FadeTransition(opacity: widget.animation, child: widget.child),
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: AnimatedBuilder( child: AnimatedBuilder(
@@ -402,6 +402,7 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final out = widget.geometry.naturalOutput; final out = widget.geometry.naturalOutput;
final inset = _QualitySlider.insetFor(context);
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
@@ -450,30 +451,38 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: EdgeInsets.fromLTRB(inset, 8, inset, 0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Expanded(
child: Text(
l10n.videoEditorQualityLow, l10n.videoEditorQualityLow,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 14, fontSize: 14,
), ),
), ),
Text( ),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.videoEditorQualityHigh, l10n.videoEditorQualityHigh,
textAlign: TextAlign.end,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 14, fontSize: 14,
), ),
), ),
),
], ],
), ),
), ),
_QualitySlider( _QualitySlider(
count: widget.options.length, count: widget.options.length,
index: _index, index: _index,
inset: inset,
onChanged: (value) => setState(() => _index = value), onChanged: (value) => setState(() => _index = value),
), ),
Row( Row(
@@ -519,23 +528,35 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
class _QualitySlider extends StatelessWidget { class _QualitySlider extends StatelessWidget {
final int count; final int count;
final int index; final int index;
final double inset;
final ValueChanged<int> onChanged; final ValueChanged<int> onChanged;
const _QualitySlider({ const _QualitySlider({
required this.count, required this.count,
required this.index, required this.index,
required this.inset,
required this.onChanged, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = MediaAccent.of(context); final accent = MediaAccent.of(context);
return SizedBox( return SizedBox(
height: 40, height: _height,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
const inset = 16.0; final width = constraints.maxWidth;
final span = math.max(1.0, constraints.maxWidth - inset * 2); final span = math.max(1.0, width - inset * 2);
void pick(double dx) { void pick(double dx) {
if (count <= 1) return; if (count <= 1) return;
final t = ((dx - inset) / span).clamp(0.0, 1.0); final t = ((dx - inset) / span).clamp(0.0, 1.0);
@@ -547,7 +568,10 @@ class _QualitySlider extends StatelessWidget {
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: (d) => pick(d.localPosition.dx), onTapDown: (d) => pick(d.localPosition.dx),
onHorizontalDragUpdate: (d) => pick(d.localPosition.dx), onHorizontalDragUpdate: (d) => pick(d.localPosition.dx),
// #***! без явного размера CustomPaint схлопывается в ноль ширины,
// дорожка рисуется мимо и по ней нечем попасть
child: CustomPaint( child: CustomPaint(
size: Size(width, _height),
painter: _QualitySliderPainter( painter: _QualitySliderPainter(
count: count, count: count,
index: index, index: index,
@@ -595,12 +595,20 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
); );
} }
static const double _barInset = 8;
static const double _trimMinInset = 16;
Widget _bottomBar() { Widget _bottomBar() {
final controller = _controller; final controller = _controller;
final gesture = MediaQuery.of(context).systemGestureInsets;
final trimInset = math.max(
_trimMinInset,
math.max(gesture.left, gesture.right) + 8 - _barInset,
);
return SafeArea( return SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), padding: const EdgeInsets.fromLTRB(_barInset, 0, _barInset, 8),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -616,7 +624,9 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
if (widget.editable && if (widget.editable &&
controller != null && controller != null &&
controller.value.isInitialized) controller.value.isInitialized)
TrimBar( Padding(
padding: EdgeInsets.symmetric(horizontal: trimInset),
child: TrimBar(
frames: _strip, frames: _strip,
controller: controller, controller: controller,
duration: _edit.sourceDuration, duration: _edit.sourceDuration,
@@ -625,6 +635,7 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
onScrub: _onScrub, onScrub: _onScrub,
onTrim: _onTrim, onTrim: _onTrim,
), ),
),
const SizedBox(height: 10), const SizedBox(height: 10),
_captionField(), _captionField(),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -3,44 +3,43 @@ import 'package:flutter/gestures.dart';
class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer { class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
DirectionalDragRecognizer({ DirectionalDragRecognizer({
required this.direction, required this.direction,
this.minAcceptDistance = 20.0, this.minAcceptDistance = kTouchSlop,
this.minAcceptVelocity,
super.debugOwner, super.debugOwner,
}) { }) {
onlyAcceptDragOnThreshold = true; onlyAcceptDragOnThreshold = true;
dragStartBehavior = DragStartBehavior.down;
} }
final double direction; final double direction;
final double minAcceptDistance; final double minAcceptDistance;
final double? minAcceptVelocity; bool Function()? enabled;
final Map<int, Offset> _initialPositions = {}; final Map<int, Offset> _initialPositions = {};
final Map<int, VelocityTracker> _velocityTrackers = {}; final Map<int, Offset> _currentDeltas = {};
final Map<int, double> _currentDeltaX = {}; bool _directionLocked = false;
@override
bool isPointerAllowed(PointerEvent event) =>
(enabled?.call() ?? true) && super.isPointerAllowed(event);
@override @override
void addAllowedPointer(PointerDownEvent event) { void addAllowedPointer(PointerDownEvent event) {
_initialPositions[event.pointer] = event.position; _initialPositions[event.pointer] = event.position;
final tracker = VelocityTracker.withKind(event.kind);
tracker.addPosition(event.timeStamp, event.localPosition);
_velocityTrackers[event.pointer] = tracker;
super.addAllowedPointer(event); super.addAllowedPointer(event);
} }
@override @override
void handleEvent(PointerEvent event) { void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent) { if (event is PointerMoveEvent) {
_velocityTrackers[event.pointer]?.addPosition(
event.timeStamp,
event.localPosition,
);
final initial = _initialPositions[event.pointer]; final initial = _initialPositions[event.pointer];
if (initial != null) { if (initial != null) {
final dx = (event.position.dx - initial.dx) * direction; final delta = event.position - initial;
_currentDeltaX[event.pointer] = dx; _currentDeltas[event.pointer] = delta;
if (dx < -kTouchSlop) { if (!_directionLocked &&
stopTrackingPointer(event.pointer); (delta.dx * direction < -kTouchSlop ||
_cleanup(event.pointer); (delta.dy.abs() > kTouchSlop &&
delta.dy.abs() > delta.dx.abs()))) {
resolvePointer(event.pointer, GestureDisposition.rejected);
return; return;
} }
} }
@@ -59,30 +58,23 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
)) { )) {
return false; return false;
} }
double maxDx = 0; _directionLocked = _currentDeltas.values.any(
for (final dx in _currentDeltaX.values) { (delta) =>
if (dx > maxDx) maxDx = dx; delta.dx * direction >= minAcceptDistance &&
} delta.dx.abs() > delta.dy.abs(),
if (maxDx < minAcceptDistance) return false; );
return _directionLocked;
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;
} }
void _cleanup(int pointer) { void _cleanup(int pointer) {
_initialPositions.remove(pointer); _initialPositions.remove(pointer);
_velocityTrackers.remove(pointer); _currentDeltas.remove(pointer);
_currentDeltaX.remove(pointer);
} }
@override @override
void didStopTrackingLastPointer(int pointer) { void didStopTrackingLastPointer(int pointer) {
_cleanup(pointer); _cleanup(pointer);
_directionLocked = false;
super.didStopTrackingLastPointer(pointer); super.didStopTrackingLastPointer(pointer);
} }
@@ -94,8 +86,7 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
} }
class RightwardDragRecognizer extends DirectionalDragRecognizer { class RightwardDragRecognizer extends DirectionalDragRecognizer {
RightwardDragRecognizer({super.debugOwner}) RightwardDragRecognizer({super.debugOwner}) : super(direction: 1);
: super(direction: 1, minAcceptVelocity: 700);
} }
class LeftwardDragRecognizer extends DirectionalDragRecognizer { class LeftwardDragRecognizer extends DirectionalDragRecognizer {
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../../core/config/ios_release.dart'; import '../../core/config/ios_release.dart';
import 'scroll_keyboard_dismiss.dart';
bool _focusIsText() { bool _focusIsText() {
final context = FocusManager.instance.primaryFocus?.context; final context = FocusManager.instance.primaryFocus?.context;
@@ -69,6 +70,9 @@ class _KeyboardDismissalState extends State<KeyboardDismissal> {
if (notification.dragDetails == null) return false; if (notification.dragDetails == null) return false;
if (notification.metrics.axis != Axis.vertical) return false; if (notification.metrics.axis != Axis.vertical) return false;
final source = notification.context; final source = notification.context;
if (source?.findAncestorWidgetOfExactType<ScrollKeyboardDismiss>() != null) {
return false;
}
if (source != null && if (source != null &&
(source.findAncestorWidgetOfExactType<EditableText>() != null || (source.findAncestorWidgetOfExactType<EditableText>() != null ||
source.findAncestorWidgetOfExactType<TextFieldTapRegion>() != source.findAncestorWidgetOfExactType<TextFieldTapRegion>() !=
+149 -18
View File
@@ -79,6 +79,19 @@ class PhotoViewerActions {
viewAllMedia == null; viewAllMedia == null;
} }
class PhotoViewerRoute<T> extends PageRouteBuilder<T> {
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 { class _ViewerMedia {
final String id; final String id;
final MessageAttachment attachment; final MessageAttachment attachment;
@@ -199,7 +212,8 @@ class PhotoViewerScreen extends StatefulWidget {
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState(); State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
} }
class _PhotoViewerScreenState extends State<PhotoViewerScreen> { class _PhotoViewerScreenState extends State<PhotoViewerScreen>
with SingleTickerProviderStateMixin {
static const int _prefetchThreshold = 3; static const int _prefetchThreshold = 3;
static const int _maxCachedVideoPlayers = 5; static const int _maxCachedVideoPlayers = 5;
static const double _compactHeight = 480; static const double _compactHeight = 480;
@@ -217,9 +231,17 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
final Map<String, Future<Map<String, String>>> _videoSourceLoads = {}; final Map<String, Future<Map<String, String>>> _videoSourceLoads = {};
final TransformationController _heroTransform = TransformationController(); final TransformationController _heroTransform = TransformationController();
final Map<String, TransformationController> _pageTransforms = {}; final Map<String, TransformationController> _pageTransforms = {};
int _pointers = 0;
bool _zoomed = false; bool _zoomed = false;
bool _swipeEnabled = true; bool _swipeEnabled = true;
final Set<int> _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 _feedLoaded = false;
bool _feedFailed = false; bool _feedFailed = false;
bool _loadingMore = false; bool _loadingMore = false;
@@ -231,6 +253,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_dismissReturn = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
)..addListener(_animateDismissReturn);
_heroTransform.addListener(_syncHero); _heroTransform.addListener(_syncHero);
_heroTransform.addListener(_syncZoom); _heroTransform.addListener(_syncZoom);
_items = _localItems(); _items = _localItems();
@@ -272,14 +298,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
_syncSwipe(); _syncSwipe();
} }
void _updatePointers(int delta) {
final next = _pointers + delta;
_pointers = next < 0 ? 0 : next;
_syncSwipe();
}
void _syncSwipe() { void _syncSwipe() {
final enabled = _pointers < 2 && !_zoomed; final enabled = _pointers.length < 2 && !_zoomed;
if (enabled == _swipeEnabled) return; if (enabled == _swipeEnabled) return;
setState(() => _swipeEnabled = enabled); setState(() => _swipeEnabled = enabled);
} }
@@ -287,6 +307,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
_dismissReturn.dispose();
_heroTransform.dispose(); _heroTransform.dispose();
for (final transform in _pageTransforms.values) { for (final transform in _pageTransforms.values) {
transform.dispose(); transform.dispose();
@@ -297,6 +318,99 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
super.dispose(); 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; AvatarFeed? get _avatarFeed => widget.avatars;
bool get _isAvatars => _avatarFeed != null; bool get _isAvatars => _avatarFeed != null;
@@ -932,9 +1046,12 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
final media = MediaQuery.of(context); final media = MediaQuery.of(context);
final padding = media.padding; final padding = media.padding;
final compact = media.size.height < _compactHeight; final compact = media.size.height < _compactHeight;
final dismissProgress =
(_dismissOffset.dy.abs() / (MediaQuery.sizeOf(context).height * 0.55))
.clamp(0.0, 1.0);
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black.withValues(alpha: 1 - dismissProgress),
body: CallbackShortcuts( body: CallbackShortcuts(
bindings: { bindings: {
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
@@ -946,14 +1063,32 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
autofocus: true, autofocus: true,
child: Stack( child: Stack(
children: [ 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( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
ignoring: !_chromeVisible, ignoring: !_chromeVisible || _dismissOffset != Offset.zero,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: _chromeVisible ? 1 : 0, opacity: _chromeVisible ? 1 : 0,
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Opacity(
opacity: 1 - dismissProgress,
child: Stack( child: Stack(
children: [ children: [
if (_canStep(_leftStep)) if (_canStep(_leftStep))
@@ -995,6 +1130,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
), ),
), ),
), ),
),
], ],
), ),
), ),
@@ -1010,11 +1146,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
gestureSettings: DeviceGestureSettings(touchSlop: swipeSlop), gestureSettings: DeviceGestureSettings(touchSlop: swipeSlop),
); );
return Listener( return MediaQuery(
onPointerDown: (_) => _updatePointers(1),
onPointerUp: (_) => _updatePointers(-1),
onPointerCancel: (_) => _updatePointers(-1),
child: MediaQuery(
data: swipeMedia, data: swipeMedia,
child: PageView.builder( child: PageView.builder(
key: ValueKey(_pager), key: ValueKey(_pager),
@@ -1028,7 +1160,6 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
child: _buildPage(i), child: _buildPage(i),
), ),
), ),
),
); );
} }
@@ -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<ScrollKeyboardDismiss> createState() => _ScrollKeyboardDismissState();
}
class _ScrollKeyboardDismissState extends State<ScrollKeyboardDismiss> {
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<ScrollNotification>(
onNotification: _onScroll,
child: widget.child,
);
}
+86 -15
View File
@@ -30,19 +30,36 @@ class SwipeRoute<T> extends PageRoute<T> {
@override @override
Duration get reverseTransitionDuration => const Duration(milliseconds: 400); 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 @override
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) { bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
if (nextRoute is PageRoute && nextRoute.fullscreenDialog) return false;
return nextRoute is SwipeRoute || return nextRoute is SwipeRoute ||
nextRoute is CupertinoRouteTransitionMixin; nextRoute is CupertinoRouteTransitionMixin ||
(nextRoute is ModalRoute && nextRoute.delegatedTransition != null);
} }
@override @override
bool get popGestureInProgress => _gestureController != null; bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) =>
previousRoute is PageRoute && !fullscreenDialog;
@override
bool get popGestureInProgress => navigator?.userGestureInProgress ?? false;
_SwipeBackController<T>? _gestureController; _SwipeBackController<T>? _gestureController;
@override @override
bool get popGestureEnabled { bool get popGestureEnabled {
if (fullscreenDialog || !isCurrent) return false;
if (isFirst) return false; if (isFirst) return false;
if (willHandlePopInternally) return false; if (willHandlePopInternally) return false;
if (popDisposition == RoutePopDisposition.doNotPop) return false; if (popDisposition == RoutePopDisposition.doNotPop) return false;
@@ -56,6 +73,9 @@ class SwipeRoute<T> extends PageRoute<T> {
final gesture = _SwipeBackController<T>( final gesture = _SwipeBackController<T>(
navigator: navigator!, navigator: navigator!,
controller: controller!, controller: controller!,
isActive: () => isActive,
isCurrent: () => isCurrent,
canPop: () => popDisposition != RoutePopDisposition.doNotPop,
); );
_gestureController = gesture; _gestureController = gesture;
gesture._onEnd = () { gesture._onEnd = () {
@@ -63,9 +83,16 @@ class SwipeRoute<T> extends PageRoute<T> {
_gestureController = null; _gestureController = null;
} }
}; };
navigator!.didStartUserGesture();
return gesture; return gesture;
} }
@override
void dispose() {
_gestureController?.dispose();
super.dispose();
}
@override @override
Widget buildPage( Widget buildPage(
BuildContext context, BuildContext context,
@@ -134,6 +161,7 @@ class _SwipeBackGestureDetectorState<T>
if (!widget.enabledCallback()) return; if (!widget.enabledCallback()) return;
_width = context.size?.width ?? MediaQuery.of(context).size.width; _width = context.size?.width ?? MediaQuery.of(context).size.width;
if (_width <= 0) _width = 1.0; if (_width <= 0) _width = 1.0;
FocusManager.instance.primaryFocus?.unfocus();
_backController = widget.onStartPopGesture(); _backController = widget.onStartPopGesture();
} }
@@ -149,13 +177,15 @@ class _SwipeBackGestureDetectorState<T>
} }
void _handleCancel() { void _handleCancel() {
_backController?.dragEnd(0.0); _backController?.dragEnd(0.0, cancelled: true);
_backController = null; _backController = null;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RawGestureDetector( return Listener(
onPointerCancel: (_) => _handleCancel(),
child: RawGestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{ gestures: <Type, GestureRecognizerFactory>{
RightwardDragRecognizer: RightwardDragRecognizer:
@@ -163,6 +193,7 @@ class _SwipeBackGestureDetectorState<T>
() => RightwardDragRecognizer(debugOwner: this), () => RightwardDragRecognizer(debugOwner: this),
(instance) { (instance) {
instance instance
..enabled = widget.enabledCallback
..onStart = _handleStart ..onStart = _handleStart
..onUpdate = _handleUpdate ..onUpdate = _handleUpdate
..onEnd = _handleEnd ..onEnd = _handleEnd
@@ -171,16 +202,28 @@ class _SwipeBackGestureDetectorState<T>
), ),
}, },
child: widget.child, child: widget.child,
),
); );
} }
} }
class _SwipeBackController<T> { class _SwipeBackController<T> {
_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 NavigatorState navigator;
final AnimationController controller; final AnimationController controller;
final ValueGetter<bool> isActive;
final ValueGetter<bool> isCurrent;
final ValueGetter<bool> canPop;
VoidCallback? _onEnd; VoidCallback? _onEnd;
AnimationStatusListener? _statusListener;
bool _ended = false;
static const double _kMinFlingVelocity = 1.0; static const double _kMinFlingVelocity = 1.0;
@@ -188,11 +231,15 @@ class _SwipeBackController<T> {
controller.value -= delta; controller.value -= delta;
} }
void dragEnd(double velocity) { void dragEnd(double velocity, {bool cancelled = false}) {
const animationCurve = Curves.fastLinearToSlowEaseIn; const animationCurve = Curves.fastEaseInToSlowEaseOut;
final bool animateForward; 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; animateForward = velocity <= 0;
} else { } else {
animateForward = controller.value > 0.5; animateForward = controller.value > 0.5;
@@ -209,9 +256,12 @@ class _SwipeBackController<T> {
curve: animationCurve, curve: animationCurve,
); );
} else { } else {
navigator.pop(); if (isCurrent()) navigator.pop();
if (controller.isAnimating) { if (controller.isAnimating) {
final backMs = lerpDouble(0, 800, controller.value)!.floor(); final backMs = math.min(
lerpDouble(0, 800, controller.value)!.floor(),
300,
);
controller.animateBack( controller.animateBack(
0.0, 0.0,
duration: Duration(milliseconds: backMs), duration: Duration(milliseconds: backMs),
@@ -221,14 +271,35 @@ class _SwipeBackController<T> {
} }
if (controller.isAnimating) { if (controller.isAnimating) {
late AnimationStatusListener statusCb; _statusListener = (status) {
statusCb = (status) { if (status == AnimationStatus.completed ||
_onEnd?.call(); status == AnimationStatus.dismissed) {
controller.removeStatusListener(statusCb); _finish();
}
}; };
controller.addStatusListener(statusCb); controller.addStatusListener(_statusListener!);
} else { } else {
_finish();
}
}
void _finish() {
if (_ended) return;
_ended = true;
final listener = _statusListener;
if (listener != null) controller.removeStatusListener(listener);
_statusListener = null;
_onEnd?.call(); _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();
});
} }
} }
+148
View File
@@ -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<SwipeToReply> createState() => _SwipeToReplyState();
}
class _SwipeToReplyState extends State<SwipeToReply>
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: <Type, GestureRecognizerFactory>{
LeftwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<LeftwardDragRecognizer>(
() => 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),
],
);
},
),
),
);
}
}
+1 -1
View File
@@ -3362,7 +3362,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get msgActionsCopyLink => 'Скопировать ссылку'; String get msgActionsCopyLink => 'Скопировать ссылку';
@override @override
String get themeSettingsCustomTitle => 'Своя'; String get themeSettingsCustomTitle => 'Собственная тема';
@override @override
String get callKometDetectedNotification => String get callKometDetectedNotification =>
+1 -1
View File
@@ -1203,7 +1203,7 @@
"e2eeExportedAndDisabled": "Перенос создан. На этом устройстве шифрование выключено: импортируйте файл на новом и включите там.", "e2eeExportedAndDisabled": "Перенос создан. На этом устройстве шифрование выключено: импортируйте файл на новом и включите там.",
"codeErrorInvalid": "Неверный код", "codeErrorInvalid": "Неверный код",
"msgActionsCopyLink": "Скопировать ссылку", "msgActionsCopyLink": "Скопировать ссылку",
"themeSettingsCustomTitle": "Своя", "themeSettingsCustomTitle": "Собственная тема",
"callKometDetectedNotification": "Этот человек использует Komet! :3", "callKometDetectedNotification": "Этот человек использует Komet! :3",
"callTooltipKometHub": "Komet", "callTooltipKometHub": "Komet",
"webPushOpenSite": "Открыть push.komet.pw", "webPushOpenSite": "Открыть push.komet.pw",
+363
View File
@@ -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<NavigatorState>();
final focus = FocusNode();
final scroll = ScrollController();
int replies = 0;
Future<void> 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<void>(
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<void> 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);
});
}
+117
View File
@@ -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<String?> getApplicationSupportPath() async => path;
@override
Future<String?> 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<void>.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<void>.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());
},
);
}
+37
View File
@@ -12,6 +12,7 @@ import 'package:ikar/backend/modules/outgoing_text.dart';
import 'package:ikar/backend/modules/outbox_queue.dart'; import 'package:ikar/backend/modules/outbox_queue.dart';
import 'package:ikar/core/crypto/e2ee_service.dart'; import 'package:ikar/core/crypto/e2ee_service.dart';
import 'package:ikar/core/crypto/encrypted_photo.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/storage/app_database.dart';
import 'package:ikar/core/utils/media_cache.dart'; import 'package:ikar/core/utils/media_cache.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -153,6 +154,42 @@ void main() {
await dir.delete(recursive: true); 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( test(
'history establishes a session; fingerprints match; restart preserves decryption', 'history establishes a session; fingerprints match; restart preserves decryption',
() async { () async {
+114
View File
@@ -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<Map> requests, {
Future<void> 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 = <bool>[];
@override
bool get eventsConnected => connected;
@override
Future<void> 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 = <Map>[];
final api = Api(transportFactory: (uri) => _Transport(uri, requests));
addTearDown(api.dispose);
await api.connect();
api.sendPing(interactive: false);
await Future<void>.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 = <Map>[];
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<void>.delayed(const Duration(milliseconds: 20));
expect(transport.eventActivity, [false, false]);
expect(requests.last['interactive'], false);
QlyraSettings.ghostMode.value = false;
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(requests.last['interactive'], true);
AppForeground.update(foreground: false);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(requests.last['interactive'], false);
});
test('a slow active ping cannot override newly enabled ghost mode', () async {
final requests = <Map>[];
final started = Completer<void>();
final release = Completer<void>();
final hidden = Completer<void>();
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<void>.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);
});
}
+209
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/backend/modules/messages.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/frontend/widgets/photo_viewer.dart';
import 'package:ikar/l10n/app_localizations.dart'; import 'package:ikar/l10n/app_localizations.dart';
import 'package:ikar/models/attachment.dart'; import 'package:ikar/models/attachment.dart';
@@ -41,7 +42,215 @@ Future<void> _pumpViewer(
await tester.pump(); await tester.pump();
} }
Future<void> _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<void>(builder: viewer)
: PhotoHeroRoute<void>(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<Transform>(
find.byKey(const ValueKey('media-dismiss-transform')),
);
return Offset(transform.transform[12], transform.transform[13]);
}
void main() { 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<Scaffold>(
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<InteractiveViewer>(
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<InteractiveViewer>(
find.byType(InteractiveViewer).first,
);
expect(
viewer.transformationController!.value.getMaxScaleOnAxis(),
greaterThan(1),
);
});
testWidgets('shows who sent the photo and when', (tester) async { testWidgets('shows who sent the photo and when', (tester) async {
await _pumpViewer(tester, message: _message()); await _pumpViewer(tester, message: _message());
+82
View File
@@ -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<void>(
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);
},
);
}
+18
View File
@@ -152,4 +152,22 @@ void main() {
expect(TranscriptionCache.get('101')?.text, 'Готово'); expect(TranscriptionCache.get('101')?.text, 'Готово');
expect(TranscriptionCache.get('101')?.status, 1); 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<TranscriptionResult>();
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);
});
} }