This commit is contained in:
mertalev 2025-07-11 15:24:26 +03:00
parent 2a1e914245
commit bd199f8985
No known key found for this signature in database
GPG Key ID: DF6ABC77AAD98C95
9 changed files with 167 additions and 143 deletions

View File

@ -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;
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/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'; import 'package:octo_image/octo_image.dart';
class FullImage extends StatelessWidget { class FullImage extends StatelessWidget {
@ -9,7 +9,7 @@ class FullImage extends StatelessWidget {
this.asset, { this.asset, {
required this.size, required this.size,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(), this.placeholder = const Thumbhash(),
super.key, super.key,
}); });

View File

@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
@ -75,6 +76,21 @@ class FixedSegment extends Segment {
spacing: spacing, 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 { class _FixedSegmentRow extends ConsumerWidget {

View File

@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/models/timeline.model.dart'; 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/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.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 { class FixedSegmentBuilder extends SegmentBuilder {
final double tileHeight; final double tileHeight;
final int columnCount; final int columnCount;
static final DateTime _dummyDate = DateTime.fromMicrosecondsSinceEpoch(0);
const FixedSegmentBuilder({ const FixedSegmentBuilder({
required super.buckets, required super.buckets,
@ -16,12 +18,11 @@ class FixedSegmentBuilder extends SegmentBuilder {
}); });
List<Segment> generate() { List<Segment> generate() {
final segments = <Segment>[]; final segments = List.filled(buckets.length, const FixedSegment.empty());
int firstIndex = 0; int firstIndex = 0;
double startOffset = 0; double startOffset = 0;
int assetIndex = 0; int assetIndex = 0;
DateTime? previousDate; DateTime previousDate = _dummyDate;
for (int i = 0; i < buckets.length; i++) { for (int i = 0; i < buckets.length; i++) {
final bucket = buckets[i]; final bucket = buckets[i];
@ -32,11 +33,10 @@ class FixedSegmentBuilder extends SegmentBuilder {
final segmentFirstIndex = firstIndex; final segmentFirstIndex = firstIndex;
firstIndex += segmentCount; firstIndex += segmentCount;
final segmentLastIndex = firstIndex - 1; final segmentLastIndex = firstIndex - 1;
final timelineHeader = switch (groupBy) { final timelineHeader = switch (groupBy) {
GroupAssetsBy.month => HeaderType.month, GroupAssetsBy.month => HeaderType.month,
GroupAssetsBy.day || GroupAssetsBy.auto => 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, GroupAssetsBy.none => HeaderType.none,
}; };
final headerExtent = SegmentBuilder.headerExtent(timelineHeader); final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
@ -45,8 +45,7 @@ class FixedSegmentBuilder extends SegmentBuilder {
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1); startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
final segmentEndOffset = startOffset; final segmentEndOffset = startOffset;
segments.add( segments[i] = FixedSegment(
FixedSegment(
firstIndex: segmentFirstIndex, firstIndex: segmentFirstIndex,
lastIndex: segmentLastIndex, lastIndex: segmentLastIndex,
startOffset: segmentStartOffset, startOffset: segmentStartOffset,
@ -58,7 +57,6 @@ class FixedSegmentBuilder extends SegmentBuilder {
headerExtent: headerExtent, headerExtent: headerExtent,
spacing: spacing, spacing: spacing,
header: timelineHeader, header: timelineHeader,
),
); );
assetIndex += assetCount; assetIndex += assetCount;

View File

@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.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/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.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 { abstract class SegmentBuilder {
final List<Bucket> buckets; final List<Bucket> buckets;
@ -23,12 +23,13 @@ abstract class SegmentBuilder {
int count, { int count, {
Size size = const Size.square(kTimelineFixedTileExtent), Size size = const Size.square(kTimelineFixedTileExtent),
double spacing = kTimelineSpacing, double spacing = kTimelineSpacing,
}) => RepaintBoundary( }) =>
RepaintBoundary(
child: FixedTimelineRow( child: FixedTimelineRow(
dimension: size.height, dimension: size.height,
spacing: spacing, spacing: spacing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
children: List.generate(count, (_) => ThumbnailPlaceholder(width: size.width, height: size.height)), children: List.filled(count, const Thumbhash()),
), ),
); );
} }

View File

@ -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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.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/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'; import 'package:octo_image/octo_image.dart';
class ImmichImage extends StatelessWidget { class ImmichImage extends StatelessWidget {
@ -14,7 +14,7 @@ class ImmichImage extends StatelessWidget {
this.width, this.width,
this.height, this.height,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(), this.placeholder = const Thumbhash(),
super.key, super.key,
}); });

View File

@ -4,107 +4,18 @@ import 'dart:ui' as ui;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; 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; 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 { class Thumbhash extends StatefulWidget {
final String? blurhash; final String? blurhash;
final BoxFit fit; final BoxFit fit;
final Color placeholderColor;
const Thumbhash({ const Thumbhash({
required this.blurhash, this.blurhash,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.placeholderColor = const Color.fromRGBO(0, 0, 0, 0.2),
super.key, super.key,
}); });
@ -112,18 +23,19 @@ class Thumbhash extends StatefulWidget {
State<Thumbhash> createState() => _ThumbhashState(); State<Thumbhash> createState() => _ThumbhashState();
} }
class _ThumbhashState extends State<Thumbhash> { class _ThumbhashState extends State<Thumbhash> {
String? blurhash; String? blurhash;
BoxFit? fit; BoxFit? fit;
ui.Image? _image; ui.Image? _image;
Color? placeholderColor;
static final _gradientCache = <ColorScheme, Gradient>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final blurhash_ = blurhash = widget.blurhash; final blurhash_ = blurhash = widget.blurhash;
fit = widget.fit; fit = widget.fit;
placeholderColor = widget.placeholderColor;
if (blurhash_ == null) { if (blurhash_ == null) {
return; return;
} }
@ -179,10 +91,20 @@ class _ThumbhashState extends State<Thumbhash> {
@override @override
Widget build(BuildContext context) { 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, image: _image,
fit: fit!, fit: fit!,
placeholderColor: placeholderColor!, placeholderGradient: gradient,
); );
} }
@ -192,3 +114,94 @@ class _ThumbhashState extends State<Thumbhash> {
super.dispose(); 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();
}
}
}

View File

@ -2,15 +2,6 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:immich_mobile/widgets/common/thumbhash.dart';
import 'package:octo_image/octo_image.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}) { OctoPlaceholderBuilder blurHashPlaceholderBuilder(String? blurhash, {required BoxFit fit}) {
return (context) => Thumbhash(blurhash: blurhash, fit: fit); return (context) => Thumbhash(blurhash: blurhash, fit: fit);
} }

View File

@ -90,12 +90,7 @@ class _BlurredBackdrop extends HookWidget {
final blurhash = asset.thumbhash; final blurhash = asset.thumbhash;
if (blurhash != null) { if (blurhash != null) {
// Use a nice cheap blur hash image decoration // Use a nice cheap blur hash image decoration
return Stack( return Thumbhash(blurhash: blurhash, fit: BoxFit.cover);
children: [
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)),
Thumbhash(blurhash: blurhash, fit: BoxFit.cover),
],
);
} }
// Fall back to using a more expensive image filtered // Fall back to using a more expensive image filtered