You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-07-07 06:16:05 +02:00
feat(mobile): sqlite asset viewer (#19552)
* add full image provider and refactor thumb providers * photo_view updates * wip: asset-viewer * fix controller dispose on page change * wip: bottom sheet * fix interactions * more bottomsheet changes * generate schema * PR feedback * refactor asset viewer * never rotate and fix background on page change * use photoview as the loading builder * precache after delay * claude: optimizing rebuild of image provider * claude: optimizing image decoding and caching * use proper cache for new full size image providers * chore: load local HEIC fullsize for iOS * make controller callbacks nullable * remove imageprovider cache * do not handle drag gestures when zoomed * use loadOriginal setting for HEIC / larger images * preload assets outside timer * never use same controllers in photo-view gallery * fix: cannot scroll down once swipe with bottom sheet --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
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
@ -40,7 +40,17 @@ sealed class BaseAsset {
|
|||||||
|
|
||||||
bool get isImage => type == AssetType.image;
|
bool get isImage => type == AssetType.image;
|
||||||
bool get isVideo => type == AssetType.video;
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
|
double? get aspectRatio {
|
||||||
|
if (width != null && height != null && height! > 0) {
|
||||||
|
return width! / height!;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overridden in subclasses
|
||||||
AssetState get storage;
|
AssetState get storage;
|
||||||
|
String get heroTag;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
@ -22,6 +22,11 @@ class LocalAsset extends BaseAsset {
|
|||||||
AssetState get storage =>
|
AssetState get storage =>
|
||||||
remoteId == null ? AssetState.local : AssetState.merged;
|
remoteId == null ? AssetState.local : AssetState.merged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get heroTag => '${id}_${remoteId ?? checksum}';
|
||||||
|
|
||||||
|
bool get hasRemote => remoteId != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''LocalAsset {
|
return '''LocalAsset {
|
||||||
|
@ -36,6 +36,11 @@ class RemoteAsset extends BaseAsset {
|
|||||||
AssetState get storage =>
|
AssetState get storage =>
|
||||||
localId == null ? AssetState.remote : AssetState.merged;
|
localId == null ? AssetState.remote : AssetState.merged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get heroTag => '${localId ?? checksum}_$id';
|
||||||
|
|
||||||
|
bool get hasLocal => localId != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''Asset {
|
return '''Asset {
|
||||||
|
@ -3,6 +3,8 @@ class ExifInfo {
|
|||||||
final int? fileSize;
|
final int? fileSize;
|
||||||
final String? description;
|
final String? description;
|
||||||
final bool isFlipped;
|
final bool isFlipped;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
final String? orientation;
|
final String? orientation;
|
||||||
final String? timeZone;
|
final String? timeZone;
|
||||||
final DateTime? dateTimeOriginal;
|
final DateTime? dateTimeOriginal;
|
||||||
@ -45,6 +47,8 @@ class ExifInfo {
|
|||||||
this.fileSize,
|
this.fileSize,
|
||||||
this.description,
|
this.description,
|
||||||
this.orientation,
|
this.orientation,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
this.timeZone,
|
this.timeZone,
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
this.isFlipped = false,
|
this.isFlipped = false,
|
||||||
@ -68,6 +72,9 @@ class ExifInfo {
|
|||||||
|
|
||||||
return other.fileSize == fileSize &&
|
return other.fileSize == fileSize &&
|
||||||
other.description == description &&
|
other.description == description &&
|
||||||
|
other.isFlipped == isFlipped &&
|
||||||
|
other.width == width &&
|
||||||
|
other.height == height &&
|
||||||
other.orientation == orientation &&
|
other.orientation == orientation &&
|
||||||
other.timeZone == timeZone &&
|
other.timeZone == timeZone &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
@ -91,6 +98,9 @@ class ExifInfo {
|
|||||||
return fileSize.hashCode ^
|
return fileSize.hashCode ^
|
||||||
description.hashCode ^
|
description.hashCode ^
|
||||||
orientation.hashCode ^
|
orientation.hashCode ^
|
||||||
|
isFlipped.hashCode ^
|
||||||
|
width.hashCode ^
|
||||||
|
height.hashCode ^
|
||||||
timeZone.hashCode ^
|
timeZone.hashCode ^
|
||||||
dateTimeOriginal.hashCode ^
|
dateTimeOriginal.hashCode ^
|
||||||
latitude.hashCode ^
|
latitude.hashCode ^
|
||||||
@ -114,6 +124,9 @@ class ExifInfo {
|
|||||||
fileSize: ${fileSize ?? 'NA'},
|
fileSize: ${fileSize ?? 'NA'},
|
||||||
description: ${description ?? 'NA'},
|
description: ${description ?? 'NA'},
|
||||||
orientation: ${orientation ?? 'NA'},
|
orientation: ${orientation ?? 'NA'},
|
||||||
|
width: ${width ?? 'NA'},
|
||||||
|
height: ${height ?? 'NA'},
|
||||||
|
isFlipped: $isFlipped,
|
||||||
timeZone: ${timeZone ?? 'NA'},
|
timeZone: ${timeZone ?? 'NA'},
|
||||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||||
latitude: ${latitude ?? 'NA'},
|
latitude: ${latitude ?? 'NA'},
|
||||||
|
@ -3,7 +3,10 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
enum Setting<T> {
|
enum Setting<T> {
|
||||||
tilesPerRow<int>(StoreKey.tilesPerRow, 4),
|
tilesPerRow<int>(StoreKey.tilesPerRow, 4),
|
||||||
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
|
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
|
||||||
showStorageIndicator<bool>(StoreKey.storageIndicator, true);
|
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
|
||||||
|
loadOriginal<bool>(StoreKey.loadOriginal, false),
|
||||||
|
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
|
||||||
|
;
|
||||||
|
|
||||||
const Setting(this.storeKey, this.defaultValue);
|
const Setting(this.storeKey, this.defaultValue);
|
||||||
|
|
||||||
|
19
mobile/lib/domain/services/asset.service.dart
Normal file
19
mobile/lib/domain/services/asset.service.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
|
||||||
|
class AssetService {
|
||||||
|
final RemoteAssetRepository _remoteAssetRepository;
|
||||||
|
|
||||||
|
const AssetService({
|
||||||
|
required RemoteAssetRepository remoteAssetRepository,
|
||||||
|
}) : _remoteAssetRepository = remoteAssetRepository;
|
||||||
|
|
||||||
|
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||||
|
if (asset is LocalAsset || asset is! RemoteAsset) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _remoteAssetRepository.getExif(asset.id);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,11 @@
|
|||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
|
||||||
|
// Singleton instance of SettingsService, to use in places
|
||||||
|
// where reactivity is not required
|
||||||
|
// ignore: non_constant_identifier_names
|
||||||
|
final AppSetting = SettingsService(storeService: StoreService.I);
|
||||||
|
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
final StoreService _storeService;
|
final StoreService _storeService;
|
||||||
|
|
||||||
|
@ -57,14 +57,19 @@ class TimelineFactory {
|
|||||||
class TimelineService {
|
class TimelineService {
|
||||||
final TimelineAssetSource _assetSource;
|
final TimelineAssetSource _assetSource;
|
||||||
final TimelineBucketSource _bucketSource;
|
final TimelineBucketSource _bucketSource;
|
||||||
|
int _totalAssets = 0;
|
||||||
|
int get totalAssets => _totalAssets;
|
||||||
|
|
||||||
TimelineService({
|
TimelineService({
|
||||||
required TimelineAssetSource assetSource,
|
required TimelineAssetSource assetSource,
|
||||||
required TimelineBucketSource bucketSource,
|
required TimelineBucketSource bucketSource,
|
||||||
}) : _assetSource = assetSource,
|
}) : _assetSource = assetSource,
|
||||||
_bucketSource = bucketSource {
|
_bucketSource = bucketSource {
|
||||||
_bucketSubscription =
|
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||||
_bucketSource().listen((_) => unawaited(reloadBucket()));
|
_totalAssets =
|
||||||
|
buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||||
|
unawaited(reloadBucket());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final AsyncMutex _mutex = AsyncMutex();
|
final AsyncMutex _mutex = AsyncMutex();
|
||||||
@ -117,6 +122,7 @@ class TimelineService {
|
|||||||
index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length;
|
index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length;
|
||||||
|
|
||||||
List<BaseAsset> getAssets(int index, int count) {
|
List<BaseAsset> getAssets(int index, int count) {
|
||||||
|
assert(index + count <= totalAssets);
|
||||||
if (!hasRange(index, count)) {
|
if (!hasRange(index, count)) {
|
||||||
throw RangeError('TimelineService::getAssets Index out of range');
|
throw RangeError('TimelineService::getAssets Index out of range');
|
||||||
}
|
}
|
||||||
@ -124,6 +130,17 @@ class TimelineService {
|
|||||||
return _buffer.slice(start, start + count);
|
return _buffer.slice(start, start + count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-cache assets around the given index for asset viewer
|
||||||
|
Future<void> preCacheAssets(int index) =>
|
||||||
|
_mutex.run(() => _loadAssets(index, 5));
|
||||||
|
|
||||||
|
BaseAsset getAsset(int index) {
|
||||||
|
if (!hasRange(index, 1)) {
|
||||||
|
throw RangeError('TimelineService::getAsset Index out of range');
|
||||||
|
}
|
||||||
|
return _buffer.elementAt(index - _bufferOffset);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _bucketSubscription?.cancel();
|
await _bucketSubscription?.cancel();
|
||||||
_bucketSubscription = null;
|
_bucketSubscription = null;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:drift/drift.dart' hide Query;
|
import 'package:drift/drift.dart' hide Query;
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
|
import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||||
@ -132,6 +133,8 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
|
|||||||
|
|
||||||
TextColumn get model => text().nullable()();
|
TextColumn get model => text().nullable()();
|
||||||
|
|
||||||
|
TextColumn get lens => text().nullable()();
|
||||||
|
|
||||||
TextColumn get orientation => text().nullable()();
|
TextColumn get orientation => text().nullable()();
|
||||||
|
|
||||||
TextColumn get timeZone => text().nullable()();
|
TextColumn get timeZone => text().nullable()();
|
||||||
@ -143,3 +146,27 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
|
|||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {assetId};
|
Set<Column> get primaryKey => {assetId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||||
|
domain.ExifInfo toDto() => domain.ExifInfo(
|
||||||
|
fileSize: fileSize,
|
||||||
|
dateTimeOriginal: dateTimeOriginal,
|
||||||
|
timeZone: timeZone,
|
||||||
|
make: make,
|
||||||
|
model: model,
|
||||||
|
iso: iso,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
country: country,
|
||||||
|
description: description,
|
||||||
|
orientation: orientation,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
f: fNumber?.toDouble(),
|
||||||
|
mm: focalLength?.toDouble(),
|
||||||
|
lens: lens,
|
||||||
|
width: width?.toDouble(),
|
||||||
|
height: height?.toDouble(),
|
||||||
|
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -27,6 +27,7 @@ typedef $$RemoteExifEntityTableCreateCompanionBuilder
|
|||||||
i0.Value<int?> iso,
|
i0.Value<int?> iso,
|
||||||
i0.Value<String?> make,
|
i0.Value<String?> make,
|
||||||
i0.Value<String?> model,
|
i0.Value<String?> model,
|
||||||
|
i0.Value<String?> lens,
|
||||||
i0.Value<String?> orientation,
|
i0.Value<String?> orientation,
|
||||||
i0.Value<String?> timeZone,
|
i0.Value<String?> timeZone,
|
||||||
i0.Value<int?> rating,
|
i0.Value<int?> rating,
|
||||||
@ -51,6 +52,7 @@ typedef $$RemoteExifEntityTableUpdateCompanionBuilder
|
|||||||
i0.Value<int?> iso,
|
i0.Value<int?> iso,
|
||||||
i0.Value<String?> make,
|
i0.Value<String?> make,
|
||||||
i0.Value<String?> model,
|
i0.Value<String?> model,
|
||||||
|
i0.Value<String?> lens,
|
||||||
i0.Value<String?> orientation,
|
i0.Value<String?> orientation,
|
||||||
i0.Value<String?> timeZone,
|
i0.Value<String?> timeZone,
|
||||||
i0.Value<int?> rating,
|
i0.Value<int?> rating,
|
||||||
@ -150,6 +152,9 @@ class $$RemoteExifEntityTableFilterComposer
|
|||||||
i0.ColumnFilters<String> get model => $composableBuilder(
|
i0.ColumnFilters<String> get model => $composableBuilder(
|
||||||
column: $table.model, builder: (column) => i0.ColumnFilters(column));
|
column: $table.model, builder: (column) => i0.ColumnFilters(column));
|
||||||
|
|
||||||
|
i0.ColumnFilters<String> get lens => $composableBuilder(
|
||||||
|
column: $table.lens, builder: (column) => i0.ColumnFilters(column));
|
||||||
|
|
||||||
i0.ColumnFilters<String> get orientation => $composableBuilder(
|
i0.ColumnFilters<String> get orientation => $composableBuilder(
|
||||||
column: $table.orientation,
|
column: $table.orientation,
|
||||||
builder: (column) => i0.ColumnFilters(column));
|
builder: (column) => i0.ColumnFilters(column));
|
||||||
@ -249,6 +254,9 @@ class $$RemoteExifEntityTableOrderingComposer
|
|||||||
i0.ColumnOrderings<String> get model => $composableBuilder(
|
i0.ColumnOrderings<String> get model => $composableBuilder(
|
||||||
column: $table.model, builder: (column) => i0.ColumnOrderings(column));
|
column: $table.model, builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
|
i0.ColumnOrderings<String> get lens => $composableBuilder(
|
||||||
|
column: $table.lens, builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
i0.ColumnOrderings<String> get orientation => $composableBuilder(
|
i0.ColumnOrderings<String> get orientation => $composableBuilder(
|
||||||
column: $table.orientation,
|
column: $table.orientation,
|
||||||
builder: (column) => i0.ColumnOrderings(column));
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
@ -345,6 +353,9 @@ class $$RemoteExifEntityTableAnnotationComposer
|
|||||||
i0.GeneratedColumn<String> get model =>
|
i0.GeneratedColumn<String> get model =>
|
||||||
$composableBuilder(column: $table.model, builder: (column) => column);
|
$composableBuilder(column: $table.model, builder: (column) => column);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<String> get lens =>
|
||||||
|
$composableBuilder(column: $table.lens, builder: (column) => column);
|
||||||
|
|
||||||
i0.GeneratedColumn<String> get orientation => $composableBuilder(
|
i0.GeneratedColumn<String> get orientation => $composableBuilder(
|
||||||
column: $table.orientation, builder: (column) => column);
|
column: $table.orientation, builder: (column) => column);
|
||||||
|
|
||||||
@ -424,6 +435,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
|
|||||||
i0.Value<int?> iso = const i0.Value.absent(),
|
i0.Value<int?> iso = const i0.Value.absent(),
|
||||||
i0.Value<String?> make = const i0.Value.absent(),
|
i0.Value<String?> make = const i0.Value.absent(),
|
||||||
i0.Value<String?> model = const i0.Value.absent(),
|
i0.Value<String?> model = const i0.Value.absent(),
|
||||||
|
i0.Value<String?> lens = const i0.Value.absent(),
|
||||||
i0.Value<String?> orientation = const i0.Value.absent(),
|
i0.Value<String?> orientation = const i0.Value.absent(),
|
||||||
i0.Value<String?> timeZone = const i0.Value.absent(),
|
i0.Value<String?> timeZone = const i0.Value.absent(),
|
||||||
i0.Value<int?> rating = const i0.Value.absent(),
|
i0.Value<int?> rating = const i0.Value.absent(),
|
||||||
@ -447,6 +459,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
|
|||||||
iso: iso,
|
iso: iso,
|
||||||
make: make,
|
make: make,
|
||||||
model: model,
|
model: model,
|
||||||
|
lens: lens,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
timeZone: timeZone,
|
timeZone: timeZone,
|
||||||
rating: rating,
|
rating: rating,
|
||||||
@ -470,6 +483,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
|
|||||||
i0.Value<int?> iso = const i0.Value.absent(),
|
i0.Value<int?> iso = const i0.Value.absent(),
|
||||||
i0.Value<String?> make = const i0.Value.absent(),
|
i0.Value<String?> make = const i0.Value.absent(),
|
||||||
i0.Value<String?> model = const i0.Value.absent(),
|
i0.Value<String?> model = const i0.Value.absent(),
|
||||||
|
i0.Value<String?> lens = const i0.Value.absent(),
|
||||||
i0.Value<String?> orientation = const i0.Value.absent(),
|
i0.Value<String?> orientation = const i0.Value.absent(),
|
||||||
i0.Value<String?> timeZone = const i0.Value.absent(),
|
i0.Value<String?> timeZone = const i0.Value.absent(),
|
||||||
i0.Value<int?> rating = const i0.Value.absent(),
|
i0.Value<int?> rating = const i0.Value.absent(),
|
||||||
@ -493,6 +507,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
|
|||||||
iso: iso,
|
iso: iso,
|
||||||
make: make,
|
make: make,
|
||||||
model: model,
|
model: model,
|
||||||
|
lens: lens,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
timeZone: timeZone,
|
timeZone: timeZone,
|
||||||
rating: rating,
|
rating: rating,
|
||||||
@ -666,6 +681,12 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
|
|||||||
late final i0.GeneratedColumn<String> model = i0.GeneratedColumn<String>(
|
late final i0.GeneratedColumn<String> model = i0.GeneratedColumn<String>(
|
||||||
'model', aliasedName, true,
|
'model', aliasedName, true,
|
||||||
type: i0.DriftSqlType.string, requiredDuringInsert: false);
|
type: i0.DriftSqlType.string, requiredDuringInsert: false);
|
||||||
|
static const i0.VerificationMeta _lensMeta =
|
||||||
|
const i0.VerificationMeta('lens');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<String> lens = i0.GeneratedColumn<String>(
|
||||||
|
'lens', aliasedName, true,
|
||||||
|
type: i0.DriftSqlType.string, requiredDuringInsert: false);
|
||||||
static const i0.VerificationMeta _orientationMeta =
|
static const i0.VerificationMeta _orientationMeta =
|
||||||
const i0.VerificationMeta('orientation');
|
const i0.VerificationMeta('orientation');
|
||||||
@override
|
@override
|
||||||
@ -709,6 +730,7 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
|
|||||||
iso,
|
iso,
|
||||||
make,
|
make,
|
||||||
model,
|
model,
|
||||||
|
lens,
|
||||||
orientation,
|
orientation,
|
||||||
timeZone,
|
timeZone,
|
||||||
rating,
|
rating,
|
||||||
@ -803,6 +825,10 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
|
|||||||
context.handle(
|
context.handle(
|
||||||
_modelMeta, model.isAcceptableOrUnknown(data['model']!, _modelMeta));
|
_modelMeta, model.isAcceptableOrUnknown(data['model']!, _modelMeta));
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('lens')) {
|
||||||
|
context.handle(
|
||||||
|
_lensMeta, lens.isAcceptableOrUnknown(data['lens']!, _lensMeta));
|
||||||
|
}
|
||||||
if (data.containsKey('orientation')) {
|
if (data.containsKey('orientation')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_orientationMeta,
|
_orientationMeta,
|
||||||
@ -868,6 +894,8 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
|
|||||||
.read(i0.DriftSqlType.string, data['${effectivePrefix}make']),
|
.read(i0.DriftSqlType.string, data['${effectivePrefix}make']),
|
||||||
model: attachedDatabase.typeMapping
|
model: attachedDatabase.typeMapping
|
||||||
.read(i0.DriftSqlType.string, data['${effectivePrefix}model']),
|
.read(i0.DriftSqlType.string, data['${effectivePrefix}model']),
|
||||||
|
lens: attachedDatabase.typeMapping
|
||||||
|
.read(i0.DriftSqlType.string, data['${effectivePrefix}lens']),
|
||||||
orientation: attachedDatabase.typeMapping
|
orientation: attachedDatabase.typeMapping
|
||||||
.read(i0.DriftSqlType.string, data['${effectivePrefix}orientation']),
|
.read(i0.DriftSqlType.string, data['${effectivePrefix}orientation']),
|
||||||
timeZone: attachedDatabase.typeMapping
|
timeZone: attachedDatabase.typeMapping
|
||||||
@ -909,6 +937,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
final int? iso;
|
final int? iso;
|
||||||
final String? make;
|
final String? make;
|
||||||
final String? model;
|
final String? model;
|
||||||
|
final String? lens;
|
||||||
final String? orientation;
|
final String? orientation;
|
||||||
final String? timeZone;
|
final String? timeZone;
|
||||||
final int? rating;
|
final int? rating;
|
||||||
@ -931,6 +960,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
this.iso,
|
this.iso,
|
||||||
this.make,
|
this.make,
|
||||||
this.model,
|
this.model,
|
||||||
|
this.lens,
|
||||||
this.orientation,
|
this.orientation,
|
||||||
this.timeZone,
|
this.timeZone,
|
||||||
this.rating,
|
this.rating,
|
||||||
@ -987,6 +1017,9 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
if (!nullToAbsent || model != null) {
|
if (!nullToAbsent || model != null) {
|
||||||
map['model'] = i0.Variable<String>(model);
|
map['model'] = i0.Variable<String>(model);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || lens != null) {
|
||||||
|
map['lens'] = i0.Variable<String>(lens);
|
||||||
|
}
|
||||||
if (!nullToAbsent || orientation != null) {
|
if (!nullToAbsent || orientation != null) {
|
||||||
map['orientation'] = i0.Variable<String>(orientation);
|
map['orientation'] = i0.Variable<String>(orientation);
|
||||||
}
|
}
|
||||||
@ -1024,6 +1057,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
iso: serializer.fromJson<int?>(json['iso']),
|
iso: serializer.fromJson<int?>(json['iso']),
|
||||||
make: serializer.fromJson<String?>(json['make']),
|
make: serializer.fromJson<String?>(json['make']),
|
||||||
model: serializer.fromJson<String?>(json['model']),
|
model: serializer.fromJson<String?>(json['model']),
|
||||||
|
lens: serializer.fromJson<String?>(json['lens']),
|
||||||
orientation: serializer.fromJson<String?>(json['orientation']),
|
orientation: serializer.fromJson<String?>(json['orientation']),
|
||||||
timeZone: serializer.fromJson<String?>(json['timeZone']),
|
timeZone: serializer.fromJson<String?>(json['timeZone']),
|
||||||
rating: serializer.fromJson<int?>(json['rating']),
|
rating: serializer.fromJson<int?>(json['rating']),
|
||||||
@ -1051,6 +1085,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
'iso': serializer.toJson<int?>(iso),
|
'iso': serializer.toJson<int?>(iso),
|
||||||
'make': serializer.toJson<String?>(make),
|
'make': serializer.toJson<String?>(make),
|
||||||
'model': serializer.toJson<String?>(model),
|
'model': serializer.toJson<String?>(model),
|
||||||
|
'lens': serializer.toJson<String?>(lens),
|
||||||
'orientation': serializer.toJson<String?>(orientation),
|
'orientation': serializer.toJson<String?>(orientation),
|
||||||
'timeZone': serializer.toJson<String?>(timeZone),
|
'timeZone': serializer.toJson<String?>(timeZone),
|
||||||
'rating': serializer.toJson<int?>(rating),
|
'rating': serializer.toJson<int?>(rating),
|
||||||
@ -1076,6 +1111,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
i0.Value<int?> iso = const i0.Value.absent(),
|
i0.Value<int?> iso = const i0.Value.absent(),
|
||||||
i0.Value<String?> make = const i0.Value.absent(),
|
i0.Value<String?> make = const i0.Value.absent(),
|
||||||
i0.Value<String?> model = const i0.Value.absent(),
|
i0.Value<String?> model = const i0.Value.absent(),
|
||||||
|
i0.Value<String?> lens = const i0.Value.absent(),
|
||||||
i0.Value<String?> orientation = const i0.Value.absent(),
|
i0.Value<String?> orientation = const i0.Value.absent(),
|
||||||
i0.Value<String?> timeZone = const i0.Value.absent(),
|
i0.Value<String?> timeZone = const i0.Value.absent(),
|
||||||
i0.Value<int?> rating = const i0.Value.absent(),
|
i0.Value<int?> rating = const i0.Value.absent(),
|
||||||
@ -1101,6 +1137,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
iso: iso.present ? iso.value : this.iso,
|
iso: iso.present ? iso.value : this.iso,
|
||||||
make: make.present ? make.value : this.make,
|
make: make.present ? make.value : this.make,
|
||||||
model: model.present ? model.value : this.model,
|
model: model.present ? model.value : this.model,
|
||||||
|
lens: lens.present ? lens.value : this.lens,
|
||||||
orientation: orientation.present ? orientation.value : this.orientation,
|
orientation: orientation.present ? orientation.value : this.orientation,
|
||||||
timeZone: timeZone.present ? timeZone.value : this.timeZone,
|
timeZone: timeZone.present ? timeZone.value : this.timeZone,
|
||||||
rating: rating.present ? rating.value : this.rating,
|
rating: rating.present ? rating.value : this.rating,
|
||||||
@ -1132,6 +1169,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
iso: data.iso.present ? data.iso.value : this.iso,
|
iso: data.iso.present ? data.iso.value : this.iso,
|
||||||
make: data.make.present ? data.make.value : this.make,
|
make: data.make.present ? data.make.value : this.make,
|
||||||
model: data.model.present ? data.model.value : this.model,
|
model: data.model.present ? data.model.value : this.model,
|
||||||
|
lens: data.lens.present ? data.lens.value : this.lens,
|
||||||
orientation:
|
orientation:
|
||||||
data.orientation.present ? data.orientation.value : this.orientation,
|
data.orientation.present ? data.orientation.value : this.orientation,
|
||||||
timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone,
|
timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone,
|
||||||
@ -1162,6 +1200,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
..write('iso: $iso, ')
|
..write('iso: $iso, ')
|
||||||
..write('make: $make, ')
|
..write('make: $make, ')
|
||||||
..write('model: $model, ')
|
..write('model: $model, ')
|
||||||
|
..write('lens: $lens, ')
|
||||||
..write('orientation: $orientation, ')
|
..write('orientation: $orientation, ')
|
||||||
..write('timeZone: $timeZone, ')
|
..write('timeZone: $timeZone, ')
|
||||||
..write('rating: $rating, ')
|
..write('rating: $rating, ')
|
||||||
@ -1189,6 +1228,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
iso,
|
iso,
|
||||||
make,
|
make,
|
||||||
model,
|
model,
|
||||||
|
lens,
|
||||||
orientation,
|
orientation,
|
||||||
timeZone,
|
timeZone,
|
||||||
rating,
|
rating,
|
||||||
@ -1215,6 +1255,7 @@ class RemoteExifEntityData extends i0.DataClass
|
|||||||
other.iso == this.iso &&
|
other.iso == this.iso &&
|
||||||
other.make == this.make &&
|
other.make == this.make &&
|
||||||
other.model == this.model &&
|
other.model == this.model &&
|
||||||
|
other.lens == this.lens &&
|
||||||
other.orientation == this.orientation &&
|
other.orientation == this.orientation &&
|
||||||
other.timeZone == this.timeZone &&
|
other.timeZone == this.timeZone &&
|
||||||
other.rating == this.rating &&
|
other.rating == this.rating &&
|
||||||
@ -1240,6 +1281,7 @@ class RemoteExifEntityCompanion
|
|||||||
final i0.Value<int?> iso;
|
final i0.Value<int?> iso;
|
||||||
final i0.Value<String?> make;
|
final i0.Value<String?> make;
|
||||||
final i0.Value<String?> model;
|
final i0.Value<String?> model;
|
||||||
|
final i0.Value<String?> lens;
|
||||||
final i0.Value<String?> orientation;
|
final i0.Value<String?> orientation;
|
||||||
final i0.Value<String?> timeZone;
|
final i0.Value<String?> timeZone;
|
||||||
final i0.Value<int?> rating;
|
final i0.Value<int?> rating;
|
||||||
@ -1262,6 +1304,7 @@ class RemoteExifEntityCompanion
|
|||||||
this.iso = const i0.Value.absent(),
|
this.iso = const i0.Value.absent(),
|
||||||
this.make = const i0.Value.absent(),
|
this.make = const i0.Value.absent(),
|
||||||
this.model = const i0.Value.absent(),
|
this.model = const i0.Value.absent(),
|
||||||
|
this.lens = const i0.Value.absent(),
|
||||||
this.orientation = const i0.Value.absent(),
|
this.orientation = const i0.Value.absent(),
|
||||||
this.timeZone = const i0.Value.absent(),
|
this.timeZone = const i0.Value.absent(),
|
||||||
this.rating = const i0.Value.absent(),
|
this.rating = const i0.Value.absent(),
|
||||||
@ -1285,6 +1328,7 @@ class RemoteExifEntityCompanion
|
|||||||
this.iso = const i0.Value.absent(),
|
this.iso = const i0.Value.absent(),
|
||||||
this.make = const i0.Value.absent(),
|
this.make = const i0.Value.absent(),
|
||||||
this.model = const i0.Value.absent(),
|
this.model = const i0.Value.absent(),
|
||||||
|
this.lens = const i0.Value.absent(),
|
||||||
this.orientation = const i0.Value.absent(),
|
this.orientation = const i0.Value.absent(),
|
||||||
this.timeZone = const i0.Value.absent(),
|
this.timeZone = const i0.Value.absent(),
|
||||||
this.rating = const i0.Value.absent(),
|
this.rating = const i0.Value.absent(),
|
||||||
@ -1308,6 +1352,7 @@ class RemoteExifEntityCompanion
|
|||||||
i0.Expression<int>? iso,
|
i0.Expression<int>? iso,
|
||||||
i0.Expression<String>? make,
|
i0.Expression<String>? make,
|
||||||
i0.Expression<String>? model,
|
i0.Expression<String>? model,
|
||||||
|
i0.Expression<String>? lens,
|
||||||
i0.Expression<String>? orientation,
|
i0.Expression<String>? orientation,
|
||||||
i0.Expression<String>? timeZone,
|
i0.Expression<String>? timeZone,
|
||||||
i0.Expression<int>? rating,
|
i0.Expression<int>? rating,
|
||||||
@ -1331,6 +1376,7 @@ class RemoteExifEntityCompanion
|
|||||||
if (iso != null) 'iso': iso,
|
if (iso != null) 'iso': iso,
|
||||||
if (make != null) 'make': make,
|
if (make != null) 'make': make,
|
||||||
if (model != null) 'model': model,
|
if (model != null) 'model': model,
|
||||||
|
if (lens != null) 'lens': lens,
|
||||||
if (orientation != null) 'orientation': orientation,
|
if (orientation != null) 'orientation': orientation,
|
||||||
if (timeZone != null) 'time_zone': timeZone,
|
if (timeZone != null) 'time_zone': timeZone,
|
||||||
if (rating != null) 'rating': rating,
|
if (rating != null) 'rating': rating,
|
||||||
@ -1356,6 +1402,7 @@ class RemoteExifEntityCompanion
|
|||||||
i0.Value<int?>? iso,
|
i0.Value<int?>? iso,
|
||||||
i0.Value<String?>? make,
|
i0.Value<String?>? make,
|
||||||
i0.Value<String?>? model,
|
i0.Value<String?>? model,
|
||||||
|
i0.Value<String?>? lens,
|
||||||
i0.Value<String?>? orientation,
|
i0.Value<String?>? orientation,
|
||||||
i0.Value<String?>? timeZone,
|
i0.Value<String?>? timeZone,
|
||||||
i0.Value<int?>? rating,
|
i0.Value<int?>? rating,
|
||||||
@ -1378,6 +1425,7 @@ class RemoteExifEntityCompanion
|
|||||||
iso: iso ?? this.iso,
|
iso: iso ?? this.iso,
|
||||||
make: make ?? this.make,
|
make: make ?? this.make,
|
||||||
model: model ?? this.model,
|
model: model ?? this.model,
|
||||||
|
lens: lens ?? this.lens,
|
||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
timeZone: timeZone ?? this.timeZone,
|
timeZone: timeZone ?? this.timeZone,
|
||||||
rating: rating ?? this.rating,
|
rating: rating ?? this.rating,
|
||||||
@ -1439,6 +1487,9 @@ class RemoteExifEntityCompanion
|
|||||||
if (model.present) {
|
if (model.present) {
|
||||||
map['model'] = i0.Variable<String>(model.value);
|
map['model'] = i0.Variable<String>(model.value);
|
||||||
}
|
}
|
||||||
|
if (lens.present) {
|
||||||
|
map['lens'] = i0.Variable<String>(lens.value);
|
||||||
|
}
|
||||||
if (orientation.present) {
|
if (orientation.present) {
|
||||||
map['orientation'] = i0.Variable<String>(orientation.value);
|
map['orientation'] = i0.Variable<String>(orientation.value);
|
||||||
}
|
}
|
||||||
@ -1474,6 +1525,7 @@ class RemoteExifEntityCompanion
|
|||||||
..write('iso: $iso, ')
|
..write('iso: $iso, ')
|
||||||
..write('make: $make, ')
|
..write('make: $make, ')
|
||||||
..write('model: $model, ')
|
..write('model: $model, ')
|
||||||
|
..write('lens: $lens, ')
|
||||||
..write('orientation: $orientation, ')
|
..write('orientation: $orientation, ')
|
||||||
..write('timeZone: $timeZone, ')
|
..write('timeZone: $timeZone, ')
|
||||||
..write('rating: $rating, ')
|
..write('rating: $rating, ')
|
||||||
|
@ -52,8 +52,8 @@ mergedBucket(:group_by AS INTEGER):
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as asset_count,
|
COUNT(*) as asset_count,
|
||||||
CASE
|
CASE
|
||||||
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day
|
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at, 'localtime') -- day
|
||||||
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month
|
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at, 'localtime') -- month
|
||||||
END AS bucket_date
|
END AS bucket_date
|
||||||
FROM
|
FROM
|
||||||
(
|
(
|
||||||
|
@ -51,7 +51,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
|
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
|
||||||
$arrayStartIndex += var2.length;
|
$arrayStartIndex += var2.length;
|
||||||
return customSelect(
|
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.deleted_at IS NULL AND 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',
|
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') 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.deleted_at IS NULL AND 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: [
|
variables: [
|
||||||
i0.Variable<int>(groupBy),
|
i0.Variable<int>(groupBy),
|
||||||
for (var $ in var2) i0.Variable<String>($)
|
for (var $ in var2) i0.Variable<String>($)
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
||||||
as entity;
|
as entity;
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
@ -43,36 +41,3 @@ class IsarExifRepository extends IsarDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DriftRemoteExifRepository extends DriftDatabaseRepository {
|
|
||||||
final Drift _db;
|
|
||||||
const DriftRemoteExifRepository(this._db) : super(_db);
|
|
||||||
|
|
||||||
Future<ExifInfo?> get(String assetId) {
|
|
||||||
final query = _db.remoteExifEntity.select()
|
|
||||||
..where((exif) => exif.assetId.equals(assetId));
|
|
||||||
|
|
||||||
return query.map((asset) => asset.toDto()).getSingleOrNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension on RemoteExifEntityData {
|
|
||||||
ExifInfo toDto() {
|
|
||||||
return ExifInfo(
|
|
||||||
fileSize: fileSize,
|
|
||||||
description: description,
|
|
||||||
orientation: orientation,
|
|
||||||
timeZone: timeZone,
|
|
||||||
dateTimeOriginal: dateTimeOriginal,
|
|
||||||
latitude: latitude,
|
|
||||||
longitude: longitude,
|
|
||||||
city: city,
|
|
||||||
state: state,
|
|
||||||
country: country,
|
|
||||||
make: make,
|
|
||||||
model: model,
|
|
||||||
f: fNumber,
|
|
||||||
iso: iso,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
||||||
|
hide ExifInfo;
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class DriftRemoteAssetRepository extends DriftDatabaseRepository {
|
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftRemoteAssetRepository(this._db) : super(_db);
|
const RemoteAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
Future<ExifInfo?> getExif(String id) {
|
||||||
|
return _db.managers.remoteExifEntity
|
||||||
|
.filter((row) => row.assetId.id.equals(id))
|
||||||
|
.map((row) => row.toDto())
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateFavorite(List<String> ids, bool isFavorite) {
|
Future<void> updateFavorite(List<String> ids, bool isFavorite) {
|
||||||
return _db.batch((batch) async {
|
return _db.batch((batch) async {
|
||||||
|
@ -5,20 +5,21 @@ import 'package:logging/logging.dart';
|
|||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class StorageRepository {
|
class StorageRepository {
|
||||||
final _log = Logger('StorageRepository');
|
const StorageRepository();
|
||||||
|
|
||||||
Future<File?> getFileForAsset(LocalAsset asset) async {
|
Future<File?> getFileForAsset(LocalAsset asset) async {
|
||||||
|
final log = Logger('StorageRepository');
|
||||||
File? file;
|
File? file;
|
||||||
try {
|
try {
|
||||||
final entity = await AssetEntity.fromId(asset.id);
|
final entity = await AssetEntity.fromId(asset.id);
|
||||||
file = await entity?.originFile;
|
file = await entity?.originFile;
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
_log.warning(
|
log.warning(
|
||||||
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
_log.warning(
|
log.warning(
|
||||||
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
error,
|
error,
|
||||||
stackTrace,
|
stackTrace,
|
||||||
|
@ -161,8 +161,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
fNumber: Value(exif.fNumber),
|
fNumber: Value(exif.fNumber),
|
||||||
fileSize: Value(exif.fileSizeInByte),
|
fileSize: Value(exif.fileSizeInByte),
|
||||||
focalLength: Value(exif.focalLength),
|
focalLength: Value(exif.focalLength),
|
||||||
latitude: Value(exif.latitude),
|
latitude: Value(exif.latitude?.toDouble()),
|
||||||
longitude: Value(exif.longitude),
|
longitude: Value(exif.longitude?.toDouble()),
|
||||||
iso: Value(exif.iso),
|
iso: Value(exif.iso),
|
||||||
make: Value(exif.make),
|
make: Value(exif.make),
|
||||||
model: Value(exif.model),
|
model: Value(exif.model),
|
||||||
@ -170,6 +170,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
timeZone: Value(exif.timeZone),
|
timeZone: Value(exif.timeZone),
|
||||||
rating: Value(exif.rating),
|
rating: Value(exif.rating),
|
||||||
projectionType: Value(exif.projectionType),
|
projectionType: Value(exif.projectionType),
|
||||||
|
lens: Value(exif.lensModel),
|
||||||
);
|
);
|
||||||
|
|
||||||
batch.insert(
|
batch.insert(
|
||||||
|
@ -238,7 +238,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
PhotoViewGalleryPageOptions buildImage(Asset asset) {
|
PhotoViewGalleryPageOptions buildImage(Asset asset) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
onDragStart: (_, details, __) {
|
onDragStart: (_, details, __, ___) {
|
||||||
localPosition.value = details.localPosition;
|
localPosition.value = details.localPosition;
|
||||||
},
|
},
|
||||||
onDragUpdate: (_, details, __) {
|
onDragUpdate: (_, details, __) {
|
||||||
@ -267,7 +267,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
|
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
onDragStart: (_, details, __) =>
|
onDragStart: (_, details, __, ___) =>
|
||||||
localPosition.value = details.localPosition,
|
localPosition.value = details.localPosition,
|
||||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||||
heroAttributes: _getHeroAttributes(asset),
|
heroAttributes: _getHeroAttributes(asset),
|
||||||
@ -370,7 +370,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
itemCount: totalAssets.value,
|
itemCount: totalAssets.value,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
onPageChanged: (value) {
|
onPageChanged: (value, _) {
|
||||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||||
|
|
||||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
|
@ -0,0 +1,473 @@
|
|||||||
|
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/domain/services/timeline.service.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
|
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
||||||
|
import 'package:platform/platform.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class AssetViewerPage extends StatelessWidget {
|
||||||
|
final int initialIndex;
|
||||||
|
final TimelineService timelineService;
|
||||||
|
|
||||||
|
const AssetViewerPage({
|
||||||
|
super.key,
|
||||||
|
required this.initialIndex,
|
||||||
|
required this.timelineService,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// This is necessary to ensure that the timeline service is available
|
||||||
|
// since the Timeline and AssetViewer are on different routes / Widget subtrees.
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [timelineServiceProvider.overrideWithValue(timelineService)],
|
||||||
|
child: AssetViewer(initialIndex: initialIndex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetViewer extends ConsumerStatefulWidget {
|
||||||
|
final int initialIndex;
|
||||||
|
final Platform? platform;
|
||||||
|
|
||||||
|
const AssetViewer({
|
||||||
|
super.key,
|
||||||
|
required this.initialIndex,
|
||||||
|
this.platform,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState createState() => _AssetViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const double _kBottomSheetMinimumExtent = 0.4;
|
||||||
|
const double _kBottomSheetSnapExtent = 0.7;
|
||||||
|
|
||||||
|
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
|
late PageController pageController;
|
||||||
|
late DraggableScrollableController bottomSheetController;
|
||||||
|
PersistentBottomSheetController? sheetCloseNotifier;
|
||||||
|
// PhotoViewGallery takes care of disposing it's controllers
|
||||||
|
PhotoViewControllerBase? viewController;
|
||||||
|
|
||||||
|
late Platform platform;
|
||||||
|
late PhotoViewControllerValue initialPhotoViewState;
|
||||||
|
bool? hasDraggedDown;
|
||||||
|
bool isSnapping = false;
|
||||||
|
bool blockGestures = false;
|
||||||
|
bool dragInProgress = false;
|
||||||
|
bool shouldPopOnDrag = false;
|
||||||
|
bool showingBottomSheet = false;
|
||||||
|
double? initialScale;
|
||||||
|
double previousExtent = _kBottomSheetMinimumExtent;
|
||||||
|
Offset dragDownPosition = Offset.zero;
|
||||||
|
int totalAssets = 0;
|
||||||
|
int backgroundOpacity = 255;
|
||||||
|
|
||||||
|
// Delayed operations that should be cancelled on disposal
|
||||||
|
final List<Timer> _delayedOperations = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
pageController = PageController(initialPage: widget.initialIndex);
|
||||||
|
platform = widget.platform ?? const LocalPlatform();
|
||||||
|
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||||
|
bottomSheetController = DraggableScrollableController();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_onAssetChanged(widget.initialIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
pageController.dispose();
|
||||||
|
bottomSheetController.dispose();
|
||||||
|
_cancelTimers();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get backgroundColor {
|
||||||
|
if (showingBottomSheet && !context.isDarkTheme) {
|
||||||
|
return Colors.white;
|
||||||
|
}
|
||||||
|
return Colors.black.withAlpha(backgroundOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelTimers() {
|
||||||
|
for (final timer in _delayedOperations) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_delayedOperations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used to calculate the scale of the asset when the bottom sheet is showing.
|
||||||
|
// It is a small increment to ensure that the asset is slightly zoomed in when the
|
||||||
|
// bottom sheet is showing, which emulates the zoom effect.
|
||||||
|
double get _getScaleForBottomSheet =>
|
||||||
|
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
|
||||||
|
0.01;
|
||||||
|
|
||||||
|
Future<void> _precacheImage(int index) async {
|
||||||
|
if (!mounted || index < 0 || index >= totalAssets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
|
final screenSize = Size(context.width, context.height);
|
||||||
|
|
||||||
|
// Precache both thumbnail and full image for smooth transitions
|
||||||
|
unawaited(
|
||||||
|
Future.wait([
|
||||||
|
precacheImage(
|
||||||
|
getThumbnailImageProvider(asset: asset, size: screenSize),
|
||||||
|
context,
|
||||||
|
onError: (_, __) {},
|
||||||
|
),
|
||||||
|
precacheImage(
|
||||||
|
getFullImageProvider(asset, size: screenSize),
|
||||||
|
context,
|
||||||
|
onError: (_, __) {},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAssetChanged(int index) {
|
||||||
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
|
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||||
|
unawaited(ref.read(timelineServiceProvider).preCacheAssets(index));
|
||||||
|
_cancelTimers();
|
||||||
|
// This will trigger the pre-caching of adjacent assets ensuring
|
||||||
|
// that they are ready when the user navigates to them.
|
||||||
|
final timer = Timer(Durations.medium4, () {
|
||||||
|
// Check if widget is still mounted before proceeding
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
for (final offset in [-1, 1]) {
|
||||||
|
unawaited(_precacheImage(index + offset));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_delayedOperations.add(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||||
|
viewController ??= controller;
|
||||||
|
if (showingBottomSheet) {
|
||||||
|
final verticalOffset = (context.height * bottomSheetController.size) -
|
||||||
|
(context.height * _kBottomSheetMinimumExtent);
|
||||||
|
controller.position = Offset(0, -verticalOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
||||||
|
_onAssetChanged(index);
|
||||||
|
viewController = controller;
|
||||||
|
|
||||||
|
// If the bottom sheet is showing, we need to adjust scale the asset to
|
||||||
|
// emulate the zoom effect
|
||||||
|
if (showingBottomSheet) {
|
||||||
|
initialScale = controller?.scale;
|
||||||
|
controller?.scale = _getScaleForBottomSheet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragStart(
|
||||||
|
_,
|
||||||
|
DragStartDetails details,
|
||||||
|
PhotoViewControllerValue value,
|
||||||
|
PhotoViewScaleStateController scaleStateController,
|
||||||
|
) {
|
||||||
|
dragDownPosition = details.localPosition;
|
||||||
|
initialPhotoViewState = value;
|
||||||
|
final isZoomed =
|
||||||
|
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
|
||||||
|
scaleStateController.scaleState == PhotoViewScaleState.covering;
|
||||||
|
if (!showingBottomSheet && isZoomed) {
|
||||||
|
blockGestures = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragEnd(BuildContext ctx, _, __) {
|
||||||
|
dragInProgress = false;
|
||||||
|
|
||||||
|
if (shouldPopOnDrag) {
|
||||||
|
// Dismiss immediately without state updates to avoid rebuilds
|
||||||
|
ctx.maybePop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not reset the state if the bottom sheet is showing
|
||||||
|
if (showingBottomSheet) {
|
||||||
|
_snapBottomSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the gestures are blocked, do not reset the state
|
||||||
|
if (blockGestures) {
|
||||||
|
blockGestures = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
shouldPopOnDrag = false;
|
||||||
|
hasDraggedDown = null;
|
||||||
|
backgroundOpacity = 255;
|
||||||
|
viewController?.animateMultiple(
|
||||||
|
position: initialPhotoViewState.position,
|
||||||
|
scale: initialPhotoViewState.scale,
|
||||||
|
rotation: initialPhotoViewState.rotation,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
||||||
|
if (blockGestures) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragInProgress = true;
|
||||||
|
final delta = details.localPosition - dragDownPosition;
|
||||||
|
hasDraggedDown ??= delta.dy > 0;
|
||||||
|
if (!hasDraggedDown! || showingBottomSheet) {
|
||||||
|
_handleDragUp(ctx, delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleDragDown(ctx, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragUp(BuildContext ctx, Offset delta) {
|
||||||
|
const double openThreshold = 50;
|
||||||
|
const double closeThreshold = 25;
|
||||||
|
|
||||||
|
final position = initialPhotoViewState.position + Offset(0, delta.dy);
|
||||||
|
final distanceToOrigin = position.distance;
|
||||||
|
|
||||||
|
if (showingBottomSheet && distanceToOrigin < closeThreshold) {
|
||||||
|
// Prevents the user from dragging the bottom sheet further down
|
||||||
|
blockGestures = true;
|
||||||
|
sheetCloseNotifier?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewController?.updateMultiple(position: position);
|
||||||
|
// Moves the bottom sheet when the asset is being dragged up
|
||||||
|
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||||
|
final centre = (ctx.height * _kBottomSheetMinimumExtent);
|
||||||
|
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distanceToOrigin > openThreshold && !showingBottomSheet) {
|
||||||
|
_openBottomSheet(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openBottomSheet(BuildContext ctx) {
|
||||||
|
setState(() {
|
||||||
|
initialScale = viewController?.scale;
|
||||||
|
viewController?.animateMultiple(scale: _getScaleForBottomSheet);
|
||||||
|
showingBottomSheet = true;
|
||||||
|
previousExtent = _kBottomSheetMinimumExtent;
|
||||||
|
sheetCloseNotifier = showBottomSheet(
|
||||||
|
context: ctx,
|
||||||
|
sheetAnimationStyle: AnimationStyle(
|
||||||
|
duration: Duration.zero,
|
||||||
|
reverseDuration: Duration.zero,
|
||||||
|
),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
|
||||||
|
),
|
||||||
|
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
||||||
|
builder: (_) {
|
||||||
|
return NotificationListener<Notification>(
|
||||||
|
onNotification: _onNotification,
|
||||||
|
child: AssetDetailBottomSheet(
|
||||||
|
controller: bottomSheetController,
|
||||||
|
initialChildSize: _kBottomSheetMinimumExtent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sheetCloseNotifier?.closed.then((_) => _handleSheetClose());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSheetClose() {
|
||||||
|
setState(() {
|
||||||
|
showingBottomSheet = false;
|
||||||
|
sheetCloseNotifier = null;
|
||||||
|
viewController?.animateMultiple(
|
||||||
|
position: Offset.zero,
|
||||||
|
scale: initialScale,
|
||||||
|
);
|
||||||
|
shouldPopOnDrag = false;
|
||||||
|
hasDraggedDown = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snapBottomSheet() {
|
||||||
|
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||||
|
bottomSheetController.size < 0.4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSnapping = true;
|
||||||
|
bottomSheetController.animateTo(
|
||||||
|
_kBottomSheetSnapExtent,
|
||||||
|
duration: Durations.short3,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _onNotification(Notification delta) {
|
||||||
|
// Ignore notifications when user dragging the asset
|
||||||
|
if (dragInProgress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta is DraggableScrollableNotification) {
|
||||||
|
_handleDraggableNotification(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
|
||||||
|
// the isSnapping guard is to prevent the notification from recursively handling the
|
||||||
|
// notification, eventually resulting in a heap overflow
|
||||||
|
if (!isSnapping && delta is ScrollEndNotification) {
|
||||||
|
_snapBottomSheet();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDraggableNotification(DraggableScrollableNotification delta) {
|
||||||
|
final verticalOffset = (context.height * delta.extent) -
|
||||||
|
(context.height * _kBottomSheetMinimumExtent);
|
||||||
|
// Moves the asset when the bottom sheet is being dragged
|
||||||
|
if (verticalOffset > 0) {
|
||||||
|
viewController?.position = Offset(0, -verticalOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentExtent = delta.extent;
|
||||||
|
final isDraggingDown = currentExtent < previousExtent;
|
||||||
|
previousExtent = currentExtent;
|
||||||
|
// Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent
|
||||||
|
if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) {
|
||||||
|
sheetCloseNotifier?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||||
|
const double dragRatio = 0.2;
|
||||||
|
const double popThreshold = 75;
|
||||||
|
|
||||||
|
final distance = delta.distance;
|
||||||
|
final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||||
|
|
||||||
|
final maxScaleDistance = ctx.height * 0.5;
|
||||||
|
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||||
|
double? updatedScale;
|
||||||
|
if (initialPhotoViewState.scale != null) {
|
||||||
|
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
||||||
|
}
|
||||||
|
|
||||||
|
final newBackgroundOpacity =
|
||||||
|
(255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||||
|
|
||||||
|
viewController?.updateMultiple(
|
||||||
|
position: initialPhotoViewState.position + delta,
|
||||||
|
scale: updatedScale,
|
||||||
|
);
|
||||||
|
if (shouldPopOnDrag != newShouldPopOnDrag ||
|
||||||
|
backgroundOpacity != newBackgroundOpacity) {
|
||||||
|
setState(() {
|
||||||
|
shouldPopOnDrag = newShouldPopOnDrag;
|
||||||
|
backgroundOpacity = newBackgroundOpacity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _placeholderBuilder(
|
||||||
|
BuildContext ctx,
|
||||||
|
ImageChunkEvent? progress,
|
||||||
|
int index,
|
||||||
|
) {
|
||||||
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
color: backgroundColor,
|
||||||
|
child: Thumbnail(
|
||||||
|
asset: asset,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
size: Size(
|
||||||
|
ctx.width,
|
||||||
|
ctx.height,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||||
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
|
final size = Size(ctx.width, ctx.height);
|
||||||
|
final imageProvider = getFullImageProvider(asset, size: size);
|
||||||
|
|
||||||
|
return PhotoViewGalleryPageOptions(
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
tightMode: true,
|
||||||
|
initialScale: PhotoViewComputedScale.contained * 0.999,
|
||||||
|
minScale: PhotoViewComputedScale.contained * 0.999,
|
||||||
|
disableScaleGestures: showingBottomSheet,
|
||||||
|
onDragStart: _onDragStart,
|
||||||
|
onDragUpdate: _onDragUpdate,
|
||||||
|
onDragEnd: _onDragEnd,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
width: ctx.width,
|
||||||
|
height: ctx.height,
|
||||||
|
color: backgroundColor,
|
||||||
|
child: Thumbnail(
|
||||||
|
asset: asset,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
size: size,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
||||||
|
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||||
|
// TODO: Add a custom scrum builder once the fix lands on stable
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black.withAlpha(backgroundOpacity),
|
||||||
|
body: PhotoViewGallery.builder(
|
||||||
|
gaplessPlayback: true,
|
||||||
|
loadingBuilder: _placeholderBuilder,
|
||||||
|
pageController: pageController,
|
||||||
|
scrollPhysics: platform.isIOS
|
||||||
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||||
|
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||||
|
,
|
||||||
|
itemCount: totalAssets,
|
||||||
|
onPageChanged: _onPageChanged,
|
||||||
|
onPageBuild: _onPageBuild,
|
||||||
|
builder: _assetBuilder,
|
||||||
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
|
enablePanAlways: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
199
mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart
Normal file
199
mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
|
||||||
|
const _kSeparator = ' • ';
|
||||||
|
|
||||||
|
class AssetDetailBottomSheet extends BaseBottomSheet {
|
||||||
|
const AssetDetailBottomSheet({
|
||||||
|
super.controller,
|
||||||
|
super.initialChildSize,
|
||||||
|
super.key,
|
||||||
|
}) : super(
|
||||||
|
actions: const [],
|
||||||
|
slivers: const [_AssetDetailBottomSheet()],
|
||||||
|
minChildSize: 0.1,
|
||||||
|
maxChildSize: 1.0,
|
||||||
|
expand: false,
|
||||||
|
shouldCloseOnMinExtent: false,
|
||||||
|
resizeOnScroll: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||||
|
const _AssetDetailBottomSheet();
|
||||||
|
|
||||||
|
String _getDateTime(BuildContext ctx, BaseAsset asset) {
|
||||||
|
final dateTime = asset.createdAt.toLocal();
|
||||||
|
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||||
|
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||||
|
return '$date$_kSeparator$time';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||||
|
final height = asset.height ?? exifInfo?.height;
|
||||||
|
final width = asset.width ?? exifInfo?.width;
|
||||||
|
final resolution =
|
||||||
|
(width != null && height != null) ? "$width x $height" : null;
|
||||||
|
final fileSize =
|
||||||
|
exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
|
||||||
|
|
||||||
|
return switch ((fileSize, resolution)) {
|
||||||
|
(null, null) => '',
|
||||||
|
(String fileSize, null) => fileSize,
|
||||||
|
(null, String resolution) => resolution,
|
||||||
|
(String fileSize, String resolution) =>
|
||||||
|
'$fileSize$_kSeparator$resolution',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
|
||||||
|
if (exifInfo == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch ((exifInfo.make, exifInfo.model)) {
|
||||||
|
(null, null) => null,
|
||||||
|
(String make, null) => make,
|
||||||
|
(null, String model) => model,
|
||||||
|
(String make, String model) => '$make $model',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
||||||
|
if (exifInfo == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fNumber =
|
||||||
|
exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||||
|
final exposureTime =
|
||||||
|
exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||||
|
final focalLength =
|
||||||
|
exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||||
|
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||||
|
|
||||||
|
return [fNumber, exposureTime, focalLength, iso]
|
||||||
|
.where((spec) => spec != null && spec.isNotEmpty)
|
||||||
|
.join(_kSeparator);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
|
|
||||||
|
return SliverList.list(
|
||||||
|
children: [
|
||||||
|
// Asset Date and Time
|
||||||
|
_SheetTile(
|
||||||
|
title: _getDateTime(context, asset),
|
||||||
|
titleStyle: context.textTheme.bodyLarge
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
// Details header
|
||||||
|
_SheetTile(
|
||||||
|
title: 'exif_bottom_sheet_details'.t(context: context),
|
||||||
|
titleStyle: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: context.textTheme.labelLarge?.color,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// File info
|
||||||
|
_SheetTile(
|
||||||
|
title: asset.name,
|
||||||
|
titleStyle: context.textTheme.labelLarge
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
leading: Icon(
|
||||||
|
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||||
|
size: 30,
|
||||||
|
color: context.textTheme.labelLarge?.color,
|
||||||
|
),
|
||||||
|
subtitle: _getFileInfo(asset, exifInfo),
|
||||||
|
subtitleStyle: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: context.textTheme.labelLarge?.color?.withAlpha(200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Camera info
|
||||||
|
if (cameraTitle != null)
|
||||||
|
_SheetTile(
|
||||||
|
title: cameraTitle,
|
||||||
|
titleStyle: context.textTheme.labelLarge
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
leading: Icon(
|
||||||
|
Icons.camera_outlined,
|
||||||
|
size: 30,
|
||||||
|
color: context.textTheme.labelLarge?.color,
|
||||||
|
),
|
||||||
|
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||||
|
subtitleStyle: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: context.textTheme.labelLarge?.color?.withAlpha(200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SheetTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget? leading;
|
||||||
|
final String? subtitle;
|
||||||
|
final TextStyle? titleStyle;
|
||||||
|
final TextStyle? subtitleStyle;
|
||||||
|
|
||||||
|
const _SheetTile({
|
||||||
|
required this.title,
|
||||||
|
this.titleStyle,
|
||||||
|
this.leading,
|
||||||
|
this.subtitle,
|
||||||
|
this.subtitleStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Widget titleWidget;
|
||||||
|
if (leading == null) {
|
||||||
|
titleWidget = LimitedBox(
|
||||||
|
maxWidth: double.infinity,
|
||||||
|
child: Text(title, style: titleStyle),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
titleWidget = Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.only(left: 15),
|
||||||
|
child: Text(title, style: titleStyle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Widget? subtitleWidget;
|
||||||
|
if (leading == null && subtitle != null) {
|
||||||
|
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
||||||
|
} else if (leading != null && subtitle != null) {
|
||||||
|
subtitleWidget = Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 15),
|
||||||
|
child: Text(subtitle!, style: subtitleStyle),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
subtitleWidget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: titleWidget,
|
||||||
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
|
leading: leading,
|
||||||
|
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||||
|
subtitle: subtitleWidget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/timeline.state.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||||
|
|
||||||
class BaseBottomSheet extends ConsumerStatefulWidget {
|
class BaseBottomSheet extends ConsumerStatefulWidget {
|
||||||
@ -74,10 +75,7 @@ class _BaseDraggableScrollableSheetState
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
elevation: 6.0,
|
elevation: 6.0,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||||
topLeft: Radius.circular(6),
|
|
||||||
topRight: Radius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
@ -86,17 +84,22 @@ class _BaseDraggableScrollableSheetState
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 10),
|
||||||
const _DragHandle(),
|
const _DragHandle(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 14),
|
||||||
|
if (widget.actions.isNotEmpty)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 120,
|
height: 80,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
children: widget.actions,
|
children: widget.actions,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.actions.isNotEmpty) const SizedBox(height: 14),
|
||||||
|
if (widget.actions.isNotEmpty)
|
||||||
|
const Divider(indent: 20, endIndent: 20),
|
||||||
|
if (widget.actions.isNotEmpty) const SizedBox(height: 14),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -118,7 +121,7 @@ class _DragHandle extends StatelessWidget {
|
|||||||
height: 6,
|
height: 6,
|
||||||
width: 32,
|
width: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.themeData.dividerColor,
|
color: context.themeData.dividerColor.lighten(amount: 0.6),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
|
import 'package:octo_image/octo_image.dart';
|
||||||
|
|
||||||
|
class FullImage extends StatelessWidget {
|
||||||
|
const FullImage(
|
||||||
|
this.asset, {
|
||||||
|
required this.size,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.placeholder = const ThumbnailPlaceholder(),
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BaseAsset asset;
|
||||||
|
final Size size;
|
||||||
|
final Widget? placeholder;
|
||||||
|
final BoxFit fit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = getFullImageProvider(asset, size: size);
|
||||||
|
return OctoImage(
|
||||||
|
fadeInDuration: const Duration(milliseconds: 0),
|
||||||
|
fadeOutDuration: const Duration(milliseconds: 100),
|
||||||
|
placeholderBuilder: placeholder != null ? (_) => placeholder! : null,
|
||||||
|
image: provider,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
fit: fit,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
provider.evict();
|
||||||
|
return const Icon(Icons.image_not_supported_outlined, size: 32);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
63
mobile/lib/presentation/widgets/images/image_provider.dart
Normal file
63
mobile/lib/presentation/widgets/images/image_provider.dart
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/widgets.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/services/setting.service.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||||
|
|
||||||
|
ImageProvider getFullImageProvider(
|
||||||
|
BaseAsset asset, {
|
||||||
|
Size size = const Size(1080, 1920),
|
||||||
|
}) {
|
||||||
|
// Create new provider and cache it
|
||||||
|
final ImageProvider provider;
|
||||||
|
if (_shouldUseLocalAsset(asset)) {
|
||||||
|
provider = LocalFullImageProvider(asset: asset as LocalAsset, size: size);
|
||||||
|
} else {
|
||||||
|
final String assetId;
|
||||||
|
if (asset is LocalAsset && asset.hasRemote) {
|
||||||
|
assetId = asset.remoteId!;
|
||||||
|
} else if (asset is RemoteAsset) {
|
||||||
|
assetId = asset.id;
|
||||||
|
} else {
|
||||||
|
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||||
|
}
|
||||||
|
provider = RemoteFullImageProvider(assetId: assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageProvider getThumbnailImageProvider({
|
||||||
|
BaseAsset? asset,
|
||||||
|
String? remoteId,
|
||||||
|
Size size = const Size.square(256),
|
||||||
|
}) {
|
||||||
|
assert(
|
||||||
|
asset != null || remoteId != null,
|
||||||
|
'Either asset or remoteId must be provided',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteId != null) {
|
||||||
|
return RemoteThumbProvider(assetId: remoteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_shouldUseLocalAsset(asset!)) {
|
||||||
|
return LocalThumbProvider(asset: asset as LocalAsset, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
if (asset is LocalAsset && asset.hasRemote) {
|
||||||
|
assetId = asset.remoteId!;
|
||||||
|
} else if (asset is RemoteAsset) {
|
||||||
|
assetId = asset.id;
|
||||||
|
} else {
|
||||||
|
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemoteThumbProvider(assetId: assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||||
|
asset is LocalAsset &&
|
||||||
|
(!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
|
241
mobile/lib/presentation/widgets/images/local_image_provider.dart
Normal file
241
mobile/lib/presentation/widgets/images/local_image_provider.dart
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.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/services/setting.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
|
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||||
|
final AssetMediaRepository _assetMediaRepository =
|
||||||
|
const AssetMediaRepository();
|
||||||
|
final CacheManager? cacheManager;
|
||||||
|
|
||||||
|
final LocalAsset asset;
|
||||||
|
final Size size;
|
||||||
|
|
||||||
|
const LocalThumbProvider({
|
||||||
|
required this.asset,
|
||||||
|
this.size = const Size.square(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}-${key.size.width}x${key.size.height}';
|
||||||
|
|
||||||
|
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||||
|
if (fileFromCache != null) {
|
||||||
|
try {
|
||||||
|
final buffer =
|
||||||
|
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||||
|
return decode(buffer);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final thumbnailBytes =
|
||||||
|
await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||||
|
final AssetMediaRepository _assetMediaRepository =
|
||||||
|
const AssetMediaRepository();
|
||||||
|
final StorageRepository _storageRepository = const StorageRepository();
|
||||||
|
|
||||||
|
final LocalAsset asset;
|
||||||
|
final Size size;
|
||||||
|
|
||||||
|
const LocalFullImageProvider({
|
||||||
|
required this.asset,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
LocalFullImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
return MultiImageStreamCompleter(
|
||||||
|
codec: _codec(key, decode),
|
||||||
|
scale: 1.0,
|
||||||
|
informationCollector: () sync* {
|
||||||
|
yield ErrorDescription(asset.name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams in each stage of the image as we ask for it
|
||||||
|
Stream<Codec> _codec(
|
||||||
|
LocalFullImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) async* {
|
||||||
|
try {
|
||||||
|
switch (key.asset.type) {
|
||||||
|
case AssetType.image:
|
||||||
|
yield* _decodeProgressive(key, decode);
|
||||||
|
break;
|
||||||
|
case AssetType.video:
|
||||||
|
final codec = await _getThumbnailCodec(key, decode);
|
||||||
|
if (codec == null) {
|
||||||
|
throw StateError("Failed to load preview for ${key.asset.name}");
|
||||||
|
}
|
||||||
|
yield codec;
|
||||||
|
break;
|
||||||
|
case AssetType.other:
|
||||||
|
case AssetType.audio:
|
||||||
|
throw StateError('Unsupported asset type ${key.asset.type}');
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
Logger('ImmichLocalImageProvider')
|
||||||
|
.severe('Error loading local image ${key.asset.name}', error, stack);
|
||||||
|
throw const ImageLoadingException(
|
||||||
|
'Could not load image from local storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Codec?> _getThumbnailCodec(
|
||||||
|
LocalFullImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) async {
|
||||||
|
final thumbBytes =
|
||||||
|
await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size);
|
||||||
|
if (thumbBytes == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
|
||||||
|
return decode(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Codec> _decodeProgressive(
|
||||||
|
LocalFullImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) async* {
|
||||||
|
final file = await _storageRepository.getFileForAsset(key.asset);
|
||||||
|
if (file == null) {
|
||||||
|
throw StateError("Opening file for asset ${key.asset.name} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileSize = await file.length();
|
||||||
|
final devicePixelRatio =
|
||||||
|
PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
|
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
|
||||||
|
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
|
||||||
|
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
|
||||||
|
|
||||||
|
if (isProgressive) {
|
||||||
|
try {
|
||||||
|
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
|
||||||
|
final size = Size(
|
||||||
|
(key.size.width * progressiveMultiplier).clamp(256, 1024),
|
||||||
|
(key.size.height * progressiveMultiplier).clamp(256, 1024),
|
||||||
|
);
|
||||||
|
final mediumThumb =
|
||||||
|
await _assetMediaRepository.getThumbnail(key.asset.id, size: size);
|
||||||
|
if (mediumThumb != null) {
|
||||||
|
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
|
||||||
|
yield await decode(mediumBuffer);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load original only when the file is smaller or if the user wants to load original images
|
||||||
|
// Or load a slightly larger image for progressive loading
|
||||||
|
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
|
||||||
|
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
|
||||||
|
final size = Size(
|
||||||
|
(key.size.width * progressiveMultiplier).clamp(512, 2048),
|
||||||
|
(key.size.height * progressiveMultiplier).clamp(512, 2048),
|
||||||
|
);
|
||||||
|
final highThumb =
|
||||||
|
await _assetMediaRepository.getThumbnail(key.asset.id, size: size);
|
||||||
|
if (highThumb != null) {
|
||||||
|
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
|
||||||
|
yield await decode(highBuffer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final buffer = await ImmutableBuffer.fromFilePath(file.path);
|
||||||
|
yield await decode(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is LocalFullImageProvider) {
|
||||||
|
return asset.id == other.asset.id &&
|
||||||
|
asset.updatedAt == other.asset.updatedAt &&
|
||||||
|
size == other.size;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
asset.id.hashCode ^ asset.updatedAt.hashCode ^ size.hashCode;
|
||||||
|
}
|
@ -1,95 +0,0 @@
|
|||||||
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/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 AssetMediaRepository _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,142 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
|
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||||
|
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
|
|
||||||
|
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||||
|
final String assetId;
|
||||||
|
final CacheManager? cacheManager;
|
||||||
|
|
||||||
|
const RemoteThumbProvider({
|
||||||
|
required this.assetId,
|
||||||
|
this.cacheManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
RemoteThumbProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
|
||||||
|
final String assetId;
|
||||||
|
final CacheManager? cacheManager;
|
||||||
|
|
||||||
|
const RemoteFullImageProvider({
|
||||||
|
required this.assetId,
|
||||||
|
this.cacheManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
RemoteFullImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||||
|
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||||
|
return MultiImageStreamCompleter(
|
||||||
|
codec: _codec(key, cache, decode, chunkEvents),
|
||||||
|
scale: 1.0,
|
||||||
|
chunkEvents: chunkEvents.stream,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Codec> _codec(
|
||||||
|
RemoteFullImageProvider key,
|
||||||
|
CacheManager cache,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
StreamController<ImageChunkEvent> chunkController,
|
||||||
|
) async* {
|
||||||
|
yield await ImageLoader.loadImageFromCache(
|
||||||
|
getPreviewUrlForRemoteId(key.assetId),
|
||||||
|
cache: cache,
|
||||||
|
decode: decode,
|
||||||
|
chunkEvents: chunkController,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (AppSetting.get(Setting.loadOriginal)) {
|
||||||
|
yield await ImageLoader.loadImageFromCache(
|
||||||
|
getOriginalUrlForRemoteId(key.assetId),
|
||||||
|
cache: cache,
|
||||||
|
decode: decode,
|
||||||
|
chunkEvents: chunkController,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await chunkController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is RemoteFullImageProvider) {
|
||||||
|
return assetId == other.assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => assetId.hashCode;
|
||||||
|
}
|
@ -1,80 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
const 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;
|
|
||||||
}
|
|
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_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/presentation/widgets/images/thumb_hash_provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
||||||
@ -25,49 +24,12 @@ class Thumbnail extends StatelessWidget {
|
|||||||
final Size size;
|
final Size size;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
|
||||||
static ImageProvider imageProvider({
|
|
||||||
BaseAsset? asset,
|
|
||||||
String? remoteId,
|
|
||||||
Size size = const Size.square(256),
|
|
||||||
}) {
|
|
||||||
assert(
|
|
||||||
asset != null || remoteId != null,
|
|
||||||
'Either asset or remoteId must be provided',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (remoteId != null) {
|
|
||||||
return RemoteThumbProvider(
|
|
||||||
assetId: remoteId,
|
|
||||||
height: size.height,
|
|
||||||
width: size.width,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset is LocalAsset) {
|
|
||||||
return LocalThumbProvider(
|
|
||||||
asset: asset,
|
|
||||||
height: size.height,
|
|
||||||
width: size.width,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset is RemoteAsset) {
|
|
||||||
return RemoteThumbProvider(
|
|
||||||
assetId: asset.id,
|
|
||||||
height: size.height,
|
|
||||||
width: size.width,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final thumbHash =
|
final thumbHash =
|
||||||
asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
||||||
final provider =
|
final provider =
|
||||||
imageProvider(asset: asset, remoteId: remoteId, size: size);
|
getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size);
|
||||||
|
|
||||||
return OctoImage.fromSet(
|
return OctoImage.fromSet(
|
||||||
image: provider,
|
image: provider,
|
||||||
|
@ -59,12 +59,15 @@ class ThumbnailTile extends ConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
child: Hero(
|
||||||
|
tag: asset.heroTag,
|
||||||
child: Thumbnail(
|
child: Thumbnail(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
size: size,
|
size: size,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (asset.isVideo)
|
if (asset.isVideo)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
@ -12,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
|||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class FixedSegment extends Segment {
|
class FixedSegment extends Segment {
|
||||||
final double tileHeight;
|
final double tileHeight;
|
||||||
@ -35,50 +37,24 @@ class FixedSegment extends Segment {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
double indexToLayoutOffset(int index) {
|
double indexToLayoutOffset(int index) {
|
||||||
index -= gridIndex;
|
final relativeIndex = index - gridIndex;
|
||||||
if (index < 0) {
|
return relativeIndex < 0
|
||||||
return startOffset;
|
? startOffset
|
||||||
}
|
: gridOffset + (mainAxisExtend * relativeIndex);
|
||||||
return gridOffset + (mainAxisExtend * index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||||
scrollOffset -= gridOffset;
|
final adjustedOffset = scrollOffset - gridOffset;
|
||||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
|
||||||
return firstIndex;
|
return gridIndex + (adjustedOffset / mainAxisExtend).floor();
|
||||||
}
|
|
||||||
final rowsAbove = (scrollOffset / mainAxisExtend).floor();
|
|
||||||
return gridIndex + rowsAbove;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||||
scrollOffset -= gridOffset;
|
final adjustedOffset = scrollOffset - gridOffset;
|
||||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
|
||||||
return firstIndex;
|
return gridIndex + (adjustedOffset / mainAxisExtend).ceil() - 1;
|
||||||
}
|
|
||||||
final firstRowBelow = (scrollOffset / mainAxisExtend).ceil();
|
|
||||||
return gridIndex + firstRowBelow - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleOnTap(WidgetRef ref, BaseAsset asset) {
|
|
||||||
final multiSelectState = ref.read(multiSelectProvider);
|
|
||||||
if (!multiSelectState.isEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
|
||||||
final multiSelectState = ref.read(multiSelectProvider);
|
|
||||||
if (multiSelectState.isEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
|
||||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -97,132 +73,128 @@ class FixedSegment extends Segment {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
|
return _FixedSegmentRow(
|
||||||
|
assetIndex: firstAssetIndex + assetIndex,
|
||||||
|
assetCount: numberOfAssets,
|
||||||
|
tileHeight: tileHeight,
|
||||||
|
spacing: spacing,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildRow(int assetIndex, int count) => RepaintBoundary(
|
class _FixedSegmentRow extends ConsumerWidget {
|
||||||
child: Consumer(
|
final int assetIndex;
|
||||||
builder: (ctx, ref, _) {
|
final int assetCount;
|
||||||
|
final double tileHeight;
|
||||||
|
final double spacing;
|
||||||
|
|
||||||
|
const _FixedSegmentRow({
|
||||||
|
required this.assetIndex,
|
||||||
|
required this.assetCount,
|
||||||
|
required this.tileHeight,
|
||||||
|
required this.spacing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isScrubbing =
|
final isScrubbing =
|
||||||
ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
|
|
||||||
// Create stable callback references to prevent unnecessary rebuilds
|
|
||||||
onTap(BaseAsset asset) => _handleOnTap(ref, asset);
|
|
||||||
onLongPress(BaseAsset asset) => _handleOnLongPress(ref, asset);
|
|
||||||
|
|
||||||
// Timeline is being scrubbed, show placeholders
|
|
||||||
if (isScrubbing) {
|
if (isScrubbing) {
|
||||||
|
return _buildPlaceholder(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||||
|
return _buildAssetRow(
|
||||||
|
context,
|
||||||
|
timelineService.getAssets(assetIndex, assetCount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<List<BaseAsset>>(
|
||||||
|
future: timelineService.loadAssets(assetIndex, assetCount),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return _buildPlaceholder(context);
|
||||||
|
}
|
||||||
|
return _buildAssetRow(context, snapshot.requireData);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceholder(BuildContext context) {
|
||||||
return SegmentBuilder.buildPlaceholder(
|
return SegmentBuilder.buildPlaceholder(
|
||||||
ctx,
|
context,
|
||||||
count,
|
assetCount,
|
||||||
size: Size.square(tileHeight),
|
size: Size.square(tileHeight),
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bucket is already loaded, show the assets
|
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) {
|
||||||
if (timelineService.hasRange(assetIndex, count)) {
|
return FixedTimelineRow(
|
||||||
final assets = timelineService.getAssets(assetIndex, count);
|
|
||||||
return _buildAssetRow(
|
|
||||||
ctx,
|
|
||||||
assets,
|
|
||||||
baseAssetIndex: assetIndex,
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
baseAssetIndex: assetIndex,
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _buildAssetRow(
|
|
||||||
BuildContext context,
|
|
||||||
List<BaseAsset> assets, {
|
|
||||||
required void Function(BaseAsset) onTap,
|
|
||||||
required void Function(BaseAsset) onLongPress,
|
|
||||||
required int baseAssetIndex,
|
|
||||||
}) =>
|
|
||||||
FixedTimelineRow(
|
|
||||||
dimension: tileHeight,
|
dimension: tileHeight,
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
textDirection: Directionality.of(context),
|
textDirection: Directionality.of(context),
|
||||||
children: List.generate(
|
children: [
|
||||||
assets.length,
|
for (int i = 0; i < assets.length; i++)
|
||||||
(i) => _AssetTileWidget(
|
_AssetTileWidget(
|
||||||
key: ValueKey(_generateUniqueKey(assets[i], baseAssetIndex + i)),
|
key: ValueKey(assets[i].heroTag),
|
||||||
asset: assets[i],
|
asset: assets[i],
|
||||||
onTap: onTap,
|
assetIndex: assetIndex + i,
|
||||||
onLongPress: onLongPress,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Generates a unique key for an asset that handles different asset types
|
|
||||||
/// and prevents duplicate keys even when assets have the same name/timestamp
|
|
||||||
String _generateUniqueKey(BaseAsset asset, int assetIndex) {
|
|
||||||
// Try to get the most unique identifier based on asset type
|
|
||||||
if (asset is RemoteAsset) {
|
|
||||||
// For remote/merged assets, use the remote ID which is globally unique
|
|
||||||
return 'asset_${asset.id}';
|
|
||||||
} else if (asset is LocalAsset) {
|
|
||||||
// For local assets, use the local ID which should be unique per device
|
|
||||||
return 'local_${asset.id}';
|
|
||||||
} else {
|
|
||||||
// Fallback for any other BaseAsset implementation
|
|
||||||
// Use checksum if available for additional uniqueness
|
|
||||||
final checksum = asset.checksum;
|
|
||||||
if (checksum != null && checksum.isNotEmpty) {
|
|
||||||
return 'checksum_${checksum.hashCode}';
|
|
||||||
} else {
|
|
||||||
// Last resort: use global asset index + object hash for uniqueness
|
|
||||||
return 'fallback_${assetIndex}_${asset.hashCode}_${asset.createdAt.microsecondsSinceEpoch}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetTileWidget extends StatelessWidget {
|
class _AssetTileWidget extends ConsumerWidget {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
final void Function(BaseAsset) onTap;
|
final int assetIndex;
|
||||||
final void Function(BaseAsset) onLongPress;
|
|
||||||
|
|
||||||
const _AssetTileWidget({
|
const _AssetTileWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.onTap,
|
required this.assetIndex,
|
||||||
required this.onLongPress,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void _handleOnTap(
|
||||||
|
BuildContext ctx,
|
||||||
|
WidgetRef ref,
|
||||||
|
int assetIndex,
|
||||||
|
BaseAsset asset,
|
||||||
|
) {
|
||||||
|
final multiSelectState = ref.read(multiSelectProvider);
|
||||||
|
if (!multiSelectState.isEnabled) {
|
||||||
|
ctx.pushRoute(
|
||||||
|
AssetViewerRoute(
|
||||||
|
initialIndex: assetIndex,
|
||||||
|
timelineService: ref.read(timelineServiceProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
||||||
|
final multiSelectState = ref.read(multiSelectProvider);
|
||||||
|
if (multiSelectState.isEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||||
|
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => onTap(asset),
|
onTap: () => _handleOnTap(context, ref, assetIndex, asset),
|
||||||
onLongPress: () => onLongPress(asset),
|
onLongPress: () => _handleOnLongPress(ref, asset),
|
||||||
child: ThumbnailTile(asset),
|
child: ThumbnailTile(asset),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
@ -7,6 +8,12 @@ final localAssetRepository = Provider<DriftLocalAssetRepository>(
|
|||||||
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
final remoteAssetRepository = Provider<DriftRemoteAssetRepository>(
|
final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||||
(ref) => DriftRemoteAssetRepository(ref.watch(driftProvider)),
|
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final assetServiceProvider = Provider(
|
||||||
|
(ref) => AssetService(
|
||||||
|
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
|
final currentAssetNotifier =
|
||||||
|
NotifierProvider<CurrentAssetNotifier, BaseAsset>(CurrentAssetNotifier.new);
|
||||||
|
|
||||||
|
class CurrentAssetNotifier extends Notifier<BaseAsset> {
|
||||||
|
@override
|
||||||
|
BaseAsset build() {
|
||||||
|
throw UnimplementedError(
|
||||||
|
'An asset must be set before using the currentAssetProvider.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAsset(BaseAsset asset) {
|
||||||
|
state = asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentAssetExifProvider = FutureProvider(
|
||||||
|
(ref) {
|
||||||
|
final currentAsset = ref.watch(currentAssetNotifier);
|
||||||
|
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
||||||
|
},
|
||||||
|
);
|
@ -8,7 +8,3 @@ part 'exif.provider.g.dart';
|
|||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
IsarExifRepository exifRepository(Ref ref) =>
|
IsarExifRepository exifRepository(Ref ref) =>
|
||||||
IsarExifRepository(ref.watch(isarProvider));
|
IsarExifRepository(ref.watch(isarProvider));
|
||||||
|
|
||||||
final remoteExifRepository = Provider<DriftRemoteExifRepository>(
|
|
||||||
(ref) => DriftRemoteExifRepository(ref.watch(driftProvider)),
|
|
||||||
);
|
|
||||||
|
@ -2,5 +2,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
|
||||||
final storageRepositoryProvider = Provider<StorageRepository>(
|
final storageRepositoryProvider = Provider<StorageRepository>(
|
||||||
(ref) => StorageRepository(),
|
(ref) => const StorageRepository(),
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
|
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
|
||||||
@ -70,6 +71,7 @@ import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||||
@ -371,6 +373,18 @@ class AppRouter extends RootStackRouter {
|
|||||||
page: RemoteTimelineRoute.page,
|
page: RemoteTimelineRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
),
|
),
|
||||||
|
AutoRoute(
|
||||||
|
page: AssetViewerRoute.page,
|
||||||
|
guards: [_authGuard, _duplicateGuard],
|
||||||
|
type: RouteType.custom(
|
||||||
|
customRouteBuilder: <T>(context, child, page) => PageRouteBuilder<T>(
|
||||||
|
fullscreenDialog: page.fullscreenDialog,
|
||||||
|
settings: page,
|
||||||
|
pageBuilder: (_, __, ___) => child,
|
||||||
|
opaque: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
@ -403,6 +403,58 @@ class ArchiveRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [AssetViewerPage]
|
||||||
|
class AssetViewerRoute extends PageRouteInfo<AssetViewerRouteArgs> {
|
||||||
|
AssetViewerRoute({
|
||||||
|
Key? key,
|
||||||
|
required int initialIndex,
|
||||||
|
required TimelineService timelineService,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
AssetViewerRoute.name,
|
||||||
|
args: AssetViewerRouteArgs(
|
||||||
|
key: key,
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
timelineService: timelineService,
|
||||||
|
),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'AssetViewerRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<AssetViewerRouteArgs>();
|
||||||
|
return AssetViewerPage(
|
||||||
|
key: args.key,
|
||||||
|
initialIndex: args.initialIndex,
|
||||||
|
timelineService: args.timelineService,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetViewerRouteArgs {
|
||||||
|
const AssetViewerRouteArgs({
|
||||||
|
this.key,
|
||||||
|
required this.initialIndex,
|
||||||
|
required this.timelineService,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final int initialIndex;
|
||||||
|
|
||||||
|
final TimelineService timelineService;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [BackupAlbumSelectionPage]
|
/// [BackupAlbumSelectionPage]
|
||||||
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
|
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
|
||||||
|
@ -2,10 +2,8 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
|
||||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||||
@ -15,20 +13,17 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
final actionServiceProvider = Provider<ActionService>(
|
final actionServiceProvider = Provider<ActionService>(
|
||||||
(ref) => ActionService(
|
(ref) => ActionService(
|
||||||
ref.watch(assetApiRepositoryProvider),
|
ref.watch(assetApiRepositoryProvider),
|
||||||
ref.watch(remoteAssetRepository),
|
ref.watch(remoteAssetRepositoryProvider),
|
||||||
ref.watch(remoteExifRepository),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class ActionService {
|
class ActionService {
|
||||||
final AssetApiRepository _assetApiRepository;
|
final AssetApiRepository _assetApiRepository;
|
||||||
final DriftRemoteAssetRepository _remoteAssetRepository;
|
final RemoteAssetRepository _remoteAssetRepository;
|
||||||
final DriftRemoteExifRepository _remoteExifRepository;
|
|
||||||
|
|
||||||
const ActionService(
|
const ActionService(
|
||||||
this._assetApiRepository,
|
this._assetApiRepository,
|
||||||
this._remoteAssetRepository,
|
this._remoteAssetRepository,
|
||||||
this._remoteExifRepository,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
|
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
|
||||||
@ -109,7 +104,7 @@ class ActionService {
|
|||||||
) async {
|
) async {
|
||||||
LatLng? initialLatLng;
|
LatLng? initialLatLng;
|
||||||
if (remoteIds.length == 1) {
|
if (remoteIds.length == 1) {
|
||||||
final exif = await _remoteExifRepository.get(remoteIds[0]);
|
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
|
||||||
|
|
||||||
if (exif?.latitude != null && exif?.longitude != null) {
|
if (exif?.latitude != null && exif?.longitude != null) {
|
||||||
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
|
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||||
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
||||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||||
@ -37,8 +39,10 @@ final class CustomImageCache implements ImageCache {
|
|||||||
/// Gets the cache for the given key
|
/// Gets the cache for the given key
|
||||||
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
|
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
|
||||||
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
|
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
|
||||||
ImageCache _cacheForKey(Object key) =>
|
ImageCache _cacheForKey(Object key) => (key is ImmichLocalImageProvider ||
|
||||||
(key is ImmichLocalImageProvider || key is ImmichRemoteImageProvider)
|
key is ImmichRemoteImageProvider ||
|
||||||
|
key is LocalFullImageProvider ||
|
||||||
|
key is RemoteFullImageProvider)
|
||||||
? _large
|
? _large
|
||||||
: _small;
|
: _small;
|
||||||
|
|
||||||
|
@ -73,6 +73,9 @@ String getThumbnailUrlForRemoteId(
|
|||||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
|
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getPreviewUrlForRemoteId(final String id) =>
|
||||||
|
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
|
||||||
|
|
||||||
String getPlaybackUrlForRemoteId(final String id) {
|
String getPlaybackUrlForRemoteId(final String id) {
|
||||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
|
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart';
|
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart';
|
||||||
@ -16,6 +15,11 @@ export 'src/photo_view_computed_scale.dart';
|
|||||||
export 'src/photo_view_scale_state.dart';
|
export 'src/photo_view_scale_state.dart';
|
||||||
export 'src/utils/photo_view_hero_attributes.dart';
|
export 'src/utils/photo_view_hero_attributes.dart';
|
||||||
|
|
||||||
|
typedef PhotoViewControllerCallback = PhotoViewControllerBase Function();
|
||||||
|
typedef PhotoViewControllerCallbackBuilder = void Function(
|
||||||
|
PhotoViewControllerCallback photoViewMethod,
|
||||||
|
);
|
||||||
|
|
||||||
/// A [StatefulWidget] that contains all the photo view rendering elements.
|
/// A [StatefulWidget] that contains all the photo view rendering elements.
|
||||||
///
|
///
|
||||||
/// Sample code to use within an image:
|
/// Sample code to use within an image:
|
||||||
@ -239,8 +243,11 @@ class PhotoView extends StatefulWidget {
|
|||||||
this.wantKeepAlive = false,
|
this.wantKeepAlive = false,
|
||||||
this.gaplessPlayback = false,
|
this.gaplessPlayback = false,
|
||||||
this.heroAttributes,
|
this.heroAttributes,
|
||||||
|
this.onPageBuild,
|
||||||
|
this.controllerCallbackBuilder,
|
||||||
this.scaleStateChangedCallback,
|
this.scaleStateChangedCallback,
|
||||||
this.enableRotation = false,
|
this.enableRotation = false,
|
||||||
|
this.semanticLabel,
|
||||||
this.controller,
|
this.controller,
|
||||||
this.scaleStateController,
|
this.scaleStateController,
|
||||||
this.maxScale,
|
this.maxScale,
|
||||||
@ -260,6 +267,7 @@ class PhotoView extends StatefulWidget {
|
|||||||
this.tightMode,
|
this.tightMode,
|
||||||
this.filterQuality,
|
this.filterQuality,
|
||||||
this.disableGestures,
|
this.disableGestures,
|
||||||
|
this.disableScaleGestures,
|
||||||
this.errorBuilder,
|
this.errorBuilder,
|
||||||
this.enablePanAlways,
|
this.enablePanAlways,
|
||||||
}) : child = null,
|
}) : child = null,
|
||||||
@ -278,6 +286,8 @@ class PhotoView extends StatefulWidget {
|
|||||||
this.backgroundDecoration,
|
this.backgroundDecoration,
|
||||||
this.wantKeepAlive = false,
|
this.wantKeepAlive = false,
|
||||||
this.heroAttributes,
|
this.heroAttributes,
|
||||||
|
this.onPageBuild,
|
||||||
|
this.controllerCallbackBuilder,
|
||||||
this.scaleStateChangedCallback,
|
this.scaleStateChangedCallback,
|
||||||
this.enableRotation = false,
|
this.enableRotation = false,
|
||||||
this.controller,
|
this.controller,
|
||||||
@ -298,9 +308,11 @@ class PhotoView extends StatefulWidget {
|
|||||||
this.gestureDetectorBehavior,
|
this.gestureDetectorBehavior,
|
||||||
this.tightMode,
|
this.tightMode,
|
||||||
this.filterQuality,
|
this.filterQuality,
|
||||||
|
this.disableScaleGestures,
|
||||||
this.disableGestures,
|
this.disableGestures,
|
||||||
this.enablePanAlways,
|
this.enablePanAlways,
|
||||||
}) : errorBuilder = null,
|
}) : semanticLabel = null,
|
||||||
|
errorBuilder = null,
|
||||||
imageProvider = null,
|
imageProvider = null,
|
||||||
gaplessPlayback = false,
|
gaplessPlayback = false,
|
||||||
loadingBuilder = null,
|
loadingBuilder = null,
|
||||||
@ -325,6 +337,11 @@ class PhotoView extends StatefulWidget {
|
|||||||
/// `true` -> keeps the state
|
/// `true` -> keeps the state
|
||||||
final bool wantKeepAlive;
|
final bool wantKeepAlive;
|
||||||
|
|
||||||
|
/// A Semantic description of the image.
|
||||||
|
///
|
||||||
|
/// Used to provide a description of the image to TalkBack on Android, and VoiceOver on iOS.
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
/// This is used to continue showing the old image (`true`), or briefly show
|
/// This is used to continue showing the old image (`true`), or briefly show
|
||||||
/// nothing (`false`), when the `imageProvider` changes. By default it's set
|
/// nothing (`false`), when the `imageProvider` changes. By default it's set
|
||||||
/// to `false`.
|
/// to `false`.
|
||||||
@ -338,6 +355,12 @@ class PhotoView extends StatefulWidget {
|
|||||||
/// by default it is `MediaQuery.of(context).size`.
|
/// by default it is `MediaQuery.of(context).size`.
|
||||||
final Size? customSize;
|
final Size? customSize;
|
||||||
|
|
||||||
|
// Called when a new PhotoView widget is built
|
||||||
|
final ValueChanged<PhotoViewControllerBase>? onPageBuild;
|
||||||
|
|
||||||
|
// Called from the parent during page change to get the new controller
|
||||||
|
final PhotoViewControllerCallbackBuilder? controllerCallbackBuilder;
|
||||||
|
|
||||||
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
|
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
|
||||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||||
|
|
||||||
@ -419,6 +442,9 @@ class PhotoView extends StatefulWidget {
|
|||||||
// Useful when custom gesture detector is used in child widget.
|
// Useful when custom gesture detector is used in child widget.
|
||||||
final bool? disableGestures;
|
final bool? disableGestures;
|
||||||
|
|
||||||
|
/// Mirror to [PhotoView.disableGestures]
|
||||||
|
final bool? disableScaleGestures;
|
||||||
|
|
||||||
/// Enable pan the widget even if it's smaller than the hole parent widget.
|
/// Enable pan the widget even if it's smaller than the hole parent widget.
|
||||||
/// Useful when you want to drag a widget without restrictions.
|
/// Useful when you want to drag a widget without restrictions.
|
||||||
final bool? enablePanAlways;
|
final bool? enablePanAlways;
|
||||||
@ -452,6 +478,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
if (widget.controller == null) {
|
if (widget.controller == null) {
|
||||||
_controlledController = true;
|
_controlledController = true;
|
||||||
_controller = PhotoViewController();
|
_controller = PhotoViewController();
|
||||||
|
widget.onPageBuild?.call(_controller);
|
||||||
} else {
|
} else {
|
||||||
_controlledController = false;
|
_controlledController = false;
|
||||||
_controller = widget.controller!;
|
_controller = widget.controller!;
|
||||||
@ -466,6 +493,8 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
}
|
}
|
||||||
|
|
||||||
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
|
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
|
||||||
|
// Pass a ref to the method back to the gallery so it can fetch the controller on page changes
|
||||||
|
widget.controllerCallbackBuilder?.call(_controllerGetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -474,6 +503,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
if (!_controlledController) {
|
if (!_controlledController) {
|
||||||
_controlledController = true;
|
_controlledController = true;
|
||||||
_controller = PhotoViewController();
|
_controller = PhotoViewController();
|
||||||
|
widget.onPageBuild?.call(_controller);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_controlledController = false;
|
_controlledController = false;
|
||||||
@ -509,6 +539,8 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhotoViewControllerBase _controllerGetter() => _controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@ -547,6 +579,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
tightMode: widget.tightMode,
|
tightMode: widget.tightMode,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
disableGestures: widget.disableGestures,
|
disableGestures: widget.disableGestures,
|
||||||
|
disableScaleGestures: widget.disableScaleGestures,
|
||||||
enablePanAlways: widget.enablePanAlways,
|
enablePanAlways: widget.enablePanAlways,
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
)
|
)
|
||||||
@ -554,6 +587,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
imageProvider: widget.imageProvider!,
|
imageProvider: widget.imageProvider!,
|
||||||
loadingBuilder: widget.loadingBuilder,
|
loadingBuilder: widget.loadingBuilder,
|
||||||
backgroundDecoration: backgroundDecoration,
|
backgroundDecoration: backgroundDecoration,
|
||||||
|
semanticLabel: widget.semanticLabel,
|
||||||
gaplessPlayback: widget.gaplessPlayback,
|
gaplessPlayback: widget.gaplessPlayback,
|
||||||
heroAttributes: widget.heroAttributes,
|
heroAttributes: widget.heroAttributes,
|
||||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||||
@ -577,6 +611,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||||||
tightMode: widget.tightMode,
|
tightMode: widget.tightMode,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
disableGestures: widget.disableGestures,
|
disableGestures: widget.disableGestures,
|
||||||
|
disableScaleGestures: widget.disableScaleGestures,
|
||||||
errorBuilder: widget.errorBuilder,
|
errorBuilder: widget.errorBuilder,
|
||||||
enablePanAlways: widget.enablePanAlways,
|
enablePanAlways: widget.enablePanAlways,
|
||||||
index: widget.index,
|
index: widget.index,
|
||||||
@ -626,6 +661,7 @@ typedef PhotoViewImageDragStartCallback = Function(
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
DragStartDetails details,
|
DragStartDetails details,
|
||||||
PhotoViewControllerValue controllerValue,
|
PhotoViewControllerValue controllerValue,
|
||||||
|
PhotoViewScaleStateController scaleStateController,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// A type definition for a callback when the user drags
|
/// A type definition for a callback when the user drags
|
||||||
|
@ -4,13 +4,14 @@ import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
|||||||
show
|
show
|
||||||
LoadingBuilder,
|
LoadingBuilder,
|
||||||
PhotoView,
|
PhotoView,
|
||||||
|
PhotoViewControllerCallback,
|
||||||
|
PhotoViewImageDragEndCallback,
|
||||||
|
PhotoViewImageDragStartCallback,
|
||||||
|
PhotoViewImageDragUpdateCallback,
|
||||||
|
PhotoViewImageLongPressStartCallback,
|
||||||
|
PhotoViewImageScaleEndCallback,
|
||||||
PhotoViewImageTapDownCallback,
|
PhotoViewImageTapDownCallback,
|
||||||
PhotoViewImageTapUpCallback,
|
PhotoViewImageTapUpCallback,
|
||||||
PhotoViewImageDragStartCallback,
|
|
||||||
PhotoViewImageDragEndCallback,
|
|
||||||
PhotoViewImageDragUpdateCallback,
|
|
||||||
PhotoViewImageScaleEndCallback,
|
|
||||||
PhotoViewImageLongPressStartCallback,
|
|
||||||
ScaleStateCycle;
|
ScaleStateCycle;
|
||||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||||
@ -19,7 +20,10 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart
|
|||||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||||
|
|
||||||
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
|
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
|
||||||
typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
|
typedef PhotoViewGalleryPageChangedCallback = void Function(
|
||||||
|
int index,
|
||||||
|
PhotoViewControllerBase? controller,
|
||||||
|
);
|
||||||
|
|
||||||
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
|
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
|
||||||
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
|
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
|
||||||
@ -114,12 +118,14 @@ class PhotoViewGallery extends StatefulWidget {
|
|||||||
this.reverse = false,
|
this.reverse = false,
|
||||||
this.pageController,
|
this.pageController,
|
||||||
this.onPageChanged,
|
this.onPageChanged,
|
||||||
|
this.onPageBuild,
|
||||||
this.scaleStateChangedCallback,
|
this.scaleStateChangedCallback,
|
||||||
this.enableRotation = false,
|
this.enableRotation = false,
|
||||||
this.scrollPhysics,
|
this.scrollPhysics,
|
||||||
this.scrollDirection = Axis.horizontal,
|
this.scrollDirection = Axis.horizontal,
|
||||||
this.customSize,
|
this.customSize,
|
||||||
this.allowImplicitScrolling = false,
|
this.allowImplicitScrolling = false,
|
||||||
|
this.enablePanAlways = false,
|
||||||
}) : itemCount = null,
|
}) : itemCount = null,
|
||||||
builder = null;
|
builder = null;
|
||||||
|
|
||||||
@ -137,12 +143,14 @@ class PhotoViewGallery extends StatefulWidget {
|
|||||||
this.reverse = false,
|
this.reverse = false,
|
||||||
this.pageController,
|
this.pageController,
|
||||||
this.onPageChanged,
|
this.onPageChanged,
|
||||||
|
this.onPageBuild,
|
||||||
this.scaleStateChangedCallback,
|
this.scaleStateChangedCallback,
|
||||||
this.enableRotation = false,
|
this.enableRotation = false,
|
||||||
this.scrollPhysics,
|
this.scrollPhysics,
|
||||||
this.scrollDirection = Axis.horizontal,
|
this.scrollDirection = Axis.horizontal,
|
||||||
this.customSize,
|
this.customSize,
|
||||||
this.allowImplicitScrolling = false,
|
this.allowImplicitScrolling = false,
|
||||||
|
this.enablePanAlways = false,
|
||||||
}) : pageOptions = null,
|
}) : pageOptions = null,
|
||||||
assert(itemCount != null),
|
assert(itemCount != null),
|
||||||
assert(builder != null);
|
assert(builder != null);
|
||||||
@ -168,6 +176,9 @@ class PhotoViewGallery extends StatefulWidget {
|
|||||||
/// Mirror to [PhotoView.wantKeepAlive]
|
/// Mirror to [PhotoView.wantKeepAlive]
|
||||||
final bool wantKeepAlive;
|
final bool wantKeepAlive;
|
||||||
|
|
||||||
|
/// Mirror to [PhotoView.enablePanAlways]
|
||||||
|
final bool enablePanAlways;
|
||||||
|
|
||||||
/// Mirror to [PhotoView.gaplessPlayback]
|
/// Mirror to [PhotoView.gaplessPlayback]
|
||||||
final bool gaplessPlayback;
|
final bool gaplessPlayback;
|
||||||
|
|
||||||
@ -180,6 +191,9 @@ class PhotoViewGallery extends StatefulWidget {
|
|||||||
/// An callback to be called on a page change
|
/// An callback to be called on a page change
|
||||||
final PhotoViewGalleryPageChangedCallback? onPageChanged;
|
final PhotoViewGalleryPageChangedCallback? onPageChanged;
|
||||||
|
|
||||||
|
/// Mirror to [PhotoView.onPageBuild]
|
||||||
|
final ValueChanged<PhotoViewControllerBase>? onPageBuild;
|
||||||
|
|
||||||
/// Mirror to [PhotoView.scaleStateChangedCallback]
|
/// Mirror to [PhotoView.scaleStateChangedCallback]
|
||||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||||
|
|
||||||
@ -206,6 +220,7 @@ class PhotoViewGallery extends StatefulWidget {
|
|||||||
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||||
late final PageController _controller =
|
late final PageController _controller =
|
||||||
widget.pageController ?? PageController();
|
widget.pageController ?? PageController();
|
||||||
|
PhotoViewControllerCallback? _getController;
|
||||||
|
|
||||||
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
|
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
|
||||||
if (widget.scaleStateChangedCallback != null) {
|
if (widget.scaleStateChangedCallback != null) {
|
||||||
@ -224,6 +239,14 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
return widget.pageOptions!.length;
|
return widget.pageOptions!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _getControllerCallbackBuilder(PhotoViewControllerCallback method) {
|
||||||
|
_getController = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageChange(int page) {
|
||||||
|
widget.onPageChanged?.call(page, _getController?.call());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Enable corner hit test
|
// Enable corner hit test
|
||||||
@ -232,7 +255,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
reverse: widget.reverse,
|
reverse: widget.reverse,
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
onPageChanged: widget.onPageChanged,
|
onPageChanged: _onPageChange,
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
itemBuilder: _buildItem,
|
itemBuilder: _buildItem,
|
||||||
scrollDirection: widget.scrollDirection,
|
scrollDirection: widget.scrollDirection,
|
||||||
@ -255,6 +278,8 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
controller: pageOption.controller,
|
controller: pageOption.controller,
|
||||||
scaleStateController: pageOption.scaleStateController,
|
scaleStateController: pageOption.scaleStateController,
|
||||||
customSize: widget.customSize,
|
customSize: widget.customSize,
|
||||||
|
onPageBuild: widget.onPageBuild,
|
||||||
|
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
||||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||||
enableRotation: widget.enableRotation,
|
enableRotation: widget.enableRotation,
|
||||||
initialScale: pageOption.initialScale,
|
initialScale: pageOption.initialScale,
|
||||||
@ -273,7 +298,9 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
filterQuality: pageOption.filterQuality,
|
filterQuality: pageOption.filterQuality,
|
||||||
basePosition: pageOption.basePosition,
|
basePosition: pageOption.basePosition,
|
||||||
disableGestures: pageOption.disableGestures,
|
disableGestures: pageOption.disableGestures,
|
||||||
|
disableScaleGestures: pageOption.disableScaleGestures,
|
||||||
heroAttributes: pageOption.heroAttributes,
|
heroAttributes: pageOption.heroAttributes,
|
||||||
|
enablePanAlways: widget.enablePanAlways,
|
||||||
child: pageOption.child,
|
child: pageOption.child,
|
||||||
)
|
)
|
||||||
: PhotoView(
|
: PhotoView(
|
||||||
@ -282,8 +309,11 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
imageProvider: pageOption.imageProvider,
|
imageProvider: pageOption.imageProvider,
|
||||||
loadingBuilder: widget.loadingBuilder,
|
loadingBuilder: widget.loadingBuilder,
|
||||||
backgroundDecoration: widget.backgroundDecoration,
|
backgroundDecoration: widget.backgroundDecoration,
|
||||||
|
semanticLabel: pageOption.semanticLabel,
|
||||||
wantKeepAlive: widget.wantKeepAlive,
|
wantKeepAlive: widget.wantKeepAlive,
|
||||||
controller: pageOption.controller,
|
controller: pageOption.controller,
|
||||||
|
onPageBuild: widget.onPageBuild,
|
||||||
|
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
||||||
scaleStateController: pageOption.scaleStateController,
|
scaleStateController: pageOption.scaleStateController,
|
||||||
customSize: widget.customSize,
|
customSize: widget.customSize,
|
||||||
gaplessPlayback: widget.gaplessPlayback,
|
gaplessPlayback: widget.gaplessPlayback,
|
||||||
@ -305,6 +335,8 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
filterQuality: pageOption.filterQuality,
|
filterQuality: pageOption.filterQuality,
|
||||||
basePosition: pageOption.basePosition,
|
basePosition: pageOption.basePosition,
|
||||||
disableGestures: pageOption.disableGestures,
|
disableGestures: pageOption.disableGestures,
|
||||||
|
disableScaleGestures: pageOption.disableScaleGestures,
|
||||||
|
enablePanAlways: widget.enablePanAlways,
|
||||||
errorBuilder: pageOption.errorBuilder,
|
errorBuilder: pageOption.errorBuilder,
|
||||||
heroAttributes: pageOption.heroAttributes,
|
heroAttributes: pageOption.heroAttributes,
|
||||||
);
|
);
|
||||||
@ -334,6 +366,7 @@ class PhotoViewGalleryPageOptions {
|
|||||||
Key? key,
|
Key? key,
|
||||||
required this.imageProvider,
|
required this.imageProvider,
|
||||||
this.heroAttributes,
|
this.heroAttributes,
|
||||||
|
this.semanticLabel,
|
||||||
this.minScale,
|
this.minScale,
|
||||||
this.maxScale,
|
this.maxScale,
|
||||||
this.initialScale,
|
this.initialScale,
|
||||||
@ -351,6 +384,7 @@ class PhotoViewGalleryPageOptions {
|
|||||||
this.gestureDetectorBehavior,
|
this.gestureDetectorBehavior,
|
||||||
this.tightMode,
|
this.tightMode,
|
||||||
this.filterQuality,
|
this.filterQuality,
|
||||||
|
this.disableScaleGestures,
|
||||||
this.disableGestures,
|
this.disableGestures,
|
||||||
this.errorBuilder,
|
this.errorBuilder,
|
||||||
}) : child = null,
|
}) : child = null,
|
||||||
@ -360,6 +394,7 @@ class PhotoViewGalleryPageOptions {
|
|||||||
const PhotoViewGalleryPageOptions.customChild({
|
const PhotoViewGalleryPageOptions.customChild({
|
||||||
required this.child,
|
required this.child,
|
||||||
this.childSize,
|
this.childSize,
|
||||||
|
this.semanticLabel,
|
||||||
this.heroAttributes,
|
this.heroAttributes,
|
||||||
this.minScale,
|
this.minScale,
|
||||||
this.maxScale,
|
this.maxScale,
|
||||||
@ -378,6 +413,7 @@ class PhotoViewGalleryPageOptions {
|
|||||||
this.gestureDetectorBehavior,
|
this.gestureDetectorBehavior,
|
||||||
this.tightMode,
|
this.tightMode,
|
||||||
this.filterQuality,
|
this.filterQuality,
|
||||||
|
this.disableScaleGestures,
|
||||||
this.disableGestures,
|
this.disableGestures,
|
||||||
}) : errorBuilder = null,
|
}) : errorBuilder = null,
|
||||||
imageProvider = null;
|
imageProvider = null;
|
||||||
@ -388,6 +424,9 @@ class PhotoViewGalleryPageOptions {
|
|||||||
/// Mirror to [PhotoView.heroAttributes]
|
/// Mirror to [PhotoView.heroAttributes]
|
||||||
final PhotoViewHeroAttributes? heroAttributes;
|
final PhotoViewHeroAttributes? heroAttributes;
|
||||||
|
|
||||||
|
/// Mirror to [PhotoView.semanticLabel]
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
/// Mirror to [PhotoView.minScale]
|
/// Mirror to [PhotoView.minScale]
|
||||||
final dynamic minScale;
|
final dynamic minScale;
|
||||||
|
|
||||||
@ -445,6 +484,9 @@ class PhotoViewGalleryPageOptions {
|
|||||||
/// Mirror to [PhotoView.disableGestures]
|
/// Mirror to [PhotoView.disableGestures]
|
||||||
final bool? disableGestures;
|
final bool? disableGestures;
|
||||||
|
|
||||||
|
/// Mirror to [PhotoView.disableGestures]
|
||||||
|
final bool? disableScaleGestures;
|
||||||
|
|
||||||
/// Quality levels for image filters.
|
/// Quality levels for image filters.
|
||||||
final FilterQuality? filterQuality;
|
final FilterQuality? filterQuality;
|
||||||
|
|
||||||
|
@ -37,6 +37,13 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
|||||||
/// Closes streams and removes eventual listeners.
|
/// Closes streams and removes eventual listeners.
|
||||||
void dispose();
|
void dispose();
|
||||||
|
|
||||||
|
void positionAnimationBuilder(void Function(Offset)? value);
|
||||||
|
void scaleAnimationBuilder(void Function(double)? value);
|
||||||
|
void rotationAnimationBuilder(void Function(double)? value);
|
||||||
|
|
||||||
|
/// Animates multiple fields of the state
|
||||||
|
void animateMultiple({Offset? position, double? scale, double? rotation});
|
||||||
|
|
||||||
/// Add a listener that will ignore updates made internally
|
/// Add a listener that will ignore updates made internally
|
||||||
///
|
///
|
||||||
/// Since it is made for internal use, it is not performatic to use more than one
|
/// Since it is made for internal use, it is not performatic to use more than one
|
||||||
@ -147,12 +154,31 @@ class PhotoViewController
|
|||||||
|
|
||||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||||
|
|
||||||
|
late void Function(Offset)? _animatePosition;
|
||||||
|
late void Function(double)? _animateScale;
|
||||||
|
late void Function(double)? _animateRotation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
|
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
late PhotoViewControllerValue prevValue;
|
late PhotoViewControllerValue prevValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void positionAnimationBuilder(void Function(Offset)? value) {
|
||||||
|
_animatePosition = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void scaleAnimationBuilder(void Function(double)? value) {
|
||||||
|
_animateScale = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void rotationAnimationBuilder(void Function(double)? value) {
|
||||||
|
_animateRotation = value;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void reset() {
|
void reset() {
|
||||||
value = initial;
|
value = initial;
|
||||||
@ -172,6 +198,21 @@ class PhotoViewController
|
|||||||
_valueNotifier.removeIgnorableListener(callback);
|
_valueNotifier.removeIgnorableListener(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void animateMultiple({Offset? position, double? scale, double? rotation}) {
|
||||||
|
if (position != null && _animatePosition != null) {
|
||||||
|
_animatePosition!(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scale != null && _animateScale != null) {
|
||||||
|
_animateScale!(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotation != null && _animateRotation != null) {
|
||||||
|
_animateRotation!(rotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_outputCtrl.close();
|
_outputCtrl.close();
|
||||||
|
@ -111,6 +111,16 @@ mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhotoViewScaleState getScaleStateFromNewScale(double newScale) {
|
||||||
|
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||||
|
if (scale != scaleBoundaries.initialScale) {
|
||||||
|
newScaleState = (newScale > scaleBoundaries.initialScale)
|
||||||
|
? PhotoViewScaleState.zoomedIn
|
||||||
|
: PhotoViewScaleState.zoomedOut;
|
||||||
|
}
|
||||||
|
return newScaleState;
|
||||||
|
}
|
||||||
|
|
||||||
void updateScaleStateFromNewScale(double newScale) {
|
void updateScaleStateFromNewScale(double newScale) {
|
||||||
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||||
if (scale != scaleBoundaries.initialScale) {
|
if (scale != scaleBoundaries.initialScale) {
|
||||||
|
@ -26,6 +26,8 @@ class PhotoViewScaleStateController {
|
|||||||
StreamController<PhotoViewScaleState>.broadcast()
|
StreamController<PhotoViewScaleState>.broadcast()
|
||||||
..sink.add(PhotoViewScaleState.initial);
|
..sink.add(PhotoViewScaleState.initial);
|
||||||
|
|
||||||
|
bool _hasZoomedOutManually = false;
|
||||||
|
|
||||||
/// The output for state/value updates
|
/// The output for state/value updates
|
||||||
Stream<PhotoViewScaleState> get outputScaleStateStream =>
|
Stream<PhotoViewScaleState> get outputScaleStateStream =>
|
||||||
_outputScaleStateCtrl.stream;
|
_outputScaleStateCtrl.stream;
|
||||||
@ -42,10 +44,20 @@ class PhotoViewScaleStateController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newValue == PhotoViewScaleState.zoomedOut) {
|
||||||
|
_hasZoomedOutManually = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue == PhotoViewScaleState.initial) {
|
||||||
|
_hasZoomedOutManually = false;
|
||||||
|
}
|
||||||
|
|
||||||
prevScaleState = _scaleStateNotifier.value;
|
prevScaleState = _scaleStateNotifier.value;
|
||||||
_scaleStateNotifier.value = newValue;
|
_scaleStateNotifier.value = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get hasZoomedOutManually => _hasZoomedOutManually;
|
||||||
|
|
||||||
/// Checks if its actual value is different than previousValue
|
/// Checks if its actual value is different than previousValue
|
||||||
bool get hasChanged => prevScaleState != scaleState;
|
bool get hasChanged => prevScaleState != scaleState;
|
||||||
|
|
||||||
@ -71,6 +83,15 @@ class PhotoViewScaleStateController {
|
|||||||
if (_scaleStateNotifier.value == newValue) {
|
if (_scaleStateNotifier.value == newValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newValue == PhotoViewScaleState.zoomedOut) {
|
||||||
|
_hasZoomedOutManually = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue == PhotoViewScaleState.initial) {
|
||||||
|
_hasZoomedOutManually = false;
|
||||||
|
}
|
||||||
|
|
||||||
prevScaleState = _scaleStateNotifier.value;
|
prevScaleState = _scaleStateNotifier.value;
|
||||||
_scaleStateNotifier.updateIgnoring(newValue);
|
_scaleStateNotifier.updateIgnoring(newValue);
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.imageProvider,
|
required this.imageProvider,
|
||||||
required this.backgroundDecoration,
|
required this.backgroundDecoration,
|
||||||
|
required this.semanticLabel,
|
||||||
required this.gaplessPlayback,
|
required this.gaplessPlayback,
|
||||||
required this.heroAttributes,
|
required this.heroAttributes,
|
||||||
required this.enableRotation,
|
required this.enableRotation,
|
||||||
@ -48,6 +49,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||||||
required this.tightMode,
|
required this.tightMode,
|
||||||
required this.filterQuality,
|
required this.filterQuality,
|
||||||
required this.disableGestures,
|
required this.disableGestures,
|
||||||
|
required this.disableScaleGestures,
|
||||||
required this.enablePanAlways,
|
required this.enablePanAlways,
|
||||||
}) : customChild = null;
|
}) : customChild = null;
|
||||||
|
|
||||||
@ -73,12 +75,15 @@ class PhotoViewCore extends StatefulWidget {
|
|||||||
required this.tightMode,
|
required this.tightMode,
|
||||||
required this.filterQuality,
|
required this.filterQuality,
|
||||||
required this.disableGestures,
|
required this.disableGestures,
|
||||||
|
required this.disableScaleGestures,
|
||||||
required this.enablePanAlways,
|
required this.enablePanAlways,
|
||||||
}) : imageProvider = null,
|
}) : semanticLabel = null,
|
||||||
|
imageProvider = null,
|
||||||
gaplessPlayback = false;
|
gaplessPlayback = false;
|
||||||
|
|
||||||
final Decoration? backgroundDecoration;
|
final Decoration? backgroundDecoration;
|
||||||
final ImageProvider? imageProvider;
|
final ImageProvider? imageProvider;
|
||||||
|
final String? semanticLabel;
|
||||||
final bool? gaplessPlayback;
|
final bool? gaplessPlayback;
|
||||||
final PhotoViewHeroAttributes? heroAttributes;
|
final PhotoViewHeroAttributes? heroAttributes;
|
||||||
final bool enableRotation;
|
final bool enableRotation;
|
||||||
@ -103,6 +108,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||||||
final HitTestBehavior? gestureDetectorBehavior;
|
final HitTestBehavior? gestureDetectorBehavior;
|
||||||
final bool tightMode;
|
final bool tightMode;
|
||||||
final bool disableGestures;
|
final bool disableGestures;
|
||||||
|
final bool disableScaleGestures;
|
||||||
final bool enablePanAlways;
|
final bool enablePanAlways;
|
||||||
|
|
||||||
final FilterQuality filterQuality;
|
final FilterQuality filterQuality;
|
||||||
@ -120,6 +126,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
TickerProviderStateMixin,
|
TickerProviderStateMixin,
|
||||||
PhotoViewControllerDelegate,
|
PhotoViewControllerDelegate,
|
||||||
HitCornersDetector {
|
HitCornersDetector {
|
||||||
|
Offset? _normalizedPosition;
|
||||||
double? _scaleBefore;
|
double? _scaleBefore;
|
||||||
double? _rotationBefore;
|
double? _rotationBefore;
|
||||||
|
|
||||||
@ -152,32 +159,33 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
void onScaleStart(ScaleStartDetails details) {
|
void onScaleStart(ScaleStartDetails details) {
|
||||||
_rotationBefore = controller.rotation;
|
_rotationBefore = controller.rotation;
|
||||||
_scaleBefore = scale;
|
_scaleBefore = scale;
|
||||||
|
_normalizedPosition = details.focalPoint - controller.position;
|
||||||
_scaleAnimationController.stop();
|
_scaleAnimationController.stop();
|
||||||
_positionAnimationController.stop();
|
_positionAnimationController.stop();
|
||||||
_rotationAnimationController.stop();
|
_rotationAnimationController.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldAllowPanRotate() => switch (scaleStateController.scaleState) {
|
||||||
|
PhotoViewScaleState.zoomedIn =>
|
||||||
|
scaleStateController.hasZoomedOutManually,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||||
final centeredFocalPoint = Offset(
|
|
||||||
details.focalPoint.dx - scaleBoundaries.outerSize.width / 2,
|
|
||||||
details.focalPoint.dy - scaleBoundaries.outerSize.height / 2,
|
|
||||||
);
|
|
||||||
final double newScale = _scaleBefore! * details.scale;
|
final double newScale = _scaleBefore! * details.scale;
|
||||||
final double scaleDelta = newScale / scale;
|
Offset delta = details.focalPoint - _normalizedPosition!;
|
||||||
final Offset newPosition =
|
|
||||||
(controller.position + details.focalPointDelta) * scaleDelta -
|
|
||||||
centeredFocalPoint * (scaleDelta - 1);
|
|
||||||
|
|
||||||
updateScaleStateFromNewScale(newScale);
|
updateScaleStateFromNewScale(newScale);
|
||||||
|
|
||||||
|
final panEnabled = widget.enablePanAlways && _shouldAllowPanRotate();
|
||||||
|
final rotationEnabled = widget.enableRotation && _shouldAllowPanRotate();
|
||||||
|
|
||||||
updateMultiple(
|
updateMultiple(
|
||||||
scale: newScale,
|
scale: newScale,
|
||||||
position: widget.enablePanAlways
|
position:
|
||||||
? newPosition
|
panEnabled ? delta : clampPosition(position: delta * details.scale),
|
||||||
: clampPosition(position: newPosition),
|
rotation: rotationEnabled ? _rotationBefore! + details.rotation : null,
|
||||||
rotation:
|
rotationFocusPoint: rotationEnabled ? details.focalPoint : null,
|
||||||
widget.enableRotation ? _rotationBefore! + details.rotation : null,
|
|
||||||
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,6 +197,16 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
|
|
||||||
widget.onScaleEnd?.call(context, details, controller.value);
|
widget.onScaleEnd?.call(context, details, controller.value);
|
||||||
|
|
||||||
|
final scaleState = getScaleStateFromNewScale(scale);
|
||||||
|
if (scaleState == PhotoViewScaleState.zoomedOut) {
|
||||||
|
scaleStateController.scaleState = PhotoViewScaleState.originalSize;
|
||||||
|
} else if (scaleState == PhotoViewScaleState.zoomedIn) {
|
||||||
|
animateRotation(controller.rotation, 0);
|
||||||
|
if (_shouldAllowPanRotate()) {
|
||||||
|
animatePosition(controller.position, Offset.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//animate back to maxScale if gesture exceeded the maxScale specified
|
//animate back to maxScale if gesture exceeded the maxScale specified
|
||||||
if (s > maxScale) {
|
if (s > maxScale) {
|
||||||
final double scaleComebackRatio = maxScale / s;
|
final double scaleComebackRatio = maxScale / s;
|
||||||
@ -232,6 +250,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void animateScale(double from, double to) {
|
void animateScale(double from, double to) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_scaleAnimation = Tween<double>(
|
_scaleAnimation = Tween<double>(
|
||||||
begin: from,
|
begin: from,
|
||||||
end: to,
|
end: to,
|
||||||
@ -242,6 +263,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void animatePosition(Offset from, Offset to) {
|
void animatePosition(Offset from, Offset to) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_positionAnimation = Tween<Offset>(begin: from, end: to)
|
_positionAnimation = Tween<Offset>(begin: from, end: to)
|
||||||
.animate(_positionAnimationController);
|
.animate(_positionAnimationController);
|
||||||
_positionAnimationController
|
_positionAnimationController
|
||||||
@ -250,6 +274,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void animateRotation(double from, double to) {
|
void animateRotation(double from, double to) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_rotationAnimation = Tween<double>(begin: from, end: to)
|
_rotationAnimation = Tween<double>(begin: from, end: to)
|
||||||
.animate(_rotationAnimationController);
|
.animate(_rotationAnimationController);
|
||||||
_rotationAnimationController
|
_rotationAnimationController
|
||||||
@ -271,11 +298,28 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _animateControllerPosition(Offset position) {
|
||||||
|
animatePosition(controller.position, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateControllerScale(double scale) {
|
||||||
|
if (controller.scale != null) {
|
||||||
|
animateScale(controller.scale!, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateControllerRotation(double rotation) {
|
||||||
|
animateRotation(controller.rotation, rotation);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
initDelegate();
|
initDelegate();
|
||||||
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
||||||
|
controller.positionAnimationBuilder(_animateControllerPosition);
|
||||||
|
controller.scaleAnimationBuilder(_animateControllerScale);
|
||||||
|
controller.rotationAnimationBuilder(_animateControllerRotation);
|
||||||
|
|
||||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||||
|
|
||||||
@ -341,7 +385,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
basePosition,
|
basePosition,
|
||||||
useImageScale,
|
useImageScale,
|
||||||
),
|
),
|
||||||
child: _buildHero(),
|
child: _buildHero(_buildChild()),
|
||||||
);
|
);
|
||||||
|
|
||||||
final child = Container(
|
final child = Container(
|
||||||
@ -363,18 +407,29 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PhotoViewGestureDetector(
|
return PhotoViewGestureDetector(
|
||||||
onDoubleTap: nextScaleState,
|
disableScaleGestures: widget.disableScaleGestures,
|
||||||
onScaleStart: onScaleStart,
|
onDoubleTap: widget.disableScaleGestures ? null : onDoubleTap,
|
||||||
onScaleUpdate: onScaleUpdate,
|
onScaleStart: widget.disableScaleGestures ? null : onScaleStart,
|
||||||
onScaleEnd: onScaleEnd,
|
onScaleUpdate: widget.disableScaleGestures ? null : onScaleUpdate,
|
||||||
|
onScaleEnd: widget.disableScaleGestures ? null : onScaleEnd,
|
||||||
onDragStart: widget.onDragStart != null
|
onDragStart: widget.onDragStart != null
|
||||||
? (details) => widget.onDragStart!(context, details, value)
|
? (details) => widget.onDragStart!(
|
||||||
|
context,
|
||||||
|
details,
|
||||||
|
widget.controller.value,
|
||||||
|
widget.scaleStateController,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
onDragEnd: widget.onDragEnd != null
|
onDragEnd: widget.onDragEnd != null
|
||||||
? (details) => widget.onDragEnd!(context, details, value)
|
? (details) =>
|
||||||
|
widget.onDragEnd!(context, details, widget.controller.value)
|
||||||
: null,
|
: null,
|
||||||
onDragUpdate: widget.onDragUpdate != null
|
onDragUpdate: widget.onDragUpdate != null
|
||||||
? (details) => widget.onDragUpdate!(context, details, value)
|
? (details) => widget.onDragUpdate!(
|
||||||
|
context,
|
||||||
|
details,
|
||||||
|
widget.controller.value,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
hitDetector: this,
|
hitDetector: this,
|
||||||
onTapUp: widget.onTapUp != null
|
onTapUp: widget.onTapUp != null
|
||||||
@ -395,7 +450,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHero() {
|
Widget _buildHero(Widget child) {
|
||||||
return heroAttributes != null
|
return heroAttributes != null
|
||||||
? Hero(
|
? Hero(
|
||||||
tag: heroAttributes!.tag,
|
tag: heroAttributes!.tag,
|
||||||
@ -403,16 +458,20 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
|
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
|
||||||
placeholderBuilder: heroAttributes!.placeholderBuilder,
|
placeholderBuilder: heroAttributes!.placeholderBuilder,
|
||||||
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
|
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
|
||||||
child: _buildChild(),
|
child: child,
|
||||||
)
|
)
|
||||||
: _buildChild();
|
: child;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChild() {
|
Widget _buildChild() {
|
||||||
return widget.hasCustomChild
|
return widget.hasCustomChild
|
||||||
? widget.customChild!
|
? widget.customChild!
|
||||||
: Image(
|
: Image(
|
||||||
|
key: widget.heroAttributes?.tag != null
|
||||||
|
? ObjectKey(widget.heroAttributes!.tag)
|
||||||
|
: null,
|
||||||
image: widget.imageProvider!,
|
image: widget.imageProvider!,
|
||||||
|
semanticLabel: widget.semanticLabel,
|
||||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
width: scaleBoundaries.childSize.width * scale,
|
width: scaleBoundaries.childSize.width * scale,
|
||||||
@ -442,6 +501,7 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
|||||||
|
|
||||||
final double offsetX = halfWidth * (basePosition.x + 1);
|
final double offsetX = halfWidth * (basePosition.x + 1);
|
||||||
final double offsetY = halfHeight * (basePosition.y + 1);
|
final double offsetY = halfHeight * (basePosition.y + 1);
|
||||||
|
|
||||||
return Offset(offsetX, offsetY);
|
return Offset(offsetX, offsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||||||
this.onTapUp,
|
this.onTapUp,
|
||||||
this.onTapDown,
|
this.onTapDown,
|
||||||
this.behavior,
|
this.behavior,
|
||||||
|
this.disableScaleGestures = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GestureDoubleTapCallback? onDoubleTap;
|
final GestureDoubleTapCallback? onDoubleTap;
|
||||||
@ -43,6 +44,8 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||||||
|
|
||||||
final HitTestBehavior? behavior;
|
final HitTestBehavior? behavior;
|
||||||
|
|
||||||
|
final bool disableScaleGestures;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scope = PhotoViewGestureDetectorScope.of(context);
|
final scope = PhotoViewGestureDetectorScope.of(context);
|
||||||
@ -96,9 +99,11 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
(PhotoViewGestureRecognizer instance) {
|
(PhotoViewGestureRecognizer instance) {
|
||||||
instance
|
instance
|
||||||
|
..dragStartBehavior = DragStartBehavior.start
|
||||||
..onStart = onScaleStart
|
..onStart = onScaleStart
|
||||||
..onUpdate = onScaleUpdate
|
..onUpdate = onScaleUpdate
|
||||||
..onEnd = onScaleEnd;
|
..onEnd = onScaleEnd
|
||||||
|
..disableScaleGestures = disableScaleGestures;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -124,10 +129,12 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
this.validateAxis,
|
this.validateAxis,
|
||||||
this.touchSlopFactor = 1,
|
this.touchSlopFactor = 1,
|
||||||
PointerDeviceKind? kind,
|
PointerDeviceKind? kind,
|
||||||
|
this.disableScaleGestures = false,
|
||||||
}) : super(supportedDevices: null);
|
}) : super(supportedDevices: null);
|
||||||
final HitCornersDetector? hitDetector;
|
final HitCornersDetector? hitDetector;
|
||||||
final Axis? validateAxis;
|
final Axis? validateAxis;
|
||||||
final double touchSlopFactor;
|
final double touchSlopFactor;
|
||||||
|
bool disableScaleGestures;
|
||||||
|
|
||||||
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||||
|
|
||||||
@ -155,7 +162,7 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void handleEvent(PointerEvent event) {
|
void handleEvent(PointerEvent event) {
|
||||||
if (validateAxis != null) {
|
if (validateAxis != null && !disableScaleGestures) {
|
||||||
bool didChangeConfiguration = false;
|
bool didChangeConfiguration = false;
|
||||||
if (event is PointerMoveEvent) {
|
if (event is PointerMoveEvent) {
|
||||||
if (!event.synthesized) {
|
if (!event.synthesized) {
|
||||||
|
@ -11,6 +11,7 @@ class ImageWrapper extends StatefulWidget {
|
|||||||
required this.imageProvider,
|
required this.imageProvider,
|
||||||
required this.loadingBuilder,
|
required this.loadingBuilder,
|
||||||
required this.backgroundDecoration,
|
required this.backgroundDecoration,
|
||||||
|
required this.semanticLabel,
|
||||||
required this.gaplessPlayback,
|
required this.gaplessPlayback,
|
||||||
required this.heroAttributes,
|
required this.heroAttributes,
|
||||||
required this.scaleStateChangedCallback,
|
required this.scaleStateChangedCallback,
|
||||||
@ -34,6 +35,7 @@ class ImageWrapper extends StatefulWidget {
|
|||||||
required this.tightMode,
|
required this.tightMode,
|
||||||
required this.filterQuality,
|
required this.filterQuality,
|
||||||
required this.disableGestures,
|
required this.disableGestures,
|
||||||
|
this.disableScaleGestures,
|
||||||
required this.errorBuilder,
|
required this.errorBuilder,
|
||||||
required this.enablePanAlways,
|
required this.enablePanAlways,
|
||||||
required this.index,
|
required this.index,
|
||||||
@ -43,6 +45,7 @@ class ImageWrapper extends StatefulWidget {
|
|||||||
final LoadingBuilder? loadingBuilder;
|
final LoadingBuilder? loadingBuilder;
|
||||||
final ImageErrorWidgetBuilder? errorBuilder;
|
final ImageErrorWidgetBuilder? errorBuilder;
|
||||||
final BoxDecoration backgroundDecoration;
|
final BoxDecoration backgroundDecoration;
|
||||||
|
final String? semanticLabel;
|
||||||
final bool gaplessPlayback;
|
final bool gaplessPlayback;
|
||||||
final PhotoViewHeroAttributes? heroAttributes;
|
final PhotoViewHeroAttributes? heroAttributes;
|
||||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||||
@ -66,6 +69,7 @@ class ImageWrapper extends StatefulWidget {
|
|||||||
final bool? tightMode;
|
final bool? tightMode;
|
||||||
final FilterQuality? filterQuality;
|
final FilterQuality? filterQuality;
|
||||||
final bool? disableGestures;
|
final bool? disableGestures;
|
||||||
|
final bool? disableScaleGestures;
|
||||||
final bool? enablePanAlways;
|
final bool? enablePanAlways;
|
||||||
final int index;
|
final int index;
|
||||||
|
|
||||||
@ -193,6 +197,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||||||
return PhotoViewCore(
|
return PhotoViewCore(
|
||||||
imageProvider: widget.imageProvider,
|
imageProvider: widget.imageProvider,
|
||||||
backgroundDecoration: widget.backgroundDecoration,
|
backgroundDecoration: widget.backgroundDecoration,
|
||||||
|
semanticLabel: widget.semanticLabel,
|
||||||
gaplessPlayback: widget.gaplessPlayback,
|
gaplessPlayback: widget.gaplessPlayback,
|
||||||
enableRotation: widget.enableRotation,
|
enableRotation: widget.enableRotation,
|
||||||
heroAttributes: widget.heroAttributes,
|
heroAttributes: widget.heroAttributes,
|
||||||
@ -212,6 +217,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||||||
tightMode: widget.tightMode ?? false,
|
tightMode: widget.tightMode ?? false,
|
||||||
filterQuality: widget.filterQuality ?? FilterQuality.none,
|
filterQuality: widget.filterQuality ?? FilterQuality.none,
|
||||||
disableGestures: widget.disableGestures ?? false,
|
disableGestures: widget.disableGestures ?? false,
|
||||||
|
disableScaleGestures: widget.disableScaleGestures ?? false,
|
||||||
enablePanAlways: widget.enablePanAlways ?? false,
|
enablePanAlways: widget.enablePanAlways ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -266,6 +272,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||||||
required this.tightMode,
|
required this.tightMode,
|
||||||
required this.filterQuality,
|
required this.filterQuality,
|
||||||
required this.disableGestures,
|
required this.disableGestures,
|
||||||
|
this.disableScaleGestures,
|
||||||
required this.enablePanAlways,
|
required this.enablePanAlways,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -296,6 +303,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||||||
final HitTestBehavior? gestureDetectorBehavior;
|
final HitTestBehavior? gestureDetectorBehavior;
|
||||||
final bool? tightMode;
|
final bool? tightMode;
|
||||||
final FilterQuality? filterQuality;
|
final FilterQuality? filterQuality;
|
||||||
|
final bool? disableScaleGestures;
|
||||||
final bool? disableGestures;
|
final bool? disableGestures;
|
||||||
final bool? enablePanAlways;
|
final bool? enablePanAlways;
|
||||||
|
|
||||||
@ -330,6 +338,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||||||
tightMode: tightMode ?? false,
|
tightMode: tightMode ?? false,
|
||||||
filterQuality: filterQuality ?? FilterQuality.none,
|
filterQuality: filterQuality ?? FilterQuality.none,
|
||||||
disableGestures: disableGestures ?? false,
|
disableGestures: disableGestures ?? false,
|
||||||
|
disableScaleGestures: disableScaleGestures ?? false,
|
||||||
enablePanAlways: enablePanAlways ?? false,
|
enablePanAlways: enablePanAlways ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user