feat: анимации с реанимации
This commit is contained in:
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user