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
+2
View File
@@ -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) {
+129
View File
@@ -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,
), ),
); );
} }
+48 -37
View File
@@ -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;
+118 -59
View File
@@ -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),
),
);
} }
} }
+7
View File
@@ -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,
+679 -36
View File
@@ -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),
),
),
),
);
}
} }
+39
View File
@@ -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",
+36
View File
@@ -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:
+24
View File
@@ -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';
+24
View File
@@ -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 => 'Копировать ссылку';
+6
View File
@@ -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": "Покинуть",
+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);
});
}