mirror of
https://github.com/immich-app/immich.git
synced 2025-06-21 14:31:03 -04: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:
parent
7347f64958
commit
bcda2c6e22
@ -56,7 +56,7 @@ custom_lint:
|
|||||||
allowed:
|
allowed:
|
||||||
# required / wanted
|
# required / wanted
|
||||||
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
||||||
- 'lib/infrastructure/repositories/storage.repository.dart'
|
- 'lib/infrastructure/repositories/{storage,asset_media}.repository.dart'
|
||||||
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
||||||
# acceptable exceptions for the time being
|
# acceptable exceptions for the time being
|
||||||
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
||||||
|
@ -17,6 +17,7 @@ targets:
|
|||||||
main: lib/infrastructure/repositories/db.repository.dart
|
main: lib/infrastructure/repositories/db.repository.dart
|
||||||
generate_for: &drift_generate_for
|
generate_for: &drift_generate_for
|
||||||
- lib/infrastructure/entities/*.dart
|
- lib/infrastructure/entities/*.dart
|
||||||
|
- lib/infrastructure/entities/*.drift
|
||||||
- lib/infrastructure/repositories/db.repository.dart
|
- lib/infrastructure/repositories/db.repository.dart
|
||||||
drift_dev:modular:
|
drift_dev:modular:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
2
mobile/drift_schemas/main/drift_schema_v1.json
generated
2
mobile/drift_schemas/main/drift_schema_v1.json
generated
File diff suppressed because one or more lines are too long
@ -36,6 +36,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
|
private let recoveredAlbumSubType = 1000000219
|
||||||
|
|
||||||
private let hashBufferSize = 2 * 1024 * 1024
|
private let hashBufferSize = 2 * 1024 * 1024
|
||||||
|
|
||||||
@ -91,9 +92,17 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
|
|
||||||
albumTypes.forEach { type in
|
albumTypes.forEach { type in
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||||
collections.enumerateObjects { (album, _, _) in
|
for i in 0..<collections.count {
|
||||||
|
let album = collections.object(at: i)
|
||||||
|
|
||||||
|
// Ignore recovered album
|
||||||
|
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||||
|
options.includeHiddenAssets = false
|
||||||
let assets = PHAsset.fetchAssets(in: album, options: options)
|
let assets = PHAsset.fetchAssets(in: album, options: options)
|
||||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||||
|
|
||||||
@ -149,7 +158,9 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
|
|
||||||
if (updated.isEmpty) { continue }
|
if (updated.isEmpty) { continue }
|
||||||
|
|
||||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil)
|
let options = PHFetchOptions()
|
||||||
|
options.includeHiddenAssets = false
|
||||||
|
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
||||||
for i in 0..<result.count {
|
for i in 0..<result.count {
|
||||||
let asset = result.object(at: i)
|
let asset = result.object(at: i)
|
||||||
|
|
||||||
@ -187,6 +198,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
collections.enumerateObjects { (album, _, _) in
|
collections.enumerateObjects { (album, _, _) in
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
|
options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
|
||||||
|
options.includeHiddenAssets = false
|
||||||
let result = PHAsset.fetchAssets(in: album, options: options)
|
let result = PHAsset.fetchAssets(in: album, options: options)
|
||||||
result.enumerateObjects { (asset, _, _) in
|
result.enumerateObjects { (asset, _, _) in
|
||||||
albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
|
albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
|
||||||
@ -203,7 +215,9 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ids: [String] = []
|
var ids: [String] = []
|
||||||
let assets = PHAsset.fetchAssets(in: album, options: nil)
|
let options = PHFetchOptions()
|
||||||
|
options.includeHiddenAssets = false
|
||||||
|
let assets = PHAsset.fetchAssets(in: album, options: options)
|
||||||
assets.enumerateObjects { (asset, _, _) in
|
assets.enumerateObjects { (asset, _, _) in
|
||||||
ids.append(asset.localIdentifier)
|
ids.append(asset.localIdentifier)
|
||||||
}
|
}
|
||||||
@ -219,6 +233,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
|
options.includeHiddenAssets = false
|
||||||
let assets = PHAsset.fetchAssets(in: album, options: options)
|
let assets = PHAsset.fetchAssets(in: album, options: options)
|
||||||
return Int64(assets.count)
|
return Int64(assets.count)
|
||||||
}
|
}
|
||||||
@ -230,6 +245,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
|
options.includeHiddenAssets = false
|
||||||
if(updatedTimeCond != nil) {
|
if(updatedTimeCond != nil) {
|
||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
|
@ -15,3 +15,8 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
|||||||
|
|
||||||
// Secure storage keys
|
// Secure storage keys
|
||||||
const String kSecuredPinCode = "secured_pin_code";
|
const String kSecuredPinCode = "secured_pin_code";
|
||||||
|
|
||||||
|
// Timeline constants
|
||||||
|
const int kTimelineNoneSegmentSize = 120;
|
||||||
|
const int kTimelineAssetLoadBatchSize = 256;
|
||||||
|
const int kTimelineAssetLoadOppositeSize = 64;
|
||||||
|
10
mobile/lib/domain/interfaces/asset_media.interface.dart
Normal file
10
mobile/lib/domain/interfaces/asset_media.interface.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
abstract interface class IAssetMediaRepository {
|
||||||
|
Future<Uint8List?> getThumbnail(
|
||||||
|
String id, {
|
||||||
|
int quality = 80,
|
||||||
|
Size size = const Size.square(256),
|
||||||
|
});
|
||||||
|
}
|
27
mobile/lib/domain/interfaces/timeline.interface.dart
Normal file
27
mobile/lib/domain/interfaces/timeline.interface.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
|
|
||||||
|
abstract interface class ITimelineRepository implements IDatabaseRepository {
|
||||||
|
Stream<List<Bucket>> watchMainBucket(
|
||||||
|
List<String> timelineUsers, {
|
||||||
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<BaseAsset>> getMainBucketAssets(
|
||||||
|
List<String> timelineUsers, {
|
||||||
|
required int offset,
|
||||||
|
required int count,
|
||||||
|
});
|
||||||
|
|
||||||
|
Stream<List<Bucket>> watchLocalBucket(
|
||||||
|
String albumId, {
|
||||||
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<BaseAsset>> getLocalBucketAssets(
|
||||||
|
String albumId, {
|
||||||
|
required int offset,
|
||||||
|
required int count,
|
||||||
|
});
|
||||||
|
}
|
@ -11,6 +11,7 @@ enum AssetVisibility {
|
|||||||
class Asset extends BaseAsset {
|
class Asset extends BaseAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String? localId;
|
final String? localId;
|
||||||
|
final String? thumbHash;
|
||||||
final AssetVisibility visibility;
|
final AssetVisibility visibility;
|
||||||
|
|
||||||
const Asset({
|
const Asset({
|
||||||
@ -25,9 +26,14 @@ class Asset extends BaseAsset {
|
|||||||
super.height,
|
super.height,
|
||||||
super.durationInSeconds,
|
super.durationInSeconds,
|
||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
|
this.thumbHash,
|
||||||
this.visibility = AssetVisibility.timeline,
|
this.visibility = AssetVisibility.timeline,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetState get storage =>
|
||||||
|
localId == null ? AssetState.remote : AssetState.merged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''Asset {
|
return '''Asset {
|
||||||
@ -41,6 +47,7 @@ class Asset extends BaseAsset {
|
|||||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||||
localId: ${localId ?? "<NA>"},
|
localId: ${localId ?? "<NA>"},
|
||||||
isFavorite: $isFavorite,
|
isFavorite: $isFavorite,
|
||||||
|
thumbHash: ${thumbHash ?? "<NA>"},
|
||||||
visibility: $visibility,
|
visibility: $visibility,
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
@ -52,10 +59,15 @@ class Asset extends BaseAsset {
|
|||||||
return super == other &&
|
return super == other &&
|
||||||
id == other.id &&
|
id == other.id &&
|
||||||
localId == other.localId &&
|
localId == other.localId &&
|
||||||
|
thumbHash == other.thumbHash &&
|
||||||
visibility == other.visibility;
|
visibility == other.visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
super.hashCode ^ id.hashCode ^ localId.hashCode ^ visibility.hashCode;
|
super.hashCode ^
|
||||||
|
id.hashCode ^
|
||||||
|
localId.hashCode ^
|
||||||
|
thumbHash.hashCode ^
|
||||||
|
visibility.hashCode;
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,12 @@ enum AssetType {
|
|||||||
audio,
|
audio,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AssetState {
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
merged,
|
||||||
|
}
|
||||||
|
|
||||||
sealed class BaseAsset {
|
sealed class BaseAsset {
|
||||||
final String name;
|
final String name;
|
||||||
final String? checksum;
|
final String? checksum;
|
||||||
@ -32,6 +38,10 @@ sealed class BaseAsset {
|
|||||||
this.isFavorite = false,
|
this.isFavorite = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get isImage => type == AssetType.image;
|
||||||
|
bool get isVideo => type == AssetType.video;
|
||||||
|
AssetState get storage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''BaseAsset {
|
return '''BaseAsset {
|
||||||
|
@ -18,6 +18,10 @@ class LocalAsset extends BaseAsset {
|
|||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetState get storage =>
|
||||||
|
remoteId == null ? AssetState.local : AssetState.merged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''LocalAsset {
|
return '''LocalAsset {
|
||||||
|
12
mobile/lib/domain/models/setting.model.dart
Normal file
12
mobile/lib/domain/models/setting.model.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
|
||||||
|
enum Setting<T> {
|
||||||
|
tilesPerRow<int>(StoreKey.tilesPerRow, 4),
|
||||||
|
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
|
||||||
|
showStorageIndicator<bool>(StoreKey.storageIndicator, true);
|
||||||
|
|
||||||
|
const Setting(this.storeKey, this.defaultValue);
|
||||||
|
|
||||||
|
final StoreKey<T> storeKey;
|
||||||
|
final T defaultValue;
|
||||||
|
}
|
40
mobile/lib/domain/models/timeline.model.dart
Normal file
40
mobile/lib/domain/models/timeline.model.dart
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
enum GroupAssetsBy {
|
||||||
|
day,
|
||||||
|
month,
|
||||||
|
none;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HeaderType {
|
||||||
|
none,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
monthAndDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Bucket {
|
||||||
|
final int assetCount;
|
||||||
|
|
||||||
|
const Bucket({required this.assetCount});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant Bucket other) {
|
||||||
|
return assetCount == other.assetCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => assetCount.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeBucket extends Bucket {
|
||||||
|
final DateTime date;
|
||||||
|
|
||||||
|
const TimeBucket({required this.date, required super.assetCount});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant TimeBucket other) {
|
||||||
|
return super == other && date == other.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => super.hashCode ^ date.hashCode;
|
||||||
|
}
|
19
mobile/lib/domain/services/setting.service.dart
Normal file
19
mobile/lib/domain/services/setting.service.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
|
||||||
|
class SettingsService {
|
||||||
|
final StoreService _storeService;
|
||||||
|
|
||||||
|
const SettingsService({required StoreService storeService})
|
||||||
|
: _storeService = storeService;
|
||||||
|
|
||||||
|
T get<T>(Setting<T> setting) =>
|
||||||
|
_storeService.get(setting.storeKey, setting.defaultValue);
|
||||||
|
|
||||||
|
Future<void> set<T>(Setting<T> setting, T value) =>
|
||||||
|
_storeService.put(setting.storeKey, value);
|
||||||
|
|
||||||
|
Stream<T> watch<T>(Setting<T> setting) => _storeService
|
||||||
|
.watch(setting.storeKey)
|
||||||
|
.map((v) => v ?? setting.defaultValue);
|
||||||
|
}
|
126
mobile/lib/domain/services/timeline.service.dart
Normal file
126
mobile/lib/domain/services/timeline.service.dart
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/timeline.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
|
||||||
|
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(
|
||||||
|
int index,
|
||||||
|
int count,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef TimelineBucketSource = Stream<List<Bucket>> Function();
|
||||||
|
|
||||||
|
class TimelineFactory {
|
||||||
|
final ITimelineRepository _timelineRepository;
|
||||||
|
final SettingsService _settingsService;
|
||||||
|
|
||||||
|
const TimelineFactory({
|
||||||
|
required ITimelineRepository timelineRepository,
|
||||||
|
required SettingsService settingsService,
|
||||||
|
}) : _timelineRepository = timelineRepository,
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
GroupAssetsBy get groupBy =>
|
||||||
|
GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)];
|
||||||
|
|
||||||
|
TimelineService main(List<String> timelineUsers) => TimelineService(
|
||||||
|
assetSource: (offset, count) => _timelineRepository
|
||||||
|
.getMainBucketAssets(timelineUsers, offset: offset, count: count),
|
||||||
|
bucketSource: () => _timelineRepository.watchMainBucket(
|
||||||
|
timelineUsers,
|
||||||
|
groupBy: groupBy,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TimelineService localAlbum({required String albumId}) => TimelineService(
|
||||||
|
assetSource: (offset, count) => _timelineRepository
|
||||||
|
.getLocalBucketAssets(albumId, offset: offset, count: count),
|
||||||
|
bucketSource: () =>
|
||||||
|
_timelineRepository.watchLocalBucket(albumId, groupBy: groupBy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimelineService {
|
||||||
|
final TimelineAssetSource _assetSource;
|
||||||
|
final TimelineBucketSource _bucketSource;
|
||||||
|
|
||||||
|
TimelineService({
|
||||||
|
required TimelineAssetSource assetSource,
|
||||||
|
required TimelineBucketSource bucketSource,
|
||||||
|
}) : _assetSource = assetSource,
|
||||||
|
_bucketSource = bucketSource {
|
||||||
|
_bucketSubscription =
|
||||||
|
_bucketSource().listen((_) => unawaited(_reloadBucket()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final AsyncMutex _mutex = AsyncMutex();
|
||||||
|
int _bufferOffset = 0;
|
||||||
|
List<BaseAsset> _buffer = [];
|
||||||
|
StreamSubscription? _bucketSubscription;
|
||||||
|
|
||||||
|
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||||
|
|
||||||
|
Future<void> _reloadBucket() => _mutex.run(() async {
|
||||||
|
_buffer = await _assetSource(_bufferOffset, _buffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<BaseAsset>> loadAssets(int index, int count) =>
|
||||||
|
_mutex.run(() => _loadAssets(index, count));
|
||||||
|
|
||||||
|
Future<List<BaseAsset>> _loadAssets(int index, int count) async {
|
||||||
|
if (hasRange(index, count)) {
|
||||||
|
return getAssets(index, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the requested offset is greater than the cached offset, the user scrolls forward "down"
|
||||||
|
final bool forward = _bufferOffset < index;
|
||||||
|
|
||||||
|
// make sure to load a meaningful amount of data (and not only the requested slice)
|
||||||
|
// otherwise, each call to [loadAssets] would result in DB call trashing performance
|
||||||
|
// fills small requests to [kTimelineAssetLoadBatchSize], adds some legroom into the opposite scroll direction for large requests
|
||||||
|
final len = math.max(
|
||||||
|
kTimelineAssetLoadBatchSize,
|
||||||
|
count + kTimelineAssetLoadOppositeSize,
|
||||||
|
);
|
||||||
|
// when scrolling forward, start shortly before the requested offset
|
||||||
|
// when scrolling backward, end shortly after the requested offset to guard against the user scrolling
|
||||||
|
// in the other direction a tiny bit resulting in another required load from the DB
|
||||||
|
final start = math.max(
|
||||||
|
0,
|
||||||
|
forward
|
||||||
|
? index - kTimelineAssetLoadOppositeSize
|
||||||
|
: (len > kTimelineAssetLoadBatchSize ? index : index + count - len),
|
||||||
|
);
|
||||||
|
|
||||||
|
final assets = await _assetSource(start, len);
|
||||||
|
_buffer = assets;
|
||||||
|
_bufferOffset = start;
|
||||||
|
|
||||||
|
return getAssets(index, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasRange(int index, int count) =>
|
||||||
|
index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length;
|
||||||
|
|
||||||
|
List<BaseAsset> getAssets(int index, int count) {
|
||||||
|
if (!hasRange(index, count)) {
|
||||||
|
throw RangeError('TimelineService::getAssets Index out of range');
|
||||||
|
}
|
||||||
|
int start = index - _bufferOffset;
|
||||||
|
return _buffer.slice(start, start + count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _bucketSubscription?.cancel();
|
||||||
|
_bucketSubscription = null;
|
||||||
|
_buffer.clear();
|
||||||
|
_bufferOffset = 0;
|
||||||
|
}
|
||||||
|
}
|
@ -23,9 +23,7 @@ extension LogOnError<T> on AsyncValue<T> {
|
|||||||
|
|
||||||
if (!skip) {
|
if (!skip) {
|
||||||
return onLoading?.call() ??
|
return onLoading?.call() ??
|
||||||
const Center(
|
const Center(child: ImmichLoadingIndicator());
|
||||||
child: ImmichLoadingIndicator(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
|
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||||
|
|
||||||
@ -15,3 +17,16 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
|||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
|
||||||
|
LocalAsset toDto() => LocalAsset(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
checksum: checksum,
|
||||||
|
type: type,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
durationInSeconds: durationInSeconds,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
81
mobile/lib/infrastructure/entities/merged_asset.drift
Normal file
81
mobile/lib/infrastructure/entities/merged_asset.drift
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import 'remote_asset.entity.dart';
|
||||||
|
import 'local_asset.entity.dart';
|
||||||
|
|
||||||
|
mergedAsset: SELECT * FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rae.id as remote_id,
|
||||||
|
lae.id as local_id,
|
||||||
|
rae.name,
|
||||||
|
rae."type",
|
||||||
|
rae.created_at,
|
||||||
|
rae.updated_at,
|
||||||
|
rae.width,
|
||||||
|
rae.height,
|
||||||
|
rae.duration_in_seconds,
|
||||||
|
rae.is_favorite,
|
||||||
|
rae.thumb_hash,
|
||||||
|
rae.checksum,
|
||||||
|
rae.owner_id
|
||||||
|
FROM
|
||||||
|
remote_asset_entity rae
|
||||||
|
LEFT JOIN
|
||||||
|
local_asset_entity lae ON rae.checksum = lae.checksum
|
||||||
|
WHERE
|
||||||
|
rae.visibility = 0 AND rae.owner_id in ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
NULL as remote_id,
|
||||||
|
lae.id as local_id,
|
||||||
|
lae.name,
|
||||||
|
lae."type",
|
||||||
|
lae.created_at,
|
||||||
|
lae.updated_at,
|
||||||
|
lae.width,
|
||||||
|
lae.height,
|
||||||
|
lae.duration_in_seconds,
|
||||||
|
lae.is_favorite,
|
||||||
|
NULL as thumb_hash,
|
||||||
|
lae.checksum,
|
||||||
|
NULL as owner_id
|
||||||
|
FROM
|
||||||
|
local_asset_entity lae
|
||||||
|
LEFT JOIN
|
||||||
|
remote_asset_entity rae ON rae.checksum = lae.checksum
|
||||||
|
WHERE
|
||||||
|
rae.id IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
|
||||||
|
mergedBucket(:group_by AS INTEGER):
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as asset_count,
|
||||||
|
CASE
|
||||||
|
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day
|
||||||
|
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month
|
||||||
|
END AS bucket_date
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rae.name,
|
||||||
|
rae.created_at
|
||||||
|
FROM
|
||||||
|
remote_asset_entity rae
|
||||||
|
LEFT JOIN
|
||||||
|
local_asset_entity lae ON rae.checksum = lae.checksum
|
||||||
|
WHERE
|
||||||
|
rae.visibility = 0 AND rae.owner_id in ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
lae.name,
|
||||||
|
lae.created_at
|
||||||
|
FROM
|
||||||
|
local_asset_entity lae
|
||||||
|
LEFT JOIN
|
||||||
|
remote_asset_entity rae ON rae.checksum = lae.checksum
|
||||||
|
WHERE
|
||||||
|
rae.id IS NULL
|
||||||
|
)
|
||||||
|
GROUP BY bucket_date
|
||||||
|
ORDER BY bucket_date DESC;
|
114
mobile/lib/infrastructure/entities/merged_asset.drift.dart
generated
Normal file
114
mobile/lib/infrastructure/entities/merged_asset.drift.dart
generated
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// dart format width=80
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:drift/drift.dart' as i0;
|
||||||
|
import 'package:drift/internal/modular.dart' as i1;
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
||||||
|
as i3;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
|
||||||
|
as i4;
|
||||||
|
|
||||||
|
class MergedAssetDrift extends i1.ModularAccessor {
|
||||||
|
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
|
||||||
|
i0.Selectable<MergedAssetResult> mergedAsset(List<String> var1,
|
||||||
|
{required i0.Limit limit}) {
|
||||||
|
var $arrayStartIndex = 1;
|
||||||
|
final expandedvar1 = $expandVar($arrayStartIndex, var1.length);
|
||||||
|
$arrayStartIndex += var1.length;
|
||||||
|
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
|
||||||
|
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||||
|
return customSelect(
|
||||||
|
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||||
|
variables: [
|
||||||
|
for (var $ in var1) i0.Variable<String>($),
|
||||||
|
...generatedlimit.introducedVariables
|
||||||
|
],
|
||||||
|
readsFrom: {
|
||||||
|
remoteAssetEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
...generatedlimit.watchedTables,
|
||||||
|
}).map((i0.QueryRow row) => MergedAssetResult(
|
||||||
|
remoteId: row.readNullable<String>('remote_id'),
|
||||||
|
localId: row.readNullable<String>('local_id'),
|
||||||
|
name: row.read<String>('name'),
|
||||||
|
type: i3.$RemoteAssetEntityTable.$convertertype
|
||||||
|
.fromSql(row.read<int>('type')),
|
||||||
|
createdAt: row.read<DateTime>('created_at'),
|
||||||
|
updatedAt: row.read<DateTime>('updated_at'),
|
||||||
|
width: row.readNullable<int>('width'),
|
||||||
|
height: row.readNullable<int>('height'),
|
||||||
|
durationInSeconds: row.readNullable<int>('duration_in_seconds'),
|
||||||
|
isFavorite: row.read<bool>('is_favorite'),
|
||||||
|
thumbHash: row.readNullable<String>('thumb_hash'),
|
||||||
|
checksum: row.readNullable<String>('checksum'),
|
||||||
|
ownerId: row.readNullable<String>('owner_id'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
i0.Selectable<MergedBucketResult> mergedBucket(List<String> var2,
|
||||||
|
{required int groupBy}) {
|
||||||
|
var $arrayStartIndex = 2;
|
||||||
|
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
|
||||||
|
$arrayStartIndex += var2.length;
|
||||||
|
return customSelect(
|
||||||
|
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at) END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||||
|
variables: [
|
||||||
|
i0.Variable<int>(groupBy),
|
||||||
|
for (var $ in var2) i0.Variable<String>($)
|
||||||
|
],
|
||||||
|
readsFrom: {
|
||||||
|
remoteAssetEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
}).map((i0.QueryRow row) => MergedBucketResult(
|
||||||
|
assetCount: row.read<int>('asset_count'),
|
||||||
|
bucketDate: row.read<String>('bucket_date'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
i3.$RemoteAssetEntityTable get remoteAssetEntity =>
|
||||||
|
i1.ReadDatabaseContainer(attachedDatabase)
|
||||||
|
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity');
|
||||||
|
i4.$LocalAssetEntityTable get localAssetEntity =>
|
||||||
|
i1.ReadDatabaseContainer(attachedDatabase)
|
||||||
|
.resultSet<i4.$LocalAssetEntityTable>('local_asset_entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
class MergedAssetResult {
|
||||||
|
final String? remoteId;
|
||||||
|
final String? localId;
|
||||||
|
final String name;
|
||||||
|
final i2.AssetType type;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int? width;
|
||||||
|
final int? height;
|
||||||
|
final int? durationInSeconds;
|
||||||
|
final bool isFavorite;
|
||||||
|
final String? thumbHash;
|
||||||
|
final String? checksum;
|
||||||
|
final String? ownerId;
|
||||||
|
MergedAssetResult({
|
||||||
|
this.remoteId,
|
||||||
|
this.localId,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.durationInSeconds,
|
||||||
|
required this.isFavorite,
|
||||||
|
this.thumbHash,
|
||||||
|
this.checksum,
|
||||||
|
this.ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MergedBucketResult {
|
||||||
|
final int assetCount;
|
||||||
|
final String bucketDate;
|
||||||
|
MergedBucketResult({
|
||||||
|
required this.assetCount,
|
||||||
|
required this.bucketDate,
|
||||||
|
});
|
||||||
|
}
|
@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
|||||||
columns: {#checksum, #ownerId},
|
columns: {#checksum, #ownerId},
|
||||||
unique: true,
|
unique: true,
|
||||||
)
|
)
|
||||||
|
@TableIndex(name: 'idx_remote_asset_checksum', columns: {#checksum})
|
||||||
class RemoteAssetEntity extends Table
|
class RemoteAssetEntity extends Table
|
||||||
with DriftDefaultsMixin, AssetEntityMixin {
|
with DriftDefaultsMixin, AssetEntityMixin {
|
||||||
const RemoteAssetEntity();
|
const RemoteAssetEntity();
|
||||||
|
@ -1178,3 +1178,6 @@ class RemoteAssetEntityCompanion
|
|||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
i0.Index get idxRemoteAssetChecksum => i0.Index('idx_remote_asset_checksum',
|
||||||
|
'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)');
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class AssetMediaRepository implements IAssetMediaRepository {
|
||||||
|
const AssetMediaRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> getThumbnail(
|
||||||
|
String id, {
|
||||||
|
int quality = 80,
|
||||||
|
Size size = const Size.square(256),
|
||||||
|
}) =>
|
||||||
|
AssetEntity(
|
||||||
|
id: id,
|
||||||
|
// The below fields are not used in thumbnailDataWithSize but are required
|
||||||
|
// to create an AssetEntity instance. It is faster to create a dummy AssetEntity
|
||||||
|
// instance than to fetch the asset from the device first.
|
||||||
|
typeInt: AssetType.image.index,
|
||||||
|
width: size.width.toInt(),
|
||||||
|
height: size.height.toInt(),
|
||||||
|
).thumbnailDataWithSize(
|
||||||
|
ThumbnailSize(size.width.toInt(), size.height.toInt()),
|
||||||
|
quality: quality,
|
||||||
|
);
|
||||||
|
}
|
@ -41,6 +41,9 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
|||||||
RemoteAssetEntity,
|
RemoteAssetEntity,
|
||||||
RemoteExifEntity,
|
RemoteExifEntity,
|
||||||
],
|
],
|
||||||
|
include: {
|
||||||
|
'package:immich_mobile/infrastructure/entities/merged_asset.drift',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
class Drift extends $Drift implements IDatabaseRepository {
|
class Drift extends $Drift implements IDatabaseRepository {
|
||||||
Drift([QueryExecutor? executor])
|
Drift([QueryExecutor? executor])
|
||||||
|
@ -3,59 +3,72 @@
|
|||||||
import 'package:drift/drift.dart' as i0;
|
import 'package:drift/drift.dart' as i0;
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
|
||||||
as i1;
|
as i1;
|
||||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
|
|
||||||
as i2;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
|
|
||||||
as i3;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
|
|
||||||
as i4;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
|
|
||||||
as i5;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
|
|
||||||
as i6;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
||||||
|
as i2;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
|
||||||
|
as i3;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
|
||||||
|
as i4;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
|
||||||
|
as i5;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
|
||||||
|
as i6;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
|
||||||
as i7;
|
as i7;
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
|
||||||
as i8;
|
as i8;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||||
|
as i9;
|
||||||
|
import 'package:drift/internal/modular.dart' as i10;
|
||||||
|
|
||||||
abstract class $Drift extends i0.GeneratedDatabase {
|
abstract class $Drift extends i0.GeneratedDatabase {
|
||||||
$Drift(i0.QueryExecutor e) : super(e);
|
$Drift(i0.QueryExecutor e) : super(e);
|
||||||
$DriftManager get managers => $DriftManager(this);
|
$DriftManager get managers => $DriftManager(this);
|
||||||
late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this);
|
late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this);
|
||||||
late final i2.$UserMetadataEntityTable userMetadataEntity =
|
late final i2.$RemoteAssetEntityTable remoteAssetEntity =
|
||||||
i2.$UserMetadataEntityTable(this);
|
i2.$RemoteAssetEntityTable(this);
|
||||||
late final i3.$PartnerEntityTable partnerEntity =
|
late final i3.$LocalAssetEntityTable localAssetEntity =
|
||||||
i3.$PartnerEntityTable(this);
|
i3.$LocalAssetEntityTable(this);
|
||||||
late final i4.$LocalAlbumEntityTable localAlbumEntity =
|
late final i4.$UserMetadataEntityTable userMetadataEntity =
|
||||||
i4.$LocalAlbumEntityTable(this);
|
i4.$UserMetadataEntityTable(this);
|
||||||
late final i5.$LocalAssetEntityTable localAssetEntity =
|
late final i5.$PartnerEntityTable partnerEntity =
|
||||||
i5.$LocalAssetEntityTable(this);
|
i5.$PartnerEntityTable(this);
|
||||||
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
|
late final i6.$LocalAlbumEntityTable localAlbumEntity =
|
||||||
i6.$LocalAlbumAssetEntityTable(this);
|
i6.$LocalAlbumEntityTable(this);
|
||||||
late final i7.$RemoteAssetEntityTable remoteAssetEntity =
|
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
|
||||||
i7.$RemoteAssetEntityTable(this);
|
i7.$LocalAlbumAssetEntityTable(this);
|
||||||
late final i8.$RemoteExifEntityTable remoteExifEntity =
|
late final i8.$RemoteExifEntityTable remoteExifEntity =
|
||||||
i8.$RemoteExifEntityTable(this);
|
i8.$RemoteExifEntityTable(this);
|
||||||
|
i9.MergedAssetDrift get mergedAssetDrift => i10.ReadDatabaseContainer(this)
|
||||||
|
.accessor<i9.MergedAssetDrift>(i9.MergedAssetDrift.new);
|
||||||
@override
|
@override
|
||||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||||
@override
|
@override
|
||||||
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
|
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
|
||||||
userEntity,
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
i3.idxLocalAssetChecksum,
|
||||||
|
i2.uQRemoteAssetOwnerChecksum,
|
||||||
|
i2.idxRemoteAssetChecksum,
|
||||||
userMetadataEntity,
|
userMetadataEntity,
|
||||||
partnerEntity,
|
partnerEntity,
|
||||||
localAlbumEntity,
|
localAlbumEntity,
|
||||||
localAssetEntity,
|
|
||||||
localAlbumAssetEntity,
|
localAlbumAssetEntity,
|
||||||
remoteAssetEntity,
|
remoteExifEntity
|
||||||
remoteExifEntity,
|
|
||||||
i5.idxLocalAssetChecksum,
|
|
||||||
i7.uQRemoteAssetOwnerChecksum
|
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
i0.StreamQueryUpdateRules get streamUpdateRules =>
|
i0.StreamQueryUpdateRules get streamUpdateRules =>
|
||||||
const i0.StreamQueryUpdateRules(
|
const i0.StreamQueryUpdateRules(
|
||||||
[
|
[
|
||||||
|
i0.WritePropagation(
|
||||||
|
on: i0.TableUpdateQuery.onTableName('user_entity',
|
||||||
|
limitUpdateKind: i0.UpdateKind.delete),
|
||||||
|
result: [
|
||||||
|
i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete),
|
||||||
|
],
|
||||||
|
),
|
||||||
i0.WritePropagation(
|
i0.WritePropagation(
|
||||||
on: i0.TableUpdateQuery.onTableName('user_entity',
|
on: i0.TableUpdateQuery.onTableName('user_entity',
|
||||||
limitUpdateKind: i0.UpdateKind.delete),
|
limitUpdateKind: i0.UpdateKind.delete),
|
||||||
@ -94,13 +107,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
kind: i0.UpdateKind.delete),
|
kind: i0.UpdateKind.delete),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
i0.WritePropagation(
|
|
||||||
on: i0.TableUpdateQuery.onTableName('user_entity',
|
|
||||||
limitUpdateKind: i0.UpdateKind.delete),
|
|
||||||
result: [
|
|
||||||
i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
i0.WritePropagation(
|
i0.WritePropagation(
|
||||||
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
|
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
|
||||||
limitUpdateKind: i0.UpdateKind.delete),
|
limitUpdateKind: i0.UpdateKind.delete),
|
||||||
@ -120,18 +126,18 @@ class $DriftManager {
|
|||||||
$DriftManager(this._db);
|
$DriftManager(this._db);
|
||||||
i1.$$UserEntityTableTableManager get userEntity =>
|
i1.$$UserEntityTableTableManager get userEntity =>
|
||||||
i1.$$UserEntityTableTableManager(_db, _db.userEntity);
|
i1.$$UserEntityTableTableManager(_db, _db.userEntity);
|
||||||
i2.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
|
i2.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
|
||||||
i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
|
i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
|
||||||
i3.$$PartnerEntityTableTableManager get partnerEntity =>
|
i3.$$LocalAssetEntityTableTableManager get localAssetEntity =>
|
||||||
i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
|
i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
|
||||||
i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
|
i4.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
|
||||||
i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
|
i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
|
||||||
i5.$$LocalAssetEntityTableTableManager get localAssetEntity =>
|
i5.$$PartnerEntityTableTableManager get partnerEntity =>
|
||||||
i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
|
i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
|
||||||
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
|
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
|
||||||
|
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
|
||||||
|
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
|
||||||
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
|
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
|
||||||
i7.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
|
|
||||||
i7.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
|
|
||||||
i8.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
|
i8.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
|
||||||
i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
|
i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
|
||||||
}
|
}
|
||||||
|
180
mobile/lib/infrastructure/repositories/timeline.repository.dart
Normal file
180
mobile/lib/infrastructure/repositories/timeline.repository.dart
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/timeline.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:stream_transform/stream_transform.dart';
|
||||||
|
|
||||||
|
class DriftTimelineRepository extends DriftDatabaseRepository
|
||||||
|
implements ITimelineRepository {
|
||||||
|
final Drift _db;
|
||||||
|
|
||||||
|
const DriftTimelineRepository(super._db) : _db = _db;
|
||||||
|
|
||||||
|
List<Bucket> _generateBuckets(int count) {
|
||||||
|
final numBuckets = (count / kTimelineNoneSegmentSize).floor();
|
||||||
|
final buckets = List.generate(
|
||||||
|
numBuckets,
|
||||||
|
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
|
||||||
|
);
|
||||||
|
if (count % kTimelineNoneSegmentSize != 0) {
|
||||||
|
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Bucket>> watchMainBucket(
|
||||||
|
List<String> userIds, {
|
||||||
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
|
}) {
|
||||||
|
if (groupBy == GroupAssetsBy.none) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
"GroupAssetsBy.none is not supported for watchMainBucket",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _db.mergedAssetDrift
|
||||||
|
.mergedBucket(userIds, groupBy: groupBy.index)
|
||||||
|
.map((row) {
|
||||||
|
final date = row.bucketDate.dateFmt(groupBy);
|
||||||
|
return TimeBucket(date: date, assetCount: row.assetCount);
|
||||||
|
})
|
||||||
|
.watch()
|
||||||
|
.throttle(const Duration(seconds: 3), trailing: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BaseAsset>> getMainBucketAssets(
|
||||||
|
List<String> userIds, {
|
||||||
|
required int offset,
|
||||||
|
required int count,
|
||||||
|
}) {
|
||||||
|
return _db.mergedAssetDrift
|
||||||
|
.mergedAsset(userIds, limit: Limit(count, offset))
|
||||||
|
.map(
|
||||||
|
(row) => row.remoteId != null
|
||||||
|
? Asset(
|
||||||
|
id: row.remoteId!,
|
||||||
|
localId: row.localId,
|
||||||
|
name: row.name,
|
||||||
|
checksum: row.checksum,
|
||||||
|
type: row.type,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
thumbHash: row.thumbHash,
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
isFavorite: row.isFavorite,
|
||||||
|
durationInSeconds: row.durationInSeconds,
|
||||||
|
)
|
||||||
|
: LocalAsset(
|
||||||
|
id: row.localId!,
|
||||||
|
remoteId: row.remoteId,
|
||||||
|
name: row.name,
|
||||||
|
checksum: row.checksum,
|
||||||
|
type: row.type,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
isFavorite: row.isFavorite,
|
||||||
|
durationInSeconds: row.durationInSeconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Bucket>> watchLocalBucket(
|
||||||
|
String albumId, {
|
||||||
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
|
}) {
|
||||||
|
if (groupBy == GroupAssetsBy.none) {
|
||||||
|
return _db.localAlbumAssetEntity
|
||||||
|
.count(where: (row) => row.albumId.equals(albumId))
|
||||||
|
.map(_generateBuckets)
|
||||||
|
.watchSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetCountExp = _db.localAssetEntity.id.count();
|
||||||
|
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy);
|
||||||
|
|
||||||
|
final query = _db.localAssetEntity.selectOnly()
|
||||||
|
..addColumns([assetCountExp, dateExp])
|
||||||
|
..join([
|
||||||
|
innerJoin(
|
||||||
|
_db.localAlbumAssetEntity,
|
||||||
|
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
||||||
|
..groupBy([dateExp])
|
||||||
|
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||||
|
|
||||||
|
return query.map((row) {
|
||||||
|
final timeline = row.read(dateExp)!.dateFmt(groupBy);
|
||||||
|
final assetCount = row.read(assetCountExp)!;
|
||||||
|
return TimeBucket(date: timeline, assetCount: assetCount);
|
||||||
|
}).watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BaseAsset>> getLocalBucketAssets(
|
||||||
|
String albumId, {
|
||||||
|
required int offset,
|
||||||
|
required int count,
|
||||||
|
}) {
|
||||||
|
final query = _db.localAssetEntity.select().join(
|
||||||
|
[
|
||||||
|
innerJoin(
|
||||||
|
_db.localAlbumAssetEntity,
|
||||||
|
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
||||||
|
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
|
||||||
|
..limit(count, offset: offset);
|
||||||
|
return query
|
||||||
|
.map((row) => row.readTable(_db.localAssetEntity).toDto())
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on Expression<DateTime> {
|
||||||
|
Expression<String> dateFmt(GroupAssetsBy groupBy) {
|
||||||
|
// DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting
|
||||||
|
// to create the correct time bucket
|
||||||
|
final localTimeExp = modify(const DateTimeModifier.localTime());
|
||||||
|
return switch (groupBy) {
|
||||||
|
GroupAssetsBy.day => localTimeExp.date,
|
||||||
|
GroupAssetsBy.month => localTimeExp.strftime("%Y-%m"),
|
||||||
|
GroupAssetsBy.none => throw ArgumentError(
|
||||||
|
"GroupAssetsBy.none is not supported for date formatting",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on String {
|
||||||
|
DateTime dateFmt(GroupAssetsBy groupBy) {
|
||||||
|
final format = switch (groupBy) {
|
||||||
|
GroupAssetsBy.day => "y-M-d",
|
||||||
|
GroupAssetsBy.month => "y-M",
|
||||||
|
GroupAssetsBy.none => throw ArgumentError(
|
||||||
|
"GroupAssetsBy.none is not supported for date formatting",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return DateFormat(format).parse(this);
|
||||||
|
} catch (e) {
|
||||||
|
throw FormatException("Invalid date format: $this", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -88,6 +88,11 @@ final _features = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_Feature(
|
||||||
|
name: 'Main Timeline',
|
||||||
|
icon: Icons.timeline_rounded,
|
||||||
|
onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@RoutePage()
|
@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/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class _Stat {
|
class _Stat {
|
||||||
const _Stat({required this.name, required this.load});
|
const _Stat({required this.name, required this.load});
|
||||||
@ -16,9 +17,16 @@ class _Stat {
|
|||||||
|
|
||||||
class _Summary extends StatelessWidget {
|
class _Summary extends StatelessWidget {
|
||||||
final String name;
|
final String name;
|
||||||
|
final Widget? leading;
|
||||||
final Future<int> countFuture;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -34,7 +42,12 @@ class _Summary extends StatelessWidget {
|
|||||||
} else {
|
} else {
|
||||||
subtitle = Text('${snapshot.data ?? 0}');
|
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))
|
.filter((f) => f.albumId.id.equals(album.id))
|
||||||
.count();
|
.count();
|
||||||
return _Summary(
|
return _Summary(
|
||||||
|
leading: const Icon(Icons.photo_album_rounded),
|
||||||
name: album.name,
|
name: album.name,
|
||||||
countFuture: countFuture,
|
countFuture: countFuture,
|
||||||
|
onTap: () => context.router.push(
|
||||||
|
LocalTimelineRoute(albumId: album.id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
itemCount: albums.length,
|
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();
|
||||||
|
}
|
||||||
|
}
|
@ -37,8 +37,7 @@ class ImageLoader {
|
|||||||
} else if (result is FileInfo) {
|
} else if (result is FileInfo) {
|
||||||
// We have the file
|
// We have the file
|
||||||
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
||||||
final decoded = await decode(buffer);
|
return decode(buffer);
|
||||||
return decoded;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
22
mobile/lib/providers/infrastructure/setting.provider.dart
Normal file
22
mobile/lib/providers/infrastructure/setting.provider.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||||
|
|
||||||
|
class SettingsNotifier extends Notifier<SettingsService> {
|
||||||
|
@override
|
||||||
|
SettingsService build() =>
|
||||||
|
SettingsService(storeService: ref.read(storeServiceProvider));
|
||||||
|
|
||||||
|
T get<T>(Setting<T> setting) => state.get(setting);
|
||||||
|
|
||||||
|
Future<void> set<T>(Setting<T> setting, T value) async {
|
||||||
|
await state.set(setting, value);
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<T> watch<T>(Setting<T> setting) => state.watch(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
final settingsProvider =
|
||||||
|
NotifierProvider<SettingsNotifier, SettingsService>(SettingsNotifier.new);
|
28
mobile/lib/providers/infrastructure/timeline.provider.dart
Normal file
28
mobile/lib/providers/infrastructure/timeline.provider.dart
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/timeline.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
|
|
||||||
|
final timelineRepositoryProvider = Provider<ITimelineRepository>(
|
||||||
|
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
|
||||||
|
(ref) =>
|
||||||
|
throw UnimplementedError('Will be overridden through a ProviderScope.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final timelineServiceProvider = Provider.autoDispose<TimelineService>(
|
||||||
|
(ref) =>
|
||||||
|
throw UnimplementedError('Will be overridden through a ProviderScope.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final timelineFactoryProvider = Provider<TimelineFactory>(
|
||||||
|
(ref) => TimelineFactory(
|
||||||
|
timelineRepository: ref.watch(timelineRepositoryProvider),
|
||||||
|
settingsService: ref.watch(settingsProvider),
|
||||||
|
),
|
||||||
|
);
|
@ -64,6 +64,8 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
|
|||||||
import 'package:immich_mobile/pages/search/search.page.dart';
|
import 'package:immich_mobile/pages/search/search.page.dart';
|
||||||
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
@ -330,5 +332,13 @@ class AppRouter extends RootStackRouter {
|
|||||||
page: RemoteMediaSummaryRoute.page,
|
page: RemoteMediaSummaryRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
),
|
),
|
||||||
|
AutoRoute(
|
||||||
|
page: LocalTimelineRoute.page,
|
||||||
|
guards: [_authGuard, _duplicateGuard],
|
||||||
|
),
|
||||||
|
AutoRoute(
|
||||||
|
page: MainTimelineRoute.page,
|
||||||
|
guards: [_authGuard, _duplicateGuard],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -871,6 +871,43 @@ class LocalMediaSummaryRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [LocalTimelinePage]
|
||||||
|
class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
|
||||||
|
LocalTimelineRoute({
|
||||||
|
Key? key,
|
||||||
|
required String albumId,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
LocalTimelineRoute.name,
|
||||||
|
args: LocalTimelineRouteArgs(key: key, albumId: albumId),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'LocalTimelineRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<LocalTimelineRouteArgs>();
|
||||||
|
return LocalTimelinePage(key: args.key, albumId: args.albumId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalTimelineRouteArgs {
|
||||||
|
const LocalTimelineRouteArgs({this.key, required this.albumId});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final String albumId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [LockedPage]
|
/// [LockedPage]
|
||||||
class LockedRoute extends PageRouteInfo<void> {
|
class LockedRoute extends PageRouteInfo<void> {
|
||||||
@ -903,6 +940,22 @@ class LoginRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [MainTimelinePage]
|
||||||
|
class MainTimelineRoute extends PageRouteInfo<void> {
|
||||||
|
const MainTimelineRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(MainTimelineRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'MainTimelineRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const MainTimelinePage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [MapLocationPickerPage]
|
/// [MapLocationPickerPage]
|
||||||
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||||
|
@ -1810,7 +1810,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
stream_transform:
|
stream_transform:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: stream_transform
|
name: stream_transform
|
||||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
@ -63,6 +63,7 @@ dependencies:
|
|||||||
share_handler: ^0.0.22
|
share_handler: ^0.0.22
|
||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
socket_io_client: ^2.0.3+1
|
socket_io_client: ^2.0.3+1
|
||||||
|
stream_transform: ^2.1.1
|
||||||
thumbhash: 0.1.0+1
|
thumbhash: 0.1.0+1
|
||||||
timezone: ^0.9.4
|
timezone: ^0.9.4
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
|
Loading…
x
Reference in New Issue
Block a user