You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-07-06 06:07:29 +02:00
feat(mobile): sqlite timeline (#19197)
* wip: timeline * more segment extensions * added scrubber * refactor: timeline state * more refactors * fix scrubber segments * added remote thumb & thumbhash provider * feat: merged view * scrub / merged asset fixes * rename stuff & add tile indicators * fix local album timeline query * ignore hidden assets during sync * ignore recovered assets during sync * old scrubber * add video indicator * handle groupBy * handle partner inTimeline * show duration * reduce widget nesting in thumb tile * merge main * chore: extend cacheExtent * ignore touch events on scrub label when not visible * scrub label ignore events and hide immediately * auto reload on sync * refactor image providers * throttle db updates --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@ -88,6 +88,11 @@ final _features = [
|
||||
}
|
||||
},
|
||||
),
|
||||
_Feature(
|
||||
name: 'Main Timeline',
|
||||
icon: Icons.timeline_rounded,
|
||||
onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()),
|
||||
),
|
||||
];
|
||||
|
||||
@RoutePage()
|
||||
|
31
mobile/lib/presentation/pages/dev/local_timeline.page.dart
Normal file
31
mobile/lib/presentation/pages/dev/local_timeline.page.dart
Normal file
@ -0,0 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LocalTimelinePage extends StatelessWidget {
|
||||
final String albumId;
|
||||
|
||||
const LocalTimelinePage({super.key, required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final timelineService =
|
||||
ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId);
|
||||
ref.onDispose(() => unawaited(timelineService.dispose()));
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
31
mobile/lib/presentation/pages/dev/main_timeline.page.dart
Normal file
31
mobile/lib/presentation/pages/dev/main_timeline.page.dart
Normal file
@ -0,0 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MainTimelinePage extends StatelessWidget {
|
||||
const MainTimelinePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final timelineService = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.main(ref.watch(timelineUsersIdsProvider));
|
||||
ref.onDispose(() => unawaited(timelineService.dispose()));
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class _Stat {
|
||||
const _Stat({required this.name, required this.load});
|
||||
@ -16,9 +17,16 @@ class _Stat {
|
||||
|
||||
class _Summary extends StatelessWidget {
|
||||
final String name;
|
||||
final Widget? leading;
|
||||
final Future<int> countFuture;
|
||||
final void Function()? onTap;
|
||||
|
||||
const _Summary({required this.name, required this.countFuture});
|
||||
const _Summary({
|
||||
required this.name,
|
||||
required this.countFuture,
|
||||
this.leading,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -34,7 +42,12 @@ class _Summary extends StatelessWidget {
|
||||
} else {
|
||||
subtitle = Text('${snapshot.data ?? 0}');
|
||||
}
|
||||
return ListTile(title: Text(name), trailing: subtitle);
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(name),
|
||||
trailing: subtitle,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -105,8 +118,12 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
||||
.filter((f) => f.albumId.id.equals(album.id))
|
||||
.count();
|
||||
return _Summary(
|
||||
leading: const Icon(Icons.photo_album_rounded),
|
||||
name: album.name,
|
||||
countFuture: countFuture,
|
||||
onTap: () => context.router.push(
|
||||
LocalTimelineRoute(albumId: album.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: albums.length,
|
||||
|
@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final IAssetMediaRepository _assetMediaRepository =
|
||||
const AssetMediaRepository();
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
final LocalAsset asset;
|
||||
final double height;
|
||||
final double width;
|
||||
|
||||
LocalThumbProvider({
|
||||
required this.asset,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
LocalThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height';
|
||||
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer =
|
||||
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
return await decode(buffer);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(
|
||||
key.asset.id,
|
||||
size: Size(key.width, key.height),
|
||||
);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${key.asset.name} failed",
|
||||
);
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return asset.id == other.asset.id &&
|
||||
asset.updatedAt == other.asset.updatedAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final double height;
|
||||
final double width;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
RemoteThumbProvider({
|
||||
required this.assetId,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
RemoteThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
final chunkController = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkController),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkController.stream,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
RemoteThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
);
|
||||
|
||||
return ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
).whenComplete(chunkController.close);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import 'dart:convert' hide Codec;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:thumbhash/thumbhash.dart';
|
||||
|
||||
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
||||
final String thumbHash;
|
||||
|
||||
ThumbHashProvider({
|
||||
required this.thumbHash,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ThumbHashProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadCodec(key, decode),
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _loadCodec(
|
||||
ThumbHashProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
|
||||
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ThumbHashProvider) {
|
||||
return thumbHash == other.thumbHash;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => thumbHash.hashCode;
|
||||
}
|
105
mobile/lib/presentation/widgets/images/thumbnail.widget.dart
Normal file
105
mobile/lib/presentation/widgets/images/thumbnail.widget.dart
Normal file
@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class Thumbnail extends StatelessWidget {
|
||||
const Thumbnail({
|
||||
required this.asset,
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
|
||||
static ImageProvider imageProvider({
|
||||
required BaseAsset asset,
|
||||
Size size = const Size.square(256),
|
||||
}) {
|
||||
if (asset is LocalAsset) {
|
||||
return LocalThumbProvider(
|
||||
asset: asset,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
);
|
||||
}
|
||||
|
||||
if (asset is Asset) {
|
||||
return RemoteThumbProvider(
|
||||
assetId: asset.id,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
);
|
||||
}
|
||||
|
||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null;
|
||||
final provider = imageProvider(asset: asset, size: size);
|
||||
|
||||
return OctoImage.fromSet(
|
||||
image: provider,
|
||||
octoSet: OctoSet(
|
||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
||||
errorBuilder: _blurHashErrorBuilder(
|
||||
thumbHash,
|
||||
provider: provider,
|
||||
fit: fit,
|
||||
asset: asset,
|
||||
),
|
||||
),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
fadeInDuration: Duration.zero,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(
|
||||
String? thumbHash, {
|
||||
BoxFit? fit,
|
||||
}) {
|
||||
return (context) => thumbHash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
OctoErrorBuilder _blurHashErrorBuilder(
|
||||
String? blurhash, {
|
||||
BaseAsset? asset,
|
||||
ImageProvider? provider,
|
||||
BoxFit? fit,
|
||||
}) =>
|
||||
(context, e, s) {
|
||||
Logger("ImThumbnail")
|
||||
.warning("Error loading thumbnail for ${asset?.name}", e, s);
|
||||
provider?.evict();
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
||||
const Opacity(
|
||||
opacity: 0.75,
|
||||
child: Icon(Icons.error_outline_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
};
|
@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
|
||||
class ThumbnailTile extends StatelessWidget {
|
||||
const ThumbnailTile(
|
||||
this.asset, {
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
this.showStorageIndicator = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)),
|
||||
if (asset.isVideo)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _VideoIndicator(asset.durationInSeconds ?? 0),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, bottom: 6.0),
|
||||
child: _TileOverlayIcon(
|
||||
switch (asset.storage) {
|
||||
AssetState.local => Icons.cloud_off_outlined,
|
||||
AssetState.remote => Icons.cloud_outlined,
|
||||
AssetState.merged => Icons.cloud_done_outlined,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (asset.isFavorite)
|
||||
const Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 10.0, bottom: 6.0),
|
||||
child: _TileOverlayIcon(Icons.favorite_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoIndicator extends StatelessWidget {
|
||||
final int durationInSeconds;
|
||||
const _VideoIndicator(this.durationInSeconds);
|
||||
|
||||
String _formatDuration(int durationInSec) {
|
||||
final int hours = durationInSec ~/ 3600;
|
||||
final int minutes = (durationInSec % 3600) ~/ 60;
|
||||
final int seconds = durationInSec % 60;
|
||||
|
||||
final String minutesPadded = minutes.toString().padLeft(2, '0');
|
||||
final String secondsPadded = seconds.toString().padLeft(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return "$hours:$minutesPadded:$secondsPadded"; // H:MM:SS
|
||||
} else {
|
||||
return "$minutesPadded:$secondsPadded"; // MM:SS
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: 3,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
// CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(durationInSeconds),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _TileOverlayIcon(Icons.play_circle_outline_rounded),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileOverlayIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
|
||||
const _TileOverlayIcon(this.icon);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
offset: const Offset(0.0, 0.0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
7
mobile/lib/presentation/widgets/timeline/constants.dart
Normal file
7
mobile/lib/presentation/widgets/timeline/constants.dart
Normal file
@ -0,0 +1,7 @@
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const double kTimelineFixedTileExtent = 256;
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
154
mobile/lib/presentation/widgets/timeline/fixed/row.dart
Normal file
154
mobile/lib/presentation/widgets/timeline/fixed/row.dart
Normal file
@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double dimension;
|
||||
final double spacing;
|
||||
final TextDirection textDirection;
|
||||
|
||||
const FixedTimelineRow({
|
||||
super.key,
|
||||
required this.dimension,
|
||||
required this.spacing,
|
||||
required this.textDirection,
|
||||
required super.children,
|
||||
});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderFixedRow(
|
||||
dimension: dimension,
|
||||
spacing: spacing,
|
||||
textDirection: textDirection,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||
renderObject.dimension = dimension;
|
||||
renderObject.spacing = spacing;
|
||||
renderObject.textDirection = textDirection;
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
}
|
||||
|
||||
class _RowParentData extends ContainerBoxParentData<RenderBox> {}
|
||||
|
||||
class RenderFixedRow extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double dimension,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _dimension = dimension,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
double get dimension => _dimension;
|
||||
double _dimension;
|
||||
|
||||
set dimension(double value) {
|
||||
if (_dimension == value) return;
|
||||
_dimension = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
double get spacing => _spacing;
|
||||
double _spacing;
|
||||
|
||||
set spacing(double value) {
|
||||
if (_spacing == value) return;
|
||||
_spacing = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
TextDirection get textDirection => _textDirection;
|
||||
TextDirection _textDirection;
|
||||
|
||||
set textDirection(TextDirection value) {
|
||||
if (_textDirection == value) return;
|
||||
_textDirection = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! _RowParentData) {
|
||||
child.parentData = _RowParentData();
|
||||
}
|
||||
}
|
||||
|
||||
double get intrinsicWidth =>
|
||||
dimension * childCount + spacing * (childCount - 1);
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => dimension;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
return defaultComputeDistanceToHighestActualBaseline(baseline);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
RenderBox? child = firstChild;
|
||||
if (child == null) {
|
||||
size = constraints.smallest;
|
||||
return;
|
||||
}
|
||||
// Use the entire width of the parent for the row.
|
||||
size = Size(constraints.maxWidth, dimension);
|
||||
// Each tile is forced to be dimension x dimension.
|
||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
||||
// Layout each child horizontally.
|
||||
while (child != null) {
|
||||
child.layout(childConstraints, parentUsesSize: false);
|
||||
final childParentData = child.parentData! as _RowParentData;
|
||||
childParentData.offset = offset;
|
||||
offset += Offset(dx, 0);
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,132 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
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/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.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';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
class FixedSegment extends Segment {
|
||||
final double tileHeight;
|
||||
final int columnCount;
|
||||
final double mainAxisExtend;
|
||||
|
||||
const FixedSegment({
|
||||
required super.firstIndex,
|
||||
required super.lastIndex,
|
||||
required super.startOffset,
|
||||
required super.endOffset,
|
||||
required super.firstAssetIndex,
|
||||
required super.bucket,
|
||||
required this.tileHeight,
|
||||
required this.columnCount,
|
||||
required super.headerExtent,
|
||||
required super.spacing,
|
||||
required super.header,
|
||||
}) : assert(tileHeight != 0),
|
||||
mainAxisExtend = tileHeight + spacing;
|
||||
|
||||
@override
|
||||
double indexToLayoutOffset(int index) {
|
||||
index -= gridIndex;
|
||||
if (index < 0) {
|
||||
return startOffset;
|
||||
}
|
||||
return gridOffset + (mainAxisExtend * index);
|
||||
}
|
||||
|
||||
@override
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= gridOffset;
|
||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
||||
return firstIndex;
|
||||
}
|
||||
final rowsAbove = (scrollOffset / mainAxisExtend).floor();
|
||||
return gridIndex + rowsAbove;
|
||||
}
|
||||
|
||||
@override
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= gridOffset;
|
||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
||||
return firstIndex;
|
||||
}
|
||||
final firstRowBelow = (scrollOffset / mainAxisExtend).ceil();
|
||||
return gridIndex + firstRowBelow - 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget builder(BuildContext context, int index) {
|
||||
if (index == firstIndex) {
|
||||
return TimelineHeader(
|
||||
bucket: bucket,
|
||||
header: header,
|
||||
height: headerExtent,
|
||||
);
|
||||
}
|
||||
|
||||
final rowIndexInSegment = index - (firstIndex + 1);
|
||||
final assetIndex = rowIndexInSegment * columnCount;
|
||||
final assetCount = bucket.assetCount;
|
||||
final numberOfAssets = math.min(columnCount, assetCount - assetIndex);
|
||||
|
||||
return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
|
||||
}
|
||||
|
||||
Widget _buildRow(int assetIndex, int count) => Consumer(
|
||||
builder: (ctx, ref, _) {
|
||||
final isScrubbing =
|
||||
ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
|
||||
// Timeline is being scrubbed, show placeholders
|
||||
if (isScrubbing) {
|
||||
return SegmentBuilder.buildPlaceholder(
|
||||
ctx,
|
||||
count,
|
||||
size: Size.square(tileHeight),
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
// Bucket is already loaded, show the assets
|
||||
if (timelineService.hasRange(assetIndex, count)) {
|
||||
final assets = timelineService.getAssets(assetIndex, count);
|
||||
return _buildAssetRow(ctx, assets);
|
||||
}
|
||||
|
||||
// Bucket is not loaded, show placeholders and load the bucket
|
||||
return FutureBuilder(
|
||||
future: timelineService.loadAssets(assetIndex, count),
|
||||
builder: (ctxx, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return SegmentBuilder.buildPlaceholder(
|
||||
ctx,
|
||||
count,
|
||||
size: Size.square(tileHeight),
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAssetRow(ctxx, snap.requireData);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) =>
|
||||
FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.generate(
|
||||
assets.length,
|
||||
(i) => RepaintBoundary(child: ThumbnailTile(assets[i])),
|
||||
),
|
||||
);
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import 'package:immich_mobile/domain/models/timeline.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_builder.dart';
|
||||
|
||||
class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final double tileHeight;
|
||||
final int columnCount;
|
||||
|
||||
const FixedSegmentBuilder({
|
||||
required super.buckets,
|
||||
required this.tileHeight,
|
||||
required this.columnCount,
|
||||
super.spacing,
|
||||
super.groupBy,
|
||||
});
|
||||
|
||||
List<Segment> generate() {
|
||||
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 assetCount = bucket.assetCount;
|
||||
final numberOfRows = (assetCount / columnCount).ceil();
|
||||
final segmentCount = numberOfRows + 1;
|
||||
|
||||
final segmentFirstIndex = firstIndex;
|
||||
firstIndex += segmentCount;
|
||||
final segmentLastIndex = firstIndex - 1;
|
||||
|
||||
final timelineHeader = switch (groupBy) {
|
||||
GroupAssetsBy.month => HeaderType.month,
|
||||
GroupAssetsBy.day =>
|
||||
bucket is TimeBucket && bucket.date.month != previousDate?.month
|
||||
? HeaderType.monthAndDay
|
||||
: HeaderType.day,
|
||||
GroupAssetsBy.none => HeaderType.none,
|
||||
};
|
||||
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||
|
||||
final segmentStartOffset = startOffset;
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
assetIndex += assetCount;
|
||||
if (bucket is TimeBucket) {
|
||||
previousDate = bucket.date;
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
}
|
60
mobile/lib/presentation/widgets/timeline/header.widget.dart
Normal file
60
mobile/lib/presentation/widgets/timeline/header.widget.dart
Normal file
@ -0,0 +1,60 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class TimelineHeader extends StatelessWidget {
|
||||
final Bucket bucket;
|
||||
final HeaderType header;
|
||||
final double height;
|
||||
|
||||
const TimelineHeader({
|
||||
super.key,
|
||||
required this.bucket,
|
||||
required this.header,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
String _formatMonth(BuildContext context, DateTime date) {
|
||||
final formatter = date.year == DateTime.now().year
|
||||
? DateFormat.MMMM(context.locale.toLanguageTag())
|
||||
: DateFormat.yMMMM(context.locale.toLanguageTag());
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
String _formatDay(BuildContext context, DateTime date) {
|
||||
final formatter = DateFormat.yMMMEd(context.locale.toLanguageTag());
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bucket is! TimeBucket || header == HeaderType.none) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final date = (bucket as TimeBucket).date;
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10),
|
||||
height: height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
if (header == HeaderType.month || header == HeaderType.monthAndDay)
|
||||
Text(
|
||||
_formatMonth(context, date),
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(fontSize: 24, fontWeight: FontWeight.w500),
|
||||
),
|
||||
if (header == HeaderType.day || header == HeaderType.monthAndDay)
|
||||
Text(
|
||||
_formatDay(context, date),
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
455
mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Normal file
455
mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Normal file
@ -0,0 +1,455 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:intl/intl.dart' hide TextDirection;
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
class Scrubber extends StatefulWidget {
|
||||
/// The view that will be scrolled with the scroll thumb
|
||||
final CustomScrollView child;
|
||||
|
||||
/// The segments of the timeline
|
||||
final List<Segment> layoutSegments;
|
||||
|
||||
final double timelineHeight;
|
||||
|
||||
final double topPadding;
|
||||
|
||||
final double bottomPadding;
|
||||
|
||||
Scrubber({
|
||||
super.key,
|
||||
Key? scrollThumbKey,
|
||||
required this.layoutSegments,
|
||||
required this.timelineHeight,
|
||||
this.topPadding = 0,
|
||||
this.bottomPadding = 0,
|
||||
required this.child,
|
||||
}) : assert(child.scrollDirection == Axis.vertical);
|
||||
|
||||
@override
|
||||
State createState() => ScrubberState();
|
||||
}
|
||||
|
||||
List<_Segment> _buildSegments({
|
||||
required List<Segment> layoutSegments,
|
||||
required double timelineHeight,
|
||||
}) {
|
||||
final segments = <_Segment>[];
|
||||
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final formatter = DateFormat.yMMM();
|
||||
for (final layoutSegment in layoutSegments) {
|
||||
final scrollPercentage =
|
||||
layoutSegment.startOffset / layoutSegments.last.endOffset;
|
||||
final startOffset = scrollPercentage * timelineHeight;
|
||||
|
||||
final date = (layoutSegment.bucket as TimeBucket).date;
|
||||
final label = formatter.format(date);
|
||||
|
||||
segments.add(
|
||||
_Segment(
|
||||
date: date,
|
||||
startOffset: startOffset,
|
||||
scrollLabel: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
||||
double _thumbTopOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
List<_Segment> _segments = [];
|
||||
|
||||
late AnimationController _thumbAnimationController;
|
||||
Timer? _fadeOutTimer;
|
||||
late Animation<double> _thumbAnimation;
|
||||
|
||||
late AnimationController _labelAnimationController;
|
||||
late Animation<double> _labelAnimation;
|
||||
|
||||
double get _scrubberHeight =>
|
||||
widget.timelineHeight - widget.topPadding - widget.bottomPadding;
|
||||
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
double get _currentOffset =>
|
||||
_scrollController.offset *
|
||||
_scrubberHeight /
|
||||
_scrollController.position.maxScrollExtent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isDragging = false;
|
||||
_segments = _buildSegments(
|
||||
layoutSegments: widget.layoutSegments,
|
||||
timelineHeight: _scrubberHeight,
|
||||
);
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: kTimelineScrubberFadeInDuration,
|
||||
);
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
);
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: kTimelineScrubberFadeInDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_scrollController = PrimaryScrollController.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.layoutSegments.lastOrNull?.endOffset !=
|
||||
widget.layoutSegments.lastOrNull?.endOffset) {
|
||||
_segments = _buildSegments(
|
||||
layoutSegments: widget.layoutSegments,
|
||||
timelineHeight: _scrubberHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeOutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetThumbTimer() {
|
||||
_fadeOutTimer?.cancel();
|
||||
_fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_fadeOutTimer = null;
|
||||
});
|
||||
}
|
||||
|
||||
bool _onScrollNotification(ScrollNotification notification) {
|
||||
if (_isDragging) {
|
||||
// If the user is dragging the thumb, we don't want to update the position
|
||||
return false;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_thumbTopOffset = _currentOffset;
|
||||
if (_labelAnimation.status != AnimationStatus.reverse) {
|
||||
_labelAnimationController.reverse();
|
||||
}
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
}
|
||||
_resetThumbTimer();
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onDragStart(WidgetRef ref) {
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeOutTimer?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
final newOffset =
|
||||
details.globalPosition.dy - widget.topPadding - widget.bottomPadding;
|
||||
|
||||
setState(() {
|
||||
_thumbTopOffset = newOffset.clamp(0, _scrubberHeight);
|
||||
final scrollPercentage = _thumbTopOffset / _scrubberHeight;
|
||||
final maxScrollExtent = _scrollController.position.maxScrollExtent;
|
||||
_scrollController.jumpTo(maxScrollExtent * scrollPercentage);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragEnd(WidgetRef ref) {
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||
_labelAnimationController.reverse();
|
||||
_isDragging = false;
|
||||
_resetThumbTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext ctx) {
|
||||
Text? label;
|
||||
if (_scrollController.hasClients) {
|
||||
// Cache to avoid multiple calls to [_currentOffset]
|
||||
final scrollOffset = _currentOffset;
|
||||
final labelText = _segments
|
||||
.lastWhereOrNull(
|
||||
(segment) => segment.startOffset <= scrollOffset,
|
||||
)
|
||||
?.scrollLabel ??
|
||||
_segments.firstOrNull?.scrollLabel;
|
||||
label = labelText != null
|
||||
? Text(
|
||||
labelText,
|
||||
style: ctx.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: Stack(
|
||||
children: [
|
||||
RepaintBoundary(child: widget.child),
|
||||
PositionedDirectional(
|
||||
top: _thumbTopOffset + widget.topPadding,
|
||||
end: 0,
|
||||
child: Consumer(
|
||||
builder: (_, ref, child) => GestureDetector(
|
||||
onVerticalDragStart: (_) => _onDragStart(ref),
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: (_) => _onDragEnd(ref),
|
||||
child: child,
|
||||
),
|
||||
child: _Scrubber(
|
||||
thumbAnimation: _thumbAnimation,
|
||||
labelAnimation: _labelAnimation,
|
||||
label: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScrollLabel extends StatelessWidget {
|
||||
final Text label;
|
||||
final Color backgroundColor;
|
||||
final Animation<double> animation;
|
||||
|
||||
const _ScrollLabel({
|
||||
required this.label,
|
||||
required this.backgroundColor,
|
||||
required this.animation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 12.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
alignment: Alignment.center,
|
||||
child: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scrubber extends StatelessWidget {
|
||||
final Text? label;
|
||||
final Animation<double> thumbAnimation;
|
||||
final Animation<double> labelAnimation;
|
||||
|
||||
const _Scrubber({
|
||||
this.label,
|
||||
required this.thumbAnimation,
|
||||
required this.labelAnimation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundColor = context.isDarkTheme
|
||||
? context.colorScheme.primary.darken(amount: .5)
|
||||
: context.colorScheme.primary;
|
||||
|
||||
return _SlideFadeTransition(
|
||||
animation: thumbAnimation,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (label != null)
|
||||
_ScrollLabel(
|
||||
label: label!,
|
||||
backgroundColor: backgroundColor,
|
||||
animation: labelAnimation,
|
||||
),
|
||||
_CircularThumb(backgroundColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircularThumb extends StatelessWidget {
|
||||
final Color backgroundColor;
|
||||
|
||||
const _CircularThumb(this.backgroundColor);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
foregroundPainter: const _ArrowPainter(Colors.white),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(48.0),
|
||||
bottomLeft: Radius.circular(48.0),
|
||||
topRight: Radius.circular(4.0),
|
||||
bottomRight: Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArrowPainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
const _ArrowPainter(this.color);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
const width = 12.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||
paint,
|
||||
);
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(o.dx, o.dy)
|
||||
..lineTo(o.dx + width, o.dy)
|
||||
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> _animation;
|
||||
final Widget _child;
|
||||
|
||||
const _SlideFadeTransition({
|
||||
required Animation<double> animation,
|
||||
required Widget child,
|
||||
}) : _animation = animation,
|
||||
_child = child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) =>
|
||||
_animation.value == 0.0 ? const SizedBox() : child!,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0.3, 0.0),
|
||||
end: const Offset(0.0, 0.0),
|
||||
).animate(_animation),
|
||||
child: FadeTransition(
|
||||
opacity: _animation,
|
||||
child: _child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment {
|
||||
final DateTime date;
|
||||
final double startOffset;
|
||||
final String scrollLabel;
|
||||
|
||||
const _Segment({
|
||||
required this.date,
|
||||
required this.startOffset,
|
||||
required this.scrollLabel,
|
||||
});
|
||||
|
||||
_Segment copyWith({
|
||||
DateTime? date,
|
||||
double? startOffset,
|
||||
String? scrollLabel,
|
||||
}) {
|
||||
return _Segment(
|
||||
date: date ?? this.date,
|
||||
startOffset: startOffset ?? this.startOffset,
|
||||
scrollLabel: scrollLabel ?? this.scrollLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Segment(scrollLabel: $scrollLabel, date: $date)';
|
||||
}
|
||||
}
|
100
mobile/lib/presentation/widgets/timeline/segment.model.dart
Normal file
100
mobile/lib/presentation/widgets/timeline/segment.model.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
|
||||
// Segments are the time groups buckets in the timeline view.
|
||||
// Each segment contains a header and a list of asset rows.
|
||||
abstract class Segment {
|
||||
// The index of the first row of the segment, usually the header, but if not, it can be any asset.
|
||||
final int firstIndex;
|
||||
// The index of the last asset of the segment.
|
||||
final int lastIndex;
|
||||
// The offset of the first row from beginning of the timeline.
|
||||
final double startOffset;
|
||||
// The offset of the last row from beginning of the timeline.
|
||||
final double endOffset;
|
||||
// The spacing between the header and the first row of the segment.
|
||||
final double spacing;
|
||||
final double headerExtent;
|
||||
// the start index of the asset of this segment from the beginning of the timeline.
|
||||
final int firstAssetIndex;
|
||||
final Bucket bucket;
|
||||
|
||||
// The index of the row after the header
|
||||
final int gridIndex;
|
||||
// The offset of the row after the header
|
||||
final double gridOffset;
|
||||
// The type of the header
|
||||
final HeaderType header;
|
||||
|
||||
const Segment({
|
||||
required this.firstIndex,
|
||||
required this.lastIndex,
|
||||
required this.startOffset,
|
||||
required this.endOffset,
|
||||
required this.firstAssetIndex,
|
||||
required this.bucket,
|
||||
required this.headerExtent,
|
||||
required this.spacing,
|
||||
required this.header,
|
||||
}) : gridIndex = firstIndex + 1,
|
||||
gridOffset = startOffset + headerExtent + spacing;
|
||||
|
||||
bool containsIndex(int index) => firstIndex <= index && index <= lastIndex;
|
||||
|
||||
bool isWithinOffset(double offset) =>
|
||||
startOffset <= offset && offset <= endOffset;
|
||||
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset);
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset);
|
||||
double indexToLayoutOffset(int index);
|
||||
|
||||
Widget builder(BuildContext context, int index);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is Segment &&
|
||||
other.firstIndex == firstIndex &&
|
||||
other.lastIndex == lastIndex &&
|
||||
other.startOffset == startOffset &&
|
||||
other.endOffset == endOffset &&
|
||||
other.spacing == spacing &&
|
||||
other.firstAssetIndex == firstAssetIndex &&
|
||||
other.headerExtent == headerExtent &&
|
||||
other.gridIndex == gridIndex &&
|
||||
other.gridOffset == gridOffset &&
|
||||
other.header == header;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
firstIndex.hashCode ^
|
||||
lastIndex.hashCode ^
|
||||
startOffset.hashCode ^
|
||||
endOffset.hashCode ^
|
||||
spacing.hashCode ^
|
||||
headerExtent.hashCode ^
|
||||
firstAssetIndex.hashCode ^
|
||||
gridIndex.hashCode ^
|
||||
gridOffset.hashCode ^
|
||||
header.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Segment(firstIndex: $firstIndex, lastIndex: $lastIndex)';
|
||||
}
|
||||
}
|
||||
|
||||
extension SegmentListExtension on List<Segment> {
|
||||
bool equals(List<Segment> other) =>
|
||||
length == other.length &&
|
||||
lastOrNull?.endOffset == other.lastOrNull?.endOffset;
|
||||
|
||||
Segment? findByIndex(int index) =>
|
||||
firstWhereOrNull((s) => s.containsIndex(index));
|
||||
|
||||
Segment? findByOffset(double offset) =>
|
||||
firstWhereOrNull((s) => s.isWithinOffset(offset)) ?? lastOrNull;
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
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';
|
||||
|
||||
abstract class SegmentBuilder {
|
||||
final List<Bucket> buckets;
|
||||
final double spacing;
|
||||
final GroupAssetsBy groupBy;
|
||||
|
||||
const SegmentBuilder({
|
||||
required this.buckets,
|
||||
this.spacing = kTimelineSpacing,
|
||||
this.groupBy = GroupAssetsBy.day,
|
||||
});
|
||||
|
||||
static double headerExtent(HeaderType header) {
|
||||
switch (header) {
|
||||
case HeaderType.month:
|
||||
return kTimelineHeaderExtent;
|
||||
case HeaderType.day:
|
||||
return kTimelineHeaderExtent * 0.90;
|
||||
case HeaderType.monthAndDay:
|
||||
return kTimelineHeaderExtent * 1.5;
|
||||
case HeaderType.none:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
static Widget buildPlaceholder(
|
||||
BuildContext context,
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
100
mobile/lib/presentation/widgets/timeline/timeline.state.dart
Normal file
100
mobile/lib/presentation/widgets/timeline/timeline.state.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.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/fixed/segment_builder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
final double maxHeight;
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
|
||||
const TimelineArgs({
|
||||
required this.maxWidth,
|
||||
required this.maxHeight,
|
||||
this.spacing = kTimelineSpacing,
|
||||
this.columnCount = kTimelineColumnCount,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant TimelineArgs other) {
|
||||
return spacing == other.spacing &&
|
||||
maxWidth == other.maxWidth &&
|
||||
maxHeight == other.maxHeight &&
|
||||
columnCount == other.columnCount;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
maxWidth.hashCode ^
|
||||
maxHeight.hashCode ^
|
||||
spacing.hashCode ^
|
||||
columnCount.hashCode;
|
||||
}
|
||||
|
||||
class TimelineState {
|
||||
final bool isScrubbing;
|
||||
|
||||
const TimelineState({this.isScrubbing = false});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant TimelineState other) {
|
||||
return isScrubbing == other.isScrubbing;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isScrubbing.hashCode;
|
||||
|
||||
TimelineState copyWith({bool? isScrubbing}) {
|
||||
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing);
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
TimelineStateNotifier();
|
||||
|
||||
void setScrubbing(bool isScrubbing) {
|
||||
state = state.copyWith(isScrubbing: isScrubbing);
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineState build() => const TimelineState(isScrubbing: false);
|
||||
}
|
||||
|
||||
// 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;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
|
||||
final groupBy = GroupAssetsBy
|
||||
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
groupBy: groupBy,
|
||||
).generate();
|
||||
});
|
||||
},
|
||||
dependencies: [timelineServiceProvider, timelineArgsProvider],
|
||||
);
|
||||
|
||||
final timelineStateProvider =
|
||||
NotifierProvider<TimelineStateNotifier, TimelineState>(
|
||||
TimelineStateNotifier.new,
|
||||
);
|
365
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
365
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
@ -0,0 +1,365 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(
|
||||
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const _SliverTimeline(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverTimeline extends StatefulWidget {
|
||||
const _SliverTimeline();
|
||||
|
||||
@override
|
||||
State createState() => _SliverTimelineState();
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends State<_SliverTimeline> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final asyncSegments = ref.watch(timelineSegmentProvider);
|
||||
final maxHeight =
|
||||
ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
|
||||
return asyncSegments.widgetWhen(
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: Scrubber(
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: context.padding.top + 10,
|
||||
bottomPadding: context.padding.bottom + 10,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) return null;
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ??
|
||||
const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<Segment> _segments;
|
||||
|
||||
const _SliverSegmentedList({
|
||||
required List<Segment> segments,
|
||||
required super.delegate,
|
||||
}) : _segments = segments;
|
||||
|
||||
@override
|
||||
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
|
||||
_RenderSliverTimelineBoxAdaptor(
|
||||
childManager: context as SliverMultiBoxAdaptorElement,
|
||||
segments: _segments,
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderSliverTimelineBoxAdaptor renderObject,
|
||||
) {
|
||||
renderObject.segments = _segments;
|
||||
}
|
||||
}
|
||||
|
||||
/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets
|
||||
class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
List<Segment> _segments;
|
||||
|
||||
set segments(List<Segment> updatedSegments) {
|
||||
if (_segments.equals(updatedSegments)) {
|
||||
return;
|
||||
}
|
||||
_segments = updatedSegments;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
_RenderSliverTimelineBoxAdaptor({
|
||||
required super.childManager,
|
||||
required List<Segment> segments,
|
||||
}) : _segments = segments;
|
||||
|
||||
int getMinChildIndexForScrollOffset(double offset) =>
|
||||
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ??
|
||||
0;
|
||||
|
||||
int getMaxChildIndexForScrollOffset(double offset) =>
|
||||
_segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ??
|
||||
0;
|
||||
|
||||
double indexToLayoutOffset(int index) =>
|
||||
(_segments.findByIndex(index) ?? _segments.lastOrNull)
|
||||
?.indexToLayoutOffset(index) ??
|
||||
0;
|
||||
|
||||
double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
|
||||
|
||||
double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
childManager.didStartLayout();
|
||||
// Assume initially that we have enough children to fill the viewport/cache area.
|
||||
childManager.setDidUnderflow(false);
|
||||
|
||||
final double scrollOffset =
|
||||
constraints.scrollOffset + constraints.cacheOrigin;
|
||||
assert(scrollOffset >= 0.0);
|
||||
|
||||
final double remainingExtent = constraints.remainingCacheExtent;
|
||||
assert(remainingExtent >= 0.0);
|
||||
|
||||
final double targetScrollOffset = scrollOffset + remainingExtent;
|
||||
|
||||
// Find the index of the first child that should be visible or in the leading cache area.
|
||||
final int firstRequiredChildIndex =
|
||||
getMinChildIndexForScrollOffset(scrollOffset);
|
||||
|
||||
// Find the index of the last child that should be visible or in the trailing cache area.
|
||||
final int? lastRequiredChildIndex = targetScrollOffset.isFinite
|
||||
? getMaxChildIndexForScrollOffset(targetScrollOffset)
|
||||
: null;
|
||||
|
||||
// Remove children that are no longer visible or within the cache area.
|
||||
if (firstChild == null) {
|
||||
collectGarbage(0, 0);
|
||||
} else {
|
||||
final int leadingChildrenToRemove =
|
||||
calculateLeadingGarbage(firstIndex: firstRequiredChildIndex);
|
||||
final int trailingChildrenToRemove = lastRequiredChildIndex == null
|
||||
? 0
|
||||
: calculateTrailingGarbage(lastIndex: lastRequiredChildIndex);
|
||||
collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove);
|
||||
}
|
||||
|
||||
// If there are currently no children laid out (e.g., initial load),
|
||||
// try to add the first child needed for the current scroll offset.
|
||||
if (firstChild == null) {
|
||||
final double firstChildLayoutOffset =
|
||||
indexToLayoutOffset(firstRequiredChildIndex);
|
||||
final bool childAdded = addInitialChild(
|
||||
index: firstRequiredChildIndex,
|
||||
layoutOffset: firstChildLayoutOffset,
|
||||
);
|
||||
|
||||
if (!childAdded) {
|
||||
// There are either no children, or we are past the end of all our children.
|
||||
final double max =
|
||||
firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset();
|
||||
geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max);
|
||||
childManager.didFinishLayout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Layout children that might have scrolled into view from the top (before the current firstChild).
|
||||
RenderBox? highestLaidOutChild;
|
||||
final childConstraints = constraints.asBoxConstraints();
|
||||
|
||||
for (int currentIndex = indexOf(firstChild!) - 1;
|
||||
currentIndex >= firstRequiredChildIndex;
|
||||
--currentIndex) {
|
||||
final RenderBox? newLeadingChild =
|
||||
insertAndLayoutLeadingChild(childConstraints);
|
||||
if (newLeadingChild == null) {
|
||||
// If a child is missing where we expect one, it indicates
|
||||
// an inconsistency in offset that needs correction.
|
||||
final Segment? segment =
|
||||
_segments.findByIndex(currentIndex) ?? _segments.firstOrNull;
|
||||
geometry = SliverGeometry(
|
||||
// Request a scroll correction based on where the missing child should have been.
|
||||
scrollOffsetCorrection:
|
||||
segment?.indexToLayoutOffset(currentIndex) ?? 0.0,
|
||||
);
|
||||
// Parent will re-layout everything.
|
||||
return;
|
||||
}
|
||||
final childParentData =
|
||||
newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData;
|
||||
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
|
||||
assert(childParentData.index == currentIndex);
|
||||
highestLaidOutChild ??= newLeadingChild;
|
||||
}
|
||||
|
||||
// If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]),
|
||||
// or even if it did, we need to ensure the first visible child is correctly laid out
|
||||
// and establish our starting point for laying out trailing children.
|
||||
|
||||
// If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children.
|
||||
// The [firstChild] that existed at the start of performLayout is still the first one we need.
|
||||
if (highestLaidOutChild == null) {
|
||||
firstChild!.layout(childConstraints);
|
||||
final childParentData =
|
||||
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
|
||||
childParentData.layoutOffset =
|
||||
indexToLayoutOffset(firstRequiredChildIndex);
|
||||
highestLaidOutChild = firstChild;
|
||||
}
|
||||
|
||||
RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild;
|
||||
|
||||
// Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children
|
||||
// until we reach the [lastRequiredChildIndex] or run out of children.
|
||||
double calculatedMaxScrollOffset = double.infinity;
|
||||
|
||||
for (int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1;
|
||||
lastRequiredChildIndex == null ||
|
||||
currentIndex <= lastRequiredChildIndex;
|
||||
++currentIndex) {
|
||||
RenderBox? child = childAfter(mostRecentlyLaidOutChild!);
|
||||
|
||||
if (child == null || indexOf(child) != currentIndex) {
|
||||
child = insertAndLayoutChild(
|
||||
childConstraints,
|
||||
after: mostRecentlyLaidOutChild,
|
||||
);
|
||||
if (child == null) {
|
||||
final Segment? segment =
|
||||
_segments.findByIndex(currentIndex) ?? _segments.lastOrNull;
|
||||
calculatedMaxScrollOffset =
|
||||
segment?.indexToLayoutOffset(currentIndex) ??
|
||||
computeMaxScrollOffset();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
child.layout(childConstraints);
|
||||
}
|
||||
|
||||
mostRecentlyLaidOutChild = child;
|
||||
final childParentData = mostRecentlyLaidOutChild.parentData!
|
||||
as SliverMultiBoxAdaptorParentData;
|
||||
assert(childParentData.index == currentIndex);
|
||||
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
|
||||
}
|
||||
|
||||
final int lastLaidOutChildIndex = indexOf(lastChild!);
|
||||
final double leadingScrollOffset =
|
||||
indexToLayoutOffset(firstRequiredChildIndex);
|
||||
final double trailingScrollOffset =
|
||||
indexToLayoutOffset(lastLaidOutChildIndex + 1);
|
||||
|
||||
assert(
|
||||
firstRequiredChildIndex == 0 ||
|
||||
(childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <=
|
||||
precisionErrorTolerance,
|
||||
);
|
||||
assert(debugAssertChildListIsNonEmptyAndContiguous());
|
||||
assert(indexOf(firstChild!) == firstRequiredChildIndex);
|
||||
assert(
|
||||
lastRequiredChildIndex == null ||
|
||||
lastLaidOutChildIndex <= lastRequiredChildIndex,
|
||||
);
|
||||
|
||||
calculatedMaxScrollOffset = math.min(
|
||||
calculatedMaxScrollOffset,
|
||||
estimateMaxScrollOffset(),
|
||||
);
|
||||
|
||||
final double paintExtent = calculatePaintOffset(
|
||||
constraints,
|
||||
from: leadingScrollOffset,
|
||||
to: trailingScrollOffset,
|
||||
);
|
||||
|
||||
final double cacheExtent = calculateCacheOffset(
|
||||
constraints,
|
||||
from: leadingScrollOffset,
|
||||
to: trailingScrollOffset,
|
||||
);
|
||||
|
||||
final double targetEndScrollOffsetForPaint =
|
||||
constraints.scrollOffset + constraints.remainingPaintExtent;
|
||||
final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite
|
||||
? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint)
|
||||
: null;
|
||||
|
||||
final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset);
|
||||
|
||||
geometry = SliverGeometry(
|
||||
scrollExtent: calculatedMaxScrollOffset,
|
||||
paintExtent: paintExtent,
|
||||
maxPaintExtent: maxPaintExtent,
|
||||
// Indicates if there's content scrolled off-screen.
|
||||
// This is true if the last child needed for painting is actually laid out,
|
||||
// or if the first child is partially visible.
|
||||
hasVisualOverflow: (targetLastIndexForPaint != null &&
|
||||
lastLaidOutChildIndex >= targetLastIndexForPaint) ||
|
||||
constraints.scrollOffset > 0.0,
|
||||
cacheExtent: cacheExtent,
|
||||
);
|
||||
|
||||
// We may have started the layout while scrolled to the end, which would not
|
||||
// expose a new child.
|
||||
if (calculatedMaxScrollOffset == trailingScrollOffset) {
|
||||
childManager.setDidUnderflow(true);
|
||||
}
|
||||
|
||||
childManager.didFinishLayout();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user