diff --git a/mobile/lib/extensions/datetime_extensions.dart b/mobile/lib/extensions/datetime_extensions.dart index 0bc95565a6..693775d544 100644 --- a/mobile/lib/extensions/datetime_extensions.dart +++ b/mobile/lib/extensions/datetime_extensions.dart @@ -85,3 +85,13 @@ extension DateRangeFormatting on DateTime { } } } + +extension IsSameExtension on DateTime { + bool isSameDay(DateTime other) { + return day == other.day && month == other.month && year == other.year; + } + + bool isSameMonth(DateTime other) { + return month == other.month && year == other.year; + } +} diff --git a/mobile/lib/presentation/widgets/images/full_image.widget.dart b/mobile/lib/presentation/widgets/images/full_image.widget.dart index 77ea996b89..b6bd06e05f 100644 --- a/mobile/lib/presentation/widgets/images/full_image.widget.dart +++ b/mobile/lib/presentation/widgets/images/full_image.widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:octo_image/octo_image.dart'; class FullImage extends StatelessWidget { @@ -9,7 +9,7 @@ class FullImage extends StatelessWidget { this.asset, { required this.size, this.fit = BoxFit.cover, - this.placeholder = const ThumbnailPlaceholder(), + this.placeholder = const Thumbhash(), super.key, }); diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index a5f0c19eb8..fece4cc580 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; @@ -75,6 +76,21 @@ class FixedSegment extends Segment { spacing: spacing, ); } + + const FixedSegment.empty() + : this( + firstIndex: 0, + lastIndex: 0, + startOffset: 0, + endOffset: 0, + firstAssetIndex: 0, + bucket: const Bucket(assetCount: 0), + tileHeight: 1, + columnCount: 0, + headerExtent: 0, + spacing: 0, + header: HeaderType.none, + ); } class _FixedSegmentRow extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart index b65582f976..8191f1f0f8 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; @@ -6,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart class FixedSegmentBuilder extends SegmentBuilder { final double tileHeight; final int columnCount; + static final DateTime _dummyDate = DateTime.fromMicrosecondsSinceEpoch(0); const FixedSegmentBuilder({ required super.buckets, @@ -16,12 +18,11 @@ class FixedSegmentBuilder extends SegmentBuilder { }); List generate() { - final segments = []; + final segments = List.filled(buckets.length, const FixedSegment.empty()); int firstIndex = 0; double startOffset = 0; int assetIndex = 0; - DateTime? previousDate; - + DateTime previousDate = _dummyDate; for (int i = 0; i < buckets.length; i++) { final bucket = buckets[i]; @@ -32,11 +33,10 @@ class FixedSegmentBuilder extends SegmentBuilder { final segmentFirstIndex = firstIndex; firstIndex += segmentCount; final segmentLastIndex = firstIndex - 1; - final timelineHeader = switch (groupBy) { GroupAssetsBy.month => HeaderType.month, GroupAssetsBy.day || GroupAssetsBy.auto => - bucket is TimeBucket && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day, + bucket is TimeBucket && !previousDate.isSameMonth(bucket.date) ? HeaderType.monthAndDay : HeaderType.day, GroupAssetsBy.none => HeaderType.none, }; final headerExtent = SegmentBuilder.headerExtent(timelineHeader); @@ -45,20 +45,18 @@ class FixedSegmentBuilder extends SegmentBuilder { startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1); final segmentEndOffset = startOffset; - segments.add( - FixedSegment( - firstIndex: segmentFirstIndex, - lastIndex: segmentLastIndex, - startOffset: segmentStartOffset, - endOffset: segmentEndOffset, - firstAssetIndex: assetIndex, - bucket: bucket, - tileHeight: tileHeight, - columnCount: columnCount, - headerExtent: headerExtent, - spacing: spacing, - header: timelineHeader, - ), + segments[i] = FixedSegment( + firstIndex: segmentFirstIndex, + lastIndex: segmentLastIndex, + startOffset: segmentStartOffset, + endOffset: segmentEndOffset, + firstAssetIndex: assetIndex, + bucket: bucket, + tileHeight: tileHeight, + columnCount: columnCount, + headerExtent: headerExtent, + spacing: spacing, + header: timelineHeader, ); assetIndex += assetCount; diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index c80595a446..77a3ce4c05 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; -import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/widgets/common/thumbhash.dart'; abstract class SegmentBuilder { final List buckets; @@ -23,12 +23,13 @@ abstract class SegmentBuilder { int count, { Size size = const Size.square(kTimelineFixedTileExtent), double spacing = kTimelineSpacing, - }) => RepaintBoundary( - child: FixedTimelineRow( - dimension: size.height, - spacing: spacing, - textDirection: Directionality.of(context), - children: List.generate(count, (_) => ThumbnailPlaceholder(width: size.width, height: size.height)), - ), - ); + }) => + RepaintBoundary( + child: FixedTimelineRow( + dimension: size.height, + spacing: spacing, + textDirection: Directionality.of(context), + children: List.filled(count, const Thumbhash()), + ), + ); } diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index c8bc9c1f6a..f404368f05 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:octo_image/octo_image.dart'; class ImmichImage extends StatelessWidget { @@ -14,7 +14,7 @@ class ImmichImage extends StatelessWidget { this.width, this.height, this.fit = BoxFit.cover, - this.placeholder = const ThumbnailPlaceholder(), + this.placeholder = const Thumbhash(), super.key, }); diff --git a/mobile/lib/widgets/common/thumbhash.dart b/mobile/lib/widgets/common/thumbhash.dart index 3b6d970742..a429e56433 100644 --- a/mobile/lib/widgets/common/thumbhash.dart +++ b/mobile/lib/widgets/common/thumbhash.dart @@ -4,107 +4,18 @@ import 'dart:ui' as ui; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:thumbhash/thumbhash.dart' as thumbhash; -class ThumbhashImage extends RenderBox { - Color _placeholderColor; - ui.Image? _image; - BoxFit _fit; - - ThumbhashImage({ - required ui.Image? image, - required BoxFit fit, - required Color placeholderColor, - }) : _image = image, - _fit = fit, - _placeholderColor = placeholderColor; - - @override - void paint(PaintingContext context, Offset offset) { - final image = _image; - final rect = offset & size; - if (image == null) { - final paint = Paint(); - paint.color = _placeholderColor; - context.canvas.drawRect(rect, paint); - return; - } - - paintImage( - canvas: context.canvas, - rect: rect, - image: image, - fit: _fit, - filterQuality: FilterQuality.low, - ); - } - - @override - void performLayout() { - size = constraints.biggest; - } - - set image(ui.Image? value) { - if (_image != value) { - _image = value; - markNeedsPaint(); - } - } - - set fit(BoxFit value) { - if (_fit != value) { - _fit = value; - markNeedsPaint(); - } - } - - set placeholderColor(Color value) { - if (_placeholderColor != value) { - _placeholderColor = value; - markNeedsPaint(); - } - } -} - -class ThumbhashLeaf extends LeafRenderObjectWidget { - final ui.Image? image; - final BoxFit fit; - final Color placeholderColor; - - const ThumbhashLeaf({ - super.key, - required this.image, - required this.fit, - required this.placeholderColor, - }); - - @override - RenderObject createRenderObject(BuildContext context) { - return ThumbhashImage( - image: image, - fit: fit, - placeholderColor: placeholderColor, - ); - } - - @override - void updateRenderObject(BuildContext context, ThumbhashImage renderObject) { - renderObject.fit = fit; - renderObject.image = image; - renderObject.placeholderColor = placeholderColor; - } -} class Thumbhash extends StatefulWidget { final String? blurhash; final BoxFit fit; - final Color placeholderColor; const Thumbhash({ - required this.blurhash, + this.blurhash, this.fit = BoxFit.cover, - this.placeholderColor = const Color.fromRGBO(0, 0, 0, 0.2), super.key, }); @@ -112,18 +23,19 @@ class Thumbhash extends StatefulWidget { State createState() => _ThumbhashState(); } + class _ThumbhashState extends State { String? blurhash; BoxFit? fit; ui.Image? _image; - Color? placeholderColor; + + static final _gradientCache = {}; @override void initState() { super.initState(); final blurhash_ = blurhash = widget.blurhash; fit = widget.fit; - placeholderColor = widget.placeholderColor; if (blurhash_ == null) { return; } @@ -179,10 +91,20 @@ class _ThumbhashState extends State { @override Widget build(BuildContext context) { - return ThumbhashLeaf( + final colorScheme = context.colorScheme; + final gradient = _gradientCache[colorScheme] ??= LinearGradient( + colors: [ + colorScheme.surfaceContainer, + colorScheme.surfaceContainer.darken(amount: .1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + return _ThumbhashLeaf( image: _image, fit: fit!, - placeholderColor: placeholderColor!, + placeholderGradient: gradient, ); } @@ -192,3 +114,94 @@ class _ThumbhashState extends State { super.dispose(); } } + +class _ThumbhashLeaf extends LeafRenderObjectWidget { + final ui.Image? image; + final BoxFit fit; + final Gradient placeholderGradient; + + const _ThumbhashLeaf({ + required this.image, + required this.fit, + required this.placeholderGradient, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _ThumbhashRenderBox( + image: image, + fit: fit, + placeholderGradient: placeholderGradient, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _ThumbhashRenderBox renderObject, + ) { + renderObject.fit = fit; + renderObject.image = image; + renderObject.placeholderGradient = placeholderGradient; + } +} + +class _ThumbhashRenderBox extends RenderBox { + ui.Image? _image; + BoxFit _fit; + Gradient _placeholderGradient; + + _ThumbhashRenderBox({ + required ui.Image? image, + required BoxFit fit, + required Gradient placeholderGradient, + }) : _image = image, + _fit = fit, + _placeholderGradient = placeholderGradient; + + @override + void paint(PaintingContext context, Offset offset) { + final image = _image; + final rect = offset & size; + if (image == null) { + final paint = Paint(); + paint.shader = _placeholderGradient.createShader(rect); + context.canvas.drawRect(rect, paint); + return; + } + + paintImage( + canvas: context.canvas, + rect: rect, + image: image, + fit: _fit, + filterQuality: FilterQuality.low, + ); + } + + @override + void performLayout() { + size = constraints.biggest; + } + + set image(ui.Image? value) { + if (_image != value) { + _image = value; + markNeedsPaint(); + } + } + + set fit(BoxFit value) { + if (_fit != value) { + _fit = value; + markNeedsPaint(); + } + } + + set placeholderGradient(Gradient value) { + if (_placeholderGradient != value) { + _placeholderGradient = value; + markNeedsPaint(); + } + } +} diff --git a/mobile/lib/widgets/common/thumbhash_placeholder.dart b/mobile/lib/widgets/common/thumbhash_placeholder.dart index a1e9bc1d21..fcd569132c 100644 --- a/mobile/lib/widgets/common/thumbhash_placeholder.dart +++ b/mobile/lib/widgets/common/thumbhash_placeholder.dart @@ -2,15 +2,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:octo_image/octo_image.dart'; -/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as -/// placeholder and [OctoError.icon] as error. -OctoSet blurHashOrPlaceholder(String? blurhash, {BoxFit fit = BoxFit.cover, Text? errorMessage}) { - return OctoSet( - placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), - errorBuilder: blurHashErrorBuilder(blurhash, fit: fit, message: errorMessage), - ); -} - OctoPlaceholderBuilder blurHashPlaceholderBuilder(String? blurhash, {required BoxFit fit}) { return (context) => Thumbhash(blurhash: blurhash, fit: fit); } diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 6c45bb7529..828f18871f 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -90,12 +90,7 @@ class _BlurredBackdrop extends HookWidget { final blurhash = asset.thumbhash; if (blurhash != null) { // Use a nice cheap blur hash image decoration - return Stack( - children: [ - const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)), - Thumbhash(blurhash: blurhash, fit: BoxFit.cover), - ], - ); + return Thumbhash(blurhash: blurhash, fit: BoxFit.cover); } // Fall back to using a more expensive image filtered