feat: анимации с реанимации

This commit is contained in:
Jganenokk
2026-08-06 19:21:45 +07:00
parent 81159bac60
commit 79c743e86a
50 changed files with 3247 additions and 925 deletions
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/animated_slash_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
Widget _host({required bool slashed}) => MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedSlashIcon(
icon: Symbols.mic,
slashedIcon: Symbols.mic_off,
slashed: slashed,
size: 24,
),
),
),
);
void main() {
testWidgets('в покое рисуется ровно одна исходная иконка', (tester) async {
await tester.pumpWidget(_host(slashed: false));
expect(find.byIcon(Symbols.mic), findsOneWidget);
expect(find.byIcon(Symbols.mic_off), findsNothing);
expect(find.byType(ClipPath), findsNothing);
});
testWidgets('в перечёркнутом покое рисуется ровно off-иконка', (
tester,
) async {
await tester.pumpWidget(_host(slashed: true));
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
expect(find.byIcon(Symbols.mic), findsNothing);
expect(find.byType(ClipPath), findsNothing);
});
testWidgets('переключение проходит через клип обеих иконок', (tester) async {
await tester.pumpWidget(_host(slashed: false));
await tester.pumpWidget(_host(slashed: true));
await tester.pump(const Duration(milliseconds: 120));
expect(find.byIcon(Symbols.mic), findsOneWidget);
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
expect(find.byType(ClipPath), findsNWidgets(2));
await tester.pumpAndSettle();
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
expect(find.byIcon(Symbols.mic), findsNothing);
});
testWidgets('обратное переключение возвращает исходную иконку', (
tester,
) async {
await tester.pumpWidget(_host(slashed: true));
await tester.pumpWidget(_host(slashed: false));
await tester.pumpAndSettle();
expect(find.byIcon(Symbols.mic), findsOneWidget);
expect(find.byIcon(Symbols.mic_off), findsNothing);
});
testWidgets('размер и цвет прокидываются в обе иконки', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedSlashIcon(
icon: Symbols.visibility,
slashedIcon: Symbols.visibility_off,
slashed: false,
size: 14,
color: const Color(0xFF00FF00),
),
),
),
),
);
final icon = tester.widget<Icon>(find.byType(Icon));
expect(icon.size, 14);
expect(icon.color, const Color(0xFF00FF00));
});
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/animated_text_swap.dart';
Widget _host(int value) => MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedValueSwap<int>(
value: value,
builder: (context, v) => Text('$v'),
),
),
),
);
final Finder _slidingParts = find.descendant(
of: find.byType(AnimatedValueSwap<int>),
matching: find.byType(FractionalTranslation),
);
void main() {
testWidgets('первое значение показывается без анимации', (tester) async {
await tester.pumpWidget(_host(3));
expect(find.text('3'), findsOneWidget);
expect(_slidingParts, findsNothing);
});
testWidgets('смена значения перелистывает старое и новое', (tester) async {
await tester.pumpWidget(_host(1));
await tester.pumpWidget(_host(2));
await tester.pump(const Duration(milliseconds: 120));
expect(find.text('1'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(_slidingParts, findsNWidgets(2));
await tester.pumpAndSettle();
expect(find.text('2'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
testWidgets('старое значение уезжает вверх, новое приходит снизу', (
tester,
) async {
await tester.pumpWidget(_host(1));
await tester.pumpWidget(_host(2));
await tester.pump(const Duration(milliseconds: 120));
final outgoing = tester.widget<FractionalTranslation>(
find
.ancestor(
of: find.text('1'),
matching: find.byType(FractionalTranslation),
)
.first,
);
final incoming = tester.widget<FractionalTranslation>(
find
.ancestor(
of: find.text('2'),
matching: find.byType(FractionalTranslation),
)
.first,
);
expect(outgoing.translation.dy, lessThan(0));
expect(incoming.translation.dy, greaterThan(0));
});
testWidgets('тот же самый номер не запускает анимацию', (tester) async {
await tester.pumpWidget(_host(7));
await tester.pumpWidget(_host(7));
await tester.pump(const Duration(milliseconds: 120));
expect(find.text('7'), findsOneWidget);
expect(_slidingParts, findsNothing);
});
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:komet/core/storage/chat_activity_store.dart';
import 'package:komet/core/storage/chat_members_store.dart';
import 'package:komet/frontend/screens/chats/chat/typing_label.dart';
const int _chatId = 900001;
const int _alice = 900101;
const int _bob = 900102;
const int _carol = 900103;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
setUp(() {
ChatMembersStore.instance.clear();
ChatActivityStore.instance.clearChat(_chatId);
ContactCache.clear();
});
group('ChatMembersStore', () {
test('счётчик читается из одного места и уведомляет слушателей', () {
final seen = <int?>[];
final listenable = ChatMembersStore.instance.listenable(_chatId);
listenable.addListener(() => seen.add(listenable.value));
ChatMembersStore.instance.setCount(_chatId, 5);
ChatMembersStore.instance.setCount(_chatId, 5);
ChatMembersStore.instance.adjust(_chatId, 2);
expect(ChatMembersStore.instance.count(_chatId), 7);
expect(seen, [5, 7]);
});
test('adjust не опускает счётчик ниже нуля', () {
ChatMembersStore.instance.setCount(_chatId, 1);
ChatMembersStore.instance.adjust(_chatId, -5);
expect(ChatMembersStore.instance.count(_chatId), 0);
});
test('adjust без известного значения ничего не выдумывает', () {
ChatMembersStore.instance.adjust(_chatId, 3);
expect(ChatMembersStore.instance.count(_chatId), isNull);
});
test('payload чата с сервера заполняет счётчик', () {
ChatMembersStore.instance.applyChatPayload({
'id': _chatId,
'participantsCount': 12,
});
expect(ChatMembersStore.instance.count(_chatId), 12);
});
});
group('Подпись «печатает»', () {
ChatActivitySnapshot snapshot(List<int> ids) {
for (final id in ids) {
ChatActivityStore.instance.mark(_chatId, id, ChatActivity.typing);
}
return ChatActivityStore.instance.snapshot(_chatId)!;
}
test('в диалоге остаётся безымянная подпись', () {
ContactCache.put(_alice, 'Алиса Тестова');
expect(chatActivityLabel(snapshot([_alice])), 'Печатает...');
});
test('в группе показывает имя печатающего', () {
ContactCache.put(_alice, 'Алиса Тестова');
expect(
chatActivityLabel(snapshot([_alice]), withNames: true),
'Алиса печатает...',
);
});
test('двое печатающих перечисляются', () {
ContactCache.put(_alice, 'Алиса Тестова');
ContactCache.put(_bob, 'Борис');
expect(
chatActivityLabel(snapshot([_alice, _bob]), withNames: true),
'Алиса и Борис печатают...',
);
});
test('трое и больше сворачиваются в «и ещё N»', () {
ContactCache.put(_alice, 'Алиса Тестова');
ContactCache.put(_bob, 'Борис');
ContactCache.put(_carol, 'Вера');
expect(
chatActivityLabel(snapshot([_alice, _bob, _carol]), withNames: true),
'Алиса и ещё 2 печатают...',
);
});
test('без известного имени откатывается к общей подписи', () {
expect(
chatActivityLabel(snapshot([_alice]), withNames: true),
'Печатает...',
);
});
test('стикеры получают свой глагол', () {
ContactCache.put(_alice, 'Алиса Тестова');
ChatActivityStore.instance.mark(_chatId, _alice, ChatActivity.sticker);
final snap = ChatActivityStore.instance.snapshot(_chatId)!;
expect(
chatActivityLabel(snap, withNames: true),
'Алиса выбирает стикер...',
);
});
test('снимок отдаёт только пользователей ведущей активности', () {
ChatActivityStore.instance.mark(_chatId, _alice, ChatActivity.sticker);
ChatActivityStore.instance.mark(_chatId, _bob, ChatActivity.typing);
final snap = ChatActivityStore.instance.snapshot(_chatId)!;
expect(snap.activity, ChatActivity.typing);
expect(snap.userIds, [_bob]);
});
});
}
+2
View File
@@ -109,6 +109,8 @@ void main() {
await tester.tap(find.byIcon(Symbols.close));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(cancelCount, 1);
expect(find.text('Пересылка от вас'), findsNothing);
+186
View File
@@ -0,0 +1,186 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/composer_morph_icon.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:lottie/lottie.dart';
import 'package:material_symbols_icons/symbols.dart';
const _assets = [
'assets/lottie/ic_mic_to_videocam.json',
'assets/lottie/ic_videocam_to_mic.json',
'assets/lottie/ic_mic_to_send.json',
'assets/lottie/ic_videocam_to_send.json',
'assets/lottie/ic_send_to_mic.json',
'assets/lottie/ic_send_to_videocam.json',
];
Widget _host(ComposerAction action) => MaterialApp(
home: Scaffold(
body: Center(
child: ComposerMorphIcon(action: action, color: const Color(0xFFFFFFFF)),
),
),
);
List<Map<String, dynamic>> _paths(Map<String, dynamic> doc) {
final layer = (doc['layers'] as List).first as Map<String, dynamic>;
final group = (layer['shapes'] as List).first as Map<String, dynamic>;
return (group['it'] as List)
.cast<Map<String, dynamic>>()
.where((item) => item['ty'] == 'sh')
.toList();
}
void main() {
group('Ассеты морфинга', () {
test('файлы существуют и разбираются', () {
for (final path in _assets) {
final file = File(path);
expect(file.existsSync(), isTrue, reason: '$path отсутствует');
final doc =
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
expect(doc['w'], doc['h'], reason: '$path должен быть квадратным');
expect(doc['op'], greaterThan(0));
expect(_paths(doc), isNotEmpty);
}
});
test('обе ключевые точки контура имеют одинаковое число вершин', () {
for (final path in _assets) {
final doc =
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
for (final shape in _paths(doc)) {
final frames = (shape['ks'] as Map)['k'] as List;
expect(frames.length, 2, reason: '$path: ожидались две ключевые точки');
final from = ((frames.first as Map)['s'] as List).first as Map;
final to = ((frames.last as Map)['s'] as List).first as Map;
for (final key in ['v', 'i', 'o']) {
expect(
(to[key] as List).length,
(from[key] as List).length,
reason: '$path: «$key» разной длины — морф не построится',
);
}
}
}
});
test('анимации не начинаются и не заканчиваются смещением', () {
for (final path in _assets) {
final doc =
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
final layer = (doc['layers'] as List).first as Map<String, dynamic>;
final transform = layer['ks'] as Map<String, dynamic>;
for (final key in ['r', 's', 'p']) {
final prop = transform[key] as Map<String, dynamic>;
if (prop['a'] != 1) continue;
final frames = (prop['k'] as List).cast<Map<String, dynamic>>();
expect(
frames.first['s'],
frames.last['s'],
reason: '$path: «$key» должен возвращаться в исходное значение',
);
}
}
});
});
group('ComposerMorphIcon', () {
testWidgets('в покое рисует обычную иконку', (tester) async {
await tester.pumpWidget(_host(ComposerAction.mic));
expect(find.byIcon(Symbols.mic), findsOneWidget);
expect(find.byType(Lottie), findsNothing);
});
testWidgets('на смене состояния запускает lottie', (tester) async {
await tester.pumpWidget(_host(ComposerAction.mic));
await tester.pumpWidget(_host(ComposerAction.videocam));
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(Lottie), findsOneWidget);
expect(find.byType(Icon), findsNothing);
});
testWidgets('после анимации возвращает обычную иконку', (tester) async {
await tester.pumpWidget(_host(ComposerAction.mic));
await tester.pumpWidget(_host(ComposerAction.send));
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(find.byIcon(Symbols.send), findsOneWidget);
expect(find.byType(Lottie), findsNothing);
});
testWidgets('каждая следующая смена тоже анимируется', (tester) async {
await tester.pumpWidget(_host(ComposerAction.mic));
const sequence = [
ComposerAction.videocam,
ComposerAction.send,
ComposerAction.videocam,
ComposerAction.mic,
ComposerAction.send,
ComposerAction.mic,
];
for (final action in sequence) {
await tester.pumpWidget(_host(action));
await tester.pump(const Duration(milliseconds: 100));
expect(
find.byType(Lottie),
findsOneWidget,
reason: 'переход в $action должен проигрываться',
);
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(
find.byType(Lottie),
findsNothing,
reason: 'переход в $action должен завершаться статикой',
);
expect(find.byIcon(composerActionIcon(action)), findsOneWidget);
}
});
testWidgets('морф переживает появление обработчика нажатия', (tester) async {
Widget host(bool sendMode) => MaterialApp(
home: Scaffold(
body: Center(
child: GlossyPill(
onTap: sendMode ? () {} : null,
keepInkLayer: true,
child: ComposerMorphIcon(
action: sendMode ? ComposerAction.send : ComposerAction.mic,
color: const Color(0xFFFFFFFF),
),
),
),
),
);
await tester.pumpWidget(host(false));
await tester.pumpWidget(host(true));
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(Lottie), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(find.byIcon(Symbols.send), findsOneWidget);
});
testWidgets('обратный переход тоже завершается статикой', (tester) async {
await tester.pumpWidget(_host(ComposerAction.send));
await tester.pumpWidget(_host(ComposerAction.videocam));
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(find.byIcon(Symbols.videocam), findsOneWidget);
expect(find.byType(Lottie), findsNothing);
});
});
}