feat/fix: бейджик для свернутого звонка. Фикс смены иконки на айос
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/core/config/app_icon.dart';
|
||||
|
||||
Set<String> _iosAlternateIcons() {
|
||||
final plist = File('ios/Runner/Info.plist').readAsStringSync();
|
||||
final anchor = plist.indexOf('<key>CFBundleAlternateIcons</key>');
|
||||
expect(
|
||||
anchor,
|
||||
isNonNegative,
|
||||
reason: 'в Info.plist нет альтернативных иконок',
|
||||
);
|
||||
final tokens = RegExp(
|
||||
r'<dict>|</dict>|<key>([^<]*)</key>',
|
||||
).allMatches(plist.substring(anchor));
|
||||
|
||||
final names = <String>{};
|
||||
var depth = 0;
|
||||
var entered = false;
|
||||
for (final token in tokens) {
|
||||
final text = token.group(0)!;
|
||||
if (text == '<dict>') {
|
||||
depth++;
|
||||
entered = true;
|
||||
} else if (text == '</dict>') {
|
||||
depth--;
|
||||
if (entered && depth == 0) break;
|
||||
} else if (entered && depth == 1) {
|
||||
names.add(token.group(1)!);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
Set<String> _androidComponents() {
|
||||
final manifest = File(
|
||||
'android/app/src/main/AndroidManifest.xml',
|
||||
).readAsStringSync();
|
||||
return RegExp(
|
||||
r'android:name="ru\.komet\.app\.(\w+)"',
|
||||
).allMatches(manifest).map((m) => m.group(1)!).toSet();
|
||||
}
|
||||
|
||||
Set<String> _androidIconKeys() {
|
||||
final source = File(
|
||||
'android/app/src/main/kotlin/ru/komet/app/MainActivity.kt',
|
||||
).readAsStringSync();
|
||||
final start = source.indexOf('iconComponents = mapOf(');
|
||||
expect(start, isNonNegative, reason: 'в MainActivity нет карты иконок');
|
||||
final body = source.substring(start, source.indexOf(')', start));
|
||||
return RegExp(r'"(\w+)" to').allMatches(body).map((m) => m.group(1)!).toSet();
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('дефолтная иконка на iOS — это primary, а не альтернативная', () {
|
||||
expect(
|
||||
AppIcon.defaultIcon.iosAlternateName,
|
||||
isNull,
|
||||
reason: 'setAlternateIconName ждёт nil, любое имя тут — APPLY_FAILED',
|
||||
);
|
||||
});
|
||||
|
||||
test('каждая альтернативная иконка объявлена в Info.plist', () {
|
||||
final declared = _iosAlternateIcons();
|
||||
for (final icon in AppIcon.values) {
|
||||
final name = icon.iosAlternateName;
|
||||
if (name == null) continue;
|
||||
expect(
|
||||
declared,
|
||||
contains(name),
|
||||
reason: 'иконка ${icon.id} шлёт в iOS имя $name',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('каждый android alias объявлен в манифесте и в MainActivity', () {
|
||||
final components = _androidComponents();
|
||||
final keys = _androidIconKeys();
|
||||
for (final icon in AppIcon.values) {
|
||||
expect(
|
||||
components,
|
||||
contains(icon.androidAlias),
|
||||
reason: 'иконка ${icon.id} шлёт в Android имя ${icon.androidAlias}',
|
||||
);
|
||||
expect(keys, contains(icon.androidAlias));
|
||||
}
|
||||
});
|
||||
|
||||
test('имена иконок не пересекаются между платформами по смыслу', () {
|
||||
final ids = AppIcon.values.map((i) => i.id).toSet();
|
||||
expect(ids.length, AppIcon.values.length);
|
||||
final aliases = AppIcon.values.map((i) => i.androidAlias).toSet();
|
||||
expect(aliases.length, AppIcon.values.length);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/core/calls/active_call.dart';
|
||||
import 'package:komet/core/calls/call_session.dart';
|
||||
import 'package:komet/core/calls/ws2_signaling.dart';
|
||||
import 'package:komet/frontend/widgets/floating_call_badge.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const _name = 'Тестовый собеседник';
|
||||
|
||||
CallSession _session() => CallSession(
|
||||
ws2Config: Ws2Config(uri: Uri.parse('wss://calls.invalid/ws2'), userId: 42),
|
||||
role: CallRole.caller,
|
||||
);
|
||||
|
||||
Widget _host() => MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder: (context, child) => Stack(
|
||||
children: [
|
||||
child!,
|
||||
const Positioned.fill(child: FloatingCallBadgeLayer()),
|
||||
],
|
||||
),
|
||||
home: const Scaffold(body: SizedBox.expand()),
|
||||
);
|
||||
|
||||
final Finder _hangup = find.byIcon(Symbols.call_end);
|
||||
|
||||
double _controlsOpacity(WidgetTester tester) => tester
|
||||
.widget<Opacity>(
|
||||
find.ancestor(of: _hangup, matching: find.byType(Opacity)).first,
|
||||
)
|
||||
.opacity;
|
||||
|
||||
Finder get _badge => find.descendant(
|
||||
of: find.byType(FloatingCallBadgeLayer),
|
||||
matching: find.byType(ScaleTransition),
|
||||
);
|
||||
|
||||
Offset _badgeCorner(WidgetTester tester) => tester.getTopLeft(_badge);
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
ActiveCall.instance.detach();
|
||||
while (ActiveCall.instance.screenVisible.value) {
|
||||
ActiveCall.instance.leaveScreen();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('бейджик виден только пока экран звонка закрыт', (tester) async {
|
||||
await tester.pumpWidget(_host());
|
||||
expect(find.text(_name), findsNothing);
|
||||
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsOneWidget);
|
||||
|
||||
ActiveCall.instance.enterScreen();
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsNothing);
|
||||
|
||||
ActiveCall.instance.leaveScreen();
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsOneWidget);
|
||||
|
||||
ActiveCall.instance.detach();
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('тап раскрывает кнопки и повторный тап их прячет', (
|
||||
tester,
|
||||
) async {
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pumpWidget(_host());
|
||||
|
||||
expect(
|
||||
_hangup,
|
||||
findsNothing,
|
||||
reason: 'свёрнутый бейджик не держит кнопки в дереве',
|
||||
);
|
||||
|
||||
await tester.tap(find.text(_name));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_hangup, findsOneWidget);
|
||||
expect(_controlsOpacity(tester), 1);
|
||||
|
||||
await tester.tap(find.text(_name));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_hangup, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('кнопки прячутся сами, если бейджик не трогают', (tester) async {
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pumpWidget(_host());
|
||||
|
||||
await tester.tap(find.text(_name));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_controlsOpacity(tester), 1);
|
||||
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_hangup, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('перетаскивание липнет к краю экрана', (tester) async {
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pumpWidget(_host());
|
||||
|
||||
final screen = tester.getSize(find.byType(FloatingCallBadgeLayer)).width;
|
||||
final start = _badgeCorner(tester);
|
||||
final width = tester.getSize(_badge).width;
|
||||
expect(start.dx + width, moreOrLessEquals(screen - 12));
|
||||
|
||||
await tester.timedDrag(
|
||||
find.text(_name),
|
||||
const Offset(-260, -120),
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_badgeCorner(tester).dx, moreOrLessEquals(12));
|
||||
expect(_badgeCorner(tester).dy, lessThan(start.dy));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/frontend/widgets/draggable_floating_layer.dart';
|
||||
|
||||
const _screen = Size(400, 800);
|
||||
const _safe = EdgeInsets.only(top: 48, bottom: 24);
|
||||
|
||||
FloatingDockGeometry _dockOf(Size size) =>
|
||||
FloatingDockGeometry(bounds: _screen, size: size, safeArea: _safe);
|
||||
|
||||
final _badge = _dockOf(const Size(120, 118));
|
||||
|
||||
void main() {
|
||||
test('бейджик стартует у правого края над нижней панелью', () {
|
||||
final resting = _badge.resting;
|
||||
expect(resting.dx, _screen.width - 120 - 12);
|
||||
expect(resting.dy, _screen.height - 118 - 24 - 96);
|
||||
});
|
||||
|
||||
test('позиция зажата в безопасной зоне', () {
|
||||
expect(_badge.clamp(const Offset(-500, -500)), Offset(12, 48 + 12));
|
||||
expect(
|
||||
_badge.clamp(const Offset(9999, 9999)),
|
||||
Offset(_screen.width - 120 - 12, _screen.height - 118 - 24 - 12),
|
||||
);
|
||||
});
|
||||
|
||||
test('без броска липнет к ближнему краю', () {
|
||||
final left = _badge.snap(const Offset(40, 300), Offset.zero);
|
||||
expect(left.dx, 12);
|
||||
expect(
|
||||
left.dy,
|
||||
300,
|
||||
reason: 'высота не меняется без вертикальной скорости',
|
||||
);
|
||||
|
||||
final right = _badge.snap(const Offset(250, 300), Offset.zero);
|
||||
expect(right.dx, _screen.width - 120 - 12);
|
||||
});
|
||||
|
||||
test('бросок перебивает ближний край', () {
|
||||
final flungRight = _badge.snap(const Offset(40, 300), const Offset(900, 0));
|
||||
expect(flungRight.dx, _screen.width - 120 - 12);
|
||||
|
||||
final flungLeft = _badge.snap(
|
||||
const Offset(250, 300),
|
||||
const Offset(-900, 0),
|
||||
);
|
||||
expect(flungLeft.dx, 12);
|
||||
});
|
||||
|
||||
test('слабый рывок не считается броском', () {
|
||||
final weak = _badge.snap(const Offset(40, 300), const Offset(200, 0));
|
||||
expect(weak.dx, 12);
|
||||
});
|
||||
|
||||
test('вертикальный бросок продолжает движение, но не за экран', () {
|
||||
final down = _badge.snap(const Offset(250, 300), const Offset(0, 1000));
|
||||
expect(down.dy, 300 + 1000 * FloatingDockGeometry.flingSeconds);
|
||||
|
||||
final overshoot = _badge.snap(
|
||||
const Offset(250, 600),
|
||||
const Offset(0, 5000),
|
||||
);
|
||||
expect(overshoot.dy, _screen.height - 118 - 24 - 12);
|
||||
|
||||
final up = _badge.snap(const Offset(250, 100), const Offset(0, -5000));
|
||||
expect(up.dy, 48 + 12);
|
||||
});
|
||||
|
||||
test('раскрытие бейджика удерживает правый край на месте', () {
|
||||
final collapsed = _badge;
|
||||
final expanded = _dockOf(const Size(152, 166));
|
||||
|
||||
final docked = collapsed.resting;
|
||||
final grown = expanded.clamp(docked);
|
||||
|
||||
expect(
|
||||
grown.dx + 152,
|
||||
docked.dx + 120,
|
||||
reason: 'кнопки должны вырастать влево, а не выезжать за экран',
|
||||
);
|
||||
});
|
||||
|
||||
test('узкий экран не переворачивает границы', () {
|
||||
final tiny = FloatingDockGeometry(
|
||||
bounds: const Size(100, 200),
|
||||
size: const Size(120, 118),
|
||||
safeArea: EdgeInsets.zero,
|
||||
);
|
||||
expect(tiny.maxX, tiny.minX);
|
||||
expect(tiny.clamp(const Offset(50, 50)).dx, 12);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user