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

Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
This commit is contained in:
sevenhill
2026-10-07 22:07:05 +03:00
parent 69ea5091eb
commit 1ef2b7420e
35 changed files with 2210 additions and 524 deletions
+363
View File
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/core/utils/haptics.dart';
import 'package:ikar/frontend/widgets/swipe_route.dart';
import 'package:ikar/frontend/widgets/swipe_to_reply.dart';
import 'package:ikar/frontend/widgets/scroll_keyboard_dismiss.dart';
const _backgroundKey = ValueKey('background');
const _pageKey = ValueKey('page');
const _messageKey = ValueKey('message');
class _Harness {
final navigatorKey = GlobalKey<NavigatorState>();
final focus = FocusNode();
final scroll = ScrollController();
int replies = 0;
Future<void> pump(WidgetTester tester, {bool canPop = true}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
navigatorKey: navigatorKey,
home: const Scaffold(body: SizedBox.expand(key: _backgroundKey)),
),
);
navigatorKey.currentState!.push(
SwipeRoute<void>(
builder: (_) => PopScope(
canPop: canPop,
child: Scaffold(
key: _pageKey,
body: Column(
children: [
Expanded(
child: ScrollKeyboardDismiss(
onDismiss: focus.unfocus,
child: CustomScrollView(
controller: scroll,
reverse: true,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.manual,
slivers: [
SliverList.builder(
itemCount: 30,
itemBuilder: (_, index) => SwipeToReply(
onReply: () => replies++,
child: SizedBox(
key: index == 2 ? _messageKey : null,
height: 100,
width: double.infinity,
child: Text('Synthetic message $index'),
),
),
),
],
),
),
),
TextField(focusNode: focus),
],
),
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> dispose(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
focus.dispose();
scroll.dispose();
}
}
void main() {
setUp(() => Haptics.enabled = false);
tearDown(() => Haptics.enabled = true);
testWidgets('slow back drag follows the finger and dismisses the keyboard', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await tester.showKeyboard(find.byType(TextField));
expect(harness.focus.hasFocus, isTrue);
expect(tester.testTextInput.isVisible, isTrue);
final start = tester.getCenter(find.byKey(_messageKey));
final gesture = await tester.startGesture(start);
await gesture.moveBy(
const Offset(40, 0),
timeStamp: const Duration(milliseconds: 300),
);
await tester.pump();
expect(harness.navigatorKey.currentState!.userGestureInProgress, isTrue);
expect(harness.focus.hasFocus, isFalse);
expect(tester.testTextInput.isVisible, isFalse);
expect(tester.getTopLeft(find.byKey(_pageKey)).dx, closeTo(40, 0.1));
await gesture.moveBy(
const Offset(80, 0),
timeStamp: const Duration(milliseconds: 600),
);
await tester.pump();
final width = tester.getSize(find.byKey(_pageKey)).width;
expect(tester.getTopLeft(find.byKey(_pageKey)).dx, closeTo(120, 0.1));
expect(
tester.getTopLeft(find.byKey(_backgroundKey)).dx,
closeTo(-(width - 120) / 3, 0.1),
);
await gesture.up(timeStamp: const Duration(milliseconds: 1000));
await tester.pumpAndSettle();
expect(find.byKey(_pageKey), findsOneWidget);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
expect(tester.getTopLeft(find.byKey(_pageKey)).dx, 0);
await harness.dispose(tester);
});
testWidgets('a slow back drag past half the screen pops once', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
final y = tester.getCenter(find.byKey(_messageKey)).dy;
final gesture = await tester.startGesture(Offset(100, y));
for (var step = 1; step <= 11; step++) {
await gesture.moveBy(
const Offset(40, 0),
timeStamp: Duration(milliseconds: step * 160),
);
await tester.pump(const Duration(milliseconds: 16));
}
await gesture.up(timeStamp: const Duration(milliseconds: 2000));
await tester.pumpAndSettle();
expect(find.byKey(_pageKey), findsNothing);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
expect(harness.replies, 0);
await harness.dispose(tester);
});
testWidgets('a cancelled back gesture restores the page past halfway', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
final y = tester.getCenter(find.byKey(_messageKey)).dy;
final gesture = await tester.startGesture(Offset(100, y));
await gesture.moveBy(
const Offset(450, 0),
timeStamp: const Duration(milliseconds: 700),
);
await tester.pump();
await gesture.cancel();
await tester.pumpAndSettle();
expect(find.byKey(_pageKey), findsOneWidget);
expect(tester.getTopLeft(find.byKey(_pageKey)).dx, 0);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
await harness.dispose(tester);
});
testWidgets('an interrupted route releases its active back gesture', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
await gesture.moveBy(
const Offset(60, 0),
timeStamp: const Duration(milliseconds: 200),
);
await tester.pump();
harness.navigatorKey.currentState!.pop();
await tester.pumpAndSettle();
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(_pageKey), findsNothing);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
expect(tester.takeException(), isNull);
await harness.dispose(tester);
});
testWidgets(
'reply uses the initial movement and leaves back navigation idle',
(tester) async {
final harness = _Harness();
await harness.pump(tester);
await tester.showKeyboard(find.byType(TextField));
final initialLeft = tester.getTopLeft(find.byKey(_messageKey)).dx;
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
await gesture.moveBy(
const Offset(-60, 0),
timeStamp: const Duration(milliseconds: 300),
);
await tester.pump();
expect(
tester.getTopLeft(find.byKey(_messageKey)).dx,
lessThan(initialLeft - 56),
);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
expect(harness.focus.hasFocus, isTrue);
expect(harness.replies, 0);
await gesture.up();
await tester.pumpAndSettle();
expect(harness.replies, 1);
expect(tester.getTopLeft(find.byKey(_messageKey)).dx, initialLeft);
await harness.dispose(tester);
},
);
testWidgets('reply resistance is gradual and retracting cancels the reply', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
await gesture.moveBy(const Offset(-100, 0));
await tester.pump();
final firstOffset = tester.getTopLeft(find.byKey(_messageKey)).dx;
await gesture.moveBy(const Offset(-100, 0));
await tester.pump();
final nextOffset = tester.getTopLeft(find.byKey(_messageKey)).dx;
expect(nextOffset, lessThan(firstOffset));
expect(firstOffset - nextOffset, lessThan(20));
await gesture.moveBy(const Offset(180, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(harness.replies, 0);
expect(tester.getTopLeft(find.byKey(_messageKey)).dx, 0);
await harness.dispose(tester);
});
testWidgets('pointer cancellation never commits a reply', (tester) async {
final harness = _Harness();
await harness.pump(tester);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
await gesture.moveBy(const Offset(-100, 0));
await tester.pump();
await gesture.cancel();
await tester.pumpAndSettle();
expect(harness.replies, 0);
expect(tester.getTopLeft(find.byKey(_messageKey)).dx, 0);
await harness.dispose(tester);
});
testWidgets(
'vertical scrolling dismisses focus without replying or popping',
(tester) async {
final harness = _Harness();
await harness.pump(tester);
await tester.showKeyboard(find.byType(TextField));
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
for (var step = 1; step <= 8; step++) {
await gesture.moveBy(
const Offset(1, 14),
timeStamp: Duration(milliseconds: step * 28),
);
await tester.pump(const Duration(milliseconds: 28));
}
await gesture.up(timeStamp: const Duration(milliseconds: 230));
await tester.pumpAndSettle();
expect(harness.scroll.offset, greaterThan(0));
expect(harness.focus.hasFocus, isFalse);
expect(harness.replies, 0);
expect(find.byKey(_pageKey), findsOneWidget);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
await harness.dispose(tester);
},
);
testWidgets('slow vertical scrolling keeps the keyboard open', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await tester.showKeyboard(find.byType(TextField));
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
for (var step = 1; step <= 10; step++) {
await gesture.moveBy(
const Offset(0, 10),
timeStamp: Duration(milliseconds: step * 140),
);
await tester.pump(const Duration(milliseconds: 140));
expect(harness.focus.hasFocus, isTrue);
}
await gesture.up(timeStamp: const Duration(milliseconds: 1500));
await tester.pumpAndSettle();
expect(harness.scroll.offset, greaterThan(0));
expect(tester.testTextInput.isVisible, isTrue);
expect(harness.replies, 0);
await harness.dispose(tester);
});
testWidgets('accelerating a slow scroll dismisses the keyboard', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester);
await tester.showKeyboard(find.byType(TextField));
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(_messageKey)),
);
for (var step = 1; step <= 5; step++) {
await gesture.moveBy(
const Offset(0, 10),
timeStamp: Duration(milliseconds: step * 140),
);
await tester.pump(const Duration(milliseconds: 140));
}
expect(harness.focus.hasFocus, isTrue);
for (var step = 1; step <= 8; step++) {
await gesture.moveBy(
const Offset(0, 12),
timeStamp: Duration(milliseconds: 700 + step * 20),
);
await tester.pump(const Duration(milliseconds: 20));
}
expect(harness.focus.hasFocus, isFalse);
await gesture.up(timeStamp: const Duration(milliseconds: 880));
await tester.pumpAndSettle();
await harness.dispose(tester);
});
testWidgets('programmatic scrolling keeps the keyboard open', (tester) async {
final harness = _Harness();
await harness.pump(tester);
await tester.showKeyboard(find.byType(TextField));
harness.scroll.jumpTo(400);
await tester.pumpAndSettle();
expect(harness.focus.hasFocus, isTrue);
expect(tester.testTextInput.isVisible, isTrue);
await harness.dispose(tester);
});
testWidgets('a blocked pop does not dismiss the composer or move the page', (
tester,
) async {
final harness = _Harness();
await harness.pump(tester, canPop: false);
await tester.showKeyboard(find.byType(TextField));
await tester.drag(find.byKey(_messageKey), const Offset(250, 0));
await tester.pumpAndSettle();
expect(harness.focus.hasFocus, isTrue);
expect(tester.getTopLeft(find.byKey(_pageKey)).dx, 0);
expect(harness.navigatorKey.currentState!.userGestureInProgress, isFalse);
await harness.dispose(tester);
});
}
+117
View File
@@ -0,0 +1,117 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:ikar/core/config/app_wallpaper_tint.dart';
import 'package:ikar/core/storage/app_database.dart';
import 'package:ikar/core/storage/chat_wallpaper_store.dart';
import 'package:ikar/core/utils/haptics.dart';
import 'package:ikar/frontend/screens/profile/custom_gradient_editor_screen.dart';
import 'package:ikar/frontend/screens/profile/theme_settings_screen.dart';
import 'package:ikar/l10n/app_localizations.dart';
import 'package:ikar/main.dart' show api;
import 'package:shared_preferences/shared_preferences.dart';
class _Paths extends PathProviderPlatform {
_Paths(this.path);
final String path;
@override
Future<String?> getApplicationSupportPath() async => path;
@override
Future<String?> getApplicationDocumentsPath() async => path;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'custom theme edits the global gradient and keeps chat overrides',
(tester) async {
final previousPaths = PathProviderPlatform.instance;
late Directory dir;
await tester.runAsync(() async {
api.state;
dir = await Directory.systemTemp.createTemp('ikar-theme-test-');
PathProviderPlatform.instance = _Paths(dir.path);
SharedPreferences.setMockInitialValues({});
Haptics.enabled = false;
await AppWallpaperTint.load();
await AppDatabase.init();
await AppDatabase.saveProfile(ProfileData.stub(42));
await AppDatabase.setActiveAccount(42);
await ChatWallpaperStore.instance.load();
await ChatWallpaperStore.instance.setTheme(
42,
7,
'synthetic-chat-theme',
);
});
addTearDown(() async {
await api.dispose();
await AppDatabase.close();
PathProviderPlatform.instance = previousPaths;
Haptics.enabled = true;
await dir.delete(recursive: true);
});
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const ThemeSettingsScreen(),
),
);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Собственная тема'));
await tester.pumpAndSettle();
expect(AppWallpaperTint.current.value, isTrue);
expect(find.text('Настроить'), findsOneWidget);
await tester.tap(find.text('Настроить'));
await tester.pumpAndSettle();
expect(find.byType(CustomGradientEditorScreen), findsOneWidget);
final editorContext = tester.element(
find.byType(CustomGradientEditorScreen),
);
Navigator.of(editorContext).pop(
const CustomGradientResult(
colors: [Colors.red, Colors.blue],
animated: false,
rotation: 0.7,
),
);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)),
);
await tester.pumpAndSettle();
final wallpaper = ChatWallpaperStore.instance.get(
42,
kGlobalWallpaperChatId,
)!;
expect(wallpaper.gradientColors!.map((color) => color.toARGB32()), [
Colors.red.toARGB32(),
Colors.blue.toARGB32(),
]);
expect(wallpaper.gradientRotation, 0.7);
expect(
ChatWallpaperStore.instance.get(42, 7)!.themeId,
'synthetic-chat-theme',
);
final restored = ChatWallpaperStore.forTesting();
await tester.runAsync(restored.load);
expect(
restored
.get(42, kGlobalWallpaperChatId)!
.gradientColors!
.map((color) => color.toARGB32()),
[Colors.red.toARGB32(), Colors.blue.toARGB32()],
);
await tester.tap(find.text('Системная'));
await tester.pumpAndSettle();
expect(AppWallpaperTint.current.value, isFalse);
expect(restored.get(42, kGlobalWallpaperChatId), isNotNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
+37
View File
@@ -12,6 +12,7 @@ import 'package:ikar/backend/modules/outgoing_text.dart';
import 'package:ikar/backend/modules/outbox_queue.dart';
import 'package:ikar/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 {
+114
View File
@@ -0,0 +1,114 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:ikar/backend/api.dart';
import 'package:ikar/backend/ikar_transport.dart';
import 'package:ikar/core/config/qlyra_settings.dart';
import 'package:ikar/core/protocol/opcode_map.dart';
import 'package:ikar/core/utils/app_foreground.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _Transport extends IkarTransport {
_Transport(
super.uri,
List<Map> requests, {
Future<void> Function(Map)? onRequest,
}) : super(
client: MockClient((request) async {
final payload = jsonDecode(request.body)['payload'] as Map;
requests.add(payload);
await onRequest?.call(payload);
return http.Response(jsonEncode({'cmd': 1, 'payload': {}}), 200);
}),
);
bool connected = false;
final eventActivity = <bool>[];
@override
bool get eventsConnected => connected;
@override
Future<void> connectEvents() async {
eventActivity.add(interactive);
connected = true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
QlyraSettings.ghostMode.value = false;
AppForeground.update(foreground: true);
});
test('explicit inactive ping reaches the server', () async {
final requests = <Map>[];
final api = Api(transportFactory: (uri) => _Transport(uri, requests));
addTearDown(api.dispose);
await api.connect();
api.sendPing(interactive: false);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(requests.last['interactive'], false);
});
test('ghost mode survives login and event reconnection', () async {
QlyraSettings.ghostMode.value = true;
final requests = <Map>[];
late _Transport transport;
final api = Api(
transportFactory: (uri) => transport = _Transport(uri, requests),
);
addTearDown(api.dispose);
await api.connect();
await api.sendRequest(Opcode.login, {
'token': 'synthetic',
'interactive': true,
});
expect(requests.last['interactive'], false);
expect(transport.eventActivity, [false]);
transport.connected = false;
api.wakeUp();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(transport.eventActivity, [false, false]);
expect(requests.last['interactive'], false);
QlyraSettings.ghostMode.value = false;
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(requests.last['interactive'], true);
AppForeground.update(foreground: false);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(requests.last['interactive'], false);
});
test('a slow active ping cannot override newly enabled ghost mode', () async {
final requests = <Map>[];
final started = Completer<void>();
final release = Completer<void>();
final hidden = Completer<void>();
final api = Api(
transportFactory: (uri) => _Transport(
uri,
requests,
onRequest: (payload) async {
if (payload['interactive'] == true && !started.isCompleted) {
started.complete();
await release.future;
}
if (payload['interactive'] == false && !hidden.isCompleted) {
hidden.complete();
}
},
),
);
addTearDown(api.dispose);
await api.connect();
api.sendPing(interactive: true);
await started.future;
QlyraSettings.ghostMode.value = true;
await Future<void>.delayed(Duration.zero);
expect(
requests.where((payload) => payload.containsKey('interactive')),
hasLength(1),
);
release.complete();
await hidden.future.timeout(const Duration(seconds: 2));
expect(requests.last['interactive'], false);
});
}
+209
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_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());
+82
View File
@@ -0,0 +1,82 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/frontend/widgets/attachment/editor_common.dart';
import 'package:ikar/frontend/widgets/attachment/video_edit.dart';
import 'package:ikar/frontend/widgets/attachment/video_editor.dart';
import 'package:ikar/l10n/app_localizations.dart';
void main() {
testWidgets(
'quality endpoints remain reachable outside Android gesture edges',
(tester) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawColor(Colors.blue, BlendMode.src);
final picture = recorder.endRecording();
final image = picture.toImageSync(32, 32);
addTearDown(image.dispose);
addTearDown(picture.dispose);
int? selected;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
systemGestureInsets: const EdgeInsets.only(left: 60, right: 70),
),
child: child!,
),
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VideoQualityEditor(
frame: image,
geometry: VideoGeometry.resolve(
null,
const Size(720, 1280),
),
adjust: ColorAdjust(),
options: const [360, 480, 720],
selected: 480,
fps: 30,
duration: const Duration(seconds: 2),
onPreview: (result) async => selected = result,
),
),
),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
final slider = find.byWidgetPredicate(
(widget) =>
widget is GestureDetector &&
widget.onHorizontalDragUpdate != null &&
widget.child is CustomPaint,
);
expect(slider, findsOneWidget);
final bounds = tester.getRect(slider);
expect(bounds.width, 360);
expect(bounds.height, greaterThanOrEqualTo(48));
await tester.tapAt(Offset(bounds.right - 80, bounds.center.dy));
await tester.pump();
await tester.tap(find.text('ГОТОВО'));
await tester.pumpAndSettle();
expect(selected, 720);
expect(tester.takeException(), isNull);
},
);
}
+18
View File
@@ -152,4 +152,22 @@ void main() {
expect(TranscriptionCache.get('101')?.text, 'Готово');
expect(TranscriptionCache.get('101')?.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);
});
}