Files
Qlyra/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart
T

543 lines
15 KiB
Dart

import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../models/attachment.dart';
import '../../photo_viewer.dart';
import '../photo_hero.dart';
import 'bubble_context.dart';
class PhotoBubble extends StatelessWidget {
static const Radius _bigRadius = Radius.circular(
BubbleContext.bubbleBorderRadius,
);
static const Radius _smallRadius = Radius.circular(4);
static const Radius _photoRadius = Radius.circular(
BubbleContext.photoBorderRadius,
);
final BubbleContext ctx;
final List<PhotoAttachment> photos;
final Widget? caption;
final bool hasContentAbove;
const PhotoBubble({
super.key,
required this.ctx,
required this.photos,
this.caption,
this.hasContentAbove = false,
});
static double layoutWidth(List<PhotoAttachment> photos) {
if (photos.length != 1) return BubbleContext.photoMaxSize;
return (photos.single.width?.toDouble() ?? 200).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
}
@override
Widget build(BuildContext context) {
final message = ctx.message;
final hasMessageCaption = message.text != null && message.text!.isNotEmpty;
final resolvedCaption =
caption ?? (hasMessageCaption ? ctx.caption() : null);
final hasCaption = resolvedCaption != null;
final count = photos.length;
Widget photosWidget;
if (count == 1) {
photosWidget = _buildSinglePhoto(
ctx,
photos[0],
hasCaption: hasCaption,
hasContentAbove: hasContentAbove,
);
} else if (count == 2) {
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
} else {
photosWidget = _buildPhotoGrid(ctx, photos);
}
if (!hasCaption) {
return Stack(
children: [
photosWidget,
Positioned(
bottom: BubbleContext.compactTimePadding,
right: BubbleContext.compactTimePadding,
child: ctx.compactTime(),
),
],
);
}
if (count == 1) {
return SizedBox(
width: layoutWidth(photos),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
photosWidget,
Padding(
padding: const EdgeInsets.only(
left: BubbleContext.captionPaddingHorizontal,
right: BubbleContext.captionPaddingRight,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: resolvedCaption),
ctx.meta(),
],
),
),
],
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
photosWidget,
Padding(
padding: const EdgeInsets.only(
left: BubbleContext.captionPaddingHorizontal,
right: BubbleContext.captionPaddingRight,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: resolvedCaption),
ctx.meta(),
],
),
),
],
);
}
Widget _buildSinglePhoto(
BubbleContext ctx,
PhotoAttachment photo, {
required bool hasCaption,
required bool hasContentAbove,
}) {
final width = photo.width?.toDouble() ?? 200;
final height = photo.height?.toDouble() ?? 200;
final constrainedWidth = width.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final constrainedHeight = height.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
final matchTop = hasCaption && !hasContentAbove;
final matchBottom = !hasCaption;
final topR = matchTop ? _bigRadius : _photoRadius;
final bottomL = matchBottom
? (ctx.isMe ? _bigRadius : _smallRadius)
: _smallRadius;
final bottomR = matchBottom
? (ctx.isMe ? _smallRadius : _bigRadius)
: _smallRadius;
final radius = BorderRadius.only(
topLeft: topR,
topRight: topR,
bottomLeft: bottomL,
bottomRight: bottomR,
);
final memWidth = (constrainedWidth * dpr).round();
final memHeight = (constrainedHeight * dpr).round();
return ClipRRect(
borderRadius: radius,
child: Stack(
children: [
_buildPhotoImage(
ctx,
photo,
constrainedWidth,
constrainedHeight,
memWidth: memWidth,
memHeight: memHeight,
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, 0),
if (ctx.uploadProgress == null)
Positioned.fill(
child: Builder(
builder: (tileContext) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(
ctx.context,
0,
tileContext: tileContext,
radius: radius,
memWidth: memWidth,
memHeight: memHeight,
),
),
),
),
],
),
);
}
Widget _buildPhotoImage(
BubbleContext ctx,
PhotoAttachment photo,
double width,
double height, {
required int memWidth,
required int memHeight,
}) {
final localPath = photo.localPath;
if (localPath != null) {
return Image.file(
File(localPath),
width: width,
height: height,
fit: BoxFit.cover,
cacheWidth: memWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, width, height),
);
}
final imageUrl = photo.baseUrl ?? '';
if (imageUrl.isNotEmpty) {
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: BoxFit.cover,
memCacheWidth: memWidth,
memCacheHeight: memHeight,
fadeInDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height),
);
}
return _buildPhotoPlaceholder(ctx.cs, width, height);
}
Widget _buildUploadOverlay(
ValueListenable<List<double>> progress,
int index,
) {
return Positioned.fill(
child: ValueListenableBuilder<List<double>>(
valueListenable: progress,
builder: (context, values, _) {
final value = index < values.length ? values[index] : 1.0;
final indeterminate = value <= 0 || value >= 1.0;
return Container(
color: Colors.black.withValues(alpha: 0.4),
alignment: Alignment.center,
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 2.5,
value: indeterminate ? null : value,
color: Colors.white,
),
),
);
},
),
);
}
BorderRadius _multiPhotoCornerRadius({
required bool matchTop,
required bool matchBottom,
required bool isMe,
}) {
final topR = matchTop ? _bigRadius : _photoRadius;
final bottomL = matchBottom ? _smallRadius : _photoRadius;
final bottomR = matchBottom
? (isMe ? _smallRadius : _bigRadius)
: _photoRadius;
return BorderRadius.only(
topLeft: topR,
topRight: topR,
bottomLeft: bottomL,
bottomRight: bottomR,
);
}
Widget _buildTwoPhotos(
BubbleContext ctx,
PhotoAttachment p1,
PhotoAttachment p2,
) {
final matchTop =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
final matchBottom =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
return ClipRRect(
borderRadius: _multiPhotoCornerRadius(
matchTop: matchTop,
matchBottom: matchBottom,
isMe: ctx.isMe,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildPhotoTile(ctx, p1, 0)),
const SizedBox(width: 2),
Expanded(child: _buildPhotoTile(ctx, p2, 1)),
],
),
);
}
Widget _buildPhotoGrid(BubbleContext ctx, List<PhotoAttachment> photos) {
final displayCount = photos.length > 4 ? 4 : photos.length;
final remaining = photos.length - 4;
final matchTop =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
final matchBottom =
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(
borderRadius: _multiPhotoCornerRadius(
matchTop: matchTop,
matchBottom: matchBottom,
isMe: ctx.isMe,
),
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
);
}
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) {
final cachePx =
(BubbleContext.photoMaxSize /
2 *
MediaQuery.of(ctx.context).devicePixelRatio)
.round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
_buildPhotoImage(
ctx,
photo,
double.infinity,
double.infinity,
memWidth: cachePx,
memHeight: cachePx,
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress == null)
_buildTileTapTarget(ctx, index, cachePx),
],
),
);
}
Widget _buildTileTapTarget(BubbleContext ctx, int index, int cachePx) {
return Positioned.fill(
child: Builder(
builder: (tileContext) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(
ctx.context,
index,
tileContext: tileContext,
radius: BorderRadius.zero,
memWidth: cachePx,
memHeight: cachePx,
),
),
),
);
}
Widget _buildPhotoTileWithOverlay(
BubbleContext ctx,
PhotoAttachment photo,
String overlay,
int index,
) {
final cachePx =
(BubbleContext.photoMaxSize /
2 *
MediaQuery.of(ctx.context).devicePixelRatio)
.round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
_buildPhotoImage(
ctx,
photo,
double.infinity,
double.infinity,
memWidth: cachePx,
memHeight: cachePx,
),
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Text(
overlay,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress == null)
_buildTileTapTarget(ctx, index, cachePx),
],
),
);
}
Widget _buildPhotoPlaceholder(
ColorScheme cs,
double w,
double h, {
VoidCallback? onRetry,
}) {
return Container(
width: w,
height: h,
color: cs.surfaceContainerHighest,
child: onRetry != null
? Center(
child: IconButton(
icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant),
onPressed: onRetry,
tooltip: 'Retry',
),
)
: Center(
child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant),
),
);
}
static ImageProvider? _photoProvider(
PhotoAttachment photo, {
required int memWidth,
required int memHeight,
}) {
final localPath = photo.localPath;
if (localPath != null) {
return ResizeImage.resizeIfNeeded(
memWidth,
null,
FileImage(File(localPath)),
);
}
final url = photo.baseUrl ?? '';
if (url.isEmpty) return null;
return ResizeImage.resizeIfNeeded(
memWidth,
memHeight,
CachedNetworkImageProvider(url),
);
}
static Size? _photoSize(PhotoAttachment photo) {
final width = photo.width ?? 0;
final height = photo.height ?? 0;
if (width <= 0 || height <= 0) return null;
return Size(width.toDouble(), height.toDouble());
}
void _openPhotoViewer(
BuildContext context,
int index, {
required BuildContext tileContext,
required BorderRadius radius,
required int memWidth,
required int memHeight,
}) {
final photo = photos[index];
final hero = PhotoHeroController(
origin: () => photoHeroRectOf(tileContext),
image: _photoProvider(photo, memWidth: memWidth, memHeight: memHeight),
size: _photoSize(photo),
radius: radius,
);
Navigator.of(context).push(
PhotoHeroRoute<void>(
hero: hero,
builder: (_) => PhotoViewerScreen(
photos: photos,
initialIndex: index,
chatId: ctx.chatId,
message: ctx.message,
actions: ctx.photoActions,
hero: hero,
),
),
);
}
}