feat: просмотрщик фото
This commit is contained in:
@@ -8,6 +8,7 @@ import '../../core/protocol/opcode_map.dart';
|
|||||||
import '../../core/protocol/packet.dart';
|
import '../../core/protocol/packet.dart';
|
||||||
import '../../core/cache/info_cache.dart';
|
import '../../core/cache/info_cache.dart';
|
||||||
import '../../core/cache/message_session_cache.dart';
|
import '../../core/cache/message_session_cache.dart';
|
||||||
|
import 'shared_content.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
import '../../core/storage/token_storage.dart';
|
import '../../core/storage/token_storage.dart';
|
||||||
import '../../core/utils/logger.dart';
|
import '../../core/utils/logger.dart';
|
||||||
@@ -560,6 +561,7 @@ class ChatsModule {
|
|||||||
ContactInfoFetch.clear();
|
ContactInfoFetch.clear();
|
||||||
PresenceFetch.clear();
|
PresenceFetch.clear();
|
||||||
ChatInfoFetch.clear();
|
ChatInfoFetch.clear();
|
||||||
|
SharedContentModule.clearPhotoIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _enqueueGlobalPush(Packet packet) {
|
void _enqueueGlobalPush(Packet packet) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class SharedMediaItem {
|
|||||||
final int senderId;
|
final int senderId;
|
||||||
final int time;
|
final int time;
|
||||||
final MessageAttachment attachment;
|
final MessageAttachment attachment;
|
||||||
|
final String? text;
|
||||||
|
|
||||||
const SharedMediaItem({
|
const SharedMediaItem({
|
||||||
required this.messageId,
|
required this.messageId,
|
||||||
@@ -24,6 +25,7 @@ class SharedMediaItem {
|
|||||||
required this.senderId,
|
required this.senderId,
|
||||||
required this.time,
|
required this.time,
|
||||||
required this.attachment,
|
required this.attachment,
|
||||||
|
this.text,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get dedupKey {
|
String get dedupKey {
|
||||||
@@ -93,11 +95,136 @@ class CommonChatEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ChatPhotoFeed {
|
||||||
|
final List<SharedMediaItem> items;
|
||||||
|
final int total;
|
||||||
|
final bool reachedEnd;
|
||||||
|
|
||||||
|
const ChatPhotoFeed({
|
||||||
|
required this.items,
|
||||||
|
required this.total,
|
||||||
|
required this.reachedEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatPhotoIndex {
|
||||||
|
final List<SharedMediaItem> items = [];
|
||||||
|
final Set<String> seen = {};
|
||||||
|
int total = 0;
|
||||||
|
bool reachedEnd = false;
|
||||||
|
bool started = false;
|
||||||
|
Future<void>? inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
String photoDedupKey(String messageId, PhotoAttachment photo) =>
|
||||||
|
'$messageId:p${photo.photoId ?? photo.baseUrl}';
|
||||||
|
|
||||||
class SharedContentModule {
|
class SharedContentModule {
|
||||||
|
static const int _photoIndexPageSize = 60;
|
||||||
|
static const int _photoIndexMaxPages = 40;
|
||||||
|
|
||||||
|
static final Map<int, _ChatPhotoIndex> _photoIndexes = {};
|
||||||
|
|
||||||
final Api _api;
|
final Api _api;
|
||||||
|
|
||||||
SharedContentModule(this._api);
|
SharedContentModule(this._api);
|
||||||
|
|
||||||
|
static void clearPhotoIndex() => _photoIndexes.clear();
|
||||||
|
|
||||||
|
Future<ChatPhotoFeed?> photoFeedFor({
|
||||||
|
required int chatId,
|
||||||
|
required String photoKey,
|
||||||
|
required Future<String?> Function() resolveAnchor,
|
||||||
|
}) async {
|
||||||
|
final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new);
|
||||||
|
|
||||||
|
for (var page = 0; page < _photoIndexMaxPages; page++) {
|
||||||
|
if (index.items.any((i) => i.dedupKey == photoKey)) {
|
||||||
|
return _snapshot(index);
|
||||||
|
}
|
||||||
|
if (index.reachedEnd) return null;
|
||||||
|
await _nextPhotoPage(chatId, index, resolveAnchor);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChatPhotoFeed> loadMorePhotos({
|
||||||
|
required int chatId,
|
||||||
|
required Future<String?> Function() resolveAnchor,
|
||||||
|
}) async {
|
||||||
|
final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new);
|
||||||
|
if (!index.reachedEnd) {
|
||||||
|
await _nextPhotoPage(chatId, index, resolveAnchor);
|
||||||
|
}
|
||||||
|
return _snapshot(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatPhotoFeed _snapshot(_ChatPhotoIndex index) {
|
||||||
|
final counted = index.items.length;
|
||||||
|
final total = index.reachedEnd
|
||||||
|
? counted
|
||||||
|
: (index.total > counted ? index.total : counted);
|
||||||
|
return ChatPhotoFeed(
|
||||||
|
items: List.unmodifiable(index.items),
|
||||||
|
total: total,
|
||||||
|
reachedEnd: index.reachedEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _nextPhotoPage(
|
||||||
|
int chatId,
|
||||||
|
_ChatPhotoIndex index,
|
||||||
|
Future<String?> Function() resolveAnchor,
|
||||||
|
) async {
|
||||||
|
final pending = index.inFlight;
|
||||||
|
if (pending != null) {
|
||||||
|
await pending;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final task = _loadPhotoPage(chatId, index, resolveAnchor);
|
||||||
|
index.inFlight = task;
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
} finally {
|
||||||
|
index.inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPhotoPage(
|
||||||
|
int chatId,
|
||||||
|
_ChatPhotoIndex index,
|
||||||
|
Future<String?> Function() resolveAnchor,
|
||||||
|
) async {
|
||||||
|
final initial = !index.started;
|
||||||
|
final anchor = initial
|
||||||
|
? await resolveAnchor()
|
||||||
|
: index.items.last.messageId;
|
||||||
|
if (anchor == null || anchor.isEmpty) {
|
||||||
|
index.reachedEnd = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final page = await fetchMedia(
|
||||||
|
chatId: chatId,
|
||||||
|
anchorMessageId: anchor,
|
||||||
|
attachTypes: const ['PHOTO'],
|
||||||
|
forward: initial ? _photoIndexPageSize : 0,
|
||||||
|
backward: _photoIndexPageSize,
|
||||||
|
);
|
||||||
|
index.started = true;
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
for (final item in page.items) {
|
||||||
|
if (!index.seen.add(item.dedupKey)) continue;
|
||||||
|
index.items.add(item);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
index.items.sort((a, b) => b.time.compareTo(a.time));
|
||||||
|
if (page.total > index.total) index.total = page.total;
|
||||||
|
|
||||||
|
if (added == 0) index.reachedEnd = true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<SharedMediaPage> fetchMedia({
|
Future<SharedMediaPage> fetchMedia({
|
||||||
required int chatId,
|
required int chatId,
|
||||||
required String anchorMessageId,
|
required String anchorMessageId,
|
||||||
@@ -134,6 +261,7 @@ class SharedContentModule {
|
|||||||
if (id == null) continue;
|
if (id == null) continue;
|
||||||
final sender = (map['sender'] as num?)?.toInt() ?? 0;
|
final sender = (map['sender'] as num?)?.toInt() ?? 0;
|
||||||
final time = (map['time'] as num?)?.toInt() ?? 0;
|
final time = (map['time'] as num?)?.toInt() ?? 0;
|
||||||
|
final text = map['text'] as String?;
|
||||||
final attaches = map['attaches'];
|
final attaches = map['attaches'];
|
||||||
if (attaches is! List) continue;
|
if (attaches is! List) continue;
|
||||||
for (final a in attaches) {
|
for (final a in attaches) {
|
||||||
@@ -147,6 +275,7 @@ class SharedContentModule {
|
|||||||
senderId: sender,
|
senderId: sender,
|
||||||
time: time,
|
time: time,
|
||||||
attachment: att,
|
attachment: att,
|
||||||
|
text: text,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,22 +30,11 @@ Future<MediaSaveResult> saveImageFromUrl(String url) async {
|
|||||||
if (file == null) {
|
if (file == null) {
|
||||||
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
|
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
|
||||||
}
|
}
|
||||||
final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
return _persist(
|
||||||
|
file,
|
||||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
saveName: 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
final state = await PhotoManager.requestPermissionExtend();
|
kind: SaveMediaKind.image,
|
||||||
if (!state.isAuth && !state.hasAccess) {
|
);
|
||||||
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
|
|
||||||
}
|
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
await PhotoManager.editor.saveImage(bytes, filename: saveName);
|
|
||||||
return const MediaSaveResult(ok: true, toGallery: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
final dir = await _targetDirectory();
|
|
||||||
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
|
|
||||||
await file.copy(target.path);
|
|
||||||
return MediaSaveResult(ok: true, location: target.path);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return MediaSaveResult(ok: false, error: e.toString());
|
return MediaSaveResult(ok: false, error: e.toString());
|
||||||
}
|
}
|
||||||
@@ -71,32 +60,54 @@ Future<MediaSaveResult> saveMediaFile({
|
|||||||
if (file == null) {
|
if (file == null) {
|
||||||
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
|
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
|
||||||
}
|
}
|
||||||
|
return _persist(file, saveName: saveName, kind: kind);
|
||||||
final toGallery =
|
|
||||||
kind == SaveMediaKind.image || kind == SaveMediaKind.video;
|
|
||||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) {
|
|
||||||
final state = await PhotoManager.requestPermissionExtend();
|
|
||||||
if (!state.isAuth && !state.hasAccess) {
|
|
||||||
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
|
|
||||||
}
|
|
||||||
if (kind == SaveMediaKind.video) {
|
|
||||||
await PhotoManager.editor.saveVideo(file, title: saveName);
|
|
||||||
} else {
|
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
await PhotoManager.editor.saveImage(bytes, filename: saveName);
|
|
||||||
}
|
|
||||||
return const MediaSaveResult(ok: true, toGallery: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
final dir = await _targetDirectory();
|
|
||||||
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
|
|
||||||
await file.copy(target.path);
|
|
||||||
return MediaSaveResult(ok: true, location: target.path);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return MediaSaveResult(ok: false, error: e.toString());
|
return MediaSaveResult(ok: false, error: e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<MediaSaveResult> saveLocalImage(String path, {String? saveName}) async {
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
return const MediaSaveResult(ok: false, error: 'файл не найден');
|
||||||
|
}
|
||||||
|
return _persist(
|
||||||
|
file,
|
||||||
|
saveName: saveName ?? 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
kind: SaveMediaKind.image,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return MediaSaveResult(ok: false, error: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MediaSaveResult> _persist(
|
||||||
|
File file, {
|
||||||
|
required String saveName,
|
||||||
|
required SaveMediaKind kind,
|
||||||
|
}) async {
|
||||||
|
final toGallery = kind == SaveMediaKind.image || kind == SaveMediaKind.video;
|
||||||
|
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) {
|
||||||
|
final state = await PhotoManager.requestPermissionExtend();
|
||||||
|
if (!state.isAuth && !state.hasAccess) {
|
||||||
|
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
|
||||||
|
}
|
||||||
|
if (kind == SaveMediaKind.video) {
|
||||||
|
await PhotoManager.editor.saveVideo(file, title: saveName);
|
||||||
|
} else {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
await PhotoManager.editor.saveImage(bytes, filename: saveName);
|
||||||
|
}
|
||||||
|
return const MediaSaveResult(ok: true, toGallery: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dir = await _targetDirectory();
|
||||||
|
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
|
||||||
|
await file.copy(target.path);
|
||||||
|
return MediaSaveResult(ok: true, location: target.path);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Directory> _targetDirectory() async {
|
Future<Directory> _targetDirectory() async {
|
||||||
try {
|
try {
|
||||||
final downloads = await getDownloadsDirectory();
|
final downloads = await getDownloadsDirectory();
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class _MemberInfo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ChatInfoTab { media }
|
||||||
|
|
||||||
class ChatInfoScreen extends StatefulWidget {
|
class ChatInfoScreen extends StatefulWidget {
|
||||||
final int chatId;
|
final int chatId;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -44,6 +46,7 @@ class ChatInfoScreen extends StatefulWidget {
|
|||||||
final String chatType;
|
final String chatType;
|
||||||
|
|
||||||
final int? dialogPeerId;
|
final int? dialogPeerId;
|
||||||
|
final ChatInfoTab? initialTab;
|
||||||
|
|
||||||
final void Function(String messageId, int time)? onJumpToMessage;
|
final void Function(String messageId, int time)? onJumpToMessage;
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ class ChatInfoScreen extends StatefulWidget {
|
|||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
required this.chatType,
|
required this.chatType,
|
||||||
this.dialogPeerId,
|
this.dialogPeerId,
|
||||||
|
this.initialTab,
|
||||||
this.onJumpToMessage,
|
this.onJumpToMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,12 +235,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
if (_selectedTab.isEmpty && _tabs.isNotEmpty) {
|
if (_selectedTab.isEmpty && _tabs.isNotEmpty) {
|
||||||
_selectedTab = _tabs.first;
|
_selectedTab = _initialTabLabel() ?? _tabs.first;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _initialTabLabel() {
|
||||||
|
if (widget.initialTab != ChatInfoTab.media) return null;
|
||||||
|
final media = AppLocalizations.of(context)!.chatInfoTabMedia;
|
||||||
|
return _tabs.contains(media) ? media : null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import '../../../core/utils/text_format.dart';
|
|||||||
import '../../widgets/confirm_dialog.dart';
|
import '../../widgets/confirm_dialog.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/message_bubble.dart';
|
import '../../widgets/message_bubble.dart';
|
||||||
|
import '../../widgets/photo_viewer.dart';
|
||||||
import '../../widgets/message_actions_overlay.dart';
|
import '../../widgets/message_actions_overlay.dart';
|
||||||
import '../../widgets/lottie_image.dart';
|
import '../../widgets/lottie_image.dart';
|
||||||
import '../../widgets/attachment_panel.dart';
|
import '../../widgets/attachment_panel.dart';
|
||||||
@@ -934,6 +935,47 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openChatInfo({ChatInfoTab? initialTab}) {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
final chatRoute = ModalRoute.of(context);
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ChatInfoScreen(
|
||||||
|
chatId: widget.chatId,
|
||||||
|
name: widget.name,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
chatType: widget.chatType,
|
||||||
|
initialTab: initialTab,
|
||||||
|
onJumpToMessage: (chatRoute == null || widget.embedded)
|
||||||
|
? null
|
||||||
|
: (messageId, time) {
|
||||||
|
navigator.popUntil((r) => r == chatRoute);
|
||||||
|
_requestGoToMessage(messageId, time);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoViewerActions _photoActions() {
|
||||||
|
return PhotoViewerActions(
|
||||||
|
goToMessage: _requestGoToMessage,
|
||||||
|
forward: _forwardMessageById,
|
||||||
|
delete: (messageId, senderId) =>
|
||||||
|
_confirmDeleteMessage(messageId, senderId == _myId),
|
||||||
|
viewAllPhotos: () => _openChatInfo(initialTab: ChatInfoTab.media),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _forwardMessageById(String messageId) {
|
||||||
|
final message = _messages.where((m) => m.id == messageId).firstOrNull;
|
||||||
|
if (message == null) {
|
||||||
|
showCustomNotification(context, 'Сообщение не загружено');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(_forwardMessages([message]));
|
||||||
|
}
|
||||||
|
|
||||||
void _requestGoToMessage(String id, int time) {
|
void _requestGoToMessage(String id, int time) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(_beginTargetNavigation);
|
setState(_beginTargetNavigation);
|
||||||
@@ -2333,12 +2375,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Haptics.send();
|
Haptics.send();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDeleteMessage(CachedMessage message, bool isMe) async {
|
Future<void> _confirmDeleteMessage(String messageId, bool isMe) async {
|
||||||
final isLocalOnly = message.id.startsWith('temp_');
|
final isLocalOnly = messageId.startsWith('temp_');
|
||||||
final canForEveryone = isMe && !isLocalOnly;
|
final canForEveryone = isMe && !isLocalOnly;
|
||||||
|
|
||||||
if (isLocalOnly) {
|
if (isLocalOnly) {
|
||||||
_startDeleteAnimation(message.id);
|
_startDeleteAnimation(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2346,7 +2388,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (forEveryone == null || !mounted) return;
|
if (forEveryone == null || !mounted) return;
|
||||||
|
|
||||||
final ok = await messagesModule.deleteMessages(widget.chatId, [
|
final ok = await messagesModule.deleteMessages(widget.chatId, [
|
||||||
message.id,
|
messageId,
|
||||||
], forEveryone: forEveryone);
|
], forEveryone: forEveryone);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -2354,7 +2396,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
showCustomNotification(context, 'Не удалось удалить сообщение');
|
showCustomNotification(context, 'Не удалось удалить сообщение');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_startDeleteAnimation(message.id);
|
_startDeleteAnimation(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startDeleteAnimation(String messageId) {
|
void _startDeleteAnimation(String messageId) {
|
||||||
@@ -2619,32 +2661,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
showCall:
|
showCall:
|
||||||
widget.chatType == 'DIALOG' && !_peerIsBot,
|
widget.chatType == 'DIALOG' && !_peerIsBot,
|
||||||
onClose: widget.onClose,
|
onClose: widget.onClose,
|
||||||
onOpenInfo: () {
|
onOpenInfo: _openChatInfo,
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
final chatRoute = ModalRoute.of(context);
|
|
||||||
navigator.push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => ChatInfoScreen(
|
|
||||||
chatId: widget.chatId,
|
|
||||||
name: widget.name,
|
|
||||||
imageUrl: widget.imageUrl,
|
|
||||||
chatType: widget.chatType,
|
|
||||||
onJumpToMessage:
|
|
||||||
(chatRoute == null || widget.embedded)
|
|
||||||
? null
|
|
||||||
: (messageId, time) {
|
|
||||||
navigator.popUntil(
|
|
||||||
(r) => r == chatRoute,
|
|
||||||
);
|
|
||||||
_requestGoToMessage(
|
|
||||||
messageId,
|
|
||||||
time,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onOpenScheduled: _openScheduledMessages,
|
onOpenScheduled: _openScheduledMessages,
|
||||||
onCall: _startCall,
|
onCall: _startCall,
|
||||||
onMenu: _openChatMenu,
|
onMenu: _openChatMenu,
|
||||||
@@ -4684,6 +4701,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
prevMessage: prevMessage,
|
prevMessage: prevMessage,
|
||||||
nextMessage: nextMessage,
|
nextMessage: nextMessage,
|
||||||
chatType: chat?.type ?? 'CHAT',
|
chatType: chat?.type ?? 'CHAT',
|
||||||
|
chatId: widget.chatId,
|
||||||
|
photoActions: _photoActions(),
|
||||||
overrideStatus: _effectiveStatus(message),
|
overrideStatus: _effectiveStatus(message),
|
||||||
otherReadTime: _otherReadTime,
|
otherReadTime: _otherReadTime,
|
||||||
reactionsListenable: _reactionNotifierFor(
|
reactionsListenable: _reactionNotifierFor(
|
||||||
@@ -4722,7 +4741,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
onStartTextSelection: (pos) =>
|
onStartTextSelection: (pos) =>
|
||||||
_startTextSelection(message, pos),
|
_startTextSelection(message, pos),
|
||||||
onDelete: () =>
|
onDelete: () =>
|
||||||
_confirmDeleteMessage(message, isMe),
|
_confirmDeleteMessage(message.id, isMe),
|
||||||
onEdit: _canEditMessage(message)
|
onEdit: _canEditMessage(message)
|
||||||
? () => _startEditMessage(message)
|
? () => _startEditMessage(message)
|
||||||
: null,
|
: null,
|
||||||
@@ -5270,12 +5289,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final tokens = await Future.wait(
|
final tokens = await _uploadPhotos(files, progress);
|
||||||
List.generate(
|
|
||||||
files.length,
|
|
||||||
(i) => _uploadOnePhoto(files[i], i, progress),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
_disposePhotoProgress(tempId);
|
_disposePhotoProgress(tempId);
|
||||||
return;
|
return;
|
||||||
@@ -5463,12 +5477,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
List<double>.filled(files.length, 0),
|
List<double>.filled(files.length, 0),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final tokens = await Future.wait(
|
final tokens = await _uploadPhotos(files, progress);
|
||||||
List.generate(
|
|
||||||
files.length,
|
|
||||||
(i) => _uploadOnePhoto(files[i], i, progress),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (tokens.any((t) => t == null)) {
|
if (tokens.any((t) => t == null)) {
|
||||||
showCustomNotification(context, 'Не удалось загрузить фото');
|
showCustomNotification(context, 'Не удалось загрузить фото');
|
||||||
@@ -5636,26 +5645,76 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const int _photoUploadConcurrency = 3;
|
||||||
|
static const int _photoUploadAttempts = 3;
|
||||||
|
|
||||||
|
Future<List<String?>> _uploadPhotos(
|
||||||
|
List<File> files,
|
||||||
|
ValueNotifier<List<double>> progress,
|
||||||
|
) async {
|
||||||
|
final tokens = List<String?>.filled(files.length, null);
|
||||||
|
var nextIndex = 0;
|
||||||
|
var failed = false;
|
||||||
|
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (!failed) {
|
||||||
|
final i = nextIndex++;
|
||||||
|
if (i >= files.length) return;
|
||||||
|
final token = await _uploadOnePhoto(files[i], i, progress);
|
||||||
|
if (token == null) {
|
||||||
|
failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokens[i] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final workerCount = math.min(_photoUploadConcurrency, files.length);
|
||||||
|
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _uploadOnePhoto(
|
Future<String?> _uploadOnePhoto(
|
||||||
File file,
|
File file,
|
||||||
int index,
|
int index,
|
||||||
ValueNotifier<List<double>> progress,
|
ValueNotifier<List<double>> progress,
|
||||||
) async {
|
) async {
|
||||||
final url = await messagesModule.requestPhotoUploadUrl();
|
for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) {
|
||||||
if (url == null || url.isEmpty) return null;
|
if (attempt > 0) {
|
||||||
return fileUploader.uploadPhoto(
|
await Future.delayed(Duration(seconds: attempt));
|
||||||
Uri.parse(url),
|
if (!mounted) return null;
|
||||||
file,
|
_setPhotoProgress(progress, index, 0);
|
||||||
filename: _photoFilename(file),
|
}
|
||||||
onProgress: (sent, total) {
|
try {
|
||||||
if (total <= 0) return;
|
final url = await messagesModule.requestPhotoUploadUrl();
|
||||||
final next = List<double>.from(progress.value);
|
if (url == null || url.isEmpty) continue;
|
||||||
if (index < next.length) {
|
final token = await fileUploader.uploadPhoto(
|
||||||
next[index] = (sent / total).clamp(0.0, 1.0);
|
Uri.parse(url),
|
||||||
progress.value = next;
|
file,
|
||||||
}
|
filename: _photoFilename(file),
|
||||||
},
|
onProgress: (sent, total) {
|
||||||
);
|
if (total <= 0) return;
|
||||||
|
_setPhotoProgress(progress, index, (sent / total).clamp(0.0, 1.0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (token != null) return token;
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setPhotoProgress(
|
||||||
|
ValueNotifier<List<double>> progress,
|
||||||
|
int index,
|
||||||
|
double value,
|
||||||
|
) {
|
||||||
|
final next = List<double>.from(progress.value);
|
||||||
|
if (index < next.length) {
|
||||||
|
next[index] = value;
|
||||||
|
progress.value = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _photoFilename(File file) {
|
String _photoFilename(File file) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../../core/config/komet_settings.dart';
|
|||||||
import '../../../../core/utils/format.dart';
|
import '../../../../core/utils/format.dart';
|
||||||
import '../../../../models/attachment.dart';
|
import '../../../../models/attachment.dart';
|
||||||
import '../../formatted_message_text.dart';
|
import '../../formatted_message_text.dart';
|
||||||
|
import '../../photo_viewer.dart';
|
||||||
|
|
||||||
enum MessageType { text, attachment, voice, control }
|
enum MessageType { text, attachment, voice, control }
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ class BubbleContext {
|
|||||||
final bool isMe;
|
final bool isMe;
|
||||||
final int myId;
|
final int myId;
|
||||||
final String chatType;
|
final String chatType;
|
||||||
|
final int? chatId;
|
||||||
|
final PhotoViewerActions? photoActions;
|
||||||
final String? overrideStatus;
|
final String? overrideStatus;
|
||||||
final ValueListenable<int>? otherReadTime;
|
final ValueListenable<int>? otherReadTime;
|
||||||
final ValueListenable<List<double>>? uploadProgress;
|
final ValueListenable<List<double>>? uploadProgress;
|
||||||
@@ -79,6 +82,8 @@ class BubbleContext {
|
|||||||
required this.isMe,
|
required this.isMe,
|
||||||
required this.myId,
|
required this.myId,
|
||||||
required this.chatType,
|
required this.chatType,
|
||||||
|
this.chatId,
|
||||||
|
this.photoActions,
|
||||||
this.overrideStatus,
|
this.overrideStatus,
|
||||||
this.otherReadTime,
|
this.otherReadTime,
|
||||||
this.uploadProgress,
|
this.uploadProgress,
|
||||||
@@ -154,6 +159,10 @@ class BubbleContext {
|
|||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
const Icon(Symbols.delete, size: 11, color: Colors.white),
|
const Icon(Symbols.delete, size: 11, color: Colors.white),
|
||||||
],
|
],
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
statusIcon(color: Colors.white, size: 12),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -161,14 +170,17 @@ class BubbleContext {
|
|||||||
|
|
||||||
Widget deletedIcon() => Icon(Symbols.delete, size: 13, color: dim);
|
Widget deletedIcon() => Icon(Symbols.delete, size: 13, color: dim);
|
||||||
|
|
||||||
Widget statusIcon() {
|
Widget statusIcon({Color? color, double size = 14}) {
|
||||||
final base = overrideStatus ?? message.status;
|
final base = overrideStatus ?? message.status;
|
||||||
final rt = otherReadTime;
|
final rt = otherReadTime;
|
||||||
if (rt == null) return _statusIconFor(base);
|
if (rt == null) return _statusIconFor(base, color: color, size: size);
|
||||||
return ValueListenableBuilder<int>(
|
return ValueListenableBuilder<int>(
|
||||||
valueListenable: rt,
|
valueListenable: rt,
|
||||||
builder: (context, readTime, _) =>
|
builder: (context, readTime, _) => _statusIconFor(
|
||||||
_statusIconFor(_readUpgradedStatus(base, readTime)),
|
_readUpgradedStatus(base, readTime),
|
||||||
|
color: color,
|
||||||
|
size: size,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,8 +193,8 @@ class BubbleContext {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _statusIconFor(String? status) {
|
Widget _statusIconFor(String? status, {Color? color, double size = 14}) {
|
||||||
final v = messageStatusVisual(status, dimColor: dim);
|
final v = messageStatusVisual(status, dimColor: color ?? dim);
|
||||||
return Icon(v.icon, size: 14, color: v.color);
|
return Icon(v.icon, size: size, color: v.color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
onTap: () => _openPhotoViewer(ctx.context, 0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -284,28 +284,51 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
final matchBottom =
|
final matchBottom =
|
||||||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
|
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
|
||||||
|
|
||||||
|
final rows = <Widget>[];
|
||||||
|
for (var i = 0; i < displayCount; i += 2) {
|
||||||
|
if (rows.isNotEmpty) rows.add(const SizedBox(height: 2));
|
||||||
|
rows.add(
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildGridTile(ctx, photos, i, remaining)),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Expanded(
|
||||||
|
child: i + 1 < displayCount
|
||||||
|
? _buildGridTile(ctx, photos, i + 1, remaining)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: _multiPhotoCornerRadius(
|
borderRadius: _multiPhotoCornerRadius(
|
||||||
matchTop: matchTop,
|
matchTop: matchTop,
|
||||||
matchBottom: matchBottom,
|
matchBottom: matchBottom,
|
||||||
isMe: ctx.isMe,
|
isMe: ctx.isMe,
|
||||||
),
|
),
|
||||||
child: GridView.count(
|
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
|
||||||
crossAxisCount: 2,
|
|
||||||
mainAxisSpacing: 2,
|
|
||||||
crossAxisSpacing: 2,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
children: List.generate(displayCount, (i) {
|
|
||||||
if (i == 3 && remaining > 0) {
|
|
||||||
return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i);
|
|
||||||
}
|
|
||||||
return _buildPhotoTile(ctx, photos[i], i);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildGridTile(
|
||||||
|
BubbleContext ctx,
|
||||||
|
List<PhotoAttachment> photos,
|
||||||
|
int index,
|
||||||
|
int remaining,
|
||||||
|
) {
|
||||||
|
if (index == 3 && remaining > 0) {
|
||||||
|
return _buildPhotoTileWithOverlay(
|
||||||
|
ctx,
|
||||||
|
photos[index],
|
||||||
|
'+$remaining',
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildPhotoTile(ctx, photos[index], index);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) {
|
Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) {
|
||||||
final cachePx =
|
final cachePx =
|
||||||
(BubbleContext.photoMaxSize /
|
(BubbleContext.photoMaxSize /
|
||||||
@@ -330,7 +353,7 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
onTap: () => _openPhotoViewer(ctx.context, index),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -378,6 +401,13 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (ctx.uploadProgress != null)
|
if (ctx.uploadProgress != null)
|
||||||
_buildUploadOverlay(ctx.uploadProgress!, index),
|
_buildUploadOverlay(ctx.uploadProgress!, index),
|
||||||
|
if (ctx.uploadProgress == null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => _openPhotoViewer(ctx.context, index),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -407,13 +437,17 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openPhotoViewer(BuildContext context, PhotoAttachment photo) {
|
void _openPhotoViewer(BuildContext context, int index) {
|
||||||
final url = photo.baseUrl ?? '';
|
|
||||||
if (url.isEmpty) return;
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
builder: (_) => PhotoViewerScreen(baseUrl: url),
|
builder: (_) => PhotoViewerScreen(
|
||||||
|
photos: photos,
|
||||||
|
initialIndex: index,
|
||||||
|
chatId: ctx.chatId,
|
||||||
|
message: ctx.message,
|
||||||
|
actions: ctx.photoActions,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:komet/main.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:ogg_opus_player/ogg_opus_player.dart';
|
import 'package:ogg_opus_player/ogg_opus_player.dart';
|
||||||
|
|
||||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
import '../../../backend/modules/messages.dart' show CachedMessage, ContactCache;
|
||||||
import '../../../backend/modules/shared_content.dart';
|
import '../../../backend/modules/shared_content.dart';
|
||||||
import '../../../core/cache/info_cache.dart';
|
import '../../../core/cache/info_cache.dart';
|
||||||
import '../../../core/utils/download_progress.dart';
|
import '../../../core/utils/download_progress.dart';
|
||||||
@@ -646,7 +646,11 @@ class _SharedMediaTabState extends State<SharedMediaTab> {
|
|||||||
),
|
),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
_MediaTile(item: items[index], onGoTo: () => _goTo(items[index])),
|
_MediaTile(
|
||||||
|
item: items[index],
|
||||||
|
onGoTo: () => _goTo(items[index]),
|
||||||
|
onGoToMessage: widget.onGoToMessage,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,8 +658,13 @@ class _SharedMediaTabState extends State<SharedMediaTab> {
|
|||||||
class _MediaTile extends StatelessWidget {
|
class _MediaTile extends StatelessWidget {
|
||||||
final SharedMediaItem item;
|
final SharedMediaItem item;
|
||||||
final VoidCallback onGoTo;
|
final VoidCallback onGoTo;
|
||||||
|
final void Function(String messageId, int time) onGoToMessage;
|
||||||
|
|
||||||
const _MediaTile({required this.item, required this.onGoTo});
|
const _MediaTile({
|
||||||
|
required this.item,
|
||||||
|
required this.onGoTo,
|
||||||
|
required this.onGoToMessage,
|
||||||
|
});
|
||||||
|
|
||||||
void _menu(BuildContext context) {
|
void _menu(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -755,7 +764,25 @@ class _MediaTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
final url = att.baseUrl ?? att.previewData ?? '';
|
final url = att.baseUrl ?? att.previewData ?? '';
|
||||||
if (url.isEmpty) return;
|
if (url.isEmpty) return;
|
||||||
pushSwipeable(context, (_) => PhotoViewerScreen(baseUrl: url));
|
|
||||||
|
final photo = att is PhotoAttachment && (att.baseUrl ?? '').isNotEmpty
|
||||||
|
? att
|
||||||
|
: PhotoAttachment(baseUrl: url);
|
||||||
|
pushSwipeable(
|
||||||
|
context,
|
||||||
|
(_) => PhotoViewerScreen(
|
||||||
|
photos: [photo],
|
||||||
|
chatId: item.chatId,
|
||||||
|
message: CachedMessage(
|
||||||
|
id: item.messageId,
|
||||||
|
accountId: 0,
|
||||||
|
chatId: item.chatId,
|
||||||
|
senderId: item.senderId,
|
||||||
|
time: item.time,
|
||||||
|
),
|
||||||
|
actions: PhotoViewerActions(goToMessage: onGoToMessage),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../../core/utils/webview_support.dart';
|
|||||||
import '../../core/config/app_link_preview.dart';
|
import '../../core/config/app_link_preview.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import 'formatted_message_text.dart';
|
import 'formatted_message_text.dart';
|
||||||
|
import 'photo_viewer.dart';
|
||||||
import 'selectable_message_text.dart';
|
import 'selectable_message_text.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
import '../../models/reaction_info.dart';
|
import '../../models/reaction_info.dart';
|
||||||
@@ -125,6 +126,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final CachedMessage? prevMessage;
|
final CachedMessage? prevMessage;
|
||||||
final CachedMessage? nextMessage;
|
final CachedMessage? nextMessage;
|
||||||
final String chatType;
|
final String chatType;
|
||||||
|
final int? chatId;
|
||||||
|
final PhotoViewerActions? photoActions;
|
||||||
final String? overrideStatus;
|
final String? overrideStatus;
|
||||||
final ValueListenable<int>? otherReadTime;
|
final ValueListenable<int>? otherReadTime;
|
||||||
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||||||
@@ -146,6 +149,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
this.prevMessage,
|
this.prevMessage,
|
||||||
this.nextMessage,
|
this.nextMessage,
|
||||||
required this.chatType,
|
required this.chatType,
|
||||||
|
this.chatId,
|
||||||
|
this.photoActions,
|
||||||
this.overrideStatus,
|
this.overrideStatus,
|
||||||
this.otherReadTime,
|
this.otherReadTime,
|
||||||
this.reactionsListenable,
|
this.reactionsListenable,
|
||||||
@@ -507,6 +512,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
isMe: isMe,
|
isMe: isMe,
|
||||||
myId: myId,
|
myId: myId,
|
||||||
chatType: chatType,
|
chatType: chatType,
|
||||||
|
chatId: chatId,
|
||||||
|
photoActions: photoActions,
|
||||||
overrideStatus: overrideStatus,
|
overrideStatus: overrideStatus,
|
||||||
otherReadTime: otherReadTime,
|
otherReadTime: otherReadTime,
|
||||||
uploadProgress: uploadProgress,
|
uploadProgress: uploadProgress,
|
||||||
|
|||||||
@@ -1,57 +1,700 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
class PhotoViewerScreen extends StatelessWidget {
|
import '../../backend/modules/messages.dart';
|
||||||
final String baseUrl;
|
import '../../backend/modules/shared_content.dart';
|
||||||
|
import '../../core/cache/info_cache.dart';
|
||||||
|
import '../../core/config/app_frost.dart';
|
||||||
|
import '../../core/utils/format.dart';
|
||||||
|
import '../../core/utils/media_cache.dart';
|
||||||
|
import '../../core/utils/media_saver.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../main.dart';
|
||||||
|
import '../../models/attachment.dart';
|
||||||
|
import 'chat_menu_overlay.dart';
|
||||||
|
import 'custom_notification.dart';
|
||||||
|
|
||||||
const PhotoViewerScreen({super.key, required this.baseUrl});
|
class PhotoViewerActions {
|
||||||
|
final void Function(String messageId, int time)? goToMessage;
|
||||||
|
final void Function(String messageId)? forward;
|
||||||
|
final void Function(String messageId, int senderId)? delete;
|
||||||
|
final VoidCallback? viewAllPhotos;
|
||||||
|
|
||||||
String get _url => baseUrl;
|
const PhotoViewerActions({
|
||||||
|
this.goToMessage,
|
||||||
|
this.forward,
|
||||||
|
this.delete,
|
||||||
|
this.viewAllPhotos,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
goToMessage == null &&
|
||||||
|
forward == null &&
|
||||||
|
delete == null &&
|
||||||
|
viewAllPhotos == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ViewerPhoto {
|
||||||
|
final String id;
|
||||||
|
final PhotoAttachment photo;
|
||||||
|
final String messageId;
|
||||||
|
final int senderId;
|
||||||
|
final int time;
|
||||||
|
final String? caption;
|
||||||
|
|
||||||
|
const _ViewerPhoto({
|
||||||
|
required this.id,
|
||||||
|
required this.photo,
|
||||||
|
required this.messageId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.time,
|
||||||
|
this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _ViewerPhoto.fromFeed(SharedMediaItem item) {
|
||||||
|
final photo = item.attachment as PhotoAttachment;
|
||||||
|
return _ViewerPhoto(
|
||||||
|
id: item.dedupKey,
|
||||||
|
photo: photo,
|
||||||
|
messageId: item.messageId,
|
||||||
|
senderId: item.senderId,
|
||||||
|
time: item.time,
|
||||||
|
caption: item.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoViewerScreen extends StatefulWidget {
|
||||||
|
final List<PhotoAttachment> photos;
|
||||||
|
final int initialIndex;
|
||||||
|
final int? chatId;
|
||||||
|
final CachedMessage? message;
|
||||||
|
final PhotoViewerActions? actions;
|
||||||
|
|
||||||
|
const PhotoViewerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.photos,
|
||||||
|
this.initialIndex = 0,
|
||||||
|
this.chatId,
|
||||||
|
this.message,
|
||||||
|
this.actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
PhotoViewerScreen.single(String baseUrl, {super.key})
|
||||||
|
: photos = [PhotoAttachment(baseUrl: baseUrl)],
|
||||||
|
initialIndex = 0,
|
||||||
|
chatId = null,
|
||||||
|
message = null,
|
||||||
|
actions = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||||
|
static const int _prefetchThreshold = 3;
|
||||||
|
|
||||||
|
late PageController _controller;
|
||||||
|
late List<_ViewerPhoto> _items;
|
||||||
|
late int _index;
|
||||||
|
|
||||||
|
final Map<String, int> _quarterTurns = {};
|
||||||
|
bool _feedLoaded = false;
|
||||||
|
bool _feedFailed = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
bool _reachedEnd = false;
|
||||||
|
bool _chromeVisible = true;
|
||||||
|
int _total = 0;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_items = _localItems();
|
||||||
|
_index = widget.initialIndex.clamp(0, _items.length - 1);
|
||||||
|
_controller = PageController(initialPage: _index);
|
||||||
|
unawaited(_loadFeed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_ViewerPhoto> _localItems() {
|
||||||
|
final message = widget.message;
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < widget.photos.length; i++)
|
||||||
|
_ViewerPhoto(
|
||||||
|
id: _localId(widget.photos[i], message, i),
|
||||||
|
photo: widget.photos[i],
|
||||||
|
messageId: message?.id ?? '',
|
||||||
|
senderId: message?.senderId ?? 0,
|
||||||
|
time: message?.time ?? 0,
|
||||||
|
caption: message?.text,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localId(PhotoAttachment photo, CachedMessage? message, int at) {
|
||||||
|
final key = _feedKey(photo, message);
|
||||||
|
return key ?? 'local:${message?.id ?? ''}:$at';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _feedKey(PhotoAttachment photo, CachedMessage? message) {
|
||||||
|
if (message == null || widget.chatId == null) return null;
|
||||||
|
if (photo.photoId == null && (photo.baseUrl ?? '').isEmpty) return null;
|
||||||
|
return photoDedupKey(message.id, photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ViewerPhoto get _current => _items[_index];
|
||||||
|
|
||||||
|
bool get _feedPending =>
|
||||||
|
!_feedLoaded &&
|
||||||
|
!_feedFailed &&
|
||||||
|
widget.chatId != null &&
|
||||||
|
_feedKey(_current.photo, widget.message) != null;
|
||||||
|
|
||||||
|
Future<void> _loadFeed() async {
|
||||||
|
final chatId = widget.chatId;
|
||||||
|
final key = _feedKey(_items[_index].photo, widget.message);
|
||||||
|
if (chatId == null || key == null) return;
|
||||||
|
|
||||||
|
final feed = await sharedContentModule.photoFeedFor(
|
||||||
|
chatId: chatId,
|
||||||
|
photoKey: key,
|
||||||
|
resolveAnchor: () => _resolveAnchor(chatId),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (feed == null) {
|
||||||
|
setState(() => _feedFailed = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = feed.items.map(_ViewerPhoto.fromFeed).toList();
|
||||||
|
final at = items.indexWhere((i) => i.id == key);
|
||||||
|
if (at == -1) {
|
||||||
|
setState(() => _feedFailed = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller.dispose();
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
_index = at;
|
||||||
|
_total = feed.total;
|
||||||
|
_reachedEnd = feed.reachedEnd;
|
||||||
|
_feedLoaded = true;
|
||||||
|
_controller = PageController(initialPage: at);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
final chatId = widget.chatId;
|
||||||
|
if (chatId == null || _loadingMore || _reachedEnd || !_feedLoaded) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final feed = await sharedContentModule.loadMorePhotos(
|
||||||
|
chatId: chatId,
|
||||||
|
resolveAnchor: () => _resolveAnchor(chatId),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = feed.items.map(_ViewerPhoto.fromFeed).toList();
|
||||||
|
_total = feed.total;
|
||||||
|
_reachedEnd = feed.reachedEnd;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _resolveAnchor(int chatId) async {
|
||||||
|
final info = await ChatInfoFetch.get(chatId);
|
||||||
|
final lastMessage = info?.raw['lastMessage'];
|
||||||
|
if (lastMessage is Map) {
|
||||||
|
final id = lastMessage['id']?.toString();
|
||||||
|
if (id != null && id.isNotEmpty) return id;
|
||||||
|
}
|
||||||
|
return widget.message?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageChanged(int index) {
|
||||||
|
setState(() => _index = index);
|
||||||
|
if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _step(int delta) {
|
||||||
|
final next = _index + delta;
|
||||||
|
if (next < 0 || next >= _items.length) return;
|
||||||
|
_controller.animateToPage(
|
||||||
|
next,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rotate() {
|
||||||
|
setState(() {
|
||||||
|
_quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
|
||||||
|
|
||||||
|
String _cacheNameFor(PhotoAttachment photo, String url) =>
|
||||||
|
'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg';
|
||||||
|
|
||||||
|
Future<File?> _fileFor(PhotoAttachment photo) async {
|
||||||
|
final localPath = photo.localPath;
|
||||||
|
if (localPath != null) {
|
||||||
|
final file = File(localPath);
|
||||||
|
return await file.exists() ? file : null;
|
||||||
|
}
|
||||||
|
final url = photo.baseUrl ?? '';
|
||||||
|
if (url.isEmpty) return null;
|
||||||
|
return MediaCache.getOrDownload(_cacheNameFor(photo, url), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (_saving) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
final photo = _current.photo;
|
||||||
|
final localPath = photo.localPath;
|
||||||
|
final url = photo.baseUrl ?? '';
|
||||||
|
|
||||||
|
final MediaSaveResult result;
|
||||||
|
if (localPath != null) {
|
||||||
|
result = await saveLocalImage(localPath);
|
||||||
|
} else if (url.isEmpty) {
|
||||||
|
result = const MediaSaveResult(ok: false, error: 'нет ссылки');
|
||||||
|
} else {
|
||||||
|
result = await saveMediaFile(
|
||||||
|
cacheName: _cacheNameFor(photo, url),
|
||||||
|
resolveUrl: () async => url,
|
||||||
|
saveName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
kind: SaveMediaKind.image,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
if (result.ok) {
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
'Не удалось сохранить: ${result.error ?? ''}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAs() async {
|
||||||
|
final file = await _fileFor(_current.photo);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (file == null) {
|
||||||
|
showCustomNotification(context, 'Не удалось загрузить фото');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||||
|
final path = await FilePicker.platform.saveFile(
|
||||||
|
dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs,
|
||||||
|
fileName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
type: FileType.any,
|
||||||
|
bytes: isMobile ? bytes : null,
|
||||||
|
);
|
||||||
|
if (path == null || !mounted) return;
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
await File(path).writeAsBytes(bytes);
|
||||||
|
if (!mounted) return;
|
||||||
|
}
|
||||||
|
showCustomNotification(context, 'Файл сохранён');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openMenu(BuildContext anchorContext) {
|
||||||
|
final actions = widget.actions;
|
||||||
|
if (actions == null) return;
|
||||||
|
final box = anchorContext.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null || !box.hasSize) return;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final item = _current;
|
||||||
|
|
||||||
|
showChatMenu(
|
||||||
|
context: context,
|
||||||
|
anchorRect: box.localToGlobal(Offset.zero) & box.size,
|
||||||
|
items: [
|
||||||
|
if (actions.goToMessage != null)
|
||||||
|
ChatMenuItem(
|
||||||
|
icon: Symbols.visibility,
|
||||||
|
label: l10n.sharedGoToMessage,
|
||||||
|
onTap: () =>
|
||||||
|
_popThen(() => actions.goToMessage!(item.messageId, item.time)),
|
||||||
|
),
|
||||||
|
if (actions.forward != null)
|
||||||
|
ChatMenuItem(
|
||||||
|
icon: Symbols.forward,
|
||||||
|
label: l10n.msgActionsForward,
|
||||||
|
onTap: () => _popThen(() => actions.forward!(item.messageId)),
|
||||||
|
),
|
||||||
|
if (actions.delete != null)
|
||||||
|
ChatMenuItem(
|
||||||
|
icon: Symbols.delete,
|
||||||
|
label: l10n.msgActionsDelete,
|
||||||
|
destructive: true,
|
||||||
|
dividerAfter: true,
|
||||||
|
onTap: () =>
|
||||||
|
_popThen(() => actions.delete!(item.messageId, item.senderId)),
|
||||||
|
),
|
||||||
|
ChatMenuItem(
|
||||||
|
icon: Symbols.download,
|
||||||
|
label: l10n.photoViewerSaveAs,
|
||||||
|
onTap: _saveAs,
|
||||||
|
),
|
||||||
|
if (actions.viewAllPhotos != null)
|
||||||
|
ChatMenuItem(
|
||||||
|
icon: Symbols.grid_view,
|
||||||
|
label: l10n.photoViewerViewAll,
|
||||||
|
onTap: () => _popThen(actions.viewAllPhotos!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _popThen(VoidCallback action) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final padding = MediaQuery.of(context).padding;
|
||||||
|
final hasMenu = !(widget.actions?.isEmpty ?? true);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: Stack(
|
body: CallbackShortcuts(
|
||||||
children: [
|
bindings: {
|
||||||
Positioned.fill(
|
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(-1),
|
||||||
child: InteractiveViewer(
|
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(1),
|
||||||
minScale: 1,
|
},
|
||||||
maxScale: 5,
|
child: Focus(
|
||||||
child: Center(
|
autofocus: true,
|
||||||
child: _url.isEmpty
|
child: Stack(
|
||||||
? const Icon(
|
children: [
|
||||||
Symbols.broken_image,
|
Positioned.fill(
|
||||||
color: Colors.white54,
|
child: PageView.builder(
|
||||||
size: 64,
|
controller: _controller,
|
||||||
)
|
itemCount: _items.length,
|
||||||
: CachedNetworkImage(
|
onPageChanged: _onPageChanged,
|
||||||
imageUrl: _url,
|
itemBuilder: (_, i) => GestureDetector(
|
||||||
fit: BoxFit.contain,
|
behavior: HitTestBehavior.opaque,
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
onTap: _toggleChrome,
|
||||||
placeholder: (_, _) => const Center(
|
child: InteractiveViewer(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
minScale: 1,
|
||||||
),
|
maxScale: 5,
|
||||||
errorWidget: (_, _, _) => const Icon(
|
child: Center(
|
||||||
Symbols.broken_image,
|
child: RotatedBox(
|
||||||
color: Colors.white54,
|
quarterTurns: _quarterTurns[_items[i].id] ?? 0,
|
||||||
size: 64,
|
child: _buildImage(_items[i].photo),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_chromeVisible,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _chromeVisible ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (_index > 0)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: _arrow(Symbols.chevron_left, () => _step(-1)),
|
||||||
|
),
|
||||||
|
if (_index < _items.length - 1)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: _arrow(Symbols.chevron_right, () => _step(1)),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: padding.top + 8,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Symbols.close,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (hasMenu)
|
||||||
|
Builder(
|
||||||
|
builder: (btnContext) => IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Symbols.more_vert,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => _openMenu(btnContext),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _buildBottomBar(padding.bottom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Positioned(
|
),
|
||||||
top: MediaQuery.of(context).padding.top + 8,
|
),
|
||||||
left: 8,
|
);
|
||||||
child: IconButton(
|
}
|
||||||
icon: const Icon(Symbols.close, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
Widget _arrow(IconData icon, VoidCallback onTap) {
|
||||||
),
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.black.withValues(alpha: 0.35),
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Icon(icon, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomBar(double bottomInset) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final caption = _current.caption;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 12, 8, bottomInset + 10),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x00000000), Color(0xB3000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (caption != null && caption.isNotEmpty) ...[
|
||||||
|
_buildCaption(caption),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildInfo(l10n)),
|
||||||
|
IconButton(
|
||||||
|
icon: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Symbols.download, color: Colors.white),
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
tooltip: l10n.sharedDownload,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Symbols.rotate_90_degrees_ccw,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _rotate,
|
||||||
|
tooltip: l10n.photoViewerRotate,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCaption(String caption) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ui.ImageFilter.blur(
|
||||||
|
sigmaX: AppFrost.panelSigma,
|
||||||
|
sigmaY: AppFrost.panelSigma,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 120),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.28),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
caption,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfo(AppLocalizations l10n) {
|
||||||
|
final item = _current;
|
||||||
|
if (item.messageId.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_feedLoaded)
|
||||||
|
Text(
|
||||||
|
l10n.photoViewerCounter(_total - _index, _total),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_feedPending)
|
||||||
|
const _CounterShimmer(),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_sentLine(l10n, item),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sentLine(AppLocalizations l10n, _ViewerPhoto item) {
|
||||||
|
final sender = ContactCache.get(item.senderId) ?? '';
|
||||||
|
final sentAt = DateTime.fromMillisecondsSinceEpoch(item.time);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final time = formatClock(sentAt);
|
||||||
|
final isToday =
|
||||||
|
sentAt.year == now.year &&
|
||||||
|
sentAt.month == now.month &&
|
||||||
|
sentAt.day == now.day;
|
||||||
|
return isToday
|
||||||
|
? l10n.photoViewerSentToday(sender, time)
|
||||||
|
: l10n.photoViewerSentOn(sender, formatDateWords(sentAt), time);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImage(PhotoAttachment photo) {
|
||||||
|
final localPath = photo.localPath;
|
||||||
|
if (localPath != null) {
|
||||||
|
return Image.file(
|
||||||
|
File(localPath),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, _, _) => _broken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final url = photo.baseUrl ?? '';
|
||||||
|
if (url.isEmpty) return _broken();
|
||||||
|
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||||
|
errorWidget: (_, _, _) => _broken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _broken() =>
|
||||||
|
const Icon(Symbols.broken_image, color: Colors.white54, size: 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CounterShimmer extends StatefulWidget {
|
||||||
|
const _CounterShimmer();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CounterShimmer> createState() => _CounterShimmerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CounterShimmerState extends State<_CounterShimmer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1100),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, _) => Opacity(
|
||||||
|
opacity: 0.25 + 0.35 * _controller.value,
|
||||||
|
child: Container(
|
||||||
|
width: 120,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,6 +522,45 @@
|
|||||||
"sharedLoadMore": "Show more",
|
"sharedLoadMore": "Show more",
|
||||||
"sharedGoToMessage": "Go to message",
|
"sharedGoToMessage": "Go to message",
|
||||||
"sharedDownload": "Download",
|
"sharedDownload": "Download",
|
||||||
|
"photoViewerCounter": "Photo {index} of {total}",
|
||||||
|
"@photoViewerCounter": {
|
||||||
|
"placeholders": {
|
||||||
|
"index": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"photoViewerSentToday": "{sender} • today at {time}",
|
||||||
|
"@photoViewerSentToday": {
|
||||||
|
"placeholders": {
|
||||||
|
"sender": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"photoViewerSentOn": "{sender} • {date} at {time}",
|
||||||
|
"@photoViewerSentOn": {
|
||||||
|
"placeholders": {
|
||||||
|
"sender": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"photoViewerSaveAs": "Save as…",
|
||||||
|
"photoViewerViewAll": "View all photos",
|
||||||
|
"photoViewerRotate": "Rotate",
|
||||||
"sharedCopyLink": "Copy link",
|
"sharedCopyLink": "Copy link",
|
||||||
"sharedLinkCopied": "Link copied",
|
"sharedLinkCopied": "Link copied",
|
||||||
"chatInfoActionLeave": "Leave",
|
"chatInfoActionLeave": "Leave",
|
||||||
|
|||||||
@@ -2570,6 +2570,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Download'**
|
/// **'Download'**
|
||||||
String get sharedDownload;
|
String get sharedDownload;
|
||||||
|
|
||||||
|
/// No description provided for @photoViewerCounter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Photo {index} of {total}'**
|
||||||
|
String photoViewerCounter(int index, int total);
|
||||||
|
|
||||||
|
/// No description provided for @photoViewerSentToday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{sender} • today at {time}'**
|
||||||
|
String photoViewerSentToday(String sender, String time);
|
||||||
|
|
||||||
|
/// No description provided for @photoViewerSentOn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{sender} • {date} at {time}'**
|
||||||
|
String photoViewerSentOn(String sender, String date, String time);
|
||||||
|
|
||||||
|
/// No description provided for @photoViewerSaveAs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save as…'**
|
||||||
|
String get photoViewerSaveAs;
|
||||||
|
|
||||||
|
/// No description provided for @photoViewerViewAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'View all photos'**
|
||||||
|
String get photoViewerViewAll;
|
||||||
|
|
||||||
|
/// No description provided for @photoViewerRotate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Rotate'**
|
||||||
|
String get photoViewerRotate;
|
||||||
|
|
||||||
/// No description provided for @sharedCopyLink.
|
/// No description provided for @sharedCopyLink.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -1308,6 +1308,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sharedDownload => 'Download';
|
String get sharedDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String photoViewerCounter(int index, int total) {
|
||||||
|
return 'Photo $index of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String photoViewerSentToday(String sender, String time) {
|
||||||
|
return '$sender • today at $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String photoViewerSentOn(String sender, String date, String time) {
|
||||||
|
return '$sender • $date at $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoViewerSaveAs => 'Save as…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoViewerViewAll => 'View all photos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoViewerRotate => 'Rotate';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sharedCopyLink => 'Copy link';
|
String get sharedCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
|||||||
@@ -1314,6 +1314,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sharedDownload => 'Скачать';
|
String get sharedDownload => 'Скачать';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String photoViewerCounter(int index, int total) {
|
||||||
|
return 'Фото $index из $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String photoViewerSentToday(String sender, String time) {
|
||||||
|
return '$sender • сегодня в $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String photoViewerSentOn(String sender, String date, String time) {
|
||||||
|
return '$sender • $date в $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoViewerSaveAs => 'Сохранить как…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoViewerViewAll => 'Все фото чата';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoViewerRotate => 'Повернуть';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sharedCopyLink => 'Копировать ссылку';
|
String get sharedCopyLink => 'Копировать ссылку';
|
||||||
|
|
||||||
|
|||||||
@@ -435,6 +435,12 @@
|
|||||||
"sharedLoadMore": "Показать ещё",
|
"sharedLoadMore": "Показать ещё",
|
||||||
"sharedGoToMessage": "Перейти к сообщению",
|
"sharedGoToMessage": "Перейти к сообщению",
|
||||||
"sharedDownload": "Скачать",
|
"sharedDownload": "Скачать",
|
||||||
|
"photoViewerCounter": "Фото {index} из {total}",
|
||||||
|
"photoViewerSentToday": "{sender} • сегодня в {time}",
|
||||||
|
"photoViewerSentOn": "{sender} • {date} в {time}",
|
||||||
|
"photoViewerSaveAs": "Сохранить как…",
|
||||||
|
"photoViewerViewAll": "Все фото чата",
|
||||||
|
"photoViewerRotate": "Повернуть",
|
||||||
"sharedCopyLink": "Копировать ссылку",
|
"sharedCopyLink": "Копировать ссылку",
|
||||||
"sharedLinkCopied": "Ссылка скопирована",
|
"sharedLinkCopied": "Ссылка скопирована",
|
||||||
"chatInfoActionLeave": "Покинуть",
|
"chatInfoActionLeave": "Покинуть",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user