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:
|
||||
# required / wanted
|
||||
- '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'
|
||||
# acceptable exceptions for the time being
|
||||
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
||||
|
@ -17,6 +17,7 @@ targets:
|
||||
main: lib/infrastructure/repositories/db.repository.dart
|
||||
generate_for: &drift_generate_for
|
||||
- lib/infrastructure/entities/*.dart
|
||||
- lib/infrastructure/entities/*.drift
|
||||
- lib/infrastructure/repositories/db.repository.dart
|
||||
drift_dev:modular:
|
||||
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 changeTokenKey = "immich:changeToken"
|
||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||
private let recoveredAlbumSubType = 1000000219
|
||||
|
||||
private let hashBufferSize = 2 * 1024 * 1024
|
||||
|
||||
@ -91,9 +92,17 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
|
||||
albumTypes.forEach { type in
|
||||
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()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||
options.includeHiddenAssets = false
|
||||
let assets = PHAsset.fetchAssets(in: album, options: options)
|
||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||
|
||||
@ -149,7 +158,9 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
|
||||
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 {
|
||||
let asset = result.object(at: i)
|
||||
|
||||
@ -187,6 +198,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
collections.enumerateObjects { (album, _, _) in
|
||||
let options = PHFetchOptions()
|
||||
options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
|
||||
options.includeHiddenAssets = false
|
||||
let result = PHAsset.fetchAssets(in: album, options: options)
|
||||
result.enumerateObjects { (asset, _, _) in
|
||||
albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
|
||||
@ -203,7 +215,9 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
}
|
||||
|
||||
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
|
||||
ids.append(asset.localIdentifier)
|
||||
}
|
||||
@ -219,6 +233,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
||||
let options = PHFetchOptions()
|
||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||
options.includeHiddenAssets = false
|
||||
let assets = PHAsset.fetchAssets(in: album, options: options)
|
||||
return Int64(assets.count)
|
||||
}
|
||||
@ -230,6 +245,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
}
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.includeHiddenAssets = false
|
||||
if(updatedTimeCond != nil) {
|
||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||
|
@ -15,3 +15,8 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// Secure storage keys
|
||||
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 {
|
||||
final String id;
|
||||
final String? localId;
|
||||
final String? thumbHash;
|
||||
final AssetVisibility visibility;
|
||||
|
||||
const Asset({
|
||||
@ -25,9 +26,14 @@ class Asset extends BaseAsset {
|
||||
super.height,
|
||||
super.durationInSeconds,
|
||||
super.isFavorite = false,
|
||||
this.thumbHash,
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetState get storage =>
|
||||
localId == null ? AssetState.remote : AssetState.merged;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@ -41,6 +47,7 @@ class Asset extends BaseAsset {
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
localId: ${localId ?? "<NA>"},
|
||||
isFavorite: $isFavorite,
|
||||
thumbHash: ${thumbHash ?? "<NA>"},
|
||||
visibility: $visibility,
|
||||
}''';
|
||||
}
|
||||
@ -52,10 +59,15 @@ class Asset extends BaseAsset {
|
||||
return super == other &&
|
||||
id == other.id &&
|
||||
localId == other.localId &&
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility;
|
||||
}
|
||||
|
||||
@override
|
||||
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,
|
||||
}
|
||||
|
||||
enum AssetState {
|
||||
local,
|
||||
remote,
|
||||
merged,
|
||||
}
|
||||
|
||||
sealed class BaseAsset {
|
||||
final String name;
|
||||
final String? checksum;
|
||||
@ -32,6 +38,10 @@ sealed class BaseAsset {
|
||||
this.isFavorite = false,
|
||||
});
|
||||
|
||||
bool get isImage => type == AssetType.image;
|
||||
bool get isVideo => type == AssetType.video;
|
||||
AssetState get storage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''BaseAsset {
|
||||
|
@ -18,6 +18,10 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetState get storage =>
|
||||
remoteId == null ? AssetState.local : AssetState.merged;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
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) {
|
||||
return onLoading?.call() ??
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
);
|
||||
const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
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/drift_default.mixin.dart';
|
||||
|
||||
@ -15,3 +17,16 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
@override
|
||||
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},
|
||||
unique: true,
|
||||
)
|
||||
@TableIndex(name: 'idx_remote_asset_checksum', columns: {#checksum})
|
||||
class RemoteAssetEntity extends Table
|
||||
with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const RemoteAssetEntity();
|
||||
|
@ -1178,3 +1178,6 @@ class RemoteAssetEntityCompanion
|
||||
.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,
|
||||
RemoteExifEntity,
|
||||
],
|
||||
include: {
|
||||
'package:immich_mobile/infrastructure/entities/merged_asset.drift',
|
||||
},
|
||||
)
|
||||
class Drift extends $Drift implements IDatabaseRepository {
|
||||
Drift([QueryExecutor? executor])
|
||||
|
@ -3,59 +3,72 @@
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
|
||||
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'
|
||||
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;
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
|
||||
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 {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
$DriftManager get managers => $DriftManager(this);
|
||||
late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this);
|
||||
late final i2.$UserMetadataEntityTable userMetadataEntity =
|
||||
i2.$UserMetadataEntityTable(this);
|
||||
late final i3.$PartnerEntityTable partnerEntity =
|
||||
i3.$PartnerEntityTable(this);
|
||||
late final i4.$LocalAlbumEntityTable localAlbumEntity =
|
||||
i4.$LocalAlbumEntityTable(this);
|
||||
late final i5.$LocalAssetEntityTable localAssetEntity =
|
||||
i5.$LocalAssetEntityTable(this);
|
||||
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
|
||||
i6.$LocalAlbumAssetEntityTable(this);
|
||||
late final i7.$RemoteAssetEntityTable remoteAssetEntity =
|
||||
i7.$RemoteAssetEntityTable(this);
|
||||
late final i2.$RemoteAssetEntityTable remoteAssetEntity =
|
||||
i2.$RemoteAssetEntityTable(this);
|
||||
late final i3.$LocalAssetEntityTable localAssetEntity =
|
||||
i3.$LocalAssetEntityTable(this);
|
||||
late final i4.$UserMetadataEntityTable userMetadataEntity =
|
||||
i4.$UserMetadataEntityTable(this);
|
||||
late final i5.$PartnerEntityTable partnerEntity =
|
||||
i5.$PartnerEntityTable(this);
|
||||
late final i6.$LocalAlbumEntityTable localAlbumEntity =
|
||||
i6.$LocalAlbumEntityTable(this);
|
||||
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
|
||||
i7.$LocalAlbumAssetEntityTable(this);
|
||||
late final i8.$RemoteExifEntityTable remoteExifEntity =
|
||||
i8.$RemoteExifEntityTable(this);
|
||||
i9.MergedAssetDrift get mergedAssetDrift => i10.ReadDatabaseContainer(this)
|
||||
.accessor<i9.MergedAssetDrift>(i9.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@override
|
||||
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
localAssetEntity,
|
||||
i3.idxLocalAssetChecksum,
|
||||
i2.uQRemoteAssetOwnerChecksum,
|
||||
i2.idxRemoteAssetChecksum,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
localAlbumEntity,
|
||||
localAssetEntity,
|
||||
localAlbumAssetEntity,
|
||||
remoteAssetEntity,
|
||||
remoteExifEntity,
|
||||
i5.idxLocalAssetChecksum,
|
||||
i7.uQRemoteAssetOwnerChecksum
|
||||
remoteExifEntity
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules get streamUpdateRules =>
|
||||
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(
|
||||
on: i0.TableUpdateQuery.onTableName('user_entity',
|
||||
limitUpdateKind: i0.UpdateKind.delete),
|
||||
@ -94,13 +107,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
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(
|
||||
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
|
||||
limitUpdateKind: i0.UpdateKind.delete),
|
||||
@ -120,18 +126,18 @@ class $DriftManager {
|
||||
$DriftManager(this._db);
|
||||
i1.$$UserEntityTableTableManager get userEntity =>
|
||||
i1.$$UserEntityTableTableManager(_db, _db.userEntity);
|
||||
i2.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
|
||||
i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
|
||||
i3.$$PartnerEntityTableTableManager get partnerEntity =>
|
||||
i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
|
||||
i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
|
||||
i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
|
||||
i5.$$LocalAssetEntityTableTableManager get localAssetEntity =>
|
||||
i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
|
||||
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
|
||||
i2.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
|
||||
i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
|
||||
i3.$$LocalAssetEntityTableTableManager get localAssetEntity =>
|
||||
i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
|
||||
i4.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
|
||||
i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
|
||||
i5.$$PartnerEntityTableTableManager get partnerEntity =>
|
||||
i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
|
||||
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
|
||||
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
|
||||
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
|
||||
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
|
||||
i7.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
|
||||
i7.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
|
||||
i8.$$RemoteExifEntityTableTableManager get 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()
|
||||
|
31
mobile/lib/presentation/pages/dev/local_timeline.page.dart
Normal file
31
mobile/lib/presentation/pages/dev/local_timeline.page.dart
Normal file
@ -0,0 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LocalTimelinePage extends StatelessWidget {
|
||||
final String albumId;
|
||||
|
||||
const LocalTimelinePage({super.key, required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final timelineService =
|
||||
ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId);
|
||||
ref.onDispose(() => unawaited(timelineService.dispose()));
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
31
mobile/lib/presentation/pages/dev/main_timeline.page.dart
Normal file
31
mobile/lib/presentation/pages/dev/main_timeline.page.dart
Normal file
@ -0,0 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MainTimelinePage extends StatelessWidget {
|
||||
const MainTimelinePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final timelineService = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.main(ref.watch(timelineUsersIdsProvider));
|
||||
ref.onDispose(() => unawaited(timelineService.dispose()));
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class _Stat {
|
||||
const _Stat({required this.name, required this.load});
|
||||
@ -16,9 +17,16 @@ class _Stat {
|
||||
|
||||
class _Summary extends StatelessWidget {
|
||||
final String name;
|
||||
final Widget? leading;
|
||||
final Future<int> countFuture;
|
||||
final void Function()? onTap;
|
||||
|
||||
const _Summary({required this.name, required this.countFuture});
|
||||
const _Summary({
|
||||
required this.name,
|
||||
required this.countFuture,
|
||||
this.leading,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -34,7 +42,12 @@ class _Summary extends StatelessWidget {
|
||||
} else {
|
||||
subtitle = Text('${snapshot.data ?? 0}');
|
||||
}
|
||||
return ListTile(title: Text(name), trailing: subtitle);
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(name),
|
||||
trailing: subtitle,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -105,8 +118,12 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
||||
.filter((f) => f.albumId.id.equals(album.id))
|
||||
.count();
|
||||
return _Summary(
|
||||
leading: const Icon(Icons.photo_album_rounded),
|
||||
name: album.name,
|
||||
countFuture: countFuture,
|
||||
onTap: () => context.router.push(
|
||||
LocalTimelineRoute(albumId: album.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: albums.length,
|
||||
|
@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final IAssetMediaRepository _assetMediaRepository =
|
||||
const AssetMediaRepository();
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
final LocalAsset asset;
|
||||
final double height;
|
||||
final double width;
|
||||
|
||||
LocalThumbProvider({
|
||||
required this.asset,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
LocalThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height';
|
||||
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer =
|
||||
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
return await decode(buffer);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(
|
||||
key.asset.id,
|
||||
size: Size(key.width, key.height),
|
||||
);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${key.asset.name} failed",
|
||||
);
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return asset.id == other.asset.id &&
|
||||
asset.updatedAt == other.asset.updatedAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final double height;
|
||||
final double width;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
RemoteThumbProvider({
|
||||
required this.assetId,
|
||||
this.height = kTimelineFixedTileExtent,
|
||||
this.width = kTimelineFixedTileExtent,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
RemoteThumbProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
final chunkController = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkController),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkController.stream,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _codec(
|
||||
RemoteThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
);
|
||||
|
||||
return ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
).whenComplete(chunkController.close);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import 'dart:convert' hide Codec;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:thumbhash/thumbhash.dart';
|
||||
|
||||
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
||||
final String thumbHash;
|
||||
|
||||
ThumbHashProvider({
|
||||
required this.thumbHash,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ThumbHashProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadCodec(key, decode),
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Codec> _loadCodec(
|
||||
ThumbHashProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
|
||||
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ThumbHashProvider) {
|
||||
return thumbHash == other.thumbHash;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => thumbHash.hashCode;
|
||||
}
|
105
mobile/lib/presentation/widgets/images/thumbnail.widget.dart
Normal file
105
mobile/lib/presentation/widgets/images/thumbnail.widget.dart
Normal file
@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class Thumbnail extends StatelessWidget {
|
||||
const Thumbnail({
|
||||
required this.asset,
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
|
||||
static ImageProvider imageProvider({
|
||||
required BaseAsset asset,
|
||||
Size size = const Size.square(256),
|
||||
}) {
|
||||
if (asset is LocalAsset) {
|
||||
return LocalThumbProvider(
|
||||
asset: asset,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
);
|
||||
}
|
||||
|
||||
if (asset is Asset) {
|
||||
return RemoteThumbProvider(
|
||||
assetId: asset.id,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
);
|
||||
}
|
||||
|
||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null;
|
||||
final provider = imageProvider(asset: asset, size: size);
|
||||
|
||||
return OctoImage.fromSet(
|
||||
image: provider,
|
||||
octoSet: OctoSet(
|
||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
||||
errorBuilder: _blurHashErrorBuilder(
|
||||
thumbHash,
|
||||
provider: provider,
|
||||
fit: fit,
|
||||
asset: asset,
|
||||
),
|
||||
),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
fadeInDuration: Duration.zero,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(
|
||||
String? thumbHash, {
|
||||
BoxFit? fit,
|
||||
}) {
|
||||
return (context) => thumbHash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
OctoErrorBuilder _blurHashErrorBuilder(
|
||||
String? blurhash, {
|
||||
BaseAsset? asset,
|
||||
ImageProvider? provider,
|
||||
BoxFit? fit,
|
||||
}) =>
|
||||
(context, e, s) {
|
||||
Logger("ImThumbnail")
|
||||
.warning("Error loading thumbnail for ${asset?.name}", e, s);
|
||||
provider?.evict();
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
||||
const Opacity(
|
||||
opacity: 0.75,
|
||||
child: Icon(Icons.error_outline_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
};
|
@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
|
||||
class ThumbnailTile extends StatelessWidget {
|
||||
const ThumbnailTile(
|
||||
this.asset, {
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
this.showStorageIndicator = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)),
|
||||
if (asset.isVideo)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _VideoIndicator(asset.durationInSeconds ?? 0),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, bottom: 6.0),
|
||||
child: _TileOverlayIcon(
|
||||
switch (asset.storage) {
|
||||
AssetState.local => Icons.cloud_off_outlined,
|
||||
AssetState.remote => Icons.cloud_outlined,
|
||||
AssetState.merged => Icons.cloud_done_outlined,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (asset.isFavorite)
|
||||
const Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 10.0, bottom: 6.0),
|
||||
child: _TileOverlayIcon(Icons.favorite_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoIndicator extends StatelessWidget {
|
||||
final int durationInSeconds;
|
||||
const _VideoIndicator(this.durationInSeconds);
|
||||
|
||||
String _formatDuration(int durationInSec) {
|
||||
final int hours = durationInSec ~/ 3600;
|
||||
final int minutes = (durationInSec % 3600) ~/ 60;
|
||||
final int seconds = durationInSec % 60;
|
||||
|
||||
final String minutesPadded = minutes.toString().padLeft(2, '0');
|
||||
final String secondsPadded = seconds.toString().padLeft(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return "$hours:$minutesPadded:$secondsPadded"; // H:MM:SS
|
||||
} else {
|
||||
return "$minutesPadded:$secondsPadded"; // MM:SS
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: 3,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
// CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(durationInSeconds),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _TileOverlayIcon(Icons.play_circle_outline_rounded),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileOverlayIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
|
||||
const _TileOverlayIcon(this.icon);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
offset: const Offset(0.0, 0.0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
7
mobile/lib/presentation/widgets/timeline/constants.dart
Normal file
7
mobile/lib/presentation/widgets/timeline/constants.dart
Normal file
@ -0,0 +1,7 @@
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const double kTimelineFixedTileExtent = 256;
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
154
mobile/lib/presentation/widgets/timeline/fixed/row.dart
Normal file
154
mobile/lib/presentation/widgets/timeline/fixed/row.dart
Normal file
@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double dimension;
|
||||
final double spacing;
|
||||
final TextDirection textDirection;
|
||||
|
||||
const FixedTimelineRow({
|
||||
super.key,
|
||||
required this.dimension,
|
||||
required this.spacing,
|
||||
required this.textDirection,
|
||||
required super.children,
|
||||
});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderFixedRow(
|
||||
dimension: dimension,
|
||||
spacing: spacing,
|
||||
textDirection: textDirection,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||
renderObject.dimension = dimension;
|
||||
renderObject.spacing = spacing;
|
||||
renderObject.textDirection = textDirection;
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
}
|
||||
|
||||
class _RowParentData extends ContainerBoxParentData<RenderBox> {}
|
||||
|
||||
class RenderFixedRow extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double dimension,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _dimension = dimension,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
double get dimension => _dimension;
|
||||
double _dimension;
|
||||
|
||||
set dimension(double value) {
|
||||
if (_dimension == value) return;
|
||||
_dimension = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
double get spacing => _spacing;
|
||||
double _spacing;
|
||||
|
||||
set spacing(double value) {
|
||||
if (_spacing == value) return;
|
||||
_spacing = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
TextDirection get textDirection => _textDirection;
|
||||
TextDirection _textDirection;
|
||||
|
||||
set textDirection(TextDirection value) {
|
||||
if (_textDirection == value) return;
|
||||
_textDirection = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! _RowParentData) {
|
||||
child.parentData = _RowParentData();
|
||||
}
|
||||
}
|
||||
|
||||
double get intrinsicWidth =>
|
||||
dimension * childCount + spacing * (childCount - 1);
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => dimension;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
return defaultComputeDistanceToHighestActualBaseline(baseline);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
RenderBox? child = firstChild;
|
||||
if (child == null) {
|
||||
size = constraints.smallest;
|
||||
return;
|
||||
}
|
||||
// Use the entire width of the parent for the row.
|
||||
size = Size(constraints.maxWidth, dimension);
|
||||
// Each tile is forced to be dimension x dimension.
|
||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
||||
// Layout each child horizontally.
|
||||
while (child != null) {
|
||||
child.layout(childConstraints, parentUsesSize: false);
|
||||
final childParentData = child.parentData! as _RowParentData;
|
||||
childParentData.offset = offset;
|
||||
offset += Offset(dx, 0);
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,132 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
class FixedSegment extends Segment {
|
||||
final double tileHeight;
|
||||
final int columnCount;
|
||||
final double mainAxisExtend;
|
||||
|
||||
const FixedSegment({
|
||||
required super.firstIndex,
|
||||
required super.lastIndex,
|
||||
required super.startOffset,
|
||||
required super.endOffset,
|
||||
required super.firstAssetIndex,
|
||||
required super.bucket,
|
||||
required this.tileHeight,
|
||||
required this.columnCount,
|
||||
required super.headerExtent,
|
||||
required super.spacing,
|
||||
required super.header,
|
||||
}) : assert(tileHeight != 0),
|
||||
mainAxisExtend = tileHeight + spacing;
|
||||
|
||||
@override
|
||||
double indexToLayoutOffset(int index) {
|
||||
index -= gridIndex;
|
||||
if (index < 0) {
|
||||
return startOffset;
|
||||
}
|
||||
return gridOffset + (mainAxisExtend * index);
|
||||
}
|
||||
|
||||
@override
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= gridOffset;
|
||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
||||
return firstIndex;
|
||||
}
|
||||
final rowsAbove = (scrollOffset / mainAxisExtend).floor();
|
||||
return gridIndex + rowsAbove;
|
||||
}
|
||||
|
||||
@override
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= gridOffset;
|
||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
||||
return firstIndex;
|
||||
}
|
||||
final firstRowBelow = (scrollOffset / mainAxisExtend).ceil();
|
||||
return gridIndex + firstRowBelow - 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget builder(BuildContext context, int index) {
|
||||
if (index == firstIndex) {
|
||||
return TimelineHeader(
|
||||
bucket: bucket,
|
||||
header: header,
|
||||
height: headerExtent,
|
||||
);
|
||||
}
|
||||
|
||||
final rowIndexInSegment = index - (firstIndex + 1);
|
||||
final assetIndex = rowIndexInSegment * columnCount;
|
||||
final assetCount = bucket.assetCount;
|
||||
final numberOfAssets = math.min(columnCount, assetCount - assetIndex);
|
||||
|
||||
return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
|
||||
}
|
||||
|
||||
Widget _buildRow(int assetIndex, int count) => Consumer(
|
||||
builder: (ctx, ref, _) {
|
||||
final isScrubbing =
|
||||
ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
|
||||
// Timeline is being scrubbed, show placeholders
|
||||
if (isScrubbing) {
|
||||
return SegmentBuilder.buildPlaceholder(
|
||||
ctx,
|
||||
count,
|
||||
size: Size.square(tileHeight),
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
// Bucket is already loaded, show the assets
|
||||
if (timelineService.hasRange(assetIndex, count)) {
|
||||
final assets = timelineService.getAssets(assetIndex, count);
|
||||
return _buildAssetRow(ctx, assets);
|
||||
}
|
||||
|
||||
// Bucket is not loaded, show placeholders and load the bucket
|
||||
return FutureBuilder(
|
||||
future: timelineService.loadAssets(assetIndex, count),
|
||||
builder: (ctxx, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return SegmentBuilder.buildPlaceholder(
|
||||
ctx,
|
||||
count,
|
||||
size: Size.square(tileHeight),
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAssetRow(ctxx, snap.requireData);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) =>
|
||||
FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.generate(
|
||||
assets.length,
|
||||
(i) => RepaintBoundary(child: ThumbnailTile(assets[i])),
|
||||
),
|
||||
);
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
|
||||
class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final double tileHeight;
|
||||
final int columnCount;
|
||||
|
||||
const FixedSegmentBuilder({
|
||||
required super.buckets,
|
||||
required this.tileHeight,
|
||||
required this.columnCount,
|
||||
super.spacing,
|
||||
super.groupBy,
|
||||
});
|
||||
|
||||
List<Segment> generate() {
|
||||
final segments = <Segment>[];
|
||||
int firstIndex = 0;
|
||||
double startOffset = 0;
|
||||
int assetIndex = 0;
|
||||
DateTime? previousDate;
|
||||
|
||||
for (int i = 0; i < buckets.length; i++) {
|
||||
final bucket = buckets[i];
|
||||
|
||||
final assetCount = bucket.assetCount;
|
||||
final numberOfRows = (assetCount / columnCount).ceil();
|
||||
final segmentCount = numberOfRows + 1;
|
||||
|
||||
final segmentFirstIndex = firstIndex;
|
||||
firstIndex += segmentCount;
|
||||
final segmentLastIndex = firstIndex - 1;
|
||||
|
||||
final timelineHeader = switch (groupBy) {
|
||||
GroupAssetsBy.month => HeaderType.month,
|
||||
GroupAssetsBy.day =>
|
||||
bucket is TimeBucket && bucket.date.month != previousDate?.month
|
||||
? HeaderType.monthAndDay
|
||||
: HeaderType.day,
|
||||
GroupAssetsBy.none => HeaderType.none,
|
||||
};
|
||||
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||
|
||||
final segmentStartOffset = startOffset;
|
||||
startOffset += headerExtent +
|
||||
(tileHeight * numberOfRows) +
|
||||
spacing * (numberOfRows - 1);
|
||||
final segmentEndOffset = startOffset;
|
||||
|
||||
segments.add(
|
||||
FixedSegment(
|
||||
firstIndex: segmentFirstIndex,
|
||||
lastIndex: segmentLastIndex,
|
||||
startOffset: segmentStartOffset,
|
||||
endOffset: segmentEndOffset,
|
||||
firstAssetIndex: assetIndex,
|
||||
bucket: bucket,
|
||||
tileHeight: tileHeight,
|
||||
columnCount: columnCount,
|
||||
headerExtent: headerExtent,
|
||||
spacing: spacing,
|
||||
header: timelineHeader,
|
||||
),
|
||||
);
|
||||
|
||||
assetIndex += assetCount;
|
||||
if (bucket is TimeBucket) {
|
||||
previousDate = bucket.date;
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
}
|
60
mobile/lib/presentation/widgets/timeline/header.widget.dart
Normal file
60
mobile/lib/presentation/widgets/timeline/header.widget.dart
Normal file
@ -0,0 +1,60 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class TimelineHeader extends StatelessWidget {
|
||||
final Bucket bucket;
|
||||
final HeaderType header;
|
||||
final double height;
|
||||
|
||||
const TimelineHeader({
|
||||
super.key,
|
||||
required this.bucket,
|
||||
required this.header,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
String _formatMonth(BuildContext context, DateTime date) {
|
||||
final formatter = date.year == DateTime.now().year
|
||||
? DateFormat.MMMM(context.locale.toLanguageTag())
|
||||
: DateFormat.yMMMM(context.locale.toLanguageTag());
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
String _formatDay(BuildContext context, DateTime date) {
|
||||
final formatter = DateFormat.yMMMEd(context.locale.toLanguageTag());
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bucket is! TimeBucket || header == HeaderType.none) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final date = (bucket as TimeBucket).date;
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10),
|
||||
height: height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
if (header == HeaderType.month || header == HeaderType.monthAndDay)
|
||||
Text(
|
||||
_formatMonth(context, date),
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(fontSize: 24, fontWeight: FontWeight.w500),
|
||||
),
|
||||
if (header == HeaderType.day || header == HeaderType.monthAndDay)
|
||||
Text(
|
||||
_formatDay(context, date),
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
455
mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Normal file
455
mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Normal file
@ -0,0 +1,455 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:intl/intl.dart' hide TextDirection;
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
class Scrubber extends StatefulWidget {
|
||||
/// The view that will be scrolled with the scroll thumb
|
||||
final CustomScrollView child;
|
||||
|
||||
/// The segments of the timeline
|
||||
final List<Segment> layoutSegments;
|
||||
|
||||
final double timelineHeight;
|
||||
|
||||
final double topPadding;
|
||||
|
||||
final double bottomPadding;
|
||||
|
||||
Scrubber({
|
||||
super.key,
|
||||
Key? scrollThumbKey,
|
||||
required this.layoutSegments,
|
||||
required this.timelineHeight,
|
||||
this.topPadding = 0,
|
||||
this.bottomPadding = 0,
|
||||
required this.child,
|
||||
}) : assert(child.scrollDirection == Axis.vertical);
|
||||
|
||||
@override
|
||||
State createState() => ScrubberState();
|
||||
}
|
||||
|
||||
List<_Segment> _buildSegments({
|
||||
required List<Segment> layoutSegments,
|
||||
required double timelineHeight,
|
||||
}) {
|
||||
final segments = <_Segment>[];
|
||||
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final formatter = DateFormat.yMMM();
|
||||
for (final layoutSegment in layoutSegments) {
|
||||
final scrollPercentage =
|
||||
layoutSegment.startOffset / layoutSegments.last.endOffset;
|
||||
final startOffset = scrollPercentage * timelineHeight;
|
||||
|
||||
final date = (layoutSegment.bucket as TimeBucket).date;
|
||||
final label = formatter.format(date);
|
||||
|
||||
segments.add(
|
||||
_Segment(
|
||||
date: date,
|
||||
startOffset: startOffset,
|
||||
scrollLabel: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
||||
double _thumbTopOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
List<_Segment> _segments = [];
|
||||
|
||||
late AnimationController _thumbAnimationController;
|
||||
Timer? _fadeOutTimer;
|
||||
late Animation<double> _thumbAnimation;
|
||||
|
||||
late AnimationController _labelAnimationController;
|
||||
late Animation<double> _labelAnimation;
|
||||
|
||||
double get _scrubberHeight =>
|
||||
widget.timelineHeight - widget.topPadding - widget.bottomPadding;
|
||||
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
double get _currentOffset =>
|
||||
_scrollController.offset *
|
||||
_scrubberHeight /
|
||||
_scrollController.position.maxScrollExtent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isDragging = false;
|
||||
_segments = _buildSegments(
|
||||
layoutSegments: widget.layoutSegments,
|
||||
timelineHeight: _scrubberHeight,
|
||||
);
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: kTimelineScrubberFadeInDuration,
|
||||
);
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
);
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: kTimelineScrubberFadeInDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_scrollController = PrimaryScrollController.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.layoutSegments.lastOrNull?.endOffset !=
|
||||
widget.layoutSegments.lastOrNull?.endOffset) {
|
||||
_segments = _buildSegments(
|
||||
layoutSegments: widget.layoutSegments,
|
||||
timelineHeight: _scrubberHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeOutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetThumbTimer() {
|
||||
_fadeOutTimer?.cancel();
|
||||
_fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_fadeOutTimer = null;
|
||||
});
|
||||
}
|
||||
|
||||
bool _onScrollNotification(ScrollNotification notification) {
|
||||
if (_isDragging) {
|
||||
// If the user is dragging the thumb, we don't want to update the position
|
||||
return false;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_thumbTopOffset = _currentOffset;
|
||||
if (_labelAnimation.status != AnimationStatus.reverse) {
|
||||
_labelAnimationController.reverse();
|
||||
}
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
}
|
||||
_resetThumbTimer();
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onDragStart(WidgetRef ref) {
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeOutTimer?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
final newOffset =
|
||||
details.globalPosition.dy - widget.topPadding - widget.bottomPadding;
|
||||
|
||||
setState(() {
|
||||
_thumbTopOffset = newOffset.clamp(0, _scrubberHeight);
|
||||
final scrollPercentage = _thumbTopOffset / _scrubberHeight;
|
||||
final maxScrollExtent = _scrollController.position.maxScrollExtent;
|
||||
_scrollController.jumpTo(maxScrollExtent * scrollPercentage);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragEnd(WidgetRef ref) {
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||
_labelAnimationController.reverse();
|
||||
_isDragging = false;
|
||||
_resetThumbTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext ctx) {
|
||||
Text? label;
|
||||
if (_scrollController.hasClients) {
|
||||
// Cache to avoid multiple calls to [_currentOffset]
|
||||
final scrollOffset = _currentOffset;
|
||||
final labelText = _segments
|
||||
.lastWhereOrNull(
|
||||
(segment) => segment.startOffset <= scrollOffset,
|
||||
)
|
||||
?.scrollLabel ??
|
||||
_segments.firstOrNull?.scrollLabel;
|
||||
label = labelText != null
|
||||
? Text(
|
||||
labelText,
|
||||
style: ctx.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: Stack(
|
||||
children: [
|
||||
RepaintBoundary(child: widget.child),
|
||||
PositionedDirectional(
|
||||
top: _thumbTopOffset + widget.topPadding,
|
||||
end: 0,
|
||||
child: Consumer(
|
||||
builder: (_, ref, child) => GestureDetector(
|
||||
onVerticalDragStart: (_) => _onDragStart(ref),
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: (_) => _onDragEnd(ref),
|
||||
child: child,
|
||||
),
|
||||
child: _Scrubber(
|
||||
thumbAnimation: _thumbAnimation,
|
||||
labelAnimation: _labelAnimation,
|
||||
label: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScrollLabel extends StatelessWidget {
|
||||
final Text label;
|
||||
final Color backgroundColor;
|
||||
final Animation<double> animation;
|
||||
|
||||
const _ScrollLabel({
|
||||
required this.label,
|
||||
required this.backgroundColor,
|
||||
required this.animation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 12.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
alignment: Alignment.center,
|
||||
child: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scrubber extends StatelessWidget {
|
||||
final Text? label;
|
||||
final Animation<double> thumbAnimation;
|
||||
final Animation<double> labelAnimation;
|
||||
|
||||
const _Scrubber({
|
||||
this.label,
|
||||
required this.thumbAnimation,
|
||||
required this.labelAnimation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundColor = context.isDarkTheme
|
||||
? context.colorScheme.primary.darken(amount: .5)
|
||||
: context.colorScheme.primary;
|
||||
|
||||
return _SlideFadeTransition(
|
||||
animation: thumbAnimation,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (label != null)
|
||||
_ScrollLabel(
|
||||
label: label!,
|
||||
backgroundColor: backgroundColor,
|
||||
animation: labelAnimation,
|
||||
),
|
||||
_CircularThumb(backgroundColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircularThumb extends StatelessWidget {
|
||||
final Color backgroundColor;
|
||||
|
||||
const _CircularThumb(this.backgroundColor);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
foregroundPainter: const _ArrowPainter(Colors.white),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(48.0),
|
||||
bottomLeft: Radius.circular(48.0),
|
||||
topRight: Radius.circular(4.0),
|
||||
bottomRight: Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArrowPainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
const _ArrowPainter(this.color);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
const width = 12.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||
paint,
|
||||
);
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(o.dx, o.dy)
|
||||
..lineTo(o.dx + width, o.dy)
|
||||
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> _animation;
|
||||
final Widget _child;
|
||||
|
||||
const _SlideFadeTransition({
|
||||
required Animation<double> animation,
|
||||
required Widget child,
|
||||
}) : _animation = animation,
|
||||
_child = child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) =>
|
||||
_animation.value == 0.0 ? const SizedBox() : child!,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0.3, 0.0),
|
||||
end: const Offset(0.0, 0.0),
|
||||
).animate(_animation),
|
||||
child: FadeTransition(
|
||||
opacity: _animation,
|
||||
child: _child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment {
|
||||
final DateTime date;
|
||||
final double startOffset;
|
||||
final String scrollLabel;
|
||||
|
||||
const _Segment({
|
||||
required this.date,
|
||||
required this.startOffset,
|
||||
required this.scrollLabel,
|
||||
});
|
||||
|
||||
_Segment copyWith({
|
||||
DateTime? date,
|
||||
double? startOffset,
|
||||
String? scrollLabel,
|
||||
}) {
|
||||
return _Segment(
|
||||
date: date ?? this.date,
|
||||
startOffset: startOffset ?? this.startOffset,
|
||||
scrollLabel: scrollLabel ?? this.scrollLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Segment(scrollLabel: $scrollLabel, date: $date)';
|
||||
}
|
||||
}
|
100
mobile/lib/presentation/widgets/timeline/segment.model.dart
Normal file
100
mobile/lib/presentation/widgets/timeline/segment.model.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
|
||||
// Segments are the time groups buckets in the timeline view.
|
||||
// Each segment contains a header and a list of asset rows.
|
||||
abstract class Segment {
|
||||
// The index of the first row of the segment, usually the header, but if not, it can be any asset.
|
||||
final int firstIndex;
|
||||
// The index of the last asset of the segment.
|
||||
final int lastIndex;
|
||||
// The offset of the first row from beginning of the timeline.
|
||||
final double startOffset;
|
||||
// The offset of the last row from beginning of the timeline.
|
||||
final double endOffset;
|
||||
// The spacing between the header and the first row of the segment.
|
||||
final double spacing;
|
||||
final double headerExtent;
|
||||
// the start index of the asset of this segment from the beginning of the timeline.
|
||||
final int firstAssetIndex;
|
||||
final Bucket bucket;
|
||||
|
||||
// The index of the row after the header
|
||||
final int gridIndex;
|
||||
// The offset of the row after the header
|
||||
final double gridOffset;
|
||||
// The type of the header
|
||||
final HeaderType header;
|
||||
|
||||
const Segment({
|
||||
required this.firstIndex,
|
||||
required this.lastIndex,
|
||||
required this.startOffset,
|
||||
required this.endOffset,
|
||||
required this.firstAssetIndex,
|
||||
required this.bucket,
|
||||
required this.headerExtent,
|
||||
required this.spacing,
|
||||
required this.header,
|
||||
}) : gridIndex = firstIndex + 1,
|
||||
gridOffset = startOffset + headerExtent + spacing;
|
||||
|
||||
bool containsIndex(int index) => firstIndex <= index && index <= lastIndex;
|
||||
|
||||
bool isWithinOffset(double offset) =>
|
||||
startOffset <= offset && offset <= endOffset;
|
||||
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset);
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset);
|
||||
double indexToLayoutOffset(int index);
|
||||
|
||||
Widget builder(BuildContext context, int index);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is Segment &&
|
||||
other.firstIndex == firstIndex &&
|
||||
other.lastIndex == lastIndex &&
|
||||
other.startOffset == startOffset &&
|
||||
other.endOffset == endOffset &&
|
||||
other.spacing == spacing &&
|
||||
other.firstAssetIndex == firstAssetIndex &&
|
||||
other.headerExtent == headerExtent &&
|
||||
other.gridIndex == gridIndex &&
|
||||
other.gridOffset == gridOffset &&
|
||||
other.header == header;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
firstIndex.hashCode ^
|
||||
lastIndex.hashCode ^
|
||||
startOffset.hashCode ^
|
||||
endOffset.hashCode ^
|
||||
spacing.hashCode ^
|
||||
headerExtent.hashCode ^
|
||||
firstAssetIndex.hashCode ^
|
||||
gridIndex.hashCode ^
|
||||
gridOffset.hashCode ^
|
||||
header.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Segment(firstIndex: $firstIndex, lastIndex: $lastIndex)';
|
||||
}
|
||||
}
|
||||
|
||||
extension SegmentListExtension on List<Segment> {
|
||||
bool equals(List<Segment> other) =>
|
||||
length == other.length &&
|
||||
lastOrNull?.endOffset == other.lastOrNull?.endOffset;
|
||||
|
||||
Segment? findByIndex(int index) =>
|
||||
firstWhereOrNull((s) => s.containsIndex(index));
|
||||
|
||||
Segment? findByOffset(double offset) =>
|
||||
firstWhereOrNull((s) => s.isWithinOffset(offset)) ?? lastOrNull;
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
|
||||
abstract class SegmentBuilder {
|
||||
final List<Bucket> buckets;
|
||||
final double spacing;
|
||||
final GroupAssetsBy groupBy;
|
||||
|
||||
const SegmentBuilder({
|
||||
required this.buckets,
|
||||
this.spacing = kTimelineSpacing,
|
||||
this.groupBy = GroupAssetsBy.day,
|
||||
});
|
||||
|
||||
static double headerExtent(HeaderType header) {
|
||||
switch (header) {
|
||||
case HeaderType.month:
|
||||
return kTimelineHeaderExtent;
|
||||
case HeaderType.day:
|
||||
return kTimelineHeaderExtent * 0.90;
|
||||
case HeaderType.monthAndDay:
|
||||
return kTimelineHeaderExtent * 1.5;
|
||||
case HeaderType.none:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
static Widget buildPlaceholder(
|
||||
BuildContext context,
|
||||
int count, {
|
||||
Size size = const Size.square(kTimelineFixedTileExtent),
|
||||
double spacing = kTimelineSpacing,
|
||||
}) =>
|
||||
RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.generate(
|
||||
count,
|
||||
(_) => ThumbnailPlaceholder(width: size.width, height: size.height),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
100
mobile/lib/presentation/widgets/timeline/timeline.state.dart
Normal file
100
mobile/lib/presentation/widgets/timeline/timeline.state.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
final double maxHeight;
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
|
||||
const TimelineArgs({
|
||||
required this.maxWidth,
|
||||
required this.maxHeight,
|
||||
this.spacing = kTimelineSpacing,
|
||||
this.columnCount = kTimelineColumnCount,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant TimelineArgs other) {
|
||||
return spacing == other.spacing &&
|
||||
maxWidth == other.maxWidth &&
|
||||
maxHeight == other.maxHeight &&
|
||||
columnCount == other.columnCount;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
maxWidth.hashCode ^
|
||||
maxHeight.hashCode ^
|
||||
spacing.hashCode ^
|
||||
columnCount.hashCode;
|
||||
}
|
||||
|
||||
class TimelineState {
|
||||
final bool isScrubbing;
|
||||
|
||||
const TimelineState({this.isScrubbing = false});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant TimelineState other) {
|
||||
return isScrubbing == other.isScrubbing;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isScrubbing.hashCode;
|
||||
|
||||
TimelineState copyWith({bool? isScrubbing}) {
|
||||
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing);
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
TimelineStateNotifier();
|
||||
|
||||
void setScrubbing(bool isScrubbing) {
|
||||
state = state.copyWith(isScrubbing: isScrubbing);
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineState build() => const TimelineState(isScrubbing: false);
|
||||
}
|
||||
|
||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||
// It should be used only after the timeline service and timeline args provider is overridden
|
||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>(
|
||||
(ref) async* {
|
||||
final args = ref.watch(timelineArgsProvider);
|
||||
final columnCount = args.columnCount;
|
||||
final spacing = args.spacing;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
|
||||
final groupBy = GroupAssetsBy
|
||||
.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
groupBy: groupBy,
|
||||
).generate();
|
||||
});
|
||||
},
|
||||
dependencies: [timelineServiceProvider, timelineArgsProvider],
|
||||
);
|
||||
|
||||
final timelineStateProvider =
|
||||
NotifierProvider<TimelineStateNotifier, TimelineState>(
|
||||
TimelineStateNotifier.new,
|
||||
);
|
365
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
365
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
@ -0,0 +1,365 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(
|
||||
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const _SliverTimeline(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverTimeline extends StatefulWidget {
|
||||
const _SliverTimeline();
|
||||
|
||||
@override
|
||||
State createState() => _SliverTimelineState();
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends State<_SliverTimeline> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final asyncSegments = ref.watch(timelineSegmentProvider);
|
||||
final maxHeight =
|
||||
ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
|
||||
return asyncSegments.widgetWhen(
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: Scrubber(
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: context.padding.top + 10,
|
||||
bottomPadding: context.padding.bottom + 10,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
if (index >= childCount) return null;
|
||||
final segment = segments.findByIndex(index);
|
||||
return segment?.builder(ctx, index) ??
|
||||
const SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
// We add repaint boundary around tiles, so skip the auto boundaries
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<Segment> _segments;
|
||||
|
||||
const _SliverSegmentedList({
|
||||
required List<Segment> segments,
|
||||
required super.delegate,
|
||||
}) : _segments = segments;
|
||||
|
||||
@override
|
||||
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
|
||||
_RenderSliverTimelineBoxAdaptor(
|
||||
childManager: context as SliverMultiBoxAdaptorElement,
|
||||
segments: _segments,
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderSliverTimelineBoxAdaptor renderObject,
|
||||
) {
|
||||
renderObject.segments = _segments;
|
||||
}
|
||||
}
|
||||
|
||||
/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets
|
||||
class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
List<Segment> _segments;
|
||||
|
||||
set segments(List<Segment> updatedSegments) {
|
||||
if (_segments.equals(updatedSegments)) {
|
||||
return;
|
||||
}
|
||||
_segments = updatedSegments;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
_RenderSliverTimelineBoxAdaptor({
|
||||
required super.childManager,
|
||||
required List<Segment> segments,
|
||||
}) : _segments = segments;
|
||||
|
||||
int getMinChildIndexForScrollOffset(double offset) =>
|
||||
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ??
|
||||
0;
|
||||
|
||||
int getMaxChildIndexForScrollOffset(double offset) =>
|
||||
_segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ??
|
||||
0;
|
||||
|
||||
double indexToLayoutOffset(int index) =>
|
||||
(_segments.findByIndex(index) ?? _segments.lastOrNull)
|
||||
?.indexToLayoutOffset(index) ??
|
||||
0;
|
||||
|
||||
double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
|
||||
|
||||
double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
childManager.didStartLayout();
|
||||
// Assume initially that we have enough children to fill the viewport/cache area.
|
||||
childManager.setDidUnderflow(false);
|
||||
|
||||
final double scrollOffset =
|
||||
constraints.scrollOffset + constraints.cacheOrigin;
|
||||
assert(scrollOffset >= 0.0);
|
||||
|
||||
final double remainingExtent = constraints.remainingCacheExtent;
|
||||
assert(remainingExtent >= 0.0);
|
||||
|
||||
final double targetScrollOffset = scrollOffset + remainingExtent;
|
||||
|
||||
// Find the index of the first child that should be visible or in the leading cache area.
|
||||
final int firstRequiredChildIndex =
|
||||
getMinChildIndexForScrollOffset(scrollOffset);
|
||||
|
||||
// Find the index of the last child that should be visible or in the trailing cache area.
|
||||
final int? lastRequiredChildIndex = targetScrollOffset.isFinite
|
||||
? getMaxChildIndexForScrollOffset(targetScrollOffset)
|
||||
: null;
|
||||
|
||||
// Remove children that are no longer visible or within the cache area.
|
||||
if (firstChild == null) {
|
||||
collectGarbage(0, 0);
|
||||
} else {
|
||||
final int leadingChildrenToRemove =
|
||||
calculateLeadingGarbage(firstIndex: firstRequiredChildIndex);
|
||||
final int trailingChildrenToRemove = lastRequiredChildIndex == null
|
||||
? 0
|
||||
: calculateTrailingGarbage(lastIndex: lastRequiredChildIndex);
|
||||
collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove);
|
||||
}
|
||||
|
||||
// If there are currently no children laid out (e.g., initial load),
|
||||
// try to add the first child needed for the current scroll offset.
|
||||
if (firstChild == null) {
|
||||
final double firstChildLayoutOffset =
|
||||
indexToLayoutOffset(firstRequiredChildIndex);
|
||||
final bool childAdded = addInitialChild(
|
||||
index: firstRequiredChildIndex,
|
||||
layoutOffset: firstChildLayoutOffset,
|
||||
);
|
||||
|
||||
if (!childAdded) {
|
||||
// There are either no children, or we are past the end of all our children.
|
||||
final double max =
|
||||
firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset();
|
||||
geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max);
|
||||
childManager.didFinishLayout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Layout children that might have scrolled into view from the top (before the current firstChild).
|
||||
RenderBox? highestLaidOutChild;
|
||||
final childConstraints = constraints.asBoxConstraints();
|
||||
|
||||
for (int currentIndex = indexOf(firstChild!) - 1;
|
||||
currentIndex >= firstRequiredChildIndex;
|
||||
--currentIndex) {
|
||||
final RenderBox? newLeadingChild =
|
||||
insertAndLayoutLeadingChild(childConstraints);
|
||||
if (newLeadingChild == null) {
|
||||
// If a child is missing where we expect one, it indicates
|
||||
// an inconsistency in offset that needs correction.
|
||||
final Segment? segment =
|
||||
_segments.findByIndex(currentIndex) ?? _segments.firstOrNull;
|
||||
geometry = SliverGeometry(
|
||||
// Request a scroll correction based on where the missing child should have been.
|
||||
scrollOffsetCorrection:
|
||||
segment?.indexToLayoutOffset(currentIndex) ?? 0.0,
|
||||
);
|
||||
// Parent will re-layout everything.
|
||||
return;
|
||||
}
|
||||
final childParentData =
|
||||
newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData;
|
||||
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
|
||||
assert(childParentData.index == currentIndex);
|
||||
highestLaidOutChild ??= newLeadingChild;
|
||||
}
|
||||
|
||||
// If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]),
|
||||
// or even if it did, we need to ensure the first visible child is correctly laid out
|
||||
// and establish our starting point for laying out trailing children.
|
||||
|
||||
// If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children.
|
||||
// The [firstChild] that existed at the start of performLayout is still the first one we need.
|
||||
if (highestLaidOutChild == null) {
|
||||
firstChild!.layout(childConstraints);
|
||||
final childParentData =
|
||||
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
|
||||
childParentData.layoutOffset =
|
||||
indexToLayoutOffset(firstRequiredChildIndex);
|
||||
highestLaidOutChild = firstChild;
|
||||
}
|
||||
|
||||
RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild;
|
||||
|
||||
// Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children
|
||||
// until we reach the [lastRequiredChildIndex] or run out of children.
|
||||
double calculatedMaxScrollOffset = double.infinity;
|
||||
|
||||
for (int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1;
|
||||
lastRequiredChildIndex == null ||
|
||||
currentIndex <= lastRequiredChildIndex;
|
||||
++currentIndex) {
|
||||
RenderBox? child = childAfter(mostRecentlyLaidOutChild!);
|
||||
|
||||
if (child == null || indexOf(child) != currentIndex) {
|
||||
child = insertAndLayoutChild(
|
||||
childConstraints,
|
||||
after: mostRecentlyLaidOutChild,
|
||||
);
|
||||
if (child == null) {
|
||||
final Segment? segment =
|
||||
_segments.findByIndex(currentIndex) ?? _segments.lastOrNull;
|
||||
calculatedMaxScrollOffset =
|
||||
segment?.indexToLayoutOffset(currentIndex) ??
|
||||
computeMaxScrollOffset();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
child.layout(childConstraints);
|
||||
}
|
||||
|
||||
mostRecentlyLaidOutChild = child;
|
||||
final childParentData = mostRecentlyLaidOutChild.parentData!
|
||||
as SliverMultiBoxAdaptorParentData;
|
||||
assert(childParentData.index == currentIndex);
|
||||
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
|
||||
}
|
||||
|
||||
final int lastLaidOutChildIndex = indexOf(lastChild!);
|
||||
final double leadingScrollOffset =
|
||||
indexToLayoutOffset(firstRequiredChildIndex);
|
||||
final double trailingScrollOffset =
|
||||
indexToLayoutOffset(lastLaidOutChildIndex + 1);
|
||||
|
||||
assert(
|
||||
firstRequiredChildIndex == 0 ||
|
||||
(childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <=
|
||||
precisionErrorTolerance,
|
||||
);
|
||||
assert(debugAssertChildListIsNonEmptyAndContiguous());
|
||||
assert(indexOf(firstChild!) == firstRequiredChildIndex);
|
||||
assert(
|
||||
lastRequiredChildIndex == null ||
|
||||
lastLaidOutChildIndex <= lastRequiredChildIndex,
|
||||
);
|
||||
|
||||
calculatedMaxScrollOffset = math.min(
|
||||
calculatedMaxScrollOffset,
|
||||
estimateMaxScrollOffset(),
|
||||
);
|
||||
|
||||
final double paintExtent = calculatePaintOffset(
|
||||
constraints,
|
||||
from: leadingScrollOffset,
|
||||
to: trailingScrollOffset,
|
||||
);
|
||||
|
||||
final double cacheExtent = calculateCacheOffset(
|
||||
constraints,
|
||||
from: leadingScrollOffset,
|
||||
to: trailingScrollOffset,
|
||||
);
|
||||
|
||||
final double targetEndScrollOffsetForPaint =
|
||||
constraints.scrollOffset + constraints.remainingPaintExtent;
|
||||
final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite
|
||||
? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint)
|
||||
: null;
|
||||
|
||||
final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset);
|
||||
|
||||
geometry = SliverGeometry(
|
||||
scrollExtent: calculatedMaxScrollOffset,
|
||||
paintExtent: paintExtent,
|
||||
maxPaintExtent: maxPaintExtent,
|
||||
// Indicates if there's content scrolled off-screen.
|
||||
// This is true if the last child needed for painting is actually laid out,
|
||||
// or if the first child is partially visible.
|
||||
hasVisualOverflow: (targetLastIndexForPaint != null &&
|
||||
lastLaidOutChildIndex >= targetLastIndexForPaint) ||
|
||||
constraints.scrollOffset > 0.0,
|
||||
cacheExtent: cacheExtent,
|
||||
);
|
||||
|
||||
// We may have started the layout while scrolled to the end, which would not
|
||||
// expose a new child.
|
||||
if (calculatedMaxScrollOffset == trailingScrollOffset) {
|
||||
childManager.setDidUnderflow(true);
|
||||
}
|
||||
|
||||
childManager.didFinishLayout();
|
||||
}
|
||||
}
|
@ -37,8 +37,7 @@ class ImageLoader {
|
||||
} else if (result is FileInfo) {
|
||||
// We have the file
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
||||
final decoded = await decode(buffer);
|
||||
return decoded;
|
||||
return decode(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
|
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/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/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/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
@ -330,5 +332,13 @@ class AppRouter extends RootStackRouter {
|
||||
page: RemoteMediaSummaryRoute.page,
|
||||
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
|
||||
/// [LockedPage]
|
||||
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
|
||||
/// [MapLocationPickerPage]
|
||||
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||
|
@ -1810,7 +1810,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
|
@ -63,6 +63,7 @@ dependencies:
|
||||
share_handler: ^0.0.22
|
||||
share_plus: ^10.1.4
|
||||
socket_io_client: ^2.0.3+1
|
||||
stream_transform: ^2.1.1
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.1
|
||||
|
Loading…
x
Reference in New Issue
Block a user