feat: просмотрщик фото

This commit is contained in:
Jganenokk
2026-07-14 15:36:18 +07:00
parent d0fc7a8ec9
commit c3d70d9bfc
17 changed files with 1539 additions and 163 deletions
+117
View File
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/frontend/widgets/photo_viewer.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/models/attachment.dart';
CachedMessage _album(List<PhotoAttachment> photos) => CachedMessage(
id: '1',
accountId: 1,
chatId: 2,
senderId: 1,
time: DateTime(2026, 1, 1).millisecondsSinceEpoch,
status: 'sent',
attachments: photos,
);
List<PhotoAttachment> _remote(int count) => List.generate(
count,
(i) => PhotoAttachment(
baseUrl: 'https://example.com/$i.jpg',
width: 1200,
height: 1600,
),
);
List<PhotoAttachment> _local(int count) => List.generate(
count,
(i) => PhotoAttachment(localPath: '/tmp/photo$i.jpg', width: 1200, height: 1600),
);
Future<void> _pumpBubble(WidgetTester tester, CachedMessage message) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
tester.view.padding = const FakeViewPadding(top: 210, bottom: 120);
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(
alignment: Alignment.topCenter,
child: MessageBubble(
message: message,
isMe: true,
myId: 1,
chatType: 'DIALOG',
),
),
),
),
);
await tester.pump();
}
int _viewerIndex(WidgetTester tester) =>
tester.widget<PhotoViewerScreen>(find.byType(PhotoViewerScreen)).initialIndex;
Size _bubbleSize(WidgetTester tester) => tester.getSize(
find
.ancestor(
of: find.byType(ClipRRect).first,
matching: find.byType(ConstrainedBox),
)
.first,
);
void main() {
testWidgets('album grid ignores safe area insets', (tester) async {
await _pumpBubble(tester, _album(_remote(4)));
final size = _bubbleSize(tester);
expect(size.height, closeTo(size.width, 1));
});
testWidgets('tapping an album photo opens the viewer at its index', (
tester,
) async {
await _pumpBubble(tester, _album(_remote(4)));
await tester.tap(find.byType(GestureDetector).at(2));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(PhotoViewerScreen), findsOneWidget);
expect(_viewerIndex(tester), 2);
});
testWidgets('the +N tile opens the viewer', (tester) async {
await _pumpBubble(tester, _album(_remote(6)));
expect(find.text('+2'), findsOneWidget);
await tester.tap(find.byType(GestureDetector).at(3));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(PhotoViewerScreen), findsOneWidget);
expect(_viewerIndex(tester), 3);
});
testWidgets('photos still uploading open from their local file', (
tester,
) async {
await _pumpBubble(tester, _album(_local(4)));
await tester.tap(find.byType(GestureDetector).first);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(PhotoViewerScreen), findsOneWidget);
});
}
+196
View File
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/photo_viewer.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/models/attachment.dart';
import 'package:material_symbols_icons/symbols.dart';
CachedMessage _message({String? text}) => CachedMessage(
id: '77',
accountId: 1,
chatId: 2,
senderId: 5,
text: text,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sent',
attachments: const [],
);
Future<void> _pumpViewer(
WidgetTester tester, {
CachedMessage? message,
PhotoViewerActions? actions,
}) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PhotoViewerScreen(
photos: const [
PhotoAttachment(baseUrl: 'https://example.com/a.jpg'),
PhotoAttachment(baseUrl: 'https://example.com/b.jpg'),
],
message: message,
actions: actions,
),
),
);
await tester.pump();
}
void main() {
testWidgets('shows who sent the photo and when', (tester) async {
await _pumpViewer(tester, message: _message());
expect(find.textContaining('сегодня в'), findsOneWidget);
});
testWidgets('rotate button turns the photo by 90 degrees', (tester) async {
await _pumpViewer(tester, message: _message());
expect(
tester.widget<RotatedBox>(find.byType(RotatedBox).first).quarterTurns,
0,
);
await tester.tap(find.byIcon(Symbols.rotate_90_degrees_ccw));
await tester.pump();
expect(
tester.widget<RotatedBox>(find.byType(RotatedBox).first).quarterTurns,
1,
);
});
testWidgets('shows the photo caption when there is one', (tester) async {
await _pumpViewer(tester, message: _message(text: 'Делу время'));
expect(find.text('Делу время'), findsOneWidget);
});
testWidgets('arrows step between photos', (tester) async {
await _pumpViewer(tester, message: _message());
expect(find.byIcon(Symbols.chevron_left), findsNothing);
expect(find.byIcon(Symbols.chevron_right), findsOneWidget);
await tester.tap(find.byIcon(Symbols.chevron_right));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byIcon(Symbols.chevron_left), findsOneWidget);
expect(find.byIcon(Symbols.chevron_right), findsNothing);
await tester.tap(find.byIcon(Symbols.chevron_left));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byIcon(Symbols.chevron_right), findsOneWidget);
});
testWidgets('arrow keys step between photos', (tester) async {
await _pumpViewer(tester, message: _message());
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byIcon(Symbols.chevron_left), findsOneWidget);
expect(find.byIcon(Symbols.chevron_right), findsNothing);
});
testWidgets('single tap hides the chrome, another tap brings it back', (
tester,
) async {
await _pumpViewer(tester, message: _message());
double chromeOpacity() =>
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity;
expect(chromeOpacity(), 1);
await tester.tapAt(tester.getCenter(find.byType(PageView)));
await tester.pump();
expect(chromeOpacity(), 0);
await tester.tapAt(tester.getCenter(find.byType(PageView)));
await tester.pump();
expect(chromeOpacity(), 1);
});
testWidgets('three-dot menu appears only with actions', (tester) async {
await _pumpViewer(tester, message: _message());
expect(find.byIcon(Symbols.more_vert), findsNothing);
await _pumpViewer(
tester,
message: _message(),
actions: PhotoViewerActions(
goToMessage: (_, _) {},
delete: (_, _) {},
),
);
expect(find.byIcon(Symbols.more_vert), findsOneWidget);
await tester.tap(find.byIcon(Symbols.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Перейти к сообщению'), findsOneWidget);
expect(find.text('Удалить'), findsOneWidget);
expect(find.text('Сохранить как…'), findsOneWidget);
expect(find.text('Переслать'), findsNothing);
});
testWidgets('menu action closes the viewer before running', (tester) async {
var ran = false;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PhotoViewerScreen(
photos: const [
PhotoAttachment(baseUrl: 'https://example.com/a.jpg'),
],
message: _message(),
actions: PhotoViewerActions(
goToMessage: (_, _) => ran = true,
),
),
),
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(PhotoViewerScreen), findsOneWidget);
await tester.tap(find.byIcon(Symbols.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Перейти к сообщению'));
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
expect(ran, isTrue);
expect(find.byType(PhotoViewerScreen), findsNothing);
});
}