543 lines
15 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|