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
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:
@@ -2,7 +2,7 @@
|
||||
|
||||
<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` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
|
||||
|
||||
@@ -18,6 +18,18 @@
|
||||
|
||||
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
|
||||
|
||||
## Доработки из Komet
|
||||
|
||||
Адаптированы изменения ветки `feature/FullStack` (`58576bc1`) и жесты из [PR №166](https://github.com/KometTeam/Komet/pull/166) (`10a194ed`). Ползунок качества видео получил полноценную область касания; качество и обрезка учитывают края системных жестов Android. Копирование, выделение и редактирование зашифрованных сообщений используют доступный расшифрованный текст. Недоступный текст не подменяется шифротекстом; локальное хранение и отправка сохраняют существующую схему шифрования.
|
||||
|
||||
В настройках темы добавлена «Собственная тема»: выбор цветов, поворота и анимации градиента. Градиент сохраняется для аккаунта между запусками, отдельные фоны чатов продолжают иметь приоритет. Жесты назад, ответа, закрытия фото и скрытия клавиатуры согласованы между собой; короткий отменённый свайп возвращает фото, масштабирование не закрывает просмотр.
|
||||
|
||||
«Невидимка» передаёт `interactive=false` в авторизованный ping и подключение SignalR, включая переподключения. Переключение настройки и уход приложения в фон обновляют активность сервера. Сервер учитывает отдельные соединения и сессии, сохраняет соединение для доставки сообщений и звонков, но не показывает невидимую сессию онлайн. Публичное время появления хранится отдельно от служебной активности сессии и не обновляется невидимыми запросами. Обновление схемы SQLite переносит прежнее время появления один раз. Видимая сессия на другом устройстве по-прежнему показывает аккаунт онлайн.
|
||||
|
||||
Проверки: полный клиентский прогон — 1189 прошли, 3 пропущены; сервер — 150 прошли, без пропусков. Анализ `lib` и `test` не выявил ошибок и предупреждений, остались 42 информационных замечания стиля. Проверены выбор качества видео, сохранение темы, доступ к расшифрованному тексту, жесты, переключение невидимости, переподключение и сохранение публичной активности при обновлении схемы. Локальные отчёты: `artifacts/komet-full-flutter-tests.log`, `artifacts/komet-server-tests.log`, `artifacts/komet-implementation-analyze.log`.
|
||||
|
||||
Эти изменения относятся к исходникам клиента и сервера. Для использования на телефоне нужны обновление сервера и сборка нового APK; текущий опубликованный выпуск их не содержит. Новый APK, развёртывание и проверка этих изменений на телефоне в этой задаче не выполнялись.
|
||||
|
||||
## Размещение на Pi
|
||||
|
||||
Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`.
|
||||
@@ -58,6 +70,14 @@ dotnet test ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj
|
||||
|
||||
`tool/deploy_ikar.ps1` упаковывает опубликованный `linux-arm64` сервер и обновляет только новый Compose-проект на Pi. Модель Vosk в `ikar_server/asr/model.zip` проверяется по SHA-256 при сборке контейнера. Скрипты в `tool/` для проверок используют временные тестовые аккаунты и удаляют их по завершении.
|
||||
|
||||
## Меню расшифровки голосовых сообщений 1.0.4 (не опубликовано)
|
||||
|
||||
Расшифровка сохранена и перенесена из маленькой кнопки «Т» в меню голосового сообщения. Нажатие на облако открывает меню с пунктом «Расшифровка голосового сообщения»: нажимается вся строка, её высота не меньше 48 логических пикселей и увеличивается при переносе текста или увеличенном шрифте. У обычных сообщений, файлов, видео, удалённых и ещё не отправленных записей этого пункта нет. Пересланные записи используют идентификаторы исходного голосового сообщения.
|
||||
|
||||
Под записью отображается состояние распознавания, затем готовый текст; через меню его можно скрыть и показать снова без повторного запроса. Одновременные нажатия не создают повторные запросы. Ошибку можно повторить; результат, уже полученный через push, не затирается поздним ответом о постановке в очередь или сетевой ошибкой.
|
||||
|
||||
Целевые тесты: `artifacts/voice-menu-tests.log`. Анализ изменённых UI-файлов и новых тестов: `artifacts/voice-menu-analyze-final.log`. Живая проверка тестовой записи подтвердила загрузку, запрос получателя, завершение распознавания через push, сохранение текста для обоих участников и запрет доступа постороннему: `artifacts/voice-transcription-live-verification.json`. Тестовые аккаунты удалены. Новая версия не публиковалась и не устанавливалась на телефон по указанию пользователя; нового APK нет.
|
||||
|
||||
## Исправления видео 1.0.3
|
||||
|
||||
Серверный ответ на `VIDEO_PLAY` раньше содержал `external` и `hls`: стандартная JSON-сериализация меняла регистр ключей `EXTERNAL` и `HLS`, которые ожидает клиент. Теперь ключ `EXTERNAL` сохраняет точное написание, а исходный MP4 больше не выдаётся за HLS-плейлист. Выдача файла поддерживает HTTP byte ranges для перемотки.
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class ChannelDtoMapperTests
|
||||
var owner = CreateUser("owner", "Owner");
|
||||
owner.PhoneNumber = "+10000000001";
|
||||
owner.AvatarStoragePath = "owner-avatar.png";
|
||||
owner.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds();
|
||||
owner.PrivacySettings = new UserPrivacySettings
|
||||
{
|
||||
UserId = owner.Id,
|
||||
@@ -89,6 +90,7 @@ public sealed class ChannelDtoMapperTests
|
||||
var owner = CreateUser("owner", "Owner");
|
||||
owner.PhoneNumber = "+10000000001";
|
||||
owner.AvatarStoragePath = "owner-avatar.png";
|
||||
owner.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds();
|
||||
owner.PrivacySettings = new UserPrivacySettings
|
||||
{
|
||||
UserId = owner.Id,
|
||||
|
||||
@@ -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",
|
||||
NormalizedUsername = "PRESENCE-USER",
|
||||
DisplayName = "Presence User",
|
||||
PasswordHash = "test"
|
||||
PasswordHash = "test",
|
||||
PublicLastSeenUnixMs = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeMilliseconds()
|
||||
};
|
||||
user.Sessions.Add(new UserSession
|
||||
{
|
||||
@@ -792,13 +793,15 @@ public sealed partial class QlyraCompatibilityControllerTests
|
||||
IEmailCodeSender? emailCodeSender = null,
|
||||
QlyraCallCompatibilityStore? qlyraCallStore = null,
|
||||
IPushNotificationSender? pushNotificationSender = null,
|
||||
VideoPreviewService? videoPreviewService = null)
|
||||
VideoPreviewService? videoPreviewService = null,
|
||||
PresenceTracker? presenceTracker = null,
|
||||
Guid? sessionId = null)
|
||||
{
|
||||
var controller = new QlyraCompatibilityController(
|
||||
dbContext,
|
||||
new TokenService(dbContext, Options.Create(new JwtOptions())),
|
||||
new DeviceAuthChallengeStore(),
|
||||
new PresenceTracker(),
|
||||
presenceTracker ?? new PresenceTracker(),
|
||||
new QlyraClientIdMapper(dbContext),
|
||||
uploadStore ?? ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()),
|
||||
new NoopHubContext<MessengerHub>(),
|
||||
@@ -814,7 +817,7 @@ public sealed partial class QlyraCompatibilityControllerTests
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim("sid", Guid.NewGuid().ToString())],
|
||||
[new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim("sid", (sessionId ?? Guid.NewGuid()).ToString())],
|
||||
"Test"))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
||||
{
|
||||
return opcode switch
|
||||
{
|
||||
1 => Ok(QlyraCompatibilityResponse.Ok(opcode, new { server = "ikar" })),
|
||||
1 => await PingAsync(opcode, request.Payload, cancellationToken),
|
||||
17 => await RequestPhoneCodeAsync(opcode, request.Payload, cancellationToken),
|
||||
18 => await VerifyPhoneCodeAsync(opcode, request.Payload, cancellationToken),
|
||||
19 => await LoginAsync(opcode, cancellationToken),
|
||||
@@ -321,6 +321,23 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
||||
}));
|
||||
}
|
||||
|
||||
private async Task<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)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated != true)
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class User
|
||||
public bool IsBlocked { get; set; }
|
||||
public DateTimeOffset? BlockedAt { get; set; }
|
||||
public long? ScheduledDeletionUnixMs { get; set; }
|
||||
public long? PublicLastSeenUnixMs { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? NormalizedPhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
|
||||
@@ -12,10 +12,9 @@ public static class DtoMapper
|
||||
user.DisplayName,
|
||||
presenceTracker.IsOnline(user.Id),
|
||||
user.PhoneNumber,
|
||||
user.Sessions
|
||||
.OrderByDescending(x => x.LastSeenAt)
|
||||
.Select(x => (DateTimeOffset?)x.LastSeenAt)
|
||||
.FirstOrDefault(),
|
||||
user.PublicLastSeenUnixMs is { } seen
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(seen)
|
||||
: null,
|
||||
user.About,
|
||||
ResolveAvatarPath(user),
|
||||
user.IsBot);
|
||||
|
||||
@@ -26,7 +26,14 @@ public sealed class MessengerHub(
|
||||
{
|
||||
var userId = Context.User!.GetRequiredUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId));
|
||||
presenceTracker.MarkOnline(userId);
|
||||
var interactive = !string.Equals(Context.GetHttpContext()?.Request.Query["interactive"].ToString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
presenceTracker.Connect(userId, Context.User!.GetRequiredSessionId(), Context.ConnectionId, interactive);
|
||||
if (interactive)
|
||||
{
|
||||
var user = await dbContext.Users.SingleAsync(user => user.Id == userId, Context.ConnectionAborted);
|
||||
user.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
await dbContext.SaveChangesAsync(Context.ConnectionAborted);
|
||||
}
|
||||
await CaptureClientVersionAsync();
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -36,7 +43,7 @@ public sealed class MessengerHub(
|
||||
if (Context.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var userId = Context.User.GetRequiredUserId();
|
||||
presenceTracker.MarkOffline(userId);
|
||||
presenceTracker.Disconnect(userId, Context.ConnectionId);
|
||||
if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession))
|
||||
{
|
||||
await NotifyCallEndedAsync(endedSession!);
|
||||
@@ -88,7 +95,7 @@ public sealed class MessengerHub(
|
||||
var caller = chat.Members.Single(member => member.UserId == callerId).User;
|
||||
var callee = chat.Members.Single(member => member.UserId != callerId).User;
|
||||
var calleeDevices = await LoadPushDevicesAsync(callee.Id);
|
||||
var hasRealtimePresence = presenceTracker.IsOnline(callee.Id);
|
||||
var hasRealtimePresence = presenceTracker.IsConnected(callee.Id);
|
||||
var hasPushDevices = calleeDevices.Count > 0;
|
||||
|
||||
if (!hasRealtimePresence && !hasPushDevices)
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Ikar.Server.Infrastructure;
|
||||
|
||||
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))
|
||||
{
|
||||
return;
|
||||
if (!_connections.TryGetValue(userId, out var connections)) return;
|
||||
connections.Remove(connectionId);
|
||||
if (connections.Count == 0) _connections.Remove(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (current <= 1)
|
||||
public void SetInteractive(Guid userId, Guid sessionId, bool interactive)
|
||||
{
|
||||
if (_connections.TryRemove(userId, out _))
|
||||
lock (_gate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_connections.TryUpdate(userId, current - 1, current))
|
||||
if (!_connections.TryGetValue(userId, out var connections)) return;
|
||||
foreach (var (id, connection) in connections.ToArray())
|
||||
{
|
||||
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 EnsureColumnAsync("Users", "ScheduledDeletionUnixMs", "INTEGER NULL", cancellationToken);
|
||||
if (await EnsureColumnAsync("Users", "PublicLastSeenUnixMs", "INTEGER NULL", cancellationToken))
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync("""
|
||||
UPDATE "Users" SET "PublicLastSeenUnixMs" = (
|
||||
SELECT CAST(strftime('%s', MAX("LastSeenAt")) AS INTEGER) * 1000
|
||||
FROM "UserSessions" WHERE "UserId" = "Users"."Id"
|
||||
);
|
||||
""", cancellationToken);
|
||||
}
|
||||
await EnsureMessageAttachmentsTableAsync(cancellationToken);
|
||||
await EnsurePushDevicesTableAsync(cancellationToken);
|
||||
await EnsureDeviceCredentialsTableAsync(cancellationToken);
|
||||
@@ -944,7 +953,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureColumnAsync(
|
||||
private async Task<bool> EnsureColumnAsync(
|
||||
string tableName,
|
||||
string columnName,
|
||||
string definition,
|
||||
@@ -967,7 +976,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
||||
{
|
||||
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,6 +985,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
||||
await using var alterCommand = connection.CreateCommand();
|
||||
alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};";
|
||||
await alterCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+44
-4
@@ -3,6 +3,8 @@ import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../core/config/config.dart';
|
||||
import '../core/config/countries.dart';
|
||||
import '../core/config/qlyra_settings.dart';
|
||||
import '../core/utils/app_foreground.dart';
|
||||
import '../core/protocol/opcode_map.dart';
|
||||
import '../core/protocol/packet.dart';
|
||||
import '../core/storage/token_storage.dart';
|
||||
@@ -18,7 +20,10 @@ enum SessionState { disconnected, connecting, connected, online }
|
||||
/// Client for ikar_server.
|
||||
class Api {
|
||||
Api({IkarTransport Function(Uri)? transportFactory})
|
||||
: _transportFactory = transportFactory ?? IkarTransport.new;
|
||||
: _transportFactory = transportFactory ?? IkarTransport.new {
|
||||
QlyraSettings.ghostMode.addListener(_presenceChanged);
|
||||
AppForeground.notifier.addListener(_presenceChanged);
|
||||
}
|
||||
|
||||
final IkarTransport Function(Uri) _transportFactory;
|
||||
final PacketDispatcher _dispatcher = PacketDispatcher();
|
||||
@@ -35,6 +40,7 @@ class Api {
|
||||
Timer? _reconnect;
|
||||
Future<void> Function()? _onReconnect;
|
||||
Future<bool>? _refreshing;
|
||||
Future<void> _presenceSync = Future<void>.value();
|
||||
bool _autoReconnect = false;
|
||||
bool _disposed = false;
|
||||
int _epoch = 0;
|
||||
@@ -62,6 +68,15 @@ class Api {
|
||||
Stream<String> get handshakeSuccessStream => _handshakes.stream;
|
||||
Stream<String> get errorStream => _errors.stream;
|
||||
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) {
|
||||
_state = state;
|
||||
if (!_states.isClosed) _states.add(state);
|
||||
@@ -81,6 +96,7 @@ class Api {
|
||||
port: endpoint.port,
|
||||
);
|
||||
final transport = _transportFactory(baseUri);
|
||||
transport.interactive = _interactive;
|
||||
_transport = transport;
|
||||
final ping = await transport.request(Opcode.ping, {});
|
||||
if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен');
|
||||
@@ -117,11 +133,25 @@ class Api {
|
||||
}
|
||||
}
|
||||
|
||||
Future<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;
|
||||
if (transport == null || _state != SessionState.online) return;
|
||||
try {
|
||||
await sendRequest(Opcode.ping, {}, silent: true);
|
||||
final active =
|
||||
(interactive ?? AppForeground.value) &&
|
||||
!QlyraSettings.ghostMode.value;
|
||||
transport.interactive = active;
|
||||
await sendRequest(Opcode.ping, {'interactive': active}, silent: true);
|
||||
if (transport.token != null && !transport.eventsConnected)
|
||||
await transport.connectEvents();
|
||||
} catch (_) {
|
||||
@@ -157,6 +187,13 @@ class Api {
|
||||
throw StateError('Сессия изменилась до отправки');
|
||||
beforeSend?.call();
|
||||
final body = Map<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) {
|
||||
final identity = await IkarDeviceKey.identity(body['phone'].toString());
|
||||
_deviceId = identity['installationId'];
|
||||
@@ -306,7 +343,8 @@ class Api {
|
||||
unawaited(_checkConnection());
|
||||
}
|
||||
|
||||
void sendPing({required bool interactive}) => unawaited(_checkConnection());
|
||||
void sendPing({required bool interactive}) =>
|
||||
unawaited(_checkConnection(interactive: interactive));
|
||||
Future<bool> reconnectToEndpoint({
|
||||
String? host,
|
||||
int? port,
|
||||
@@ -357,6 +395,8 @@ class Api {
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
QlyraSettings.ghostMode.removeListener(_presenceChanged);
|
||||
AppForeground.notifier.removeListener(_presenceChanged);
|
||||
await disconnect();
|
||||
_dispatcher.dispose();
|
||||
await _states.close();
|
||||
|
||||
@@ -16,6 +16,7 @@ class IkarTransport {
|
||||
final Uri baseUri;
|
||||
final http.Client _client;
|
||||
String? token;
|
||||
bool interactive = true;
|
||||
WebSocket? _socket;
|
||||
Timer? _keepAlive;
|
||||
final _events = StreamController<Map<String, dynamic>>.broadcast();
|
||||
@@ -72,7 +73,10 @@ class IkarTransport {
|
||||
.replace(
|
||||
scheme: baseUri.scheme == 'https' ? 'wss' : 'ws',
|
||||
port: baseUri.port,
|
||||
queryParameters: {'access_token': token!},
|
||||
queryParameters: {
|
||||
'access_token': token!,
|
||||
'interactive': '$interactive',
|
||||
},
|
||||
);
|
||||
final socket = await IkarNetwork.connectWebSocket(
|
||||
uri.toString(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'e2ee_service.dart';
|
||||
// #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет
|
||||
enum MessageDecryptionState { decrypted, wrongKey, unavailable }
|
||||
|
||||
// #***! результат по одному сообщению
|
||||
@immutable
|
||||
class MessageDecryption {
|
||||
final String? plaintext;
|
||||
@@ -32,6 +33,7 @@ class MessageDecryption {
|
||||
bool get isDecrypted => state == MessageDecryptionState.decrypted;
|
||||
}
|
||||
|
||||
// #***! кэш расшифровок, при смене ключа сбрасывается целиком
|
||||
class MessageDecryptionCache {
|
||||
MessageDecryptionCache._() {
|
||||
ChatEncryptionStore.instance.revision.addListener(clear);
|
||||
@@ -40,12 +42,15 @@ class MessageDecryptionCache {
|
||||
|
||||
static final MessageDecryptionCache instance = MessageDecryptionCache._();
|
||||
|
||||
// #***! тысяча последних, дальше вытесняем
|
||||
static const int _maxEntries = 1000;
|
||||
int _generation = 0;
|
||||
|
||||
final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries =
|
||||
LinkedHashMap();
|
||||
final Set<String> _inFlight = {};
|
||||
final Map<String, Future<void>> _inFlight = {};
|
||||
|
||||
// #***! пузырь подписывается сюда и перерисуется когда текст расшифруется
|
||||
ValueListenable<MessageDecryption?> listenableFor(String messageId) =>
|
||||
_entryFor(messageId);
|
||||
|
||||
@@ -60,36 +65,106 @@ class MessageDecryptionCache {
|
||||
}
|
||||
}
|
||||
|
||||
// #***! известный текст кладём сразу, например своё только что отправленное
|
||||
void seed(String messageId, String plaintext) {
|
||||
_entryFor(messageId).value = MessageDecryption.decrypted(plaintext);
|
||||
}
|
||||
|
||||
// #***! поменяли временный id на настоящий, переносим расшифровку
|
||||
void adopt(String fromMessageId, String toMessageId) {
|
||||
final value = _entries[fromMessageId]?.value;
|
||||
if (value != null) _entryFor(toMessageId).value = value;
|
||||
}
|
||||
|
||||
// #***! _inFlight чтоб не расшифровывать одно сообщение дважды
|
||||
void request({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String messageId,
|
||||
required String cipherText,
|
||||
}) {
|
||||
if (cipherText.isEmpty) return;
|
||||
final e2ee =
|
||||
E2eeService.instance.available &&
|
||||
KometCrypto.classifyText(cipherText) != TextClass.none;
|
||||
if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) {
|
||||
return;
|
||||
final job = _begin(accountId, chatId, messageId, cipherText);
|
||||
if (job != null) unawaited(job);
|
||||
}
|
||||
if (_entryFor(messageId).value != null) return;
|
||||
if (!_inFlight.add(messageId)) return;
|
||||
_evictStale(messageId);
|
||||
unawaited(
|
||||
e2ee
|
||||
? _resolveSealed(accountId, chatId, messageId, cipherText)
|
||||
: _resolve(accountId, chatId, messageId, cipherText),
|
||||
|
||||
Future<MessageDecryption?> resolve({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String messageId,
|
||||
required String cipherText,
|
||||
}) async {
|
||||
try {
|
||||
await _begin(accountId, chatId, messageId, cipherText);
|
||||
} catch (_) {
|
||||
return const MessageDecryption.unavailable();
|
||||
}
|
||||
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 cipherText,
|
||||
) async {
|
||||
try {
|
||||
final generation = _generation;
|
||||
final row = await AppDatabase.loadMessage(accountId, chatId, messageId);
|
||||
if (_generation != generation) return;
|
||||
if (row == null) return;
|
||||
final flag = row['e2ee'] as int? ?? 0;
|
||||
final sealed = row['text_sealed'];
|
||||
@@ -110,6 +186,7 @@ class MessageDecryptionCache {
|
||||
chatId,
|
||||
sealed,
|
||||
);
|
||||
if (_generation != generation) return;
|
||||
_entryFor(messageId).value = text == null
|
||||
? const MessageDecryption.unavailable()
|
||||
: MessageDecryption.decrypted(text);
|
||||
@@ -136,15 +213,11 @@ class MessageDecryptionCache {
|
||||
);
|
||||
case TextClass.legacy:
|
||||
if (ChatCryptoService.instance.isEnabled(accountId, chatId)) {
|
||||
_inFlight.remove(messageId);
|
||||
await _resolve(accountId, chatId, messageId, cipherText);
|
||||
}
|
||||
case TextClass.none:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
_inFlight.remove(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolve(
|
||||
@@ -153,13 +226,15 @@ class MessageDecryptionCache {
|
||||
String messageId,
|
||||
String cipherText,
|
||||
) async {
|
||||
try {
|
||||
final generation = _generation;
|
||||
final crypto = ChatCryptoService.instance;
|
||||
final result = await crypto.decrypt(accountId, chatId, cipherText);
|
||||
if (_generation != generation) return;
|
||||
if (result.isOk) {
|
||||
_entryFor(messageId).value = MessageDecryption.decrypted(result.text!);
|
||||
return;
|
||||
}
|
||||
// #***! ключа нет показываем как неверный ключ только если текст правда похож на шифр
|
||||
switch (result.failure) {
|
||||
case CryptoFailure.wrongKey:
|
||||
_entryFor(messageId).value = const MessageDecryption.wrongKey();
|
||||
@@ -173,12 +248,10 @@ class MessageDecryptionCache {
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
_inFlight.remove(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_generation++;
|
||||
_entries.clear();
|
||||
_inFlight.clear();
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ class VoiceTranscription {
|
||||
if (result.status != 1 && TranscriptionCache.get(id)?.status == 1) return;
|
||||
TranscriptionCache.put(id, result, expanded: true);
|
||||
} catch (_) {
|
||||
if (TranscriptionCache.get(id)?.status == 1) return;
|
||||
TranscriptionCache.put(
|
||||
id,
|
||||
TranscriptionResult(status: -1),
|
||||
|
||||
@@ -14,10 +14,11 @@ import 'package:ikar/core/config/app_fonts.dart';
|
||||
import 'package:ikar/core/config/app_frost.dart';
|
||||
import 'package:ikar/core/config/app_message_actions_style.dart';
|
||||
import 'package:ikar/core/utils/haptics.dart';
|
||||
import 'package:ikar/core/crypto/message_decryption_cache.dart';
|
||||
import 'package:ikar/core/media/voice_transcription.dart';
|
||||
import 'package:ikar/core/utils/text_format.dart';
|
||||
import 'package:ikar/frontend/widgets/animated_text_swap.dart';
|
||||
import 'package:ikar/frontend/widgets/directional_drag_recognizer.dart';
|
||||
export 'package:ikar/frontend/widgets/swipe_to_reply.dart';
|
||||
import 'package:ikar/frontend/widgets/liquid_glass.dart';
|
||||
import 'package:ikar/frontend/widgets/message_actions_overlay.dart'
|
||||
show
|
||||
@@ -33,120 +34,6 @@ import 'package:ikar/main.dart' show animojiModule, messagesModule;
|
||||
import 'package:ikar/models/animoji.dart' show Animoji;
|
||||
import 'package:ikar/models/chat_reaction_settings.dart';
|
||||
|
||||
class SwipeToReply extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool isMe;
|
||||
final VoidCallback onReply;
|
||||
|
||||
const SwipeToReply({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.isMe,
|
||||
required this.onReply,
|
||||
});
|
||||
|
||||
@override
|
||||
State<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 {
|
||||
final String? text;
|
||||
final bool isPreview;
|
||||
@@ -447,7 +334,7 @@ class _SelectableMessageRowState extends State<SelectableMessageRow> {
|
||||
isMe: widget.isMe,
|
||||
bottomReservedSpace: widget.composerHeight?.value ?? 0,
|
||||
messageText: widget.message.text,
|
||||
copyText: widget.message.selectableText,
|
||||
copyText: MessageDecryptionCache.instance.readableText(widget.message),
|
||||
controller: controller,
|
||||
style: AppMessageActionsStyle.effective,
|
||||
interaction: MessageActionsInteraction.tap,
|
||||
@@ -582,7 +469,7 @@ class _SelectableMessageRowState extends State<SelectableMessageRow> {
|
||||
isMe: widget.isMe,
|
||||
bottomReservedSpace: widget.composerHeight?.value ?? 0,
|
||||
messageText: widget.message.text,
|
||||
copyText: widget.message.selectableText,
|
||||
copyText: MessageDecryptionCache.instance.readableText(widget.message),
|
||||
controller: controller,
|
||||
style: MessageActionsStyle.list,
|
||||
interaction: MessageActionsInteraction.click,
|
||||
|
||||
@@ -51,6 +51,8 @@ import 'package:ikar/frontend/screens/chats/poll_create_screen.dart';
|
||||
import 'package:ikar/frontend/widgets/animated_text_swap.dart';
|
||||
import 'package:ikar/frontend/widgets/custom_notification.dart';
|
||||
import '../../widgets/undo_notification.dart';
|
||||
import '../../widgets/scroll_keyboard_dismiss.dart';
|
||||
import '../../widgets/swipe_to_reply.dart';
|
||||
import '../../../core/security/app_lock.dart';
|
||||
import 'package:ikar/frontend/widgets/chat_menu_overlay.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -145,7 +147,6 @@ import '../../widgets/sticker_pack_sheet.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import '../../widgets/swipe_to_pop.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../widgets/directional_drag_recognizer.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/schedule_time_picker.dart';
|
||||
import '../../widgets/chat_wallpaper_sheet.dart';
|
||||
@@ -581,7 +582,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
set _historyKickedOff(bool v) => _chatController.historyKickedOff = v;
|
||||
|
||||
final GlobalKey _messageListKey = GlobalKey();
|
||||
_ChatMessageList? _messageListWidget;
|
||||
Widget? _messageListWidget;
|
||||
final Set<String> _deletingIds = {};
|
||||
|
||||
static const double _avgMessageHeight = 72.0;
|
||||
@@ -2681,7 +2682,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
void _startTextSelection(CachedMessage message, Offset globalPosition) {
|
||||
if (message.isControl || message.selectableText == null) return;
|
||||
if (message.isControl ||
|
||||
MessageDecryptionCache.instance.readableText(message) == null) {
|
||||
return;
|
||||
}
|
||||
_textSelectionDrag.value = null;
|
||||
_textSelection.value = (id: message.id, pos: globalPosition);
|
||||
}
|
||||
@@ -2707,7 +2711,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
List<CachedMessage> _copyableSelection(Set<String> ids) => [
|
||||
for (final m in _messages)
|
||||
if (ids.contains(m.id) && (m.selectableText ?? '').isNotEmpty) m,
|
||||
if (ids.contains(m.id) &&
|
||||
(MessageDecryptionCache.instance.readableText(m) ?? '').isNotEmpty)
|
||||
m,
|
||||
];
|
||||
|
||||
CachedMessage? _singleEditable(Set<String> ids) {
|
||||
@@ -2719,7 +2725,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
void _copySelected(List<CachedMessage> messages) {
|
||||
if (messages.isEmpty) return;
|
||||
final text = messages.map((m) => m.selectableText!).join('\n\n');
|
||||
final text = messages
|
||||
.map((m) => MessageDecryptionCache.instance.readableText(m) ?? '')
|
||||
.where((text) => text.isNotEmpty)
|
||||
.join('\n\n');
|
||||
if (text.isEmpty) return;
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
Haptics.tap();
|
||||
showCustomNotification(
|
||||
@@ -3130,6 +3140,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
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 content =
|
||||
@@ -3143,7 +3161,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (sheetContext) => _EditMessageSheet(
|
||||
text: message.text ?? '',
|
||||
text: originalText,
|
||||
formatRanges: message.formatRanges,
|
||||
contextMenuBuilder: _formatContextMenu,
|
||||
),
|
||||
@@ -3158,8 +3176,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final oldElements = serializeFormatElements(
|
||||
message.formatRanges.where((r) => composerFormats.contains(r.format)),
|
||||
);
|
||||
if (newText == (message.text ?? '') &&
|
||||
_sameElements(elements, oldElements)) {
|
||||
if (newText == originalText && _sameElements(elements, oldElements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5990,8 +6007,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesList() =>
|
||||
_messageListWidget ??= _ChatMessageList(this, key: _messageListKey);
|
||||
Widget _buildMessagesList() => _messageListWidget ??= ScrollKeyboardDismiss(
|
||||
onDismiss: _messageFocusNode.unfocus,
|
||||
child: _ChatMessageList(this, key: _messageListKey),
|
||||
);
|
||||
|
||||
EdgeInsets _messagesListPadding(BuildContext context) {
|
||||
if (AppChatChrome.current.value == ChatChromeStyle.color) {
|
||||
@@ -6199,8 +6218,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final swipeable =
|
||||
(message.isControl || isChannel || !_canReply)
|
||||
? pressable
|
||||
: _SwipeToReply(
|
||||
isMe: isMe,
|
||||
: SwipeToReply(
|
||||
onReply: () => _startReply(message),
|
||||
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 {
|
||||
final String? text;
|
||||
final bool isPreview;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -6,12 +8,17 @@ import '../../widgets/connection_status.dart';
|
||||
import '../../../core/config/app_amoled.dart';
|
||||
import '../../../core/config/app_theme_mode.dart';
|
||||
import '../../../core/config/app_theme_schedule.dart';
|
||||
import '../../../core/config/app_wallpaper_tint.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/chat_wallpaper_store.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/mesh_gradient_background.dart';
|
||||
import '../../widgets/settings_radio_tile.dart';
|
||||
import '../../widgets/settings_card.dart';
|
||||
import 'custom_gradient_editor_screen.dart';
|
||||
|
||||
class ThemeSettingsScreen extends StatelessWidget {
|
||||
const ThemeSettingsScreen({super.key});
|
||||
@@ -45,9 +52,14 @@ class ThemeSettingsScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeModeCard extends StatelessWidget {
|
||||
class _ThemeModeCard extends StatefulWidget {
|
||||
const _ThemeModeCard();
|
||||
|
||||
@override
|
||||
State<_ThemeModeCard> createState() => _ThemeModeCardState();
|
||||
}
|
||||
|
||||
class _ThemeModeCardState extends State<_ThemeModeCard> {
|
||||
static const _items = [
|
||||
(mode: AppThemeMode.system, icon: Symbols.brightness_auto),
|
||||
(mode: AppThemeMode.light, icon: Symbols.light_mode),
|
||||
@@ -55,6 +67,72 @@ class _ThemeModeCard extends StatelessWidget {
|
||||
(mode: AppThemeMode.schedule, icon: Symbols.schedule),
|
||||
];
|
||||
|
||||
int _accountId = 0;
|
||||
ChatWallpaper? _wallpaper;
|
||||
bool _ready = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<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) {
|
||||
switch (mode) {
|
||||
case AppThemeMode.system:
|
||||
@@ -72,6 +150,7 @@ class _ThemeModeCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = _wallpaper?.gradientColors;
|
||||
return SettingsPanel(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 12),
|
||||
child: Column(
|
||||
@@ -91,24 +170,72 @@ class _ThemeModeCard extends StatelessWidget {
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ValueListenableBuilder<AppThemeMode>(
|
||||
valueListenable: AppThemeModeConfig.current,
|
||||
builder: (context, current, _) {
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
AppThemeModeConfig.current,
|
||||
AppWallpaperTint.current,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final current = AppThemeModeConfig.current.value;
|
||||
final customSelected = AppWallpaperTint.current.value;
|
||||
return Column(
|
||||
children: [
|
||||
for (final item in _items)
|
||||
_ModeTile(
|
||||
icon: item.icon,
|
||||
label: _labelFor(l10n, item.mode),
|
||||
selected: current == item.mode,
|
||||
selected: !customSelected && current == item.mode,
|
||||
onTap: (position) {
|
||||
if (current == item.mode) return;
|
||||
if (!customSelected && current == item.mode) return;
|
||||
Haptics.selection();
|
||||
if (customSelected) {
|
||||
unawaited(AppWallpaperTint.save(false));
|
||||
}
|
||||
QlyraApp.stateOf(
|
||||
context,
|
||||
)?.applyThemeModeWithReveal(item.mode, position);
|
||||
},
|
||||
),
|
||||
_ModeTile(
|
||||
icon: Symbols.palette,
|
||||
label: l10n.themeSettingsCustomTitle,
|
||||
selected: customSelected,
|
||||
onTap: (_) {
|
||||
if (!_ready || _accountId == 0) return;
|
||||
if (customSelected) return;
|
||||
unawaited(_enableCustom());
|
||||
},
|
||||
),
|
||||
if (customSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 36),
|
||||
child: SettingsNavTile(
|
||||
isLast: true,
|
||||
onTap: _ready ? _openEditor : null,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: colors != null && colors.isNotEmpty
|
||||
? MeshGradientBackground(
|
||||
colors: colors,
|
||||
animate: false,
|
||||
rotation: _wallpaper?.gradientRotation ?? 0,
|
||||
)
|
||||
: ColoredBox(
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Symbols.palette,
|
||||
color: cs.onSurface,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: l10n.themeSettingsCustomizeAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -68,6 +68,7 @@ class PhotoHeroController {
|
||||
class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
|
||||
PhotoHeroRoute({required this.hero, required WidgetBuilder builder})
|
||||
: super(
|
||||
opaque: false,
|
||||
transitionDuration: const Duration(milliseconds: 320),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 280),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
@@ -301,7 +302,7 @@ class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> {
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
FadeTransition(opacity: widget.animation, child: widget.child),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedBuilder(
|
||||
|
||||
@@ -402,6 +402,7 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final out = widget.geometry.naturalOutput;
|
||||
final inset = _QualitySlider.insetFor(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
@@ -450,30 +451,38 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: EdgeInsets.fromLTRB(inset, 8, inset, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.videoEditorQualityLow,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.videoEditorQualityHigh,
|
||||
textAlign: TextAlign.end,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_QualitySlider(
|
||||
count: widget.options.length,
|
||||
index: _index,
|
||||
inset: inset,
|
||||
onChanged: (value) => setState(() => _index = value),
|
||||
),
|
||||
Row(
|
||||
@@ -519,23 +528,35 @@ class _VideoQualityEditorState extends State<VideoQualityEditor> {
|
||||
class _QualitySlider extends StatelessWidget {
|
||||
final int count;
|
||||
final int index;
|
||||
final double inset;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
const _QualitySlider({
|
||||
required this.count,
|
||||
required this.index,
|
||||
required this.inset,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
static const double _minInset = 16.0;
|
||||
static const double _height = 48.0;
|
||||
|
||||
// #***! концы дорожки уводим из зоны системных жестов, иначе крайние
|
||||
// значения на телефоне с жестовой навигацией нечем зацепить
|
||||
static double insetFor(BuildContext context) {
|
||||
final gesture = MediaQuery.of(context).systemGestureInsets;
|
||||
return math.max(_minInset, math.max(gesture.left, gesture.right) + 8);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = MediaAccent.of(context);
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
height: _height,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const inset = 16.0;
|
||||
final span = math.max(1.0, constraints.maxWidth - inset * 2);
|
||||
final width = constraints.maxWidth;
|
||||
final span = math.max(1.0, width - inset * 2);
|
||||
void pick(double dx) {
|
||||
if (count <= 1) return;
|
||||
final t = ((dx - inset) / span).clamp(0.0, 1.0);
|
||||
@@ -547,7 +568,10 @@ class _QualitySlider extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (d) => pick(d.localPosition.dx),
|
||||
onHorizontalDragUpdate: (d) => pick(d.localPosition.dx),
|
||||
// #***! без явного размера CustomPaint схлопывается в ноль ширины,
|
||||
// дорожка рисуется мимо и по ней нечем попасть
|
||||
child: CustomPaint(
|
||||
size: Size(width, _height),
|
||||
painter: _QualitySliderPainter(
|
||||
count: count,
|
||||
index: index,
|
||||
|
||||
@@ -595,12 +595,20 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
static const double _barInset = 8;
|
||||
static const double _trimMinInset = 16;
|
||||
|
||||
Widget _bottomBar() {
|
||||
final controller = _controller;
|
||||
final gesture = MediaQuery.of(context).systemGestureInsets;
|
||||
final trimInset = math.max(
|
||||
_trimMinInset,
|
||||
math.max(gesture.left, gesture.right) + 8 - _barInset,
|
||||
);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
padding: const EdgeInsets.fromLTRB(_barInset, 0, _barInset, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -616,7 +624,9 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
|
||||
if (widget.editable &&
|
||||
controller != null &&
|
||||
controller.value.isInitialized)
|
||||
TrimBar(
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: trimInset),
|
||||
child: TrimBar(
|
||||
frames: _strip,
|
||||
controller: controller,
|
||||
duration: _edit.sourceDuration,
|
||||
@@ -625,6 +635,7 @@ class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
|
||||
onScrub: _onScrub,
|
||||
onTrim: _onTrim,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_captionField(),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
@@ -3,44 +3,43 @@ import 'package:flutter/gestures.dart';
|
||||
class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
DirectionalDragRecognizer({
|
||||
required this.direction,
|
||||
this.minAcceptDistance = 20.0,
|
||||
this.minAcceptVelocity,
|
||||
this.minAcceptDistance = kTouchSlop,
|
||||
super.debugOwner,
|
||||
}) {
|
||||
onlyAcceptDragOnThreshold = true;
|
||||
dragStartBehavior = DragStartBehavior.down;
|
||||
}
|
||||
|
||||
final double direction;
|
||||
final double minAcceptDistance;
|
||||
final double? minAcceptVelocity;
|
||||
bool Function()? enabled;
|
||||
|
||||
final Map<int, Offset> _initialPositions = {};
|
||||
final Map<int, VelocityTracker> _velocityTrackers = {};
|
||||
final Map<int, double> _currentDeltaX = {};
|
||||
final Map<int, Offset> _currentDeltas = {};
|
||||
bool _directionLocked = false;
|
||||
|
||||
@override
|
||||
bool isPointerAllowed(PointerEvent event) =>
|
||||
(enabled?.call() ?? true) && super.isPointerAllowed(event);
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
_initialPositions[event.pointer] = event.position;
|
||||
final tracker = VelocityTracker.withKind(event.kind);
|
||||
tracker.addPosition(event.timeStamp, event.localPosition);
|
||||
_velocityTrackers[event.pointer] = tracker;
|
||||
super.addAllowedPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (event is PointerMoveEvent) {
|
||||
_velocityTrackers[event.pointer]?.addPosition(
|
||||
event.timeStamp,
|
||||
event.localPosition,
|
||||
);
|
||||
final initial = _initialPositions[event.pointer];
|
||||
if (initial != null) {
|
||||
final dx = (event.position.dx - initial.dx) * direction;
|
||||
_currentDeltaX[event.pointer] = dx;
|
||||
if (dx < -kTouchSlop) {
|
||||
stopTrackingPointer(event.pointer);
|
||||
_cleanup(event.pointer);
|
||||
final delta = event.position - initial;
|
||||
_currentDeltas[event.pointer] = delta;
|
||||
if (!_directionLocked &&
|
||||
(delta.dx * direction < -kTouchSlop ||
|
||||
(delta.dy.abs() > kTouchSlop &&
|
||||
delta.dy.abs() > delta.dx.abs()))) {
|
||||
resolvePointer(event.pointer, GestureDisposition.rejected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -59,30 +58,23 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
double maxDx = 0;
|
||||
for (final dx in _currentDeltaX.values) {
|
||||
if (dx > maxDx) maxDx = dx;
|
||||
}
|
||||
if (maxDx < minAcceptDistance) return false;
|
||||
|
||||
final minVelocity = minAcceptVelocity;
|
||||
if (minVelocity == null) return true;
|
||||
for (final tracker in _velocityTrackers.values) {
|
||||
final vx = tracker.getVelocity().pixelsPerSecond.dx * direction;
|
||||
if (vx >= minVelocity) return true;
|
||||
}
|
||||
return false;
|
||||
_directionLocked = _currentDeltas.values.any(
|
||||
(delta) =>
|
||||
delta.dx * direction >= minAcceptDistance &&
|
||||
delta.dx.abs() > delta.dy.abs(),
|
||||
);
|
||||
return _directionLocked;
|
||||
}
|
||||
|
||||
void _cleanup(int pointer) {
|
||||
_initialPositions.remove(pointer);
|
||||
_velocityTrackers.remove(pointer);
|
||||
_currentDeltaX.remove(pointer);
|
||||
_currentDeltas.remove(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
_cleanup(pointer);
|
||||
_directionLocked = false;
|
||||
super.didStopTrackingLastPointer(pointer);
|
||||
}
|
||||
|
||||
@@ -94,8 +86,7 @@ class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||
}
|
||||
|
||||
class RightwardDragRecognizer extends DirectionalDragRecognizer {
|
||||
RightwardDragRecognizer({super.debugOwner})
|
||||
: super(direction: 1, minAcceptVelocity: 700);
|
||||
RightwardDragRecognizer({super.debugOwner}) : super(direction: 1);
|
||||
}
|
||||
|
||||
class LeftwardDragRecognizer extends DirectionalDragRecognizer {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../core/config/ios_release.dart';
|
||||
import 'scroll_keyboard_dismiss.dart';
|
||||
|
||||
bool _focusIsText() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
@@ -69,6 +70,9 @@ class _KeyboardDismissalState extends State<KeyboardDismissal> {
|
||||
if (notification.dragDetails == null) return false;
|
||||
if (notification.metrics.axis != Axis.vertical) return false;
|
||||
final source = notification.context;
|
||||
if (source?.findAncestorWidgetOfExactType<ScrollKeyboardDismiss>() != null) {
|
||||
return false;
|
||||
}
|
||||
if (source != null &&
|
||||
(source.findAncestorWidgetOfExactType<EditableText>() != null ||
|
||||
source.findAncestorWidgetOfExactType<TextFieldTapRegion>() !=
|
||||
|
||||
@@ -79,6 +79,19 @@ class PhotoViewerActions {
|
||||
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 {
|
||||
final String id;
|
||||
final MessageAttachment attachment;
|
||||
@@ -199,7 +212,8 @@ class PhotoViewerScreen extends StatefulWidget {
|
||||
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
|
||||
}
|
||||
|
||||
class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
class _PhotoViewerScreenState extends State<PhotoViewerScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const int _prefetchThreshold = 3;
|
||||
static const int _maxCachedVideoPlayers = 5;
|
||||
static const double _compactHeight = 480;
|
||||
@@ -217,9 +231,17 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
final Map<String, Future<Map<String, String>>> _videoSourceLoads = {};
|
||||
final TransformationController _heroTransform = TransformationController();
|
||||
final Map<String, TransformationController> _pageTransforms = {};
|
||||
int _pointers = 0;
|
||||
bool _zoomed = false;
|
||||
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 _feedFailed = false;
|
||||
bool _loadingMore = false;
|
||||
@@ -231,6 +253,10 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dismissReturn = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
)..addListener(_animateDismissReturn);
|
||||
_heroTransform.addListener(_syncHero);
|
||||
_heroTransform.addListener(_syncZoom);
|
||||
_items = _localItems();
|
||||
@@ -272,14 +298,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
_syncSwipe();
|
||||
}
|
||||
|
||||
void _updatePointers(int delta) {
|
||||
final next = _pointers + delta;
|
||||
_pointers = next < 0 ? 0 : next;
|
||||
_syncSwipe();
|
||||
}
|
||||
|
||||
void _syncSwipe() {
|
||||
final enabled = _pointers < 2 && !_zoomed;
|
||||
final enabled = _pointers.length < 2 && !_zoomed;
|
||||
if (enabled == _swipeEnabled) return;
|
||||
setState(() => _swipeEnabled = enabled);
|
||||
}
|
||||
@@ -287,6 +307,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_dismissReturn.dispose();
|
||||
_heroTransform.dispose();
|
||||
for (final transform in _pageTransforms.values) {
|
||||
transform.dispose();
|
||||
@@ -297,6 +318,99 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canDismiss {
|
||||
final route = ModalRoute.of(context);
|
||||
return !_closing &&
|
||||
!_dismissReturn.isAnimating &&
|
||||
route?.isCurrent == true &&
|
||||
route?.popDisposition != RoutePopDisposition.doNotPop &&
|
||||
Navigator.of(context).canPop() &&
|
||||
(!_controller.hasClients ||
|
||||
!_controller.position.isScrollingNotifier.value) &&
|
||||
(_current.isVideo ||
|
||||
_transformFor(_current.id).value.getMaxScaleOnAxis() <= 1.01);
|
||||
}
|
||||
|
||||
void _onMediaPointerDown(PointerDownEvent event) {
|
||||
_pointers.add(event.pointer);
|
||||
_syncSwipe();
|
||||
if (_pointers.length != 1) {
|
||||
_dismissAllowed = false;
|
||||
_restoreDismiss();
|
||||
return;
|
||||
}
|
||||
_dismissAllowed = event.kind != PointerDeviceKind.mouse && _canDismiss;
|
||||
_pointerOrigin = event.position;
|
||||
_velocityTracker = VelocityTracker.withKind(event.kind)
|
||||
..addPosition(event.timeStamp, event.position);
|
||||
}
|
||||
|
||||
void _onMediaPointerMove(PointerMoveEvent event) {
|
||||
if (!_dismissAllowed || _pointers.length != 1) return;
|
||||
_velocityTracker?.addPosition(event.timeStamp, event.position);
|
||||
final delta = event.position - _pointerOrigin!;
|
||||
if (!_draggingToDismiss) {
|
||||
if (delta.distance < kTouchSlop) return;
|
||||
if (delta.dx.abs() >= delta.dy.abs()) {
|
||||
_dismissAllowed = false;
|
||||
return;
|
||||
}
|
||||
if (!_current.isVideo &&
|
||||
_transformFor(_current.id).value.getMaxScaleOnAxis() > 1.01) {
|
||||
_dismissAllowed = false;
|
||||
return;
|
||||
}
|
||||
_draggingToDismiss = true;
|
||||
}
|
||||
setState(() => _dismissOffset = Offset(delta.dx * 0.35, delta.dy));
|
||||
}
|
||||
|
||||
void _onMediaPointerUp(PointerUpEvent event) {
|
||||
_pointers.remove(event.pointer);
|
||||
_syncSwipe();
|
||||
if (!_draggingToDismiss || !_dismissAllowed) return;
|
||||
_dismissAllowed = false;
|
||||
_draggingToDismiss = false;
|
||||
_velocityTracker?.addPosition(event.timeStamp, event.position);
|
||||
final velocity = _velocityTracker?.getVelocity().pixelsPerSecond.dy ?? 0;
|
||||
final distance = _dismissOffset.dy;
|
||||
final threshold = (MediaQuery.sizeOf(context).height * 0.18).clamp(96, 180);
|
||||
final fling =
|
||||
distance.abs() >= 32 &&
|
||||
velocity.abs() >= 900 &&
|
||||
velocity.sign == distance.sign;
|
||||
final route = ModalRoute.of(context);
|
||||
if ((distance.abs() >= threshold || fling) &&
|
||||
route?.isCurrent == true &&
|
||||
route?.popDisposition != RoutePopDisposition.doNotPop) {
|
||||
_closing = true;
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
_restoreDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
void _onMediaPointerCancel(PointerCancelEvent event) {
|
||||
_pointers.remove(event.pointer);
|
||||
_syncSwipe();
|
||||
_dismissAllowed = false;
|
||||
_restoreDismiss();
|
||||
}
|
||||
|
||||
void _restoreDismiss() {
|
||||
_draggingToDismiss = false;
|
||||
if (_closing || _dismissOffset == Offset.zero) return;
|
||||
_returnFrom = _dismissOffset;
|
||||
_dismissReturn.forward(from: 0);
|
||||
}
|
||||
|
||||
void _animateDismissReturn() {
|
||||
final value = Curves.easeOutCubic.transform(_dismissReturn.value);
|
||||
setState(
|
||||
() => _dismissOffset = Offset.lerp(_returnFrom, Offset.zero, value)!,
|
||||
);
|
||||
}
|
||||
|
||||
AvatarFeed? get _avatarFeed => widget.avatars;
|
||||
|
||||
bool get _isAvatars => _avatarFeed != null;
|
||||
@@ -932,9 +1046,12 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
final media = MediaQuery.of(context);
|
||||
final padding = media.padding;
|
||||
final compact = media.size.height < _compactHeight;
|
||||
final dismissProgress =
|
||||
(_dismissOffset.dy.abs() / (MediaQuery.sizeOf(context).height * 0.55))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.black.withValues(alpha: 1 - dismissProgress),
|
||||
body: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
||||
@@ -946,14 +1063,32 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
autofocus: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildPager()),
|
||||
Positioned.fill(
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: _onMediaPointerDown,
|
||||
onPointerMove: _onMediaPointerMove,
|
||||
onPointerUp: _onMediaPointerUp,
|
||||
onPointerCancel: _onMediaPointerCancel,
|
||||
child: Transform.translate(
|
||||
key: const ValueKey('media-dismiss-transform'),
|
||||
offset: _dismissOffset,
|
||||
child: Transform.scale(
|
||||
scale: 1 - 0.12 * dismissProgress,
|
||||
child: _buildPager(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_chromeVisible,
|
||||
ignoring: !_chromeVisible || _dismissOffset != Offset.zero,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _chromeVisible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
child: Opacity(
|
||||
opacity: 1 - dismissProgress,
|
||||
child: Stack(
|
||||
children: [
|
||||
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),
|
||||
);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (_) => _updatePointers(1),
|
||||
onPointerUp: (_) => _updatePointers(-1),
|
||||
onPointerCancel: (_) => _updatePointers(-1),
|
||||
child: MediaQuery(
|
||||
return MediaQuery(
|
||||
data: swipeMedia,
|
||||
child: PageView.builder(
|
||||
key: ValueKey(_pager),
|
||||
@@ -1028,7 +1160,6 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -30,19 +30,36 @@ class SwipeRoute<T> extends PageRoute<T> {
|
||||
@override
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 400);
|
||||
|
||||
@override
|
||||
DelegatedTransitionBuilder? get delegatedTransition =>
|
||||
(context, animation, secondaryAnimation, allowSnapshotting, child) =>
|
||||
CupertinoPageTransition(
|
||||
primaryRouteAnimation: kAlwaysCompleteAnimation,
|
||||
secondaryRouteAnimation: secondaryAnimation,
|
||||
linearTransition: Navigator.of(context).userGestureInProgress,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
@override
|
||||
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
||||
if (nextRoute is PageRoute && nextRoute.fullscreenDialog) return false;
|
||||
return nextRoute is SwipeRoute ||
|
||||
nextRoute is CupertinoRouteTransitionMixin;
|
||||
nextRoute is CupertinoRouteTransitionMixin ||
|
||||
(nextRoute is ModalRoute && nextRoute.delegatedTransition != null);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get popGestureInProgress => _gestureController != null;
|
||||
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) =>
|
||||
previousRoute is PageRoute && !fullscreenDialog;
|
||||
|
||||
@override
|
||||
bool get popGestureInProgress => navigator?.userGestureInProgress ?? false;
|
||||
|
||||
_SwipeBackController<T>? _gestureController;
|
||||
|
||||
@override
|
||||
bool get popGestureEnabled {
|
||||
if (fullscreenDialog || !isCurrent) return false;
|
||||
if (isFirst) return false;
|
||||
if (willHandlePopInternally) return false;
|
||||
if (popDisposition == RoutePopDisposition.doNotPop) return false;
|
||||
@@ -56,6 +73,9 @@ class SwipeRoute<T> extends PageRoute<T> {
|
||||
final gesture = _SwipeBackController<T>(
|
||||
navigator: navigator!,
|
||||
controller: controller!,
|
||||
isActive: () => isActive,
|
||||
isCurrent: () => isCurrent,
|
||||
canPop: () => popDisposition != RoutePopDisposition.doNotPop,
|
||||
);
|
||||
_gestureController = gesture;
|
||||
gesture._onEnd = () {
|
||||
@@ -63,9 +83,16 @@ class SwipeRoute<T> extends PageRoute<T> {
|
||||
_gestureController = null;
|
||||
}
|
||||
};
|
||||
navigator!.didStartUserGesture();
|
||||
return gesture;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_gestureController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
@@ -134,6 +161,7 @@ class _SwipeBackGestureDetectorState<T>
|
||||
if (!widget.enabledCallback()) return;
|
||||
_width = context.size?.width ?? MediaQuery.of(context).size.width;
|
||||
if (_width <= 0) _width = 1.0;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_backController = widget.onStartPopGesture();
|
||||
}
|
||||
|
||||
@@ -149,13 +177,15 @@ class _SwipeBackGestureDetectorState<T>
|
||||
}
|
||||
|
||||
void _handleCancel() {
|
||||
_backController?.dragEnd(0.0);
|
||||
_backController?.dragEnd(0.0, cancelled: true);
|
||||
_backController = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
return Listener(
|
||||
onPointerCancel: (_) => _handleCancel(),
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
RightwardDragRecognizer:
|
||||
@@ -163,6 +193,7 @@ class _SwipeBackGestureDetectorState<T>
|
||||
() => RightwardDragRecognizer(debugOwner: this),
|
||||
(instance) {
|
||||
instance
|
||||
..enabled = widget.enabledCallback
|
||||
..onStart = _handleStart
|
||||
..onUpdate = _handleUpdate
|
||||
..onEnd = _handleEnd
|
||||
@@ -171,16 +202,28 @@ class _SwipeBackGestureDetectorState<T>
|
||||
),
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 AnimationController controller;
|
||||
final ValueGetter<bool> isActive;
|
||||
final ValueGetter<bool> isCurrent;
|
||||
final ValueGetter<bool> canPop;
|
||||
VoidCallback? _onEnd;
|
||||
AnimationStatusListener? _statusListener;
|
||||
bool _ended = false;
|
||||
|
||||
static const double _kMinFlingVelocity = 1.0;
|
||||
|
||||
@@ -188,11 +231,15 @@ class _SwipeBackController<T> {
|
||||
controller.value -= delta;
|
||||
}
|
||||
|
||||
void dragEnd(double velocity) {
|
||||
const animationCurve = Curves.fastLinearToSlowEaseIn;
|
||||
void dragEnd(double velocity, {bool cancelled = false}) {
|
||||
const animationCurve = Curves.fastEaseInToSlowEaseOut;
|
||||
final bool animateForward;
|
||||
|
||||
if (velocity.abs() >= _kMinFlingVelocity) {
|
||||
if (!isCurrent()) {
|
||||
animateForward = isActive();
|
||||
} else if (cancelled || !canPop()) {
|
||||
animateForward = true;
|
||||
} else if (velocity.abs() >= _kMinFlingVelocity) {
|
||||
animateForward = velocity <= 0;
|
||||
} else {
|
||||
animateForward = controller.value > 0.5;
|
||||
@@ -209,9 +256,12 @@ class _SwipeBackController<T> {
|
||||
curve: animationCurve,
|
||||
);
|
||||
} else {
|
||||
navigator.pop();
|
||||
if (isCurrent()) navigator.pop();
|
||||
if (controller.isAnimating) {
|
||||
final backMs = lerpDouble(0, 800, controller.value)!.floor();
|
||||
final backMs = math.min(
|
||||
lerpDouble(0, 800, controller.value)!.floor(),
|
||||
300,
|
||||
);
|
||||
controller.animateBack(
|
||||
0.0,
|
||||
duration: Duration(milliseconds: backMs),
|
||||
@@ -221,14 +271,35 @@ class _SwipeBackController<T> {
|
||||
}
|
||||
|
||||
if (controller.isAnimating) {
|
||||
late AnimationStatusListener statusCb;
|
||||
statusCb = (status) {
|
||||
_onEnd?.call();
|
||||
controller.removeStatusListener(statusCb);
|
||||
};
|
||||
controller.addStatusListener(statusCb);
|
||||
} else {
|
||||
_onEnd?.call();
|
||||
_statusListener = (status) {
|
||||
if (status == AnimationStatus.completed ||
|
||||
status == AnimationStatus.dismissed) {
|
||||
_finish();
|
||||
}
|
||||
};
|
||||
controller.addStatusListener(_statusListener!);
|
||||
} else {
|
||||
_finish();
|
||||
}
|
||||
}
|
||||
|
||||
void _finish() {
|
||||
if (_ended) return;
|
||||
_ended = true;
|
||||
final listener = _statusListener;
|
||||
if (listener != null) controller.removeStatusListener(listener);
|
||||
_statusListener = null;
|
||||
_onEnd?.call();
|
||||
if (navigator.mounted) navigator.didStopUserGesture();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_ended) return;
|
||||
_ended = true;
|
||||
final listener = _statusListener;
|
||||
if (listener != null) controller.removeStatusListener(listener);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (navigator.mounted) navigator.didStopUserGesture();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3362,7 +3362,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get msgActionsCopyLink => 'Скопировать ссылку';
|
||||
|
||||
@override
|
||||
String get themeSettingsCustomTitle => 'Своя';
|
||||
String get themeSettingsCustomTitle => 'Собственная тема';
|
||||
|
||||
@override
|
||||
String get callKometDetectedNotification =>
|
||||
|
||||
+1
-1
@@ -1203,7 +1203,7 @@
|
||||
"e2eeExportedAndDisabled": "Перенос создан. На этом устройстве шифрование выключено: импортируйте файл на новом и включите там.",
|
||||
"codeErrorInvalid": "Неверный код",
|
||||
"msgActionsCopyLink": "Скопировать ссылку",
|
||||
"themeSettingsCustomTitle": "Своя",
|
||||
"themeSettingsCustomTitle": "Собственная тема",
|
||||
"callKometDetectedNotification": "Этот человек использует Komet! :3",
|
||||
"callTooltipKometHub": "Komet",
|
||||
"webPushOpenSite": "Открыть push.komet.pw",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:ikar/backend/modules/outgoing_text.dart';
|
||||
import 'package:ikar/backend/modules/outbox_queue.dart';
|
||||
import 'package:ikar/core/crypto/e2ee_service.dart';
|
||||
import 'package:ikar/core/crypto/encrypted_photo.dart';
|
||||
import 'package:ikar/core/crypto/message_decryption_cache.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/core/utils/media_cache.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -153,6 +154,42 @@ void main() {
|
||||
await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test(
|
||||
'copy and edit resolve the same sealed text without exposing ciphertext',
|
||||
() async {
|
||||
await handshake();
|
||||
const plaintext = 'synthetic message for editing';
|
||||
final wire = await service.encryptText(42, 7, plaintext);
|
||||
final incoming = CachedMessage(
|
||||
id: '501',
|
||||
accountId: 84,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: wire,
|
||||
time: 501,
|
||||
);
|
||||
final decoded = await service.inspect(
|
||||
incoming,
|
||||
commit: (message) => AppDatabase.saveMessages([message.toDbRow()]),
|
||||
);
|
||||
final cache = MessageDecryptionCache.instance;
|
||||
cache.clear();
|
||||
expect(cache.readableText(decoded), isNull);
|
||||
final results = await Future.wait([
|
||||
cache.resolveText(decoded),
|
||||
cache.resolveText(decoded),
|
||||
]);
|
||||
expect(results, [plaintext, plaintext]);
|
||||
expect(cache.readableText(decoded), plaintext);
|
||||
cache.clear();
|
||||
final pending = cache.resolveText(decoded);
|
||||
cache.clear();
|
||||
expect(await pending, isNull);
|
||||
expect(cache.readableText(decoded), isNull);
|
||||
expect((await AppDatabase.loadMessage(84, 7, '501'))!['text'], wire);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'history establishes a session; fingerprints match; restart preserves decryption',
|
||||
() async {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/photo_hero.dart';
|
||||
import 'package:ikar/frontend/widgets/photo_viewer.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
@@ -41,7 +42,215 @@ Future<void> _pumpViewer(
|
||||
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() {
|
||||
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 {
|
||||
await _pumpViewer(tester, message: _message());
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -152,4 +152,22 @@ void main() {
|
||||
expect(TranscriptionCache.get('101')?.text, 'Готово');
|
||||
expect(TranscriptionCache.get('101')?.status, 1);
|
||||
});
|
||||
|
||||
test('a late request error cannot erase a completed push result', () async {
|
||||
final target = VoiceTranscriptionTarget.fromMessage(_message())!;
|
||||
final response = Completer<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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user