feat/fix: бейджик для свернутого звонка. Фикс смены иконки на айос

This commit is contained in:
Jganenokk
2026-08-21 18:44:40 +07:00
parent 8ce5df9b7e
commit eb94461c04
21 changed files with 1637 additions and 112 deletions
+96
View File
@@ -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);
});
}
+128
View File
@@ -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));
});
}
+94
View File
@@ -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);
});
}