Merge e152748725 into c42cea5ca9
commit
0a9ba82f1b
|
|
@ -15,6 +15,18 @@ class ScrollToDateEvent extends Event {
|
|||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
/// Emitted when a tile is tapped in the dense zoomed-out layout: instead of
|
||||
/// opening the asset, the timeline zooms in one stop centered on this asset.
|
||||
/// Modeled as an event (not a direct callback) so the tile widget — which lives
|
||||
/// deep inside the sliver list — doesn't need a reference to the timeline's
|
||||
/// gesture/zoom state, mirroring how [ScrollToDateEvent] decouples scrubber
|
||||
/// taps from the timeline.
|
||||
class TimelineZoomToAssetEvent extends Event {
|
||||
final int assetIndex;
|
||||
|
||||
const TimelineZoomToAssetEvent(this.assetIndex);
|
||||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerShowDetailsEvent extends Event {
|
||||
const ViewerShowDetailsEvent();
|
||||
|
|
|
|||
|
|
@ -167,6 +167,16 @@ class TimelineService {
|
|||
return getAssets(index, count);
|
||||
}
|
||||
|
||||
/// Read-only fetch straight from the asset source, bypassing the shared buffer
|
||||
/// so it can't race with the active grid. Used to pre-warm thumbnails for the
|
||||
/// adjacent zoom levels. Returns an empty list for out-of-range requests.
|
||||
Future<List<BaseAsset>> peekAssets(int index, int count) {
|
||||
if (index < 0 || count <= 0 || index >= _totalAssets) {
|
||||
return Future.value(const []);
|
||||
}
|
||||
return _assetSource(index, math.min(count, _totalAssets - index));
|
||||
}
|
||||
|
||||
bool hasRange(int index, int count) =>
|
||||
index >= 0 &&
|
||||
index < _totalAssets &&
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ abstract class ImageRequest {
|
|||
|
||||
void _onCancelled();
|
||||
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(
|
||||
int address,
|
||||
int length, {
|
||||
int? targetWidth,
|
||||
int? targetHeight,
|
||||
}) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
|
|
@ -62,7 +67,7 @@ abstract class ImageRequest {
|
|||
return null;
|
||||
}
|
||||
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
final codec = await descriptor.instantiateCodec(targetWidth: targetWidth, targetHeight: targetHeight);
|
||||
if (_isCancelled) {
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
|
|
@ -72,8 +77,18 @@ abstract class ImageRequest {
|
|||
return (codec, descriptor);
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||
final result = await _codecFromEncodedPlatformImage(address, length);
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(
|
||||
int address,
|
||||
int length, {
|
||||
int? targetWidth,
|
||||
int? targetHeight,
|
||||
}) async {
|
||||
final result = await _codecFromEncodedPlatformImage(
|
||||
address,
|
||||
length,
|
||||
targetWidth: targetWidth,
|
||||
targetHeight: targetHeight,
|
||||
);
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -96,7 +111,14 @@ abstract class ImageRequest {
|
|||
return frame;
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(
|
||||
int address,
|
||||
int width,
|
||||
int height,
|
||||
int rowBytes, {
|
||||
int? targetWidth,
|
||||
int? targetHeight,
|
||||
}) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
|
|
@ -125,7 +147,7 @@ abstract class ImageRequest {
|
|||
);
|
||||
buffer.dispose();
|
||||
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
final codec = await descriptor.instantiateCodec(targetWidth: targetWidth, targetHeight: targetHeight);
|
||||
if (_isCancelled) {
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ part of 'image_request.dart';
|
|||
class RemoteImageRequest extends ImageRequest {
|
||||
final String uri;
|
||||
|
||||
RemoteImageRequest({required this.uri});
|
||||
/// When set, the server thumbnail is downscaled to this edge (px) on decode so
|
||||
/// tiny grid tiles use proportionally small textures. Null = full resolution.
|
||||
final int? decodeEdge;
|
||||
|
||||
RemoteImageRequest({required this.uri, this.decodeEdge});
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
|
|
@ -14,9 +18,15 @@ class RemoteImageRequest extends ImageRequest {
|
|||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
// Bound width only; instantiateCodec scales height proportionally, so the
|
||||
// thumbnail keeps its aspect ratio (cover-cropped to the square tile).
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(
|
||||
pointer,
|
||||
length,
|
||||
targetWidth: decodeEdge,
|
||||
),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes, targetWidth: decodeEdge),
|
||||
_ => null,
|
||||
};
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||
|
||||
Stream<List<Bucket>> _watchMainBucket(List<String> userIds, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
throw UnsupportedError("GroupAssetsBy.none is not supported for watchMainBucket");
|
||||
// No dedicated count query exists for merged assets, so reuse the day-grouped
|
||||
// bucket query purely as a total-count source and slice it into fixed-size segments.
|
||||
return _db.mergedAssetDrift
|
||||
.mergedBucket(userIds: userIds, groupBy: GroupAssetsBy.day.index)
|
||||
.watch()
|
||||
.map((rows) => _generateBuckets(rows.fold<int>(0, (acc, row) => acc + row.assetCount)));
|
||||
}
|
||||
|
||||
return _db.mergedAssetDrift.mergedBucket(userIds: userIds, groupBy: groupBy.index).map((row) {
|
||||
|
|
|
|||
|
|
@ -184,7 +184,13 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
|||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
|
||||
// Only downscale (and cache per-edge) below the default full edge; at or above
|
||||
// it, keep decodeEdge null so the request path is identical to before.
|
||||
final edge = size.width.toInt();
|
||||
final decodeEdge = edge < kThumbnailResolution.width ? edge : null;
|
||||
return assetId != null
|
||||
? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited, decodeEdge: decodeEdge)
|
||||
: null;
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
|
|
|
|||
|
|
@ -44,13 +44,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||
return true;
|
||||
}
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id;
|
||||
return id == other.id && size == other.size;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode;
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/repositories/metadata.repository.da
|
|||
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
|
|
@ -14,10 +15,28 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
|||
final String url;
|
||||
final bool edited;
|
||||
|
||||
RemoteImageProvider({required this.url, this.edited = true});
|
||||
/// Optional decode edge (px). When set, the thumbnail is downscaled on decode
|
||||
/// and cached separately per edge, so dense grid tiles get small textures.
|
||||
final int? decodeEdge;
|
||||
|
||||
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true})
|
||||
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
|
||||
RemoteImageProvider({required this.url, this.edited = true, this.decodeEdge});
|
||||
|
||||
RemoteImageProvider.thumbnail({
|
||||
required String assetId,
|
||||
required String thumbhash,
|
||||
this.edited = true,
|
||||
this.decodeEdge,
|
||||
}) : url = getThumbnailUrlForRemoteId(
|
||||
assetId,
|
||||
thumbhash: thumbhash,
|
||||
edited: edited,
|
||||
// Dense zoom-out tiles fetch the server's tiny "micro" thumbnail instead
|
||||
// of the ~250px one; the server falls back to the thumbnail if it hasn't
|
||||
// generated a micro for that asset yet.
|
||||
type: (decodeEdge != null && decodeEdge <= kTinyThumbnailMaxEdge)
|
||||
? AssetMediaSize.micro
|
||||
: AssetMediaSize.thumbnail,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
|
|
@ -37,7 +56,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
|||
}
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = RemoteImageRequest(uri: key.url);
|
||||
final request = this.request = RemoteImageRequest(uri: key.url, decodeEdge: key.decodeEdge);
|
||||
return loadRequest(request, decode, isFinal: true);
|
||||
}
|
||||
|
||||
|
|
@ -47,13 +66,13 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
|||
return true;
|
||||
}
|
||||
if (other is RemoteImageProvider) {
|
||||
return url == other.url && edited == other.edited;
|
||||
return url == other.url && edited == other.edited && decodeEdge == other.decodeEdge;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => url.hashCode ^ edited.hashCode;
|
||||
int get hashCode => url.hashCode ^ edited.hashCode ^ decodeEdge.hashCode;
|
||||
}
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
|
|||
this.lockSelection = false,
|
||||
this.heroOffset,
|
||||
this.showStackIndicator = false,
|
||||
this.showAssetIndicators = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -32,6 +33,10 @@ class ThumbnailTile extends ConsumerStatefulWidget {
|
|||
final int? heroOffset;
|
||||
final bool showStackIndicator;
|
||||
|
||||
/// When false, hides the type/video/live-photo, favorite and stack overlay
|
||||
/// icons (used at the widest zoom-out levels where tiles are tiny).
|
||||
final bool showAssetIndicators;
|
||||
|
||||
@override
|
||||
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
|
||||
}
|
||||
|
|
@ -137,7 +142,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
|||
},
|
||||
),
|
||||
),
|
||||
if (asset != null)
|
||||
if (asset != null && widget.showAssetIndicators)
|
||||
AnimatedOpacity(
|
||||
opacity: _hideIndicators ? 0.0 : 1.0,
|
||||
duration: Durations.short4,
|
||||
|
|
@ -182,7 +187,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
|||
},
|
||||
),
|
||||
|
||||
if (asset != null && asset.isFavorite)
|
||||
if (asset != null && asset.isFavorite && widget.showAssetIndicators)
|
||||
AnimatedOpacity(
|
||||
duration: Durations.short4,
|
||||
opacity: _hideIndicators ? 0.0 : 1.0,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,62 @@ const Size kTimelineFixedTileExtent = Size.square(256);
|
|||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
/// Absolute bounds for the number of asset tiles per row reachable by pinch.
|
||||
const int kTimelineMinColumnCount = 1;
|
||||
const int kTimelineMaxColumnCount = 32;
|
||||
|
||||
/// Manual "assets per row" slider range (the normal grid). The wider zoom-out
|
||||
/// levels (month/year-like) are reached only by pinch / the No-grouping layout.
|
||||
const int kTimelineSliderMinColumnCount = 1;
|
||||
const int kTimelineSliderMaxColumnCount = 6;
|
||||
|
||||
/// Discrete zoom stops the pinch snaps to and that tap-zoom steps through.
|
||||
/// Even counts (so an equal number of columns can be added to each side of the
|
||||
/// focal asset when zooming), plus a single-image-wide level.
|
||||
const List<int> kTimelineZoomStops = [1, 2, 4, 6, 16, 32];
|
||||
|
||||
/// Densest grouped (day/month) level before pinching further switches the
|
||||
/// timeline into the continuous "No grouping" layout.
|
||||
const int kTimelineGroupedMaxColumnCount = 6;
|
||||
|
||||
/// Floating date-label granularity boundaries in No-grouping mode: at or below
|
||||
/// [kTimelineGroupedMaxColumnCount] columns shows a day, up to this shows a
|
||||
/// month, and wider shows a year.
|
||||
const int kTimelineMonthLabelMaxColumns = 16;
|
||||
|
||||
const double kScrubberThumbHeight = 48.0;
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
/// Default (and maximum) thumbnail decode edge, in pixels. Used for every
|
||||
/// non-timeline caller and as the cap for the per-tile decode size below.
|
||||
const Size kThumbnailResolution = Size.square(320);
|
||||
|
||||
/// Physical-pixel decode edges that timeline tiles snap up to. Decoding a tiny
|
||||
/// tile (a dense zoom level) at a small edge keeps its GPU texture and decode
|
||||
/// cost proportional to what is actually shown, instead of always decoding the
|
||||
/// full [kThumbnailResolution]. Capped at [kThumbnailResolution] so a tile is
|
||||
/// never decoded larger than the previous fixed size (no regression for the
|
||||
/// coarse, large-tile levels).
|
||||
const List<int> kThumbnailDecodeStops = [48, 96, 256];
|
||||
|
||||
/// Thumbnails decoded at or below this edge (the dense zoom-out levels) are routed
|
||||
/// to a dedicated high-count image-cache tier, so a screenful of hundreds of tiny
|
||||
/// tiles can't evict the normal-size thumbnails — and vice-versa.
|
||||
const int kTinyThumbnailMaxEdge = 128;
|
||||
|
||||
/// Snaps a tile's displayed edge (logical px × [devicePixelRatio]) up to a
|
||||
/// [kThumbnailDecodeStops] bucket, falling back to the full [kThumbnailResolution]
|
||||
/// edge for large tiles. Bucketing avoids fragmenting the image cache across
|
||||
/// slightly different tile sizes.
|
||||
int thumbnailDecodeEdge(double tileExtentLogical, double devicePixelRatio) {
|
||||
final physical = tileExtentLogical * devicePixelRatio;
|
||||
for (final stop in kThumbnailDecodeStops) {
|
||||
if (physical <= stop) {
|
||||
return stop;
|
||||
}
|
||||
}
|
||||
return kThumbnailResolution.width.toInt();
|
||||
}
|
||||
|
||||
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/material.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/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
|
|
@ -87,6 +90,12 @@ class FixedSegment extends Segment {
|
|||
columnCount: columnCount,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
int rowIndexForAsset(int assetIndex) {
|
||||
final assetIndexInSegment = assetIndex - firstAssetIndex;
|
||||
return (assetIndexInSegment / columnCount).floor();
|
||||
}
|
||||
}
|
||||
|
||||
class _FixedSegmentRow extends ConsumerWidget {
|
||||
|
|
@ -143,6 +152,11 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||
TimelineService timelineService,
|
||||
bool isDynamicLayout,
|
||||
) {
|
||||
// Decode thumbnails at the displayed tile size, so dense (small-tile) zoom
|
||||
// levels use proportionally small textures instead of the full default edge.
|
||||
final thumbnailSize = Size.square(
|
||||
thumbnailDecodeEdge(tileHeight, MediaQuery.devicePixelRatioOf(context)).toDouble(),
|
||||
);
|
||||
final children = [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
|
|
@ -152,6 +166,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
thumbnailSize: thumbnailSize,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
|
@ -200,15 +215,29 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||
class _AssetTileWidget extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final int assetIndex;
|
||||
final Size thumbnailSize;
|
||||
|
||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
|
||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.thumbnailSize});
|
||||
|
||||
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
|
||||
// Ignore stray taps fired by individual fingers during a pinch-zoom gesture.
|
||||
if (ref.read(timelineStateProvider).isPinching) {
|
||||
return;
|
||||
}
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
|
||||
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
} else {
|
||||
// At the dense zoomed-out levels (wider than the grouped cap) tapping zooms in
|
||||
// one stage instead of opening the asset — tiles are too small to act on
|
||||
// directly. Use the live column count so this respects in-session pinch zoom,
|
||||
// not just the persisted slider preference.
|
||||
if (ref.read(timelineArgsProvider).columnCount > kTimelineGroupedMaxColumnCount) {
|
||||
EventStream.shared.emit(TimelineZoomToAssetEvent(assetIndex));
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
AssetViewer.setAsset(ref, asset);
|
||||
|
|
@ -226,6 +255,10 @@ class _AssetTileWidget extends ConsumerWidget {
|
|||
}
|
||||
|
||||
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
||||
// Don't enter selection from a finger held during a pinch-zoom gesture.
|
||||
if (ref.read(timelineStateProvider).isPinching) {
|
||||
return;
|
||||
}
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (multiSelectState.isEnabled || multiSelectState.forceEnable) {
|
||||
return;
|
||||
|
|
@ -254,21 +287,40 @@ class _AssetTileWidget extends ConsumerWidget {
|
|||
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
|
||||
final lockSelection = _getLockSelectionStatus(ref);
|
||||
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
|
||||
// Also hide all overlay icons during a zoom transition: otherwise, committing to a
|
||||
// level that shows them makes every tile's icons fade in at once mid-animation,
|
||||
// which reads as a stutter. They reappear once the new level has settled.
|
||||
final isZooming = ref.watch(timelineStateProvider.select((s) => s.isPinching));
|
||||
// Hide the cloud/storage indicator at the widest zoom-out levels (16/32) where
|
||||
// tiles are tiny and the icons are just clutter.
|
||||
final showStorageIndicator =
|
||||
!isZooming &&
|
||||
ref.watch(
|
||||
timelineArgsProvider.select(
|
||||
(args) => args.showStorageIndicator && args.columnCount < kTimelineMonthLabelMaxColumns,
|
||||
),
|
||||
);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
|
||||
final showStackIndicator = !isZooming && ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
|
||||
// At the widest zoom-out levels (16/32) hide the type/video/favorite/stack overlays.
|
||||
final showAssetIndicators =
|
||||
!isZooming &&
|
||||
ref.watch(timelineArgsProvider.select((args) => args.columnCount < kTimelineMonthLabelMaxColumns));
|
||||
|
||||
final Widget tile = ThumbnailTile(
|
||||
asset,
|
||||
size: thumbnailSize,
|
||||
lockSelection: lockSelection,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
showStackIndicator: showStackIndicator,
|
||||
showAssetIndicators: showAssetIndicators,
|
||||
heroOffset: heroOffset,
|
||||
);
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
||||
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(
|
||||
asset,
|
||||
lockSelection: lockSelection,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
showStackIndicator: showStackIndicator,
|
||||
heroOffset: heroOffset,
|
||||
),
|
||||
child: tile,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart' show kTimelineMonthLabelMaxColumns;
|
||||
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';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
|
||||
class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final double tileHeight;
|
||||
|
|
@ -16,14 +21,32 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
|||
});
|
||||
|
||||
List<Segment> generate() {
|
||||
final isContinuous = isContinuousTimelineLayout(columnCount, groupBy);
|
||||
// At the wide zoom levels (>6 cols) with a date-based grouping
|
||||
// (day/month/auto), aggregate consecutive buckets into one segment per
|
||||
// visible period — month at 16-wide, year at 32-wide — and leave a blank
|
||||
// row of vertical space between them so the user's grouping preference
|
||||
// is still visible at a glance. "No grouping" stays asset-level chunked.
|
||||
final usePeriodGaps = isContinuous && groupBy != GroupAssetsBy.none;
|
||||
final isYearPeriod = columnCount >= kTimelineMonthLabelMaxColumns * 2;
|
||||
|
||||
final segments = <Segment>[];
|
||||
int firstIndex = 0;
|
||||
double startOffset = 0;
|
||||
int assetIndex = 0;
|
||||
DateTime? previousDate;
|
||||
|
||||
for (int i = 0; i < buckets.length; i++) {
|
||||
final bucket = buckets[i];
|
||||
final List<Bucket> effectiveBuckets;
|
||||
if (!isContinuous) {
|
||||
effectiveBuckets = buckets;
|
||||
} else if (usePeriodGaps) {
|
||||
effectiveBuckets = _aggregateByPeriod(year: isYearPeriod);
|
||||
} else {
|
||||
effectiveBuckets = _alignContinuousBuckets();
|
||||
}
|
||||
|
||||
for (int i = 0; i < effectiveBuckets.length; i++) {
|
||||
final bucket = effectiveBuckets[i];
|
||||
|
||||
final assetCount = bucket.assetCount;
|
||||
final numberOfRows = (assetCount / columnCount).ceil();
|
||||
|
|
@ -33,13 +56,25 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
|||
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,
|
||||
GroupAssetsBy.none => HeaderType.none,
|
||||
};
|
||||
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||
final timelineHeader = isContinuous
|
||||
? HeaderType.none
|
||||
: switch (groupBy) {
|
||||
GroupAssetsBy.month => HeaderType.month,
|
||||
GroupAssetsBy.day || GroupAssetsBy.auto =>
|
||||
bucket is TimeBucket && bucket.date.month != previousDate?.month
|
||||
? HeaderType.monthAndDay
|
||||
: HeaderType.day,
|
||||
GroupAssetsBy.none => HeaderType.none,
|
||||
};
|
||||
// For period-gapped segments the header is invisible (HeaderType.none)
|
||||
// but reserves a row's worth of vertical space, so the period boundary
|
||||
// reads as a blank gap. The very first segment skips the gap.
|
||||
final double headerExtent;
|
||||
if (usePeriodGaps && i > 0) {
|
||||
headerExtent = tileHeight + spacing;
|
||||
} else {
|
||||
headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||
}
|
||||
|
||||
final segmentStartOffset = startOffset;
|
||||
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
|
||||
|
|
@ -68,4 +103,106 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
|||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// Walks [buckets] and merges all consecutive entries whose dates fall in the
|
||||
/// same period — same year for [year] = true, same year-and-month otherwise —
|
||||
/// into a single [TimeBucket]. Buckets without a date (rare; only the
|
||||
/// no-grouping merged-bucket path) flush the current accumulator and become
|
||||
/// their own segment. Used by the wide-zoom day/month/auto layout to give one
|
||||
/// segment per visible period.
|
||||
List<Bucket> _aggregateByPeriod({required bool year}) {
|
||||
if (buckets.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final result = <Bucket>[];
|
||||
int accumulatedCount = 0;
|
||||
DateTime? accumulatedDate;
|
||||
|
||||
void flush() {
|
||||
if (accumulatedCount <= 0) {
|
||||
return;
|
||||
}
|
||||
result.add(
|
||||
accumulatedDate != null
|
||||
? TimeBucket(assetCount: accumulatedCount, date: accumulatedDate!)
|
||||
: Bucket(assetCount: accumulatedCount),
|
||||
);
|
||||
accumulatedCount = 0;
|
||||
accumulatedDate = null;
|
||||
}
|
||||
|
||||
bool samePeriod(DateTime a, DateTime b) {
|
||||
if (a.year != b.year) {
|
||||
return false;
|
||||
}
|
||||
return year || a.month == b.month;
|
||||
}
|
||||
|
||||
for (final bucket in buckets) {
|
||||
final bucketDate = bucket is TimeBucket ? bucket.date : null;
|
||||
if (bucketDate == null) {
|
||||
flush();
|
||||
result.add(Bucket(assetCount: bucket.assetCount));
|
||||
continue;
|
||||
}
|
||||
if (accumulatedDate == null) {
|
||||
accumulatedDate = bucketDate;
|
||||
accumulatedCount = bucket.assetCount;
|
||||
} else if (samePeriod(accumulatedDate!, bucketDate)) {
|
||||
accumulatedCount += bucket.assetCount;
|
||||
} else {
|
||||
flush();
|
||||
accumulatedDate = bucketDate;
|
||||
accumulatedCount = bucket.assetCount;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Re-slices all buckets into continuous chunks whose size is a multiple of
|
||||
/// [columnCount], so every row is full and no interior gaps appear. Each chunk
|
||||
/// inherits the date of its first asset's source bucket so the scrubber and any
|
||||
/// month/year UI can still resolve dates from segments at the wider zoom levels.
|
||||
List<Bucket> _alignContinuousBuckets() {
|
||||
if (buckets.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final chunkSize = math.max(columnCount, (kTimelineNoneSegmentSize ~/ columnCount) * columnCount);
|
||||
final result = <Bucket>[];
|
||||
int chunkRemaining = 0;
|
||||
DateTime? chunkDate;
|
||||
|
||||
void flush() {
|
||||
if (chunkRemaining <= 0) {
|
||||
return;
|
||||
}
|
||||
result.add(
|
||||
chunkDate != null
|
||||
? TimeBucket(assetCount: chunkRemaining, date: chunkDate!)
|
||||
: Bucket(assetCount: chunkRemaining),
|
||||
);
|
||||
chunkRemaining = 0;
|
||||
chunkDate = null;
|
||||
}
|
||||
|
||||
for (final bucket in buckets) {
|
||||
final bucketDate = bucket is TimeBucket ? bucket.date : null;
|
||||
int taken = bucket.assetCount;
|
||||
while (taken > 0) {
|
||||
if (chunkRemaining == 0) {
|
||||
chunkDate = bucketDate;
|
||||
}
|
||||
final space = chunkSize - chunkRemaining;
|
||||
final take = math.min(taken, space);
|
||||
chunkRemaining += take;
|
||||
taken -= take;
|
||||
if (chunkRemaining == chunkSize) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ class TimelineHeader extends HookConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (bucket is! TimeBucket || header == HeaderType.none) {
|
||||
return const SizedBox.shrink();
|
||||
// [HeaderType.none] still reserves [height] in the layout — used by the
|
||||
// wide zoom levels of day/month/auto groupings to leave a blank row gap
|
||||
// between months/years without rendering a visible header label.
|
||||
return height > 0 ? SizedBox(height: height) : const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final date = (bucket as TimeBucket).date;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ abstract class Segment {
|
|||
int getMaxChildIndexForScrollOffset(double scrollOffset);
|
||||
double indexToLayoutOffset(int index);
|
||||
|
||||
/// Returns the in-segment row index (0-based, excluding the header) that contains
|
||||
/// the given global [assetIndex]. The caller can compute the row's child index via
|
||||
/// `firstIndex + 1 + rowIndexForAsset(...)` and its scroll offset via
|
||||
/// `indexToLayoutOffset(rowChildIndex)`.
|
||||
int rowIndexForAsset(int assetIndex);
|
||||
|
||||
Widget builder(BuildContext context, int index);
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -52,21 +52,26 @@ class TimelineArgs {
|
|||
class TimelineState {
|
||||
final bool isScrubbing;
|
||||
final bool isScrolling;
|
||||
final bool isPinching;
|
||||
|
||||
const TimelineState({this.isScrubbing = false, this.isScrolling = false});
|
||||
const TimelineState({this.isScrubbing = false, this.isScrolling = false, this.isPinching = false});
|
||||
|
||||
bool get isInteracting => isScrubbing || isScrolling;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant TimelineState other) {
|
||||
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling;
|
||||
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling && isPinching == other.isPinching;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode;
|
||||
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode ^ isPinching.hashCode;
|
||||
|
||||
TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) {
|
||||
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling);
|
||||
TimelineState copyWith({bool? isScrubbing, bool? isScrolling, bool? isPinching}) {
|
||||
return TimelineState(
|
||||
isScrubbing: isScrubbing ?? this.isScrubbing,
|
||||
isScrolling: isScrolling ?? this.isScrolling,
|
||||
isPinching: isPinching ?? this.isPinching,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,16 +84,37 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
|||
state = state.copyWith(isScrolling: isScrolling);
|
||||
}
|
||||
|
||||
void setPinching(bool isPinching) {
|
||||
state = state.copyWith(isPinching: isPinching);
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false, isPinching: false);
|
||||
}
|
||||
|
||||
/// Session-only column count override set by the pinch-zoom gesture. Persists for
|
||||
/// the current session but is not written to metadata, so the next app launch falls
|
||||
/// back to the user's slider preference instead of inheriting the last pinch state.
|
||||
final pinchZoomColumnsProvider = StateProvider<int?>((_) => null);
|
||||
|
||||
/// True when the timeline uses the continuous header-less layout. Two opt-in
|
||||
/// triggers — both require an explicit user action, so existing day/month/auto
|
||||
/// users never see this layout unless they choose it:
|
||||
/// 1. User picked "No grouping" in settings (groupBy == none), OR
|
||||
/// 2. User pinched out past [kTimelineGroupedMaxColumnCount] (the manual
|
||||
/// slider is capped at this value, so reaching the wide range is a
|
||||
/// deliberate gesture rather than a silent preference change).
|
||||
bool isContinuousTimelineLayout(int columnCount, GroupAssetsBy groupBy) =>
|
||||
groupBy == GroupAssetsBy.none || columnCount > kTimelineGroupedMaxColumnCount;
|
||||
|
||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||
// It should be used only after the timeline service and timeline args provider is overridden
|
||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||
final args = ref.watch(timelineArgsProvider);
|
||||
final columnCount = args.columnCount;
|
||||
final spacing = args.spacing;
|
||||
// Drop the inter-tile spacing at the widest zoom-out levels (15/32) for a
|
||||
// seamless, borderless grid.
|
||||
final spacing = columnCount >= kTimelineMonthLabelMaxColumns ? 0.0 : args.spacing;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,6 +39,10 @@ class _TimelineDragRegionState extends State<TimelineDragRegion> {
|
|||
Timer? scrollTimer;
|
||||
late bool scrollNotified;
|
||||
|
||||
// Number of fingers currently down in this region. Drag-to-select must only start
|
||||
// with a single finger, so a two-finger pinch-zoom doesn't also select assets.
|
||||
int _activePointers = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -62,14 +66,19 @@ class _TimelineDragRegionState extends State<TimelineDragRegion> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
gestures: {
|
||||
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
|
||||
() => _CustomLongPressGestureRecognizer(),
|
||||
_registerCallbacks,
|
||||
),
|
||||
},
|
||||
child: widget.child,
|
||||
return Listener(
|
||||
onPointerDown: (_) => _activePointers++,
|
||||
onPointerUp: (_) => _activePointers = _activePointers > 0 ? _activePointers - 1 : 0,
|
||||
onPointerCancel: (_) => _activePointers = _activePointers > 0 ? _activePointers - 1 : 0,
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
|
||||
() => _CustomLongPressGestureRecognizer(),
|
||||
_registerCallbacks,
|
||||
),
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +106,12 @@ class _TimelineDragRegionState extends State<TimelineDragRegion> {
|
|||
}
|
||||
|
||||
void _onLongPressStart(LongPressStartDetails event) {
|
||||
// Only start drag-select with a single finger; ignore it during a pinch.
|
||||
if (_activePointers > 1) {
|
||||
anchorAsset = null;
|
||||
return;
|
||||
}
|
||||
|
||||
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
|
||||
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
|
||||
final height = context.size?.height;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,27 @@ import 'package:flutter/painting.dart';
|
|||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
|
||||
/// [ImageCache] that uses two caches for small and large images
|
||||
/// so that a single large image does not evict all small images
|
||||
/// [ImageCache] that segments by image size so one class of image can't evict
|
||||
/// another: full images ([_large]), normal thumbnails ([_small]), the dense
|
||||
/// zoom-out tiles ([_tiny]) and thumbhashes ([_thumbhash]).
|
||||
final class CustomImageCache implements ImageCache {
|
||||
final _thumbhash = ImageCache()..maximumSize = 0;
|
||||
final _small = ImageCache();
|
||||
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images
|
||||
// Dense zoom-out grids show hundreds of tiny tiles per screen. They get their
|
||||
// own high-count tier so they don't evict normal thumbnails; bytes stay bounded
|
||||
// because each tile is only a few KB at these edges.
|
||||
final _tiny = ImageCache()
|
||||
..maximumSize = 8000
|
||||
..maximumSizeBytes = 96 << 20; // 96 MiB
|
||||
|
||||
@override
|
||||
int get maximumSize => _small.maximumSize + _large.maximumSize;
|
||||
int get maximumSize => _small.maximumSize + _large.maximumSize + _tiny.maximumSize;
|
||||
|
||||
@override
|
||||
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes;
|
||||
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes + _tiny.maximumSizeBytes;
|
||||
|
||||
@override
|
||||
set maximumSize(int value) => _small.maximumSize = value;
|
||||
|
|
@ -26,12 +34,14 @@ final class CustomImageCache implements ImageCache {
|
|||
void clear() {
|
||||
_small.clear();
|
||||
_large.clear();
|
||||
_tiny.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void clearLiveImages() {
|
||||
_small.clearLiveImages();
|
||||
_large.clearLiveImages();
|
||||
_tiny.clearLiveImages();
|
||||
}
|
||||
|
||||
/// Gets the cache for the given key
|
||||
|
|
@ -39,6 +49,8 @@ final class CustomImageCache implements ImageCache {
|
|||
return switch (key) {
|
||||
LocalFullImageProvider() || RemoteFullImageProvider() => _large,
|
||||
ThumbHashProvider() => _thumbhash,
|
||||
RemoteImageProvider(:final decodeEdge?) when decodeEdge <= kTinyThumbnailMaxEdge => _tiny,
|
||||
LocalThumbProvider(:final size) when size.shortestSide <= kTinyThumbnailMaxEdge => _tiny,
|
||||
_ => _small,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,19 +63,19 @@ final class CustomImageCache implements ImageCache {
|
|||
}
|
||||
|
||||
@override
|
||||
int get currentSize => _small.currentSize + _large.currentSize;
|
||||
int get currentSize => _small.currentSize + _large.currentSize + _tiny.currentSize;
|
||||
|
||||
@override
|
||||
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes;
|
||||
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes + _tiny.currentSizeBytes;
|
||||
|
||||
@override
|
||||
bool evict(Object key, {bool includeLive = true}) => _cacheForKey(key).evict(key, includeLive: includeLive);
|
||||
|
||||
@override
|
||||
int get liveImageCount => _small.liveImageCount + _large.liveImageCount;
|
||||
int get liveImageCount => _small.liveImageCount + _large.liveImageCount + _tiny.liveImageCount;
|
||||
|
||||
@override
|
||||
int get pendingImageCount => _small.pendingImageCount + _large.pendingImageCount;
|
||||
int get pendingImageCount => _small.pendingImageCount + _large.pendingImageCount + _tiny.pendingImageCount;
|
||||
|
||||
@override
|
||||
ImageStreamCompleter? putIfAbsent(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
|
|||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
|
||||
|
||||
|
|
@ -21,6 +22,11 @@ class GroupSettings extends HookConsumerWidget {
|
|||
Future<void> updateAppSettings(GroupAssetsBy groupBy) async {
|
||||
await ref.read(metadataProvider).write(MetadataKey.timelineGroupAssetsBy, groupBy);
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
// Force the timeline service to recreate with the new grouping while we're
|
||||
// still on the settings page — when the user returns to the timeline the
|
||||
// new buckets are already loading and the live grid swaps in without a
|
||||
// visible loading screen.
|
||||
ref.invalidate(timelineServiceProvider);
|
||||
}
|
||||
|
||||
void changeGroupValue(GroupAssetsBy? value) {
|
||||
|
|
@ -51,6 +57,10 @@ class GroupSettings extends HookConsumerWidget {
|
|||
title: 'asset_list_layout_settings_group_automatically'.t(context: context),
|
||||
value: GroupAssetsBy.auto,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'group_no'.t(context: context),
|
||||
value: GroupAssetsBy.none,
|
||||
),
|
||||
],
|
||||
groupBy: groupBy.value,
|
||||
onRadioChanged: changeGroupValue,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
|
|
@ -14,9 +16,18 @@ class LayoutSettings extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow)));
|
||||
// The pinch gesture can set far wider counts (up to 32) than the manual slider
|
||||
// range, so clamp the displayed value to keep the Slider within bounds.
|
||||
final tilesPerRow = useState(
|
||||
ref
|
||||
.read(appConfigProvider.select((s) => s.timeline.tilesPerRow))
|
||||
.clamp(kTimelineSliderMinColumnCount, kTimelineSliderMaxColumnCount),
|
||||
);
|
||||
useValueChanged<int, void>(tilesPerRow.value, (_, __) {
|
||||
ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, tilesPerRow.value);
|
||||
// Slider sets the persisted preference — clear any in-session pinch override
|
||||
// so the new slider value is what the timeline actually shows.
|
||||
ref.read(pinchZoomColumnsProvider.notifier).state = null;
|
||||
});
|
||||
|
||||
return Column(
|
||||
|
|
@ -30,9 +41,9 @@ class LayoutSettings extends HookConsumerWidget {
|
|||
valueNotifier: tilesPerRow,
|
||||
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
|
||||
label: "${tilesPerRow.value}",
|
||||
maxValue: 6,
|
||||
minValue: 2,
|
||||
noDivisons: 4,
|
||||
maxValue: kTimelineSliderMaxColumnCount.toDouble(),
|
||||
minValue: kTimelineSliderMinColumnCount.toDouble(),
|
||||
noDivisons: kTimelineSliderMaxColumnCount - kTimelineSliderMinColumnCount,
|
||||
onChangeEnd: (value) {
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -48,6 +48,24 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('main assets', () {
|
||||
test('supports GroupAssetsBy.none by slicing all assets into count-based buckets', () async {
|
||||
final user = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: user.id);
|
||||
await ctx.newRemoteAsset(ownerId: user.id);
|
||||
|
||||
final query = sut.main([user.id], .none);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
// none does not group by date; the total asset count is preserved across the segments
|
||||
final total = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
expect(total, 2);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('person assets', () {
|
||||
test('does not duplicate an asset that has multiple face records for the same person', () async {
|
||||
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
|
||||
|
|
|
|||
Loading…
Reference in New Issue