mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/custom-date-range
This commit is contained in:
@@ -48,7 +48,6 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, size)
|
||||
copyPixelsToBuffer(buffer)
|
||||
recycle()
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to width.toLong(),
|
||||
@@ -57,8 +56,9 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
NativeBuffer.free(pointer)
|
||||
recycle()
|
||||
throw e
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: '>=1.29.0 <=1.30.0'
|
||||
version: '>=1.29.0 <=1.36.0'
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer }
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
enum AssetDateAggregation { start, end }
|
||||
|
||||
@@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event {
|
||||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
class ViewerShowDetailsEvent extends Event {
|
||||
const ViewerShowDetailsEvent();
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
|
||||
@@ -73,9 +73,12 @@ enum StoreKey<T> {
|
||||
autoPlayVideo<bool>._(139),
|
||||
albumGridView<bool>._(140),
|
||||
|
||||
// Image viewer navigation settings
|
||||
tapToNavigate<bool>._(141),
|
||||
|
||||
// Map custom time range settings
|
||||
mapCustomFrom<String>._(141),
|
||||
mapCustomTo<String>._(142),
|
||||
mapCustomFrom<String>._(142),
|
||||
mapCustomTo<String>._(143),
|
||||
|
||||
// Experimental stuff
|
||||
photoManagerCustomFilter<bool>._(1000),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class Tag {
|
||||
final String id;
|
||||
final String value;
|
||||
|
||||
const Tag({required this.id, required this.value});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Tag(id: $id, value: $value)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Tag other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id && other.value == value;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ value.hashCode;
|
||||
}
|
||||
|
||||
static Tag fromDto(TagResponseDto dto) {
|
||||
return Tag(id: dto.id, value: dto.value);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@ class RemoteAlbumService {
|
||||
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
|
||||
AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
|
||||
};
|
||||
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
|
||||
|
||||
@@ -172,46 +172,25 @@ class RemoteAlbumService {
|
||||
return _repository.getAlbumsContainingAsset(assetId);
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their newest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
|
||||
for (final album in albums) {
|
||||
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
|
||||
Future<List<RemoteAlbum>> _sortByAssetDate(
|
||||
List<RemoteAlbum> albums, {
|
||||
required AssetDateAggregation aggregation,
|
||||
}) async {
|
||||
if (albums.isEmpty) return [];
|
||||
|
||||
final albumIds = albums.map((e) => e.id).toList();
|
||||
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
|
||||
|
||||
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
|
||||
|
||||
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().toList();
|
||||
|
||||
if (sortedAlbums.length < albums.length) {
|
||||
final returnedIdSet = sortedIds.toSet();
|
||||
final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id));
|
||||
sortedAlbums.addAll(emptyAlbums);
|
||||
}
|
||||
|
||||
// await all database queries
|
||||
final entries = await Future.wait(
|
||||
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||
);
|
||||
final assetTimestamps = Map.fromEntries(entries);
|
||||
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their oldest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {
|
||||
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
|
||||
};
|
||||
|
||||
// await all database queries
|
||||
final entries = await Future.wait(
|
||||
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||
);
|
||||
final assetTimestamps = Map.fromEntries(entries);
|
||||
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
return sortedAlbums;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,12 @@ class SyncStreamService {
|
||||
return false;
|
||||
}
|
||||
|
||||
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
||||
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
||||
|
||||
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
|
||||
final migrations = (jsonDecode(value) as List).cast<String>();
|
||||
int previousLength = migrations.length;
|
||||
await _runPreSyncTasks(migrations, semVer);
|
||||
await _runPreSyncTasks(migrations, serverSemVer);
|
||||
|
||||
if (migrations.length != previousLength) {
|
||||
_logger.info("Updated pre-sync migration status: $migrations");
|
||||
@@ -82,10 +82,14 @@ class SyncStreamService {
|
||||
|
||||
// Start the sync stream and handle events
|
||||
bool shouldReset = false;
|
||||
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
|
||||
await _syncApiRepository.streamChanges(
|
||||
_handleEvents,
|
||||
serverVersion: serverSemVer,
|
||||
onReset: () => shouldReset = true,
|
||||
);
|
||||
if (shouldReset) {
|
||||
_logger.info("Resetting sync state as requested by server");
|
||||
await _syncApiRepository.streamChanges(_handleEvents);
|
||||
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
|
||||
}
|
||||
|
||||
previousLength = migrations.length;
|
||||
@@ -282,6 +286,8 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.deletePeopleV1(data.cast());
|
||||
case SyncEntityType.assetFaceV1:
|
||||
return _syncStreamRepository.updateAssetFacesV1(data.cast());
|
||||
case SyncEntityType.assetFaceV2:
|
||||
return _syncStreamRepository.updateAssetFacesV2(data.cast());
|
||||
case SyncEntityType.assetFaceDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
||||
default:
|
||||
|
||||
@@ -183,8 +183,8 @@ class TimelineService {
|
||||
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, math.min(5, _totalAssets - index)));
|
||||
// Preload assets around the given index for asset viewer
|
||||
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
|
||||
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));
|
||||
|
||||
|
||||
@@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
damping: 80,
|
||||
);
|
||||
}
|
||||
|
||||
class SnapScrollPhysics extends ScrollPhysics {
|
||||
static const _minFlingVelocity = 700.0;
|
||||
static const minSnapDistance = 30.0;
|
||||
|
||||
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||
|
||||
const SnapScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
SnapScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return SnapScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
|
||||
assert(
|
||||
position is SnapScrollPosition,
|
||||
'SnapScrollPhysics can only be used with Scrollables that use a '
|
||||
'controller whose createScrollPosition returns a SnapScrollPosition',
|
||||
);
|
||||
|
||||
final snapOffset = (position as SnapScrollPosition).snapOffset;
|
||||
if (snapOffset <= 0) {
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
|
||||
if (position.pixels >= snapOffset) {
|
||||
final simulation = super.createBallisticSimulation(position, velocity);
|
||||
if (simulation == null || simulation.x(double.infinity) >= snapOffset) {
|
||||
return simulation;
|
||||
}
|
||||
}
|
||||
|
||||
return ScrollSpringSimulation(
|
||||
_spring,
|
||||
position.pixels,
|
||||
target(position, velocity, snapOffset),
|
||||
velocity,
|
||||
tolerance: toleranceFor(position),
|
||||
);
|
||||
}
|
||||
|
||||
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
||||
if (velocity > _minFlingVelocity) return snapOffset;
|
||||
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
||||
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
||||
}
|
||||
}
|
||||
|
||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
||||
double snapOffset;
|
||||
|
||||
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
|
||||
}
|
||||
|
||||
class ProxyScrollController extends ScrollController {
|
||||
final ScrollController scrollController;
|
||||
|
||||
ProxyScrollController({required this.scrollController});
|
||||
|
||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
||||
|
||||
@override
|
||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
|
||||
return ProxyScrollPosition(
|
||||
scrollController: scrollController,
|
||||
physics: physics,
|
||||
context: context,
|
||||
oldPosition: oldPosition,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyScrollPosition extends SnapScrollPosition {
|
||||
final ScrollController scrollController;
|
||||
|
||||
ProxyScrollPosition({
|
||||
required this.scrollController,
|
||||
required super.physics,
|
||||
required super.context,
|
||||
super.oldPosition,
|
||||
});
|
||||
|
||||
@override
|
||||
double setPixels(double newPixels) {
|
||||
final overscroll = super.setPixels(newPixels);
|
||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||
scrollController.position.forcePixels(pixels);
|
||||
}
|
||||
return overscroll;
|
||||
}
|
||||
|
||||
@override
|
||||
void forcePixels(double value) {
|
||||
super.forcePixels(value);
|
||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||
scrollController.position.forcePixels(pixels);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||
? scrollController.position.maxScrollExtent
|
||||
: super.maxScrollExtent;
|
||||
|
||||
@override
|
||||
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||
? scrollController.position.minScrollExtent
|
||||
: super.minScrollExtent;
|
||||
|
||||
@override
|
||||
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
|
||||
? scrollController.position.viewportDimension
|
||||
: super.viewportDimension;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get sourceType => text()();
|
||||
|
||||
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
|
||||
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
+202
-68
@@ -5,11 +5,12 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'
|
||||
as i2;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
||||
as i3;
|
||||
import 'package:drift/internal/modular.dart' as i4;
|
||||
as i4;
|
||||
import 'package:drift/internal/modular.dart' as i5;
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
|
||||
as i5;
|
||||
as i6;
|
||||
|
||||
typedef $$AssetFaceEntityTableCreateCompanionBuilder =
|
||||
i1.AssetFaceEntityCompanion Function({
|
||||
@@ -23,6 +24,8 @@ typedef $$AssetFaceEntityTableCreateCompanionBuilder =
|
||||
required int boundingBoxX2,
|
||||
required int boundingBoxY2,
|
||||
required String sourceType,
|
||||
i0.Value<bool> isVisible,
|
||||
i0.Value<DateTime?> deletedAt,
|
||||
});
|
||||
typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
|
||||
i1.AssetFaceEntityCompanion Function({
|
||||
@@ -36,6 +39,8 @@ typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<int> boundingBoxX2,
|
||||
i0.Value<int> boundingBoxY2,
|
||||
i0.Value<String> sourceType,
|
||||
i0.Value<bool> isVisible,
|
||||
i0.Value<DateTime?> deletedAt,
|
||||
});
|
||||
|
||||
final class $$AssetFaceEntityTableReferences
|
||||
@@ -51,29 +56,29 @@ final class $$AssetFaceEntityTableReferences
|
||||
super.$_typedResult,
|
||||
);
|
||||
|
||||
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
||||
i4.ReadDatabaseContainer(db)
|
||||
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
|
||||
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
|
||||
.createAlias(
|
||||
i0.$_aliasNameGenerator(
|
||||
i4.ReadDatabaseContainer(db)
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
|
||||
.assetId,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
||||
),
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
||||
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
||||
final $_column = $_itemColumn<String>('asset_id')!;
|
||||
|
||||
final manager = i3
|
||||
final manager = i4
|
||||
.$$RemoteAssetEntityTableTableManager(
|
||||
$_db,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
$_db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
)
|
||||
.filter((f) => f.id.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
|
||||
@@ -83,29 +88,29 @@ final class $$AssetFaceEntityTableReferences
|
||||
);
|
||||
}
|
||||
|
||||
static i5.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
|
||||
i4.ReadDatabaseContainer(db)
|
||||
.resultSet<i5.$PersonEntityTable>('person_entity')
|
||||
static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i6.$PersonEntityTable>('person_entity')
|
||||
.createAlias(
|
||||
i0.$_aliasNameGenerator(
|
||||
i4.ReadDatabaseContainer(db)
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
|
||||
.personId,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity').id,
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity').id,
|
||||
),
|
||||
);
|
||||
|
||||
i5.$$PersonEntityTableProcessedTableManager? get personId {
|
||||
i6.$$PersonEntityTableProcessedTableManager? get personId {
|
||||
final $_column = $_itemColumn<String>('person_id');
|
||||
if ($_column == null) return null;
|
||||
final manager = i5
|
||||
final manager = i6
|
||||
.$$PersonEntityTableTableManager(
|
||||
$_db,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
$_db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
)
|
||||
.filter((f) => f.id.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_personIdTable($_db));
|
||||
@@ -165,24 +170,34 @@ class $$AssetFaceEntityTableFilterComposer
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableFilterComposer get assetId {
|
||||
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||
i0.ColumnFilters<bool> get isVisible => $composableBuilder(
|
||||
column: $table.isVisible,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get deletedAt => $composableBuilder(
|
||||
column: $table.deletedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i4.$$RemoteAssetEntityTableFilterComposer get assetId {
|
||||
final i4.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.assetId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i3.$$RemoteAssetEntityTableFilterComposer(
|
||||
}) => i4.$$RemoteAssetEntityTableFilterComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -192,24 +207,24 @@ class $$AssetFaceEntityTableFilterComposer
|
||||
return composer;
|
||||
}
|
||||
|
||||
i5.$$PersonEntityTableFilterComposer get personId {
|
||||
final i5.$$PersonEntityTableFilterComposer composer = $composerBuilder(
|
||||
i6.$$PersonEntityTableFilterComposer get personId {
|
||||
final i6.$$PersonEntityTableFilterComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.personId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i5.$$PersonEntityTableFilterComposer(
|
||||
}) => i6.$$PersonEntityTableFilterComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -269,25 +284,35 @@ class $$AssetFaceEntityTableOrderingComposer
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
||||
final i3.$$RemoteAssetEntityTableOrderingComposer composer =
|
||||
i0.ColumnOrderings<bool> get isVisible => $composableBuilder(
|
||||
column: $table.isVisible,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get deletedAt => $composableBuilder(
|
||||
column: $table.deletedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i4.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
||||
final i4.$$RemoteAssetEntityTableOrderingComposer composer =
|
||||
$composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.assetId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i3.$$RemoteAssetEntityTableOrderingComposer(
|
||||
}) => i4.$$RemoteAssetEntityTableOrderingComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -297,24 +322,24 @@ class $$AssetFaceEntityTableOrderingComposer
|
||||
return composer;
|
||||
}
|
||||
|
||||
i5.$$PersonEntityTableOrderingComposer get personId {
|
||||
final i5.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
|
||||
i6.$$PersonEntityTableOrderingComposer get personId {
|
||||
final i6.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.personId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i5.$$PersonEntityTableOrderingComposer(
|
||||
}) => i6.$$PersonEntityTableOrderingComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -372,25 +397,31 @@ class $$AssetFaceEntityTableAnnotationComposer
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
||||
final i3.$$RemoteAssetEntityTableAnnotationComposer composer =
|
||||
i0.GeneratedColumn<bool> get isVisible =>
|
||||
$composableBuilder(column: $table.isVisible, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get deletedAt =>
|
||||
$composableBuilder(column: $table.deletedAt, builder: (column) => column);
|
||||
|
||||
i4.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
||||
final i4.$$RemoteAssetEntityTableAnnotationComposer composer =
|
||||
$composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.assetId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i3.$$RemoteAssetEntityTableAnnotationComposer(
|
||||
}) => i4.$$RemoteAssetEntityTableAnnotationComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -400,24 +431,24 @@ class $$AssetFaceEntityTableAnnotationComposer
|
||||
return composer;
|
||||
}
|
||||
|
||||
i5.$$PersonEntityTableAnnotationComposer get personId {
|
||||
final i5.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
|
||||
i6.$$PersonEntityTableAnnotationComposer get personId {
|
||||
final i6.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.personId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i5.$$PersonEntityTableAnnotationComposer(
|
||||
}) => i6.$$PersonEntityTableAnnotationComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -468,6 +499,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
i0.Value<int> boundingBoxX2 = const i0.Value.absent(),
|
||||
i0.Value<int> boundingBoxY2 = const i0.Value.absent(),
|
||||
i0.Value<String> sourceType = const i0.Value.absent(),
|
||||
i0.Value<bool> isVisible = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
|
||||
}) => i1.AssetFaceEntityCompanion(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
@@ -479,6 +512,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
boundingBoxX2: boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2,
|
||||
sourceType: sourceType,
|
||||
isVisible: isVisible,
|
||||
deletedAt: deletedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -492,6 +527,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
required int boundingBoxX2,
|
||||
required int boundingBoxY2,
|
||||
required String sourceType,
|
||||
i0.Value<bool> isVisible = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
|
||||
}) => i1.AssetFaceEntityCompanion.insert(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
@@ -503,6 +540,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
boundingBoxX2: boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2,
|
||||
sourceType: sourceType,
|
||||
isVisible: isVisible,
|
||||
deletedAt: deletedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
@@ -709,6 +748,33 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _isVisibleMeta = const i0.VerificationMeta(
|
||||
'isVisible',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<bool> isVisible = i0.GeneratedColumn<bool>(
|
||||
'is_visible',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_visible" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const i3.Constant(true),
|
||||
);
|
||||
static const i0.VerificationMeta _deletedAtMeta = const i0.VerificationMeta(
|
||||
'deletedAt',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> deletedAt =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'deleted_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
id,
|
||||
@@ -721,6 +787,8 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
boundingBoxX2,
|
||||
boundingBoxY2,
|
||||
sourceType,
|
||||
isVisible,
|
||||
deletedAt,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -824,6 +892,18 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
} else if (isInserting) {
|
||||
context.missing(_sourceTypeMeta);
|
||||
}
|
||||
if (data.containsKey('is_visible')) {
|
||||
context.handle(
|
||||
_isVisibleMeta,
|
||||
isVisible.isAcceptableOrUnknown(data['is_visible']!, _isVisibleMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('deleted_at')) {
|
||||
context.handle(
|
||||
_deletedAtMeta,
|
||||
deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -873,6 +953,14 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}source_type'],
|
||||
)!,
|
||||
isVisible: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_visible'],
|
||||
)!,
|
||||
deletedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}deleted_at'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -899,6 +987,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
final int boundingBoxX2;
|
||||
final int boundingBoxY2;
|
||||
final String sourceType;
|
||||
final bool isVisible;
|
||||
final DateTime? deletedAt;
|
||||
const AssetFaceEntityData({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
@@ -910,6 +1000,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY2,
|
||||
required this.sourceType,
|
||||
required this.isVisible,
|
||||
this.deletedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -926,6 +1018,10 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
map['bounding_box_x2'] = i0.Variable<int>(boundingBoxX2);
|
||||
map['bounding_box_y2'] = i0.Variable<int>(boundingBoxY2);
|
||||
map['source_type'] = i0.Variable<String>(sourceType);
|
||||
map['is_visible'] = i0.Variable<bool>(isVisible);
|
||||
if (!nullToAbsent || deletedAt != null) {
|
||||
map['deleted_at'] = i0.Variable<DateTime>(deletedAt);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -945,6 +1041,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
boundingBoxX2: serializer.fromJson<int>(json['boundingBoxX2']),
|
||||
boundingBoxY2: serializer.fromJson<int>(json['boundingBoxY2']),
|
||||
sourceType: serializer.fromJson<String>(json['sourceType']),
|
||||
isVisible: serializer.fromJson<bool>(json['isVisible']),
|
||||
deletedAt: serializer.fromJson<DateTime?>(json['deletedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -961,6 +1059,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
'boundingBoxX2': serializer.toJson<int>(boundingBoxX2),
|
||||
'boundingBoxY2': serializer.toJson<int>(boundingBoxY2),
|
||||
'sourceType': serializer.toJson<String>(sourceType),
|
||||
'isVisible': serializer.toJson<bool>(isVisible),
|
||||
'deletedAt': serializer.toJson<DateTime?>(deletedAt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -975,6 +1075,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
int? boundingBoxX2,
|
||||
int? boundingBoxY2,
|
||||
String? sourceType,
|
||||
bool? isVisible,
|
||||
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
|
||||
}) => i1.AssetFaceEntityData(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
@@ -986,6 +1088,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt,
|
||||
);
|
||||
AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) {
|
||||
return AssetFaceEntityData(
|
||||
@@ -1013,6 +1117,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
sourceType: data.sourceType.present
|
||||
? data.sourceType.value
|
||||
: this.sourceType,
|
||||
isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible,
|
||||
deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1028,7 +1134,9 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
..write('boundingBoxY1: $boundingBoxY1, ')
|
||||
..write('boundingBoxX2: $boundingBoxX2, ')
|
||||
..write('boundingBoxY2: $boundingBoxY2, ')
|
||||
..write('sourceType: $sourceType')
|
||||
..write('sourceType: $sourceType, ')
|
||||
..write('isVisible: $isVisible, ')
|
||||
..write('deletedAt: $deletedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1045,6 +1153,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
boundingBoxX2,
|
||||
boundingBoxY2,
|
||||
sourceType,
|
||||
isVisible,
|
||||
deletedAt,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1059,7 +1169,9 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
other.boundingBoxY1 == this.boundingBoxY1 &&
|
||||
other.boundingBoxX2 == this.boundingBoxX2 &&
|
||||
other.boundingBoxY2 == this.boundingBoxY2 &&
|
||||
other.sourceType == this.sourceType);
|
||||
other.sourceType == this.sourceType &&
|
||||
other.isVisible == this.isVisible &&
|
||||
other.deletedAt == this.deletedAt);
|
||||
}
|
||||
|
||||
class AssetFaceEntityCompanion
|
||||
@@ -1074,6 +1186,8 @@ class AssetFaceEntityCompanion
|
||||
final i0.Value<int> boundingBoxX2;
|
||||
final i0.Value<int> boundingBoxY2;
|
||||
final i0.Value<String> sourceType;
|
||||
final i0.Value<bool> isVisible;
|
||||
final i0.Value<DateTime?> deletedAt;
|
||||
const AssetFaceEntityCompanion({
|
||||
this.id = const i0.Value.absent(),
|
||||
this.assetId = const i0.Value.absent(),
|
||||
@@ -1085,6 +1199,8 @@ class AssetFaceEntityCompanion
|
||||
this.boundingBoxX2 = const i0.Value.absent(),
|
||||
this.boundingBoxY2 = const i0.Value.absent(),
|
||||
this.sourceType = const i0.Value.absent(),
|
||||
this.isVisible = const i0.Value.absent(),
|
||||
this.deletedAt = const i0.Value.absent(),
|
||||
});
|
||||
AssetFaceEntityCompanion.insert({
|
||||
required String id,
|
||||
@@ -1097,6 +1213,8 @@ class AssetFaceEntityCompanion
|
||||
required int boundingBoxX2,
|
||||
required int boundingBoxY2,
|
||||
required String sourceType,
|
||||
this.isVisible = const i0.Value.absent(),
|
||||
this.deletedAt = const i0.Value.absent(),
|
||||
}) : id = i0.Value(id),
|
||||
assetId = i0.Value(assetId),
|
||||
imageWidth = i0.Value(imageWidth),
|
||||
@@ -1117,6 +1235,8 @@ class AssetFaceEntityCompanion
|
||||
i0.Expression<int>? boundingBoxX2,
|
||||
i0.Expression<int>? boundingBoxY2,
|
||||
i0.Expression<String>? sourceType,
|
||||
i0.Expression<bool>? isVisible,
|
||||
i0.Expression<DateTime>? deletedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
@@ -1129,6 +1249,8 @@ class AssetFaceEntityCompanion
|
||||
if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2,
|
||||
if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2,
|
||||
if (sourceType != null) 'source_type': sourceType,
|
||||
if (isVisible != null) 'is_visible': isVisible,
|
||||
if (deletedAt != null) 'deleted_at': deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1143,6 +1265,8 @@ class AssetFaceEntityCompanion
|
||||
i0.Value<int>? boundingBoxX2,
|
||||
i0.Value<int>? boundingBoxY2,
|
||||
i0.Value<String>? sourceType,
|
||||
i0.Value<bool>? isVisible,
|
||||
i0.Value<DateTime?>? deletedAt,
|
||||
}) {
|
||||
return i1.AssetFaceEntityCompanion(
|
||||
id: id ?? this.id,
|
||||
@@ -1155,6 +1279,8 @@ class AssetFaceEntityCompanion
|
||||
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1191,6 +1317,12 @@ class AssetFaceEntityCompanion
|
||||
if (sourceType.present) {
|
||||
map['source_type'] = i0.Variable<String>(sourceType.value);
|
||||
}
|
||||
if (isVisible.present) {
|
||||
map['is_visible'] = i0.Variable<bool>(isVisible.value);
|
||||
}
|
||||
if (deletedAt.present) {
|
||||
map['deleted_at'] = i0.Variable<DateTime>(deletedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1206,7 +1338,9 @@ class AssetFaceEntityCompanion
|
||||
..write('boundingBoxY1: $boundingBoxY1, ')
|
||||
..write('boundingBoxX2: $boundingBoxX2, ')
|
||||
..write('boundingBoxY2: $boundingBoxY2, ')
|
||||
..write('sourceType: $sourceType')
|
||||
..write('sourceType: $sourceType, ')
|
||||
..write('isVisible: $isVisible, ')
|
||||
..write('deletedAt: $deletedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 19;
|
||||
int get schemaVersion => 20;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -226,6 +226,10 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
|
||||
await m.createIndex(v19.idxStackPrimaryAssetId);
|
||||
},
|
||||
from19To20: (m, v20) async {
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8360,6 +8360,550 @@ final class Schema19 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema20 extends i0.VersionedSchema {
|
||||
Schema20({required super.database}) : super(version: 20);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxRemoteAlbumOwnerId,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetLocalDateTimeDay,
|
||||
idxRemoteAssetLocalDateTimeMonth,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape28 remoteAssetEntity = Shape28(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
_column_101,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape26 localAssetEntity = Shape26(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_98,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
|
||||
'idx_remote_album_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||
'idx_remote_asset_local_date_time_day',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||
'idx_remote_asset_local_date_time_month',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_99,
|
||||
_column_100,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape29 assetFaceEntity = Shape29(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
_column_102,
|
||||
_column_18,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape25 trashedLocalAssetEntity = Shape25(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_97,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape29 extends i0.VersionedTable {
|
||||
Shape29({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get assetId =>
|
||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get personId =>
|
||||
columnsByName['person_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get imageWidth =>
|
||||
columnsByName['image_width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get imageHeight =>
|
||||
columnsByName['image_height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxX1 =>
|
||||
columnsByName['bounding_box_x1']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxY1 =>
|
||||
columnsByName['bounding_box_y1']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxX2 =>
|
||||
columnsByName['bounding_box_x2']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxY2 =>
|
||||
columnsByName['bounding_box_y2']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get sourceType =>
|
||||
columnsByName['source_type']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isVisible =>
|
||||
columnsByName['is_visible']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<DateTime> get deletedAt =>
|
||||
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>(
|
||||
'is_visible',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_visible" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const CustomExpression('1'),
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -8379,6 +8923,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -8472,6 +9017,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from18To19(migrator, schema);
|
||||
return 19;
|
||||
case 19:
|
||||
final schema = Schema20(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from19To20(migrator, schema);
|
||||
return 20;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -8497,6 +9047,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -8517,5 +9068,6 @@ i1.OnUpgrade stepByStep({
|
||||
from16To17: from16To17,
|
||||
from17To18: from17To18,
|
||||
from18To19: from18To19,
|
||||
from19To20: from19To20,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -184,7 +184,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
if (keepFavorites) {
|
||||
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false);
|
||||
whereClause =
|
||||
whereClause & _db.localAssetEntity.isFavorite.equals(false) & _db.remoteAssetEntity.isFavorite.equals(false);
|
||||
}
|
||||
|
||||
query.where(whereClause);
|
||||
|
||||
@@ -16,9 +16,15 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
|
||||
final query = _db.select(_db.assetFaceEntity).join([
|
||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||
])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false));
|
||||
final query =
|
||||
_db.select(_db.assetFaceEntity).join([
|
||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||
])..where(
|
||||
_db.assetFaceEntity.assetId.equals(assetId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull() &
|
||||
_db.personEntity.isHidden.equals(false),
|
||||
);
|
||||
|
||||
return query.map((row) {
|
||||
final person = row.readTable(_db.personEntity);
|
||||
@@ -39,7 +45,9 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
..where(
|
||||
people.isHidden.equals(false) &
|
||||
assets.deletedAt.isNull() &
|
||||
assets.visibility.equalsValue(AssetVisibility.timeline),
|
||||
assets.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
faces.isVisible.equals(true) &
|
||||
faces.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
|
||||
..orderBy([
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
@@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
}).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
|
||||
..join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
]);
|
||||
Future<List<String>> getSortedAlbumIds(List<String> albumIds, {required AssetDateAggregation aggregation}) async {
|
||||
if (albumIds.isEmpty) return [];
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
|
||||
}
|
||||
final jsonIds = jsonEncode(albumIds);
|
||||
final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX';
|
||||
|
||||
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
|
||||
..join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
]);
|
||||
final rows = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT
|
||||
raae.album_id,
|
||||
$sqlAgg(rae.local_date_time) AS asset_date
|
||||
FROM json_each(?) ids
|
||||
INNER JOIN remote_album_asset_entity raae
|
||||
ON raae.album_id = ids.value
|
||||
INNER JOIN remote_asset_entity rae
|
||||
ON rae.id = raae.asset_id
|
||||
GROUP BY raae.album_id
|
||||
ORDER BY asset_date ASC
|
||||
''',
|
||||
variables: [Variable<String>(jsonIds)],
|
||||
readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity},
|
||||
)
|
||||
.get();
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
|
||||
return rows.map((row) => row.read<String>('album_id')).toList();
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
|
||||
@@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository {
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
tagIds: filter.tagIds,
|
||||
type: type,
|
||||
page: page,
|
||||
size: 100,
|
||||
@@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository {
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
tagIds: filter.tagIds,
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -25,6 +26,7 @@ class SyncApiRepository {
|
||||
|
||||
Future<void> streamChanges(
|
||||
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
|
||||
required SemVer serverVersion,
|
||||
Function()? onReset,
|
||||
int batchSize = kSyncEventBatchSize,
|
||||
http.Client? httpClient,
|
||||
@@ -64,7 +66,8 @@ class SyncApiRepository {
|
||||
SyncRequestType.partnerStacksV1,
|
||||
SyncRequestType.userMetadataV1,
|
||||
SyncRequestType.peopleV1,
|
||||
SyncRequestType.assetFacesV1,
|
||||
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2,
|
||||
],
|
||||
reset: shouldReset,
|
||||
).toJson(),
|
||||
@@ -190,6 +193,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.personV1: SyncPersonV1.fromJson,
|
||||
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
|
||||
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
|
||||
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
|
||||
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
|
||||
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
|
||||
};
|
||||
|
||||
@@ -652,6 +652,37 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetFacesV2(Iterable<SyncAssetFaceV2> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetFace in data) {
|
||||
final companion = AssetFaceEntityCompanion(
|
||||
assetId: Value(assetFace.assetId),
|
||||
personId: Value(assetFace.personId),
|
||||
imageWidth: Value(assetFace.imageWidth),
|
||||
imageHeight: Value(assetFace.imageHeight),
|
||||
boundingBoxX1: Value(assetFace.boundingBoxX1),
|
||||
boundingBoxY1: Value(assetFace.boundingBoxY1),
|
||||
boundingBoxX2: Value(assetFace.boundingBoxX2),
|
||||
boundingBoxY2: Value(assetFace.boundingBoxY2),
|
||||
sourceType: Value(assetFace.sourceType),
|
||||
deletedAt: Value(assetFace.deletedAt),
|
||||
isVisible: Value(assetFace.isVisible),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.assetFaceEntity,
|
||||
companion.copyWith(id: Value(assetFace.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetFacesV2', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAssetFacesV1(Iterable<SyncAssetFaceDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final tagsApiRepositoryProvider = Provider<TagsApiRepository>(
|
||||
(ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi),
|
||||
);
|
||||
|
||||
class TagsApiRepository extends ApiRepository {
|
||||
final TagsApi _api;
|
||||
const TagsApiRepository(this._api);
|
||||
|
||||
Future<List<TagResponseDto>?> getAllTags() async {
|
||||
return await _api.getAllTags();
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
|
||||
groupBy: groupBy,
|
||||
origin: TimelineOrigin.archive,
|
||||
joinLocal: true,
|
||||
);
|
||||
|
||||
TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||
@@ -424,7 +425,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.assetFaceEntity.personId.equals(personId),
|
||||
_db.assetFaceEntity.personId.equals(personId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
);
|
||||
|
||||
return query.map((row) {
|
||||
@@ -449,7 +452,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.assetFaceEntity.personId.equals(personId),
|
||||
_db.assetFaceEntity.personId.equals(personId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
@@ -479,7 +484,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.assetFaceEntity.personId.equals(personId),
|
||||
_db.assetFaceEntity.personId.equals(personId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
@@ -214,6 +214,7 @@ class SearchFilter {
|
||||
String? ocr;
|
||||
String? language;
|
||||
String? assetId;
|
||||
List<String>? tagIds;
|
||||
Set<PersonDto> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
@@ -231,6 +232,7 @@ class SearchFilter {
|
||||
this.ocr,
|
||||
this.language,
|
||||
this.assetId,
|
||||
this.tagIds,
|
||||
required this.people,
|
||||
required this.location,
|
||||
required this.camera,
|
||||
@@ -246,6 +248,7 @@ class SearchFilter {
|
||||
(description == null || (description!.isEmpty)) &&
|
||||
(assetId == null || (assetId!.isEmpty)) &&
|
||||
(ocr == null || (ocr!.isEmpty)) &&
|
||||
(tagIds ?? []).isEmpty &&
|
||||
people.isEmpty &&
|
||||
location.country == null &&
|
||||
location.state == null &&
|
||||
@@ -269,6 +272,7 @@ class SearchFilter {
|
||||
String? ocr,
|
||||
String? assetId,
|
||||
Set<PersonDto>? people,
|
||||
List<String>? tagIds,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
SearchDateFilter? date,
|
||||
@@ -290,12 +294,13 @@ class SearchFilter {
|
||||
display: display ?? this.display,
|
||||
rating: rating ?? this.rating,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
tagIds: tagIds ?? this.tagIds,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -309,6 +314,7 @@ class SearchFilter {
|
||||
other.ocr == ocr &&
|
||||
other.assetId == assetId &&
|
||||
other.people == people &&
|
||||
other.tagIds == tagIds &&
|
||||
other.location == location &&
|
||||
other.camera == camera &&
|
||||
other.date == date &&
|
||||
@@ -326,6 +332,7 @@ class SearchFilter {
|
||||
ocr.hashCode ^
|
||||
assetId.hashCode ^
|
||||
people.hashCode ^
|
||||
tagIds.hashCode ^
|
||||
location.hashCode ^
|
||||
camera.hashCode ^
|
||||
date.hashCode ^
|
||||
|
||||
@@ -6,6 +6,7 @@ class ServerFeatures {
|
||||
final bool oauthEnabled;
|
||||
final bool passwordLogin;
|
||||
final bool ocr;
|
||||
final bool smartSearch;
|
||||
|
||||
const ServerFeatures({
|
||||
required this.trash,
|
||||
@@ -13,21 +14,30 @@ class ServerFeatures {
|
||||
required this.oauthEnabled,
|
||||
required this.passwordLogin,
|
||||
this.ocr = false,
|
||||
this.smartSearch = false,
|
||||
});
|
||||
|
||||
ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) {
|
||||
ServerFeatures copyWith({
|
||||
bool? trash,
|
||||
bool? map,
|
||||
bool? oauthEnabled,
|
||||
bool? passwordLogin,
|
||||
bool? ocr,
|
||||
bool? smartSearch,
|
||||
}) {
|
||||
return ServerFeatures(
|
||||
trash: trash ?? this.trash,
|
||||
map: map ?? this.map,
|
||||
oauthEnabled: oauthEnabled ?? this.oauthEnabled,
|
||||
passwordLogin: passwordLogin ?? this.passwordLogin,
|
||||
ocr: ocr ?? this.ocr,
|
||||
smartSearch: smartSearch ?? this.smartSearch,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)';
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
|
||||
}
|
||||
|
||||
ServerFeatures.fromDto(ServerFeaturesDto dto)
|
||||
@@ -35,7 +45,8 @@ class ServerFeatures {
|
||||
map = dto.map,
|
||||
oauthEnabled = dto.oauth,
|
||||
passwordLogin = dto.passwordLogin,
|
||||
ocr = dto.ocr;
|
||||
ocr = dto.ocr,
|
||||
smartSearch = dto.smartSearch;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant ServerFeatures other) {
|
||||
@@ -45,11 +56,17 @@ class ServerFeatures {
|
||||
other.map == map &&
|
||||
other.oauthEnabled == oauthEnabled &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.ocr == ocr;
|
||||
other.ocr == ocr &&
|
||||
other.smartSearch == smartSearch;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode;
|
||||
return trash.hashCode ^
|
||||
map.hashCode ^
|
||||
oauthEnabled.hashCode ^
|
||||
passwordLogin.hashCode ^
|
||||
ocr.hashCode ^
|
||||
smartSearch.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class SharedLink {
|
||||
final String key;
|
||||
final bool showMetadata;
|
||||
final SharedLinkSource type;
|
||||
final String? slug;
|
||||
|
||||
const SharedLink({
|
||||
required this.id,
|
||||
@@ -27,6 +28,7 @@ class SharedLink {
|
||||
required this.key,
|
||||
required this.showMetadata,
|
||||
required this.type,
|
||||
required this.slug,
|
||||
});
|
||||
|
||||
SharedLink copyWith({
|
||||
@@ -41,6 +43,7 @@ class SharedLink {
|
||||
String? key,
|
||||
bool? showMetadata,
|
||||
SharedLinkSource? type,
|
||||
String? slug,
|
||||
}) {
|
||||
return SharedLink(
|
||||
id: id ?? this.id,
|
||||
@@ -54,6 +57,7 @@ class SharedLink {
|
||||
key: key ?? this.key,
|
||||
showMetadata: showMetadata ?? this.showMetadata,
|
||||
type: type ?? this.type,
|
||||
slug: slug ?? this.slug,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +70,7 @@ class SharedLink {
|
||||
expiresAt = dto.expiresAt,
|
||||
key = dto.key,
|
||||
showMetadata = dto.showMetadata,
|
||||
slug = dto.slug,
|
||||
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
|
||||
title = dto.type == SharedLinkType.ALBUM
|
||||
? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
|
||||
@@ -78,7 +83,7 @@ class SharedLink {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
|
||||
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -94,7 +99,8 @@ class SharedLink {
|
||||
other.expiresAt == expiresAt &&
|
||||
other.key == key &&
|
||||
other.showMetadata == showMetadata &&
|
||||
other.type == type;
|
||||
other.type == type &&
|
||||
other.slug == slug;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -108,5 +114,6 @@ class SharedLink {
|
||||
expiresAt.hashCode ^
|
||||
key.hashCode ^
|
||||
showMetadata.hashCode ^
|
||||
type.hashCode;
|
||||
type.hashCode ^
|
||||
slug.hashCode;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final user = sharedUsers.value[index];
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(user: user, radius: 22),
|
||||
leading: UserCircleAvatar(user: user),
|
||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
||||
|
||||
@@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget {
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: UserCircleAvatar(user: sharedUsers.value[index], radius: 18, size: 36),
|
||||
child: UserCircleAvatar(user: sharedUsers.value[index], size: 36),
|
||||
);
|
||||
}),
|
||||
itemCount: sharedUsers.value.length,
|
||||
|
||||
@@ -221,8 +221,37 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
onDragUpdate: (_, details, __) {
|
||||
handleSwipeUpDown(details);
|
||||
},
|
||||
onTapDown: (_, __, ___) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
onTapDown: (ctx, tapDownDetails, _) {
|
||||
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
|
||||
if (!tapToNavigate) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
double tapX = tapDownDetails.globalPosition.dx;
|
||||
double screenWidth = ctx.width;
|
||||
|
||||
// We want to change images if the user taps in the leftmost or
|
||||
// rightmost quarter of the screen
|
||||
bool tappedLeftSide = tapX < screenWidth / 4;
|
||||
bool tappedRightSide = tapX > screenWidth * (3 / 4);
|
||||
|
||||
int? currentPage = controller.page?.toInt();
|
||||
int maxPage = renderList.totalAssets - 1;
|
||||
|
||||
if (tappedLeftSide && currentPage != null) {
|
||||
// Nested if because we don't want to fallback to show/hide controls
|
||||
if (currentPage != 0) {
|
||||
controller.jumpToPage(currentPage - 1);
|
||||
}
|
||||
} else if (tappedRightSide && currentPage != null) {
|
||||
// Nested if because we don't want to fallback to show/hide controls
|
||||
if (currentPage != maxPage) {
|
||||
controller.jumpToPage(currentPage + 1);
|
||||
}
|
||||
} else {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
}
|
||||
},
|
||||
onLongPressStart: asset.isMotionPhoto
|
||||
? (_, __, ___) {
|
||||
|
||||
@@ -109,9 +109,43 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (context.router.current.name == SplashScreenRoute.name) {
|
||||
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
|
||||
if (needBetaMigration) {
|
||||
bool migrate =
|
||||
(await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("New Timeline Experience"),
|
||||
content: const Text(
|
||||
"The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
|
||||
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
|
||||
],
|
||||
),
|
||||
)) ??
|
||||
false;
|
||||
if (migrate != true) {
|
||||
migrate =
|
||||
(await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Are you sure?"),
|
||||
content: const Text(
|
||||
"If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
|
||||
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
|
||||
],
|
||||
),
|
||||
)) ??
|
||||
false;
|
||||
}
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
|
||||
return;
|
||||
if (migrate) {
|
||||
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -12,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_converter.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
@@ -30,27 +29,10 @@ class EditImagePage extends ConsumerWidget {
|
||||
final bool isEdited;
|
||||
|
||||
const EditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
|
||||
Future<Uint8List> _imageToUint8List(Image image) async {
|
||||
final Completer<Uint8List> completer = Completer();
|
||||
image.image
|
||||
.resolve(const ImageConfiguration())
|
||||
.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) {
|
||||
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
} else {
|
||||
completer.completeError('Failed to convert image to bytes');
|
||||
}
|
||||
});
|
||||
}, onError: (exception, stackTrace) => completer.completeError(exception)),
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _saveEditedImage(BuildContext context, Asset asset, Image image, WidgetRef ref) async {
|
||||
try {
|
||||
final Uint8List imageData = await _imageToUint8List(image);
|
||||
final Uint8List imageData = await imageToUint8List(image);
|
||||
await ref
|
||||
.read(fileMediaRepositoryProvider)
|
||||
.saveImage(imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg");
|
||||
|
||||
@@ -29,6 +29,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
|
||||
final descriptionFocusNode = useFocusNode();
|
||||
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
|
||||
final slugController = useTextEditingController(text: existingLink?.slug ?? "");
|
||||
final slugFocusNode = useFocusNode();
|
||||
final showMetadata = useState(existingLink?.showMetadata ?? true);
|
||||
final allowDownload = useState(existingLink?.allowDownload ?? true);
|
||||
final allowUpload = useState(existingLink?.allowUpload ?? false);
|
||||
@@ -108,6 +110,26 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSlugField() {
|
||||
return TextField(
|
||||
controller: slugController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
focusNode: slugFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'custom_url'.tr(),
|
||||
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'custom_url'.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
|
||||
),
|
||||
onTapOutside: (_) => slugFocusNode.unfocus(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildShowMetaButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showMetadata.value,
|
||||
@@ -261,6 +283,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
allowUpload: allowUpload.value,
|
||||
description: descriptionController.text.isEmpty ? null : descriptionController.text,
|
||||
password: passwordController.text.isEmpty ? null : passwordController.text,
|
||||
slug: slugController.text.isEmpty ? null : slugController.text,
|
||||
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
|
||||
);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
@@ -274,7 +297,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (newLink != null && serverUrl != null) {
|
||||
newShareLink.value = "${serverUrl}share/${newLink.key}";
|
||||
final hasSlug = newLink.slug?.isNotEmpty == true;
|
||||
final urlPath = hasSlug ? newLink.slug : newLink.key;
|
||||
final basePath = hasSlug ? 's' : 'share';
|
||||
newShareLink.value = "$serverUrl$basePath/$urlPath";
|
||||
copyLinkToClipboard();
|
||||
} else if (newLink == null) {
|
||||
ImmichToast.show(
|
||||
@@ -292,6 +318,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
bool? meta;
|
||||
String? desc;
|
||||
String? password;
|
||||
String? slug;
|
||||
DateTime? expiry;
|
||||
bool? changeExpiry;
|
||||
|
||||
@@ -315,6 +342,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
password = passwordController.text;
|
||||
}
|
||||
|
||||
if (slugController.text != (existingLink!.slug ?? "")) {
|
||||
slug = slugController.text.isEmpty ? null : slugController.text;
|
||||
} else {
|
||||
slug = existingLink!.slug;
|
||||
}
|
||||
|
||||
if (editExpiry.value) {
|
||||
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
|
||||
changeExpiry = true;
|
||||
@@ -329,6 +362,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
allowUpload: upload,
|
||||
description: desc,
|
||||
password: password,
|
||||
slug: slug,
|
||||
expiresAt: expiry,
|
||||
changeExpiry: changeExpiry,
|
||||
);
|
||||
@@ -349,6 +383,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
|
||||
Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
|
||||
child: buildShowMetaButton(),
|
||||
|
||||
@@ -118,7 +118,7 @@ class MapPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// finds the nearest asset marker from the tap point and store it as the selectedMarker
|
||||
Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
|
||||
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
|
||||
// Guard map not created
|
||||
if (mapController.value == null) {
|
||||
return;
|
||||
|
||||
@@ -28,7 +28,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
|
||||
}
|
||||
|
||||
Future<void> onMapClick(Point<num> point, LatLng centre) async {
|
||||
Future<void> onMapClick(Point<num> _, LatLng centre) async {
|
||||
selectedLatLng.value = centre;
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||
if (marker.value != null) {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
|
||||
final children = <Widget>[];
|
||||
|
||||
final items = [
|
||||
(variant: ImmichVariant.filled, title: "Filled Variant"),
|
||||
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
|
||||
];
|
||||
|
||||
for (final (:variant, :title) in items) {
|
||||
children.add(Text(title));
|
||||
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
class _ComponentTitle extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _ComponentTitle(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(title, style: context.textTheme.titleLarge);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class ImmichUIShowcasePage extends StatelessWidget {
|
||||
const ImmichUIShowcasePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Immich UI Showcase')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 10,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _ComponentTitle("IconButton"),
|
||||
..._showcaseBuilder(
|
||||
(variant, color) =>
|
||||
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
|
||||
),
|
||||
const _ComponentTitle("CloseButton"),
|
||||
..._showcaseBuilder(
|
||||
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
|
||||
),
|
||||
const _ComponentTitle("TextButton"),
|
||||
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.filled,
|
||||
color: ImmichColor.primary,
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.filled,
|
||||
color: ImmichColor.primary,
|
||||
loading: true,
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.primary,
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: "Text Button",
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.primary,
|
||||
loading: true,
|
||||
),
|
||||
const _ComponentTitle("Form"),
|
||||
ImmichForm(
|
||||
onSubmit: () {},
|
||||
child: const Column(
|
||||
spacing: 10,
|
||||
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
@RoutePage()
|
||||
class DriftActivitiesPage extends HookConsumerWidget {
|
||||
final RemoteAlbum album;
|
||||
final String? assetId;
|
||||
final String? assetName;
|
||||
|
||||
const DriftActivitiesPage({super.key, required this.album});
|
||||
const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id));
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, assetId));
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
void scrollToBottom() {
|
||||
@@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(album.name),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(album.name),
|
||||
if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
@@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
activityWidgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: CommentBubble(activity: activity),
|
||||
child: CommentBubble(activity: activity, isAssetActivity: assetId != null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_rounded, size: 28),
|
||||
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
showUploadButton: false,
|
||||
|
||||
@@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(user: user, radius: 22),
|
||||
leading: UserCircleAvatar(user: user),
|
||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
|
||||
@@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final user = sharedUsers[index];
|
||||
return ListTile(
|
||||
leading: UserCircleAvatar(user: user, radius: 22),
|
||||
leading: UserCircleAvatar(user: user),
|
||||
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
|
||||
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
@@ -14,6 +13,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/image_converter.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -33,23 +33,6 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
final bool isEdited;
|
||||
|
||||
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
|
||||
Future<Uint8List> _imageToUint8List(Image image) async {
|
||||
final Completer<Uint8List> completer = Completer();
|
||||
image.image
|
||||
.resolve(const ImageConfiguration())
|
||||
.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) {
|
||||
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
} else {
|
||||
completer.completeError('Failed to convert image to bytes');
|
||||
}
|
||||
});
|
||||
}, onError: (exception, stackTrace) => completer.completeError(exception)),
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void _exitEditing(BuildContext context) {
|
||||
// this assumes that the only way to get to this page is from the AssetViewerRoute
|
||||
@@ -58,7 +41,7 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
|
||||
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
|
||||
try {
|
||||
final Uint8List imageData = await _imageToUint8List(image);
|
||||
final Uint8List imageData = await imageToUint8List(image);
|
||||
LocalAsset? localAsset;
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:crop_image/crop_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/image_converter.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ProfilePictureCropPage extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const ProfilePictureCropPage({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfilePictureCropPage> createState() => _ProfilePictureCropPageState();
|
||||
}
|
||||
|
||||
class _ProfilePictureCropPageState extends ConsumerState<ProfilePictureCropPage> {
|
||||
late final CropController _cropController;
|
||||
bool _isLoading = false;
|
||||
bool _didInitCropController = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cropController = CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1));
|
||||
|
||||
// Lock aspect ratio to 1:1 for circular/square crop
|
||||
// CropController depends on CropImage initializing its bitmap size.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _didInitCropController) {
|
||||
return;
|
||||
}
|
||||
_didInitCropController = true;
|
||||
|
||||
_cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
|
||||
_cropController.aspectRatio = 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cropController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleDone() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final croppedImage = await _cropController.croppedImage();
|
||||
final pngBytes = await imageToUint8List(croppedImage);
|
||||
final xFile = XFile.fromData(pngBytes, mimeType: 'image/png');
|
||||
final success = await ref
|
||||
.read(uploadProfileImageProvider.notifier)
|
||||
.upload(xFile, fileName: 'profile-picture.png');
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (success) {
|
||||
final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath;
|
||||
ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath);
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user != null) {
|
||||
unawaited(ref.read(currentUserProvider.notifier).refresh());
|
||||
}
|
||||
unawaited(ref.read(backupProvider.notifier).updateDiskInfo());
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'profile_picture_set'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
unawaited(context.maybePop());
|
||||
}
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'errors.unable_to_set_profile_picture'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'errors.unable_to_set_profile_picture'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Create Image widget from asset
|
||||
final image = Image(image: getFullImageProvider(widget.asset));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("set_profile_picture".tr()),
|
||||
leading: _isLoading ? null : const ImmichCloseButton(),
|
||||
actions: [
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
else
|
||||
ImmichIconButton(
|
||||
icon: Icons.done_rounded,
|
||||
color: ImmichColor.primary,
|
||||
variant: ImmichVariant.ghost,
|
||||
onPressed: _handleDone,
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
child: CropImage(controller: _cropController, image: image, gridColor: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
@@ -20,9 +21,11 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/feature_check.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||
@@ -39,8 +42,15 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
||||
final searchHintText = useState<String>('sunrise_on_the_beach'.t(context: context));
|
||||
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
|
||||
final textSearchType = useState<TextSearchType>(
|
||||
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename,
|
||||
);
|
||||
final searchHintText = useState<String>(
|
||||
serverFeatures.smartSearch
|
||||
? 'sunrise_on_the_beach'.t(context: context)
|
||||
: 'file_name_or_extension'.t(context: context),
|
||||
);
|
||||
final textSearchController = useTextEditingController();
|
||||
final preFilter = ref.watch(searchPreFilterProvider);
|
||||
final filter = useState<SearchFilter>(
|
||||
@@ -54,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
assetId: preFilter?.assetId,
|
||||
tagIds: preFilter?.tagIds ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final tagCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final isSearching = useState(false);
|
||||
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
||||
|
||||
SnackBar searchInfoSnackBar(String message) {
|
||||
return SnackBar(
|
||||
@@ -140,10 +150,12 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
handleOnSelect(Set<PersonDto> value) {
|
||||
filter.value = filter.value.copyWith(people: value);
|
||||
|
||||
peopleCurrentFilterWidget.value = Text(
|
||||
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
|
||||
if (label.isNotEmpty) {
|
||||
peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
|
||||
} else {
|
||||
peopleCurrentFilterWidget.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
@@ -169,6 +181,42 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
showTagPicker() {
|
||||
handleOnSelect(Iterable<Tag> tags) {
|
||||
filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList());
|
||||
final label = tags.map((t) => t.value).join(', ');
|
||||
if (label.isEmpty) {
|
||||
tagCurrentFilterWidget.value = null;
|
||||
} else {
|
||||
tagCurrentFilterWidget.value = Text(
|
||||
label.isEmpty ? 'tags'.t(context: context) : label,
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(tagIds: []);
|
||||
tagCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_tags_title'.t(context: context),
|
||||
expanded: true,
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
showLocationPicker() {
|
||||
handleOnSelect(Map<String, String?> value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
@@ -518,23 +566,26 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image_search_rounded),
|
||||
title: Text(
|
||||
'search_by_context'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
||||
FeatureCheck(
|
||||
feature: (features) => features.smartSearch,
|
||||
child: MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image_search_rounded),
|
||||
title: Text(
|
||||
'search_by_context'.t(context: context),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value = 'sunrise_on_the_beach'.t(context: context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
@@ -647,6 +698,13 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.sell_outlined,
|
||||
onTap: showTagPicker,
|
||||
label: 'tags'.t(context: context),
|
||||
currentFilter: tagCurrentFilterWidget.value,
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.camera_alt_outlined,
|
||||
onTap: showCameraPicker,
|
||||
@@ -666,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (isRatingEnabled) ...[
|
||||
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
label: 'search_filter_star_rating'.t(context: context),
|
||||
currentFilter: ratingCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
@@ -22,6 +23,18 @@ class DeleteTrashActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
|
||||
|
||||
final confirmDelete =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => TrashDeleteDialog(count: selectCount),
|
||||
) ??
|
||||
false;
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class SetAlbumCoverActionButton extends ConsumerWidget {
|
||||
final String albumId;
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SetAlbumCoverActionButton({
|
||||
super.key,
|
||||
required this.albumId,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'album_cover_updated'.t(context: context);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.image_outlined,
|
||||
label: 'set_as_album_cover'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import 'package:auto_route/auto_route.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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SetProfilePictureActionButton extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(ProfilePictureCropRoute(asset: asset));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.account_circle_outlined,
|
||||
label: "set_as_profile_picture".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
|
||||
prefixIcon: user != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
||||
child: UserCircleAvatar(user: user, size: 30),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: IconButton(
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
|
||||
class ActivitiesBottomSheet extends HookConsumerWidget {
|
||||
final DraggableScrollableController controller;
|
||||
final double initialChildSize;
|
||||
final bool scrollToBottomInitially;
|
||||
|
||||
const ActivitiesBottomSheet({
|
||||
required this.controller,
|
||||
this.initialChildSize = 0.35,
|
||||
this.scrollToBottomInitially = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
|
||||
|
||||
Future<void> onAddComment(String comment) async {
|
||||
await activityNotifier.addComment(comment);
|
||||
}
|
||||
|
||||
Widget buildActivitiesSliver() {
|
||||
return activities.widgetWhen(
|
||||
onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
onData: (data) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == data.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final activity = data[data.length - 1 - index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: CommentBubble(activity: activity, isAssetActivity: true),
|
||||
);
|
||||
}, childCount: data.length + 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
actions: [],
|
||||
slivers: [buildActivitiesSliver()],
|
||||
footer: Padding(
|
||||
// TODO: avoid fixed padding, use context.padding.bottom
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(indent: 16, endIndent: 16),
|
||||
DriftActivityTextField(
|
||||
isEnabled: album.isActivityEnabled,
|
||||
isBottomSheet: true,
|
||||
// likeId: likedId,
|
||||
onSubmit: onAddComment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
controller: controller,
|
||||
initialChildSize: initialChildSize,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 0.88,
|
||||
expand: false,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
|
||||
class AssetDetails extends ConsumerWidget {
|
||||
final double minHeight;
|
||||
|
||||
const AssetDetails({required this.minHeight, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DragHandle(),
|
||||
const DateTimeDetails(),
|
||||
const PeopleDetails(),
|
||||
const LocationDetails(),
|
||||
const TechnicalDetails(),
|
||||
const RatingDetails(),
|
||||
const AppearsInDetails(),
|
||||
SizedBox(height: context.padding.bottom + 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AppearsInDetails extends ConsumerWidget {
|
||||
const AppearsInDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
|
||||
|
||||
return assetAlbums.when(
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
albums.sortBy((a) => a.name);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: albums.map((album) {
|
||||
final isOwner = album.ownerId == userId;
|
||||
return AlbumTile(
|
||||
album: album,
|
||||
isOwner: isOwner,
|
||||
onAlbumSelected: (album) async {
|
||||
ref.invalidate(assetViewerProvider);
|
||||
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class DateTimeDetails extends ConsumerWidget {
|
||||
const DateTimeDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner
|
||||
? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context)
|
||||
: null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||
dateTime: exifInfo!.dateTimeOriginal!,
|
||||
timeZone: exifInfo.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||
return '$date$_kSeparator$time $timezone';
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
||||
|
||||
@override
|
||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||
}
|
||||
|
||||
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
|
||||
late TextEditingController _controller;
|
||||
final _descriptionFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.exif.description ?? '');
|
||||
}
|
||||
|
||||
Future<void> saveDescription(String? previousDescription) async {
|
||||
final newDescription = _controller.text.trim();
|
||||
|
||||
if (newDescription == previousDescription) {
|
||||
_descriptionFocus.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
|
||||
|
||||
if (!editAction.success) {
|
||||
_controller.text = previousDescription ?? '';
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'exif_bottom_sheet_description_error'.t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
_descriptionFocus.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||
_controller.text = currentDescription;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.isEditable,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
focusNode: _descriptionFocus,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class DragHandle extends StatelessWidget {
|
||||
const DragHandle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(2)),
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
+5
-6
@@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class SheetLocationDetails extends ConsumerStatefulWidget {
|
||||
const SheetLocationDetails({super.key});
|
||||
class LocationDetails extends ConsumerStatefulWidget {
|
||||
const LocationDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SheetLocationDetailsState();
|
||||
ConsumerState createState() => _LocationDetailsState();
|
||||
}
|
||||
|
||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
MapLibreMapController? _mapController;
|
||||
|
||||
String? _getLocationName(ExifInfo? exifInfo) {
|
||||
@@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
final currentExif = current.valueOrNull;
|
||||
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
+8
-8
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
@@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
|
||||
class SheetPeopleDetails extends ConsumerStatefulWidget {
|
||||
const SheetPeopleDetails({super.key});
|
||||
class PeopleDetails extends ConsumerStatefulWidget {
|
||||
const PeopleDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SheetPeopleDetailsState();
|
||||
ConsumerState createState() => _PeopleDetailsState();
|
||||
}
|
||||
|
||||
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
@@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final person in people)
|
||||
_PeopleAvatar(
|
||||
_Avatar(
|
||||
person: person,
|
||||
assetFileCreatedAt: asset.createdAt,
|
||||
onTap: () {
|
||||
@@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
}
|
||||
}
|
||||
|
||||
class _PeopleAvatar extends StatelessWidget {
|
||||
class _Avatar extends StatelessWidget {
|
||||
final DriftPerson person;
|
||||
final DateTime assetFileCreatedAt;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onNameTap;
|
||||
final double imageSize = 96;
|
||||
|
||||
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
|
||||
const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
|
||||
class RatingDetails extends ConsumerWidget {
|
||||
const RatingDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
if (!isRatingEnabled) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
filledColor: context.themeData.colorScheme.primary,
|
||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||
itemSize: 40,
|
||||
onRatingUpdate: (rating) async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
||||
},
|
||||
onClearRating: () async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
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/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class TechnicalDetails extends ConsumerWidget {
|
||||
const TechnicalDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SheetTile(
|
||||
title: 'details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
_buildFileInfoTile(context, ref, asset, exifInfo),
|
||||
if (cameraTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
if (lensTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final icon = Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 24,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
);
|
||||
final subtitle = _getFileInfo(asset, exifInfo);
|
||||
final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary);
|
||||
|
||||
if (asset is LocalAsset) {
|
||||
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
|
||||
return FutureBuilder<String?>(
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
return SheetTile(
|
||||
title: snapshot.data ?? asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: icon,
|
||||
subtitle: subtitle,
|
||||
subtitleStyle: subtitleStyle,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: icon,
|
||||
subtitle: subtitle,
|
||||
subtitleStyle: subtitleStyle,
|
||||
);
|
||||
}
|
||||
|
||||
static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final height = asset.height;
|
||||
final width = asset.width;
|
||||
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : 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',
|
||||
};
|
||||
}
|
||||
|
||||
static 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',
|
||||
};
|
||||
}
|
||||
|
||||
static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) return null;
|
||||
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
static String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) return null;
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/gestures.dart' show Drag, kTouchSlop;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.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/asset_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.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/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
enum _DragIntent { none, scroll, dismiss }
|
||||
|
||||
class AssetPage extends ConsumerStatefulWidget {
|
||||
final int index;
|
||||
final int heroOffset;
|
||||
final void Function(int direction)? onTapNavigate;
|
||||
|
||||
const AssetPage({super.key, required this.index, required this.heroOffset, this.onTapNavigate});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _AssetPageState();
|
||||
}
|
||||
|
||||
class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
PhotoViewControllerBase? _viewController;
|
||||
StreamSubscription? _scaleBoundarySub;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier);
|
||||
|
||||
late PhotoViewControllerValue _initialPhotoViewState;
|
||||
|
||||
bool _showingDetails = false;
|
||||
bool _isZoomed = false;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
|
||||
DragStartDetails? _dragStart;
|
||||
_DragIntent _dragIntent = _DragIntent.none;
|
||||
Drag? _drag;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_proxyScrollController.addListener(_onScroll);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_proxyScrollController.hasClients) return;
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
if (_showingDetails && _snapOffset > 0) {
|
||||
_proxyScrollController.jumpTo(_snapOffset);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_proxyScrollController.dispose();
|
||||
_scaleBoundarySub?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_videoScaleStateNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
switch (event) {
|
||||
case ViewerShowDetailsEvent():
|
||||
_showDetails();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void _showDetails() {
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
|
||||
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
bool _willClose(double scrollVelocity) {
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
|
||||
|
||||
final position = _proxyScrollController.position;
|
||||
return _proxyScrollController.position.pixels < _snapOffset &&
|
||||
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final offset = _proxyScrollController.offset;
|
||||
if (offset > SnapScrollPhysics.minSnapDistance) {
|
||||
_viewer.setShowingDetails(true);
|
||||
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
||||
_viewer.setShowingDetails(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _beginDrag(DragStartDetails details) {
|
||||
_dragStart = details;
|
||||
|
||||
if (_viewController != null) {
|
||||
_initialPhotoViewState = _viewController!.value;
|
||||
}
|
||||
|
||||
if (_showingDetails) {
|
||||
_dragIntent = _DragIntent.scroll;
|
||||
_startProxyDrag();
|
||||
}
|
||||
}
|
||||
|
||||
void _startProxyDrag() {
|
||||
if (_proxyScrollController.hasClients && _dragStart != null) {
|
||||
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateDrag(DragUpdateDetails details) {
|
||||
if (_dragStart == null) return;
|
||||
|
||||
if (_dragIntent == _DragIntent.none) {
|
||||
_dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) {
|
||||
< 0 => _DragIntent.scroll,
|
||||
> 0 => _DragIntent.dismiss,
|
||||
_ => _DragIntent.none,
|
||||
};
|
||||
}
|
||||
|
||||
switch (_dragIntent) {
|
||||
case _DragIntent.none:
|
||||
case _DragIntent.scroll:
|
||||
if (_drag == null) _startProxyDrag();
|
||||
_drag?.update(details);
|
||||
case _DragIntent.dismiss:
|
||||
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void _endDrag(DragEndDetails details) {
|
||||
if (_dragStart == null) return;
|
||||
|
||||
final start = _dragStart;
|
||||
_dragStart = null;
|
||||
|
||||
final intent = _dragIntent;
|
||||
_dragIntent = _DragIntent.none;
|
||||
|
||||
switch (intent) {
|
||||
case _DragIntent.none:
|
||||
case _DragIntent.scroll:
|
||||
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
|
||||
if (_willClose(scrollVelocity)) {
|
||||
_viewer.setShowingDetails(false);
|
||||
}
|
||||
_drag?.end(details);
|
||||
_drag = null;
|
||||
case _DragIntent.dismiss:
|
||||
const popThreshold = 75.0;
|
||||
if (details.localPosition.dy - start!.localPosition.dy > popThreshold) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
_viewController?.animateMultiple(
|
||||
position: _initialPhotoViewState.position,
|
||||
scale: _viewController?.initialScale ?? _initialPhotoViewState.scale,
|
||||
rotation: _initialPhotoViewState.rotation,
|
||||
);
|
||||
_viewer.setOpacity(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragStart(
|
||||
BuildContext context,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerBase controller,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
) {
|
||||
if (!_showingDetails && _isZoomed) return;
|
||||
_beginDrag(details);
|
||||
}
|
||||
|
||||
void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) =>
|
||||
_updateDrag(details);
|
||||
|
||||
void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details);
|
||||
|
||||
void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0));
|
||||
|
||||
void _handleDragDown(BuildContext context, Offset delta) {
|
||||
const dragRatio = 0.2;
|
||||
|
||||
final distance = delta.dy.abs();
|
||||
final maxScaleDistance = context.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale;
|
||||
final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null;
|
||||
|
||||
final opacity = 1.0 - (scaleReduction / dragRatio);
|
||||
|
||||
_viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale);
|
||||
_viewer.setOpacity(opacity);
|
||||
}
|
||||
|
||||
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
|
||||
if (_showingDetails || _dragStart != null) return;
|
||||
|
||||
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
|
||||
if (!tapToNavigate) {
|
||||
_viewer.toggleControls();
|
||||
return;
|
||||
}
|
||||
|
||||
final tapX = details.globalPosition.dx;
|
||||
final screenWidth = context.width;
|
||||
|
||||
// Navigate if the user taps in the leftmost or rightmost quarter of the screen
|
||||
final tappedLeftSide = tapX < screenWidth / 4;
|
||||
final tappedRightSide = tapX > screenWidth * (3 / 4);
|
||||
|
||||
if (tappedLeftSide) {
|
||||
widget.onTapNavigate?.call(-1);
|
||||
} else if (tappedRightSide) {
|
||||
widget.onTapNavigate?.call(1);
|
||||
} else {
|
||||
_viewer.toggleControls();
|
||||
}
|
||||
}
|
||||
|
||||
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed =
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.covering ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
||||
_viewer.setZoomed(_isZoomed);
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_showingDetails) _viewer.setControls(true);
|
||||
}
|
||||
|
||||
void _listenForScaleBoundaries(PhotoViewControllerBase? controller) {
|
||||
_scaleBoundarySub?.cancel();
|
||||
_scaleBoundarySub = null;
|
||||
if (controller == null || controller.scaleBoundaries != null) return;
|
||||
_scaleBoundarySub = controller.outputStateStream.listen((_) {
|
||||
if (controller.scaleBoundaries != null) {
|
||||
_scaleBoundarySub?.cancel();
|
||||
_scaleBoundarySub = null;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) {
|
||||
final sb = _viewController?.scaleBoundaries;
|
||||
if (sb != null) return sb.childSize.height * sb.initialScale;
|
||||
|
||||
if (asset == null || asset.width == null || asset.height == null) return maxHeight;
|
||||
|
||||
final r = asset.width! / asset.height!;
|
||||
return math.min(maxWidth / r, maxHeight);
|
||||
}
|
||||
|
||||
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||
_viewController = controller;
|
||||
_listenForScaleBoundaries(controller);
|
||||
}
|
||||
|
||||
Widget _buildPhotoView(
|
||||
BaseAsset displayAsset,
|
||||
BaseAsset asset, {
|
||||
required bool isCurrentPage,
|
||||
required bool showingDetails,
|
||||
required bool isPlayingMotionVideo,
|
||||
required BoxDecoration backgroundDecoration,
|
||||
}) {
|
||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
||||
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
final size = context.sizeData;
|
||||
return PhotoView(
|
||||
key: ValueKey(displayAsset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
disableScaleGestures: showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
key: ValueKey(displayAsset),
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
heroAttributes: heroAttributes,
|
||||
filterQuality: FilterQuality.high,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
tightMode: true,
|
||||
onPageBuild: _onPageBuild,
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(displayAsset),
|
||||
asset: displayAsset,
|
||||
scaleStateNotifier: _videoScaleStateNotifier,
|
||||
disableScaleGestures: showingDetails,
|
||||
image: Image(
|
||||
key: ValueKey(displayAsset.heroTag),
|
||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
final viewportWidth = MediaQuery.widthOf(context);
|
||||
final viewportHeight = MediaQuery.heightOf(context);
|
||||
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
||||
|
||||
final detailsOffset = (viewportHeight + imageHeight - kMinInteractiveDimension) / 2;
|
||||
final snapTarget = viewportHeight / 3;
|
||||
|
||||
_snapOffset = detailsOffset - snapTarget;
|
||||
|
||||
if (_proxyScrollController.hasClients) {
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
|
||||
currentAssetExifProvider.overrideWith((ref) {
|
||||
final a = ref.watch(currentAssetNotifier);
|
||||
if (a == null) return Future.value(null);
|
||||
return ref.watch(assetServiceProvider).getExif(a);
|
||||
}),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
Offstage(
|
||||
child: SingleChildScrollView(
|
||||
controller: _proxyScrollController,
|
||||
physics: const SnapScrollPhysics(),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
child: _buildPhotoView(
|
||||
displayAsset,
|
||||
asset,
|
||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
||||
showingDetails: _showingDetails,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: detailsOffset),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: _beginDrag,
|
||||
onVerticalDragUpdate: _updateDrag,
|
||||
onVerticalDragEnd: _endDrag,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showingDetails ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
|
||||
class AssetPreloader {
|
||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||
|
||||
final TimelineService timelineService;
|
||||
final bool Function() mounted;
|
||||
|
||||
Timer? _timer;
|
||||
ImageStream? _prevStream;
|
||||
ImageStream? _nextStream;
|
||||
|
||||
AssetPreloader({required this.timelineService, required this.mounted});
|
||||
|
||||
void preload(int index, Size size) {
|
||||
unawaited(timelineService.preloadAssets(index));
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Durations.medium4, () async {
|
||||
if (!mounted()) return;
|
||||
final (prev, next) = await (
|
||||
timelineService.getAssetAsync(index - 1),
|
||||
timelineService.getAssetAsync(index + 1),
|
||||
).wait;
|
||||
if (!mounted()) return;
|
||||
_prevStream?.removeListener(_dummyListener);
|
||||
_nextStream?.removeListener(_dummyListener);
|
||||
_prevStream = prev != null ? _resolveImage(prev, size) : null;
|
||||
_nextStream = next != null ? _resolveImage(next, size) : null;
|
||||
});
|
||||
}
|
||||
|
||||
ImageStream _resolveImage(BaseAsset asset, Size size) {
|
||||
return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_prevStream?.removeListener(_dummyListener);
|
||||
_nextStream?.removeListener(_dummyListener);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/asset_viewer/asset.provider.dart';
|
||||
|
||||
class AssetStackRow extends ConsumerWidget {
|
||||
const AssetStackRow({super.key});
|
||||
@@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: _StackList(stack: stackChildren),
|
||||
),
|
||||
);
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _StackList(stack: stackChildren);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.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/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AssetViewerPage extends StatelessWidget {
|
||||
@@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
void changeAsset(WidgetRef ref, BaseAsset asset) {
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
// Always holds the current asset from the timeline
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
@@ -94,140 +82,67 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
const double _kBottomSheetMinimumExtent = 0.4;
|
||||
const double _kBottomSheetSnapExtent = 0.67;
|
||||
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||
late PageController pageController;
|
||||
late DraggableScrollableController bottomSheetController;
|
||||
PersistentBottomSheetController? sheetCloseController;
|
||||
// PhotoViewGallery takes care of disposing it's controllers
|
||||
PhotoViewControllerBase? viewController;
|
||||
StreamSubscription? reloadSubscription;
|
||||
|
||||
late final int heroOffset;
|
||||
late PhotoViewControllerValue initialPhotoViewState;
|
||||
bool? hasDraggedDown;
|
||||
bool isSnapping = false;
|
||||
bool blockGestures = false;
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
bool assetReloadRequested = false;
|
||||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
int stackIndex = 0;
|
||||
BuildContext? scaffoldContext;
|
||||
Map<String, GlobalKey> videoPlayerKeys = {};
|
||||
|
||||
// Delayed operations that should be cancelled on disposal
|
||||
final List<Timer> _delayedOperations = [];
|
||||
|
||||
ImageStream? _prevPreCacheStream;
|
||||
ImageStream? _nextPreCacheStream;
|
||||
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
late final _pageController = PageController(initialPage: widget.initialIndex);
|
||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
|
||||
bool _assetReloadRequested = false;
|
||||
|
||||
void _onTapNavigate(int direction) {
|
||||
final page = _pageController.page?.toInt();
|
||||
if (page == null) return;
|
||||
final target = page + direction;
|
||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
||||
if (target >= 0 && target <= maxPage) {
|
||||
_pageController.jumpToPage(target);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
||||
pageController = PageController(initialPage: widget.initialIndex);
|
||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
bottomSheetController = DraggableScrollableController();
|
||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset != null) {
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
}
|
||||
if (ref.read(assetViewerProvider).showingControls) {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
} else {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||
}
|
||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
_reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pageController.dispose();
|
||||
bottomSheetController.dispose();
|
||||
_cancelTimers();
|
||||
reloadSubscription?.cancel();
|
||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
_pageController.dispose();
|
||||
_preloader.dispose();
|
||||
_reloadSubscription?.cancel();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
|
||||
Color get backgroundColor {
|
||||
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
return Colors.black.withAlpha(opacity);
|
||||
}
|
||||
|
||||
void _cancelTimers() {
|
||||
for (final timer in _delayedOperations) {
|
||||
timer.cancel();
|
||||
}
|
||||
_delayedOperations.clear();
|
||||
}
|
||||
|
||||
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||
|
||||
ImageStream _precacheImage(BaseAsset asset) {
|
||||
final provider = getFullImageProvider(asset, size: context.sizeData);
|
||||
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
|
||||
}
|
||||
|
||||
void _precacheAssets(int index) {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
unawaited(timelineService.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, () async {
|
||||
// Check if widget is still mounted before proceeding
|
||||
if (!mounted) return;
|
||||
|
||||
final (prevAsset, nextAsset) = await (
|
||||
timelineService.getAssetAsync(index - 1),
|
||||
timelineService.getAssetAsync(index + 1),
|
||||
).wait;
|
||||
if (!mounted) return;
|
||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||
_prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null;
|
||||
_nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null;
|
||||
});
|
||||
_delayedOperations.add(timer);
|
||||
}
|
||||
|
||||
void _onAssetInit(Duration _) {
|
||||
_precacheAssets(widget.initialIndex);
|
||||
void _onAssetInit(Duration timeStamp) {
|
||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||
_handleCasting();
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
if (asset == null) return;
|
||||
|
||||
widget.changeAsset(ref, asset);
|
||||
_precacheAssets(index);
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
_preloader.preload(index, context.sizeData);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
@@ -238,460 +153,106 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
// hide any casting snackbars if they exist
|
||||
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||
|
||||
// send image to casting if the server has it
|
||||
if (asset is RemoteAsset) {
|
||||
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||
ref.read(castProvider.notifier).loadMedia(asset, false);
|
||||
} else {
|
||||
// casting cannot show local assets
|
||||
context.scaffoldMessenger.clearSnackBars();
|
||||
|
||||
if (ref.read(castProvider).isCasting) {
|
||||
ref.read(castProvider.notifier).stop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
"local_asset_cast_failed".tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||
viewController ??= controller;
|
||||
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||
final verticalOffset =
|
||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
// Apply the zoom effect when the bottom sheet is showing
|
||||
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
||||
_onAssetChanged(index);
|
||||
viewController = controller;
|
||||
}
|
||||
|
||||
void _onDragStart(
|
||||
_,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerBase controller,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
) {
|
||||
viewController = controller;
|
||||
dragDownPosition = details.localPosition;
|
||||
initialPhotoViewState = controller.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;
|
||||
}
|
||||
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
context.scaffoldMessenger.clearSnackBars();
|
||||
ref.read(castProvider.notifier).stop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
"local_asset_cast_failed".tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
final position = initialPhotoViewState.position + Offset(0, delta.dy);
|
||||
final distanceToOrigin = position.distance;
|
||||
|
||||
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 && !ref.read(readonlyModeProvider)) {
|
||||
_openBottomSheet(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||
const double dragRatio = 0.2;
|
||||
const double popThreshold = 75;
|
||||
|
||||
final distance = delta.distance;
|
||||
shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||
|
||||
final maxScaleDistance = ctx.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
double? updatedScale;
|
||||
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
|
||||
if (initialScale != null) {
|
||||
updatedScale = initialScale * (1.0 - scaleReduction);
|
||||
}
|
||||
|
||||
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
|
||||
viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
|
||||
}
|
||||
|
||||
void _onTapDown(_, __, ___) {
|
||||
if (!showingBottomSheet) {
|
||||
ref.read(assetViewerProvider.notifier).toggleControls();
|
||||
}
|
||||
}
|
||||
|
||||
bool _onNotification(Notification delta) {
|
||||
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 currentExtent = delta.extent;
|
||||
final isDraggingDown = currentExtent < previousExtent;
|
||||
previousExtent = currentExtent;
|
||||
// Closes the bottom sheet if the user is dragging down
|
||||
if (isDraggingDown && delta.extent < 0.67) {
|
||||
if (dragInProgress) {
|
||||
blockGestures = true;
|
||||
}
|
||||
// Jump to a lower position before starting close animation to prevent glitch
|
||||
if (bottomSheetController.isAttached) {
|
||||
bottomSheetController.jumpTo(0.67);
|
||||
}
|
||||
sheetCloseController?.close();
|
||||
}
|
||||
|
||||
// If the asset is being dragged down, we do not want to update the asset position again
|
||||
if (dragInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
|
||||
// Moves the asset when the bottom sheet is being dragged
|
||||
if (verticalOffset > 0) {
|
||||
viewController?.position = Offset(0, -verticalOffset);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
if (event is TimelineReloadEvent) {
|
||||
_onTimelineReloadEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is ViewerReloadAssetEvent) {
|
||||
assetReloadRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is ViewerOpenBottomSheetEvent) {
|
||||
final extent = _kBottomSheetMinimumExtent + 0.3;
|
||||
_openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode);
|
||||
final offset = _getVerticalOffsetForBottomSheet(extent);
|
||||
viewController?.position = Offset(0, -offset);
|
||||
return;
|
||||
switch (event) {
|
||||
case TimelineReloadEvent():
|
||||
_onTimelineReloadEvent();
|
||||
case ViewerReloadAssetEvent():
|
||||
_assetReloadRequested = true;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void _onTimelineReloadEvent() {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
totalAssets = timelineService.totalAssets;
|
||||
final totalAssets = timelineService.totalAssets;
|
||||
|
||||
if (totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
var index = pageController.page?.round() ?? 0;
|
||||
var index = _pageController.page?.round() ?? 0;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset != null) {
|
||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||
if (newIndex != null && newIndex != index) {
|
||||
index = newIndex;
|
||||
pageController.jumpToPage(index);
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (index >= totalAssets) {
|
||||
index = totalAssets - 1;
|
||||
pageController.jumpToPage(index);
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
|
||||
if (assetReloadRequested) {
|
||||
assetReloadRequested = false;
|
||||
if (_assetReloadRequested) {
|
||||
_assetReloadRequested = false;
|
||||
_onAssetReloadEvent(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssetReloadEvent(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
|
||||
if (newAsset == null) {
|
||||
return;
|
||||
}
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
// Do not reload / close the bottom sheet if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_onAssetChanged(pageController.page!.round());
|
||||
sheetCloseController?.close();
|
||||
});
|
||||
}
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
|
||||
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
|
||||
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
||||
builder: (_) {
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: _onNotification,
|
||||
child: activitiesMode
|
||||
? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent)
|
||||
: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent),
|
||||
);
|
||||
},
|
||||
);
|
||||
sheetCloseController?.closed.then((_) => _handleSheetClose());
|
||||
}
|
||||
|
||||
void _handleSheetClose() {
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: viewController?.initialScale);
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||
sheetCloseController = null;
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
}
|
||||
|
||||
void _snapBottomSheet() {
|
||||
if (!bottomSheetController.isAttached ||
|
||||
bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||
bottomSheetController.size < 0.4) {
|
||||
return;
|
||||
}
|
||||
isSnapping = true;
|
||||
bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (!dragInProgress) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showingBottomSheet) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||
}
|
||||
}
|
||||
|
||||
void _onLongPress(_, __, ___) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||
scaffoldContext ??= ctx;
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = timelineService.getAssetSafe(index);
|
||||
|
||||
// If asset is not available in buffer, return a placeholder
|
||||
if (asset == null) {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
|
||||
child: Container(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
color: backgroundColor,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
|
||||
}
|
||||
|
||||
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
return _imageBuilder(ctx, displayAsset);
|
||||
}
|
||||
|
||||
return _videoBuilder(ctx, displayAsset);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
||||
final size = ctx.sizeData;
|
||||
return PhotoViewGalleryPageOptions(
|
||||
key: ValueKey(asset.heroTag),
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
disableScaleGestures: showingBottomSheet,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onTapDown: _onTapDown,
|
||||
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GlobalKey _getVideoPlayerKey(String id) {
|
||||
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
|
||||
return videoPlayerKeys[id]!;
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onTapDown: _onTapDown,
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: true,
|
||||
child: SizedBox(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
child: NativeVideoViewer(
|
||||
key: _getVideoPlayerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
image: Image(
|
||||
key: ValueKey(asset),
|
||||
image: getFullImageProvider(asset, size: ctx.sizeData),
|
||||
fit: BoxFit.contain,
|
||||
height: ctx.height,
|
||||
width: ctx.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPop<T>(bool didPop, T? result) {
|
||||
ref.read(currentAssetNotifier.notifier).dispose();
|
||||
_onAssetChanged(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Rebuild the widget when the asset viewer state changes
|
||||
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
ref.watch(isPlayingMotionVideoProvider);
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed));
|
||||
final backgroundColor = showingDetails
|
||||
? context.colorScheme.surface
|
||||
: Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)));
|
||||
|
||||
// Listen for casting changes and send initial asset to the cast provider
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async {
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) {
|
||||
if (!isCasting) return;
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleCasting();
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for control visibility changes and change system UI mode accordingly
|
||||
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
|
||||
if (showingControls) {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
} else {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||
}
|
||||
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
|
||||
final (controls, details) = state;
|
||||
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||
? SystemUiMode.immersiveSticky
|
||||
: SystemUiMode.edgeToEdge;
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
|
||||
});
|
||||
|
||||
// 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 PopScope(
|
||||
onPopInvokedWithResult: _onPop,
|
||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
@@ -705,33 +266,30 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
scrollPhysics: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||
itemCount: totalAssets,
|
||||
onPageChanged: _onPageChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
builder: _assetBuilder,
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||
onPageChanged: (index) => _onAssetChanged(index),
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
if (!showingBottomSheet)
|
||||
const Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class AssetViewerState {
|
||||
final int backgroundOpacity;
|
||||
final bool showingBottomSheet;
|
||||
final double backgroundOpacity;
|
||||
final bool showingDetails;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
const AssetViewerState({
|
||||
this.backgroundOpacity = 255,
|
||||
this.showingBottomSheet = false,
|
||||
this.backgroundOpacity = 1.0,
|
||||
this.showingDetails = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
|
||||
AssetViewerState copyWith({
|
||||
int? backgroundOpacity,
|
||||
bool? showingBottomSheet,
|
||||
double? backgroundOpacity,
|
||||
bool? showingDetails,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
return AssetViewerState(
|
||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
|
||||
showingDetails: showingDetails ?? this.showingDetails,
|
||||
showingControls: showingControls ?? this.showingControls,
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
);
|
||||
@@ -35,7 +39,7 @@ class AssetViewerState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -44,8 +48,9 @@ class AssetViewerState {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AssetViewerState &&
|
||||
other.backgroundOpacity == backgroundOpacity &&
|
||||
other.showingBottomSheet == showingBottomSheet &&
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
@@ -53,8 +58,9 @@ class AssetViewerState {
|
||||
@override
|
||||
int get hashCode =>
|
||||
backgroundOpacity.hashCode ^
|
||||
showingBottomSheet.hashCode ^
|
||||
showingDetails.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
@@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
void setOpacity(int opacity) {
|
||||
void setOpacity(double opacity) {
|
||||
if (opacity == state.backgroundOpacity) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
|
||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
|
||||
}
|
||||
|
||||
void setBottomSheet(bool showing) {
|
||||
if (showing == state.showingBottomSheet) {
|
||||
void setShowingDetails(bool showing) {
|
||||
if (showing == state.showingDetails) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
@@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
state = state.copyWith(showingControls: !state.showingControls);
|
||||
}
|
||||
|
||||
void setZoomed(bool isZoomed) {
|
||||
if (isZoomed == state.isZoomed) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isZoomed: isZoomed);
|
||||
}
|
||||
|
||||
void setStackIndex(int index) {
|
||||
if (index == state.stackIndex) {
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
@@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
],
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: isSheetOpen
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
textTheme: context.themeData.textTheme.copyWith(
|
||||
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: showingDetails
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
textTheme: context.themeData.textTheme.copyWith(
|
||||
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
final DraggableScrollableController? controller;
|
||||
final double initialChildSize;
|
||||
|
||||
const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
actions: [],
|
||||
slivers: const [_AssetDetailBottomSheet()],
|
||||
controller: controller,
|
||||
initialChildSize: initialChildSize,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 0.88,
|
||||
expand: false,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const _AssetDetailBottomSheet();
|
||||
|
||||
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
||||
|
||||
// Use EXIF timezone information if available (matching web app behavior)
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
||||
dateTime: exifInfo!.dateTimeOriginal!,
|
||||
timeZone: exifInfo.timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
||||
return '$date$_kSeparator$time $timezone';
|
||||
}
|
||||
|
||||
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final height = asset.height;
|
||||
final width = asset.width;
|
||||
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : 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 exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
||||
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
||||
}
|
||||
|
||||
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!asset.hasRemote) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
|
||||
|
||||
return assetAlbums.when(
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
albums.sortBy((a) => a.name);
|
||||
|
||||
return Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (albums.isNotEmpty)
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: albums.map((album) {
|
||||
final isOwner = album.ownerId == userId;
|
||||
return AlbumTile(
|
||||
album: album,
|
||||
isOwner: isOwner,
|
||||
onAlbumSelected: (album) async {
|
||||
ref.invalidate(assetViewerProvider);
|
||||
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
// Build file info tile based on asset type
|
||||
Widget buildFileInfoTile() {
|
||||
if (asset is LocalAsset) {
|
||||
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
|
||||
return FutureBuilder<String?>(
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
final displayName = snapshot.data ?? asset.name;
|
||||
return SheetTile(
|
||||
title: displayName,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 24,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// For remote assets, use the name directly
|
||||
return SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
||||
size: 24,
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
const SheetPeopleDetails(),
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
SheetTile(
|
||||
title: 'details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
// File info
|
||||
buildFileInfoTile(),
|
||||
// Camera info
|
||||
if (cameraTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Lens info
|
||||
if (lensTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Rating bar
|
||||
if (isRatingEnabled) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
filledColor: context.themeData.colorScheme.primary,
|
||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||
itemSize: 40,
|
||||
onRatingUpdate: (rating) async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
||||
},
|
||||
onClearRating: () async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Appears in (Albums)
|
||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||
// padding at the bottom to avoid cut-off
|
||||
const SizedBox(height: 60),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
||||
|
||||
@override
|
||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||
}
|
||||
|
||||
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
|
||||
late TextEditingController _controller;
|
||||
final _descriptionFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.exif.description ?? '');
|
||||
}
|
||||
|
||||
Future<void> saveDescription(String? previousDescription) async {
|
||||
final newDescription = _controller.text.trim();
|
||||
|
||||
if (newDescription == previousDescription) {
|
||||
_descriptionFocus.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
|
||||
|
||||
if (!editAction.success) {
|
||||
_controller.text = previousDescription ?? '';
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'exif_bottom_sheet_description_error'.t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
_descriptionFocus.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch the current asset EXIF provider to get updates
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
// Update controller text when EXIF data changes
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||
_controller.text = currentDescription;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.isEditable,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
focusNode: _descriptionFocus,
|
||||
maxLines: null, // makes it grow as text is added
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
@@ -19,12 +20,13 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
@@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
final bool showControls;
|
||||
final int playbackDelayFactor;
|
||||
final Widget image;
|
||||
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
||||
final bool disableScaleGestures;
|
||||
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
@@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
required this.image,
|
||||
this.showControls = true,
|
||||
this.playbackDelayFactor = 1,
|
||||
this.scaleStateNotifier,
|
||||
this.disableScaleGestures = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
|
||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||
final aspectRatio = useState<double?>(null);
|
||||
|
||||
useMemoized(() async {
|
||||
if (!context.mounted || aspectRatio.value != null) {
|
||||
return null;
|
||||
@@ -205,7 +212,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
|
||||
Size? videoContextSize;
|
||||
if (videoAspectRatio == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
final contextAspectRatio = context.width / context.height;
|
||||
if (videoAspectRatio > contextAspectRatio) {
|
||||
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
|
||||
} else {
|
||||
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
|
||||
}
|
||||
return videoContextSize;
|
||||
}
|
||||
|
||||
ref.listen(currentAssetNotifier, (_, value) {
|
||||
final playerController = controller.value;
|
||||
if (playerController != null && value != asset) {
|
||||
@@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// This remains under the video to avoid flickering
|
||||
// For motion videos, this is the image portion of the asset
|
||||
Center(key: ValueKey(asset.heroTag), child: image),
|
||||
if (aspectRatio.value != null && !isCasting)
|
||||
Visibility.maintain(
|
||||
key: ValueKey(asset),
|
||||
visible: isVisible.value,
|
||||
child: Center(
|
||||
return SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
||||
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
|
||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
||||
Visibility.maintain(
|
||||
key: ValueKey(asset),
|
||||
child: AspectRatio(
|
||||
visible: isVisible.value,
|
||||
child: PhotoView.customChild(
|
||||
key: ValueKey(asset),
|
||||
aspectRatio: aspectRatio.value!,
|
||||
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
|
||||
enableRotation: false,
|
||||
disableScaleGestures: disableScaleGestures,
|
||||
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
||||
childSize: videoContextSize(aspectRatio.value, context),
|
||||
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showControls) const Center(child: VideoViewerControls()),
|
||||
],
|
||||
if (showControls) const Center(child: VideoViewerControls()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
@@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
if (showBottomSheet) {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
showControls = false;
|
||||
}
|
||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
@@ -81,27 +81,35 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void toggleControlsVisibility() {
|
||||
if (showBuffering) {
|
||||
return;
|
||||
}
|
||||
if (showControls) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
} else {
|
||||
showControlsAndStartHideTimer();
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: showControlsAndStartHideTimer,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !showControls,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: toggleControlsVisibility,
|
||||
child: IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering)
|
||||
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(assetViewerProvider.notifier).setControls(false),
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying:
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying:
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
|
||||
class ViewerBottomAppBar extends ConsumerWidget {
|
||||
const ViewerBottomAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
+20
-15
@@ -3,16 +3,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||
@@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
opacity = 0.0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
@@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
onPressed: () {
|
||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
||||
context.router.push(
|
||||
DriftActivitiesRoute(
|
||||
album: album,
|
||||
assetId: asset is RemoteAsset ? asset.id : null,
|
||||
assetName: asset.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: AppBar(
|
||||
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
|
||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
||||
leading: const _AppBarBackButton(),
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
shape: const Border(),
|
||||
actions: isShowingSheet || isReadonlyModeEnabled
|
||||
actions: showingDetails || isReadonlyModeEnabled
|
||||
? null
|
||||
: isInLockedView
|
||||
? lockedViewActions
|
||||
@@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
@@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
iconSize: 22,
|
||||
iconColor: foregroundColor,
|
||||
padding: EdgeInsets.zero,
|
||||
elevation: isShowingSheet ? 4 : 0,
|
||||
elevation: showingDetails ? 4 : 0,
|
||||
),
|
||||
onPressed: context.maybePop,
|
||||
child: const Icon(Icons.arrow_back_rounded),
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
@@ -113,6 +114,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
if (ownsAlbum && multiselect.selectedAssets.length == 1)
|
||||
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
],
|
||||
slivers: ownsAlbum
|
||||
? [
|
||||
|
||||
@@ -48,7 +48,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
return null;
|
||||
}
|
||||
|
||||
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
|
||||
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* {
|
||||
if (isCancelled) {
|
||||
this.request = null;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
@@ -57,14 +57,19 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
|
||||
try {
|
||||
final image = await request.load(decode);
|
||||
if (image == null || isCancelled) {
|
||||
if ((image == null && evictOnError) || isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
} else if (image == null) {
|
||||
return;
|
||||
}
|
||||
yield image;
|
||||
} catch (e) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
} catch (e, stack) {
|
||||
if (evictOnError) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
}
|
||||
_log.warning('Non-fatal image load error', e, stack);
|
||||
} finally {
|
||||
this.request = null;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
|
||||
yield* loadRequest(request, decode);
|
||||
|
||||
if (!Store.get(StoreKey.loadOriginal, false)) {
|
||||
|
||||
@@ -93,9 +93,10 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||
headers: headers,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode);
|
||||
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
|
||||
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
|
||||
|
||||
if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
|
||||
if (!loadOriginal) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDate = widget.person.birthDate ?? DateTime.now();
|
||||
_selectedDate = widget.person.birthDate ?? DateTime(DateTime.now().year - 30, 1, 1);
|
||||
}
|
||||
|
||||
void saveBirthday() async {
|
||||
@@ -90,6 +90,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
selectedDate: _selectedDate,
|
||||
locale: context.locale,
|
||||
minimumDate: DateTime(1800, 1, 1),
|
||||
maximumDate: DateTime.now(),
|
||||
onDateTimeChanged: (DateTime value) {
|
||||
setState(() {
|
||||
_selectedDate = value;
|
||||
|
||||
@@ -29,7 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
class Timeline extends ConsumerWidget {
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -43,6 +43,7 @@ class Timeline extends ConsumerWidget {
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.readOnly = false,
|
||||
this.persistentBottomBar = false,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -56,17 +57,18 @@ class Timeline extends ConsumerWidget {
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final bool readOnly;
|
||||
final bool persistentBottomBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWithValue(
|
||||
TimelineArgs(
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
||||
@@ -78,14 +80,15 @@ class Timeline extends ConsumerWidget {
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
key: const ValueKey('_sliver_timeline'),
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
initialScrollOffset: initialScrollOffset,
|
||||
maxWidth: constraints.maxWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -106,14 +109,15 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
|
||||
|
||||
class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
const _SliverTimeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
this.topSliverWidgetHeight,
|
||||
this.appBar,
|
||||
this.bottomSheet,
|
||||
this.withScrubber = true,
|
||||
this.persistentBottomBar = false,
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.maxWidth,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -121,8 +125,10 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
final bool withScrubber;
|
||||
final bool persistentBottomBar;
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final double? maxWidth;
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SliverTimelineState();
|
||||
@@ -160,6 +166,20 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.maxWidth != oldWidget.maxWidth) {
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final index = _getCurrentAssetIndex(segments);
|
||||
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
|
||||
final _ = ref.refresh(timelineArgsProvider);
|
||||
_restoreAssetIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
switch (event) {
|
||||
case ScrollToTopEvent():
|
||||
@@ -177,10 +197,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onMultiSelectionToggled(_, bool isEnabled) {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
void _restoreAssetPosition(_) {
|
||||
if (_restoreAssetIndex == null) return;
|
||||
|
||||
@@ -203,6 +219,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
_restoreAssetIndex = null;
|
||||
}
|
||||
|
||||
void _onMultiSelectionToggled(_, bool isEnabled) {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) {
|
||||
final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent);
|
||||
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
|
||||
@@ -341,6 +361,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
|
||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
|
||||
final isBottomWidgetVisible =
|
||||
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
||||
|
||||
return PopScope(
|
||||
canPop: !isMultiSelectEnabled,
|
||||
@@ -407,66 +430,56 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: NotificationListener<ScrollEndNotification>(
|
||||
onNotification: (notification) {
|
||||
final currentIndex = _getCurrentAssetIndex(segments);
|
||||
if (currentIndex != null && mounted) {
|
||||
_restoreAssetIndex = currentIndex;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
timeline,
|
||||
if (!isSelectionMode && isMultiSelectEnabled) ...[
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
if (widget.bottomSheet != null) widget.bottomSheet!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -343,6 +343,22 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> setAlbumCover(ActionSource source, String albumId) async {
|
||||
final assets = _getAssets(source);
|
||||
final asset = assets.first;
|
||||
if (asset is! RemoteAsset) {
|
||||
return const ActionResult(count: 1, success: false, error: 'Asset must be remote');
|
||||
}
|
||||
|
||||
try {
|
||||
await _service.setAlbumCover(albumId, asset.id);
|
||||
return const ActionResult(count: 1, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to set album cover', error, stack);
|
||||
return ActionResult(count: 1, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> updateDescription(ActionSource source, String description) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
if (ids.length != 1) {
|
||||
|
||||
+12
@@ -31,6 +31,18 @@ class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedAssetNotifier extends CurrentAssetNotifier {
|
||||
final BaseAsset _asset;
|
||||
|
||||
ScopedAssetNotifier(this._asset);
|
||||
|
||||
@override
|
||||
BaseAsset? build() {
|
||||
setAsset(_asset);
|
||||
return _asset;
|
||||
}
|
||||
}
|
||||
|
||||
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||
|
||||
class TagNotifier extends AsyncNotifier<Set<Tag>> {
|
||||
@override
|
||||
Future<Set<Tag>> build() async {
|
||||
final repo = ref.read(tagsApiRepositoryProvider);
|
||||
final allTags = await repo.getAllTags();
|
||||
if (allTags == null) {
|
||||
return {};
|
||||
}
|
||||
return allTags.map((t) => Tag.fromDto(t)).toSet();
|
||||
}
|
||||
}
|
||||
|
||||
final tagProvider = AsyncNotifierProvider<TagNotifier, Set<Tag>>(TagNotifier.new);
|
||||
@@ -61,10 +61,10 @@ class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState>
|
||||
|
||||
final UserService _userService;
|
||||
|
||||
Future<bool> upload(XFile file) async {
|
||||
Future<bool> upload(XFile file, {String? fileName}) async {
|
||||
state = state.copyWith(status: UploadProfileStatus.loading);
|
||||
|
||||
var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes());
|
||||
var profileImagePath = await _userService.createProfileImage(fileName ?? file.name, await file.readAsBytes());
|
||||
|
||||
if (profileImagePath != null) {
|
||||
dPrint(() => "Successfully upload profile image");
|
||||
|
||||
@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
|
||||
import 'package:immich_mobile/pages/search/search.page.dart';
|
||||
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
|
||||
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||
@@ -88,7 +88,6 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
|
||||
@@ -107,6 +106,7 @@ import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||
@@ -199,6 +199,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: EditImageRoute.page),
|
||||
AutoRoute(page: CropImageRoute.page),
|
||||
AutoRoute(page: FilterImageRoute.page),
|
||||
AutoRoute(page: ProfilePictureCropRoute.page),
|
||||
CustomRoute(
|
||||
page: FavoritesRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
@@ -338,7 +339,6 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
|
||||
@@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
||||
DriftActivitiesRoute({
|
||||
Key? key,
|
||||
required RemoteAlbum album,
|
||||
String? assetId,
|
||||
String? assetName,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftActivitiesRoute.name,
|
||||
args: DriftActivitiesRouteArgs(key: key, album: album),
|
||||
args: DriftActivitiesRouteArgs(
|
||||
key: key,
|
||||
album: album,
|
||||
assetId: assetId,
|
||||
assetName: assetName,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
@@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftActivitiesRouteArgs>();
|
||||
return DriftActivitiesPage(key: args.key, album: args.album);
|
||||
return DriftActivitiesPage(
|
||||
key: args.key,
|
||||
album: args.album,
|
||||
assetId: args.assetId,
|
||||
assetName: args.assetName,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftActivitiesRouteArgs {
|
||||
const DriftActivitiesRouteArgs({this.key, required this.album});
|
||||
const DriftActivitiesRouteArgs({
|
||||
this.key,
|
||||
required this.album,
|
||||
this.assetId,
|
||||
this.assetName,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final RemoteAlbum album;
|
||||
|
||||
final String? assetId;
|
||||
|
||||
final String? assetName;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album}';
|
||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1852,22 +1873,6 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ImmichUIShowcasePage]
|
||||
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
|
||||
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
|
||||
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ImmichUIShowcaseRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const ImmichUIShowcasePage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LibraryPage]
|
||||
class LibraryRoute extends PageRouteInfo<void> {
|
||||
@@ -2438,6 +2443,44 @@ class PlacesCollectionRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ProfilePictureCropPage]
|
||||
class ProfilePictureCropRoute
|
||||
extends PageRouteInfo<ProfilePictureCropRouteArgs> {
|
||||
ProfilePictureCropRoute({
|
||||
Key? key,
|
||||
required BaseAsset asset,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ProfilePictureCropRoute.name,
|
||||
args: ProfilePictureCropRouteArgs(key: key, asset: asset),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'ProfilePictureCropRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ProfilePictureCropRouteArgs>();
|
||||
return ProfilePictureCropPage(key: args.key, asset: args.asset);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ProfilePictureCropRouteArgs {
|
||||
const ProfilePictureCropRouteArgs({this.key, required this.asset});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final BaseAsset asset;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ProfilePictureCropRouteArgs{key: $key, asset: $asset}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [RecentlyTakenPage]
|
||||
class RecentlyTakenRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -240,6 +240,12 @@ class ActionService {
|
||||
return _downloadRepository.downloadAllAssets(assets);
|
||||
}
|
||||
|
||||
Future<bool> setAlbumCover(String albumId, String assetId) async {
|
||||
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, thumbnailAssetId: assetId);
|
||||
await _remoteAlbumRepository.update(updatedAlbum);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (deletedIds.isEmpty) {
|
||||
|
||||
@@ -35,6 +35,7 @@ class ApiService implements Authentication {
|
||||
late ViewsApi viewApi;
|
||||
late MemoriesApi memoriesApi;
|
||||
late SessionsApi sessionsApi;
|
||||
late TagsApi tagsApi;
|
||||
|
||||
ApiService() {
|
||||
// The below line ensures that the api clients are initialized when the service is instantiated
|
||||
@@ -74,6 +75,7 @@ class ApiService implements Authentication {
|
||||
viewApi = ViewsApi(_apiClient);
|
||||
memoriesApi = MemoriesApi(_apiClient);
|
||||
sessionsApi = SessionsApi(_apiClient);
|
||||
tagsApi = TagsApi(_apiClient);
|
||||
}
|
||||
|
||||
Future<void> _setUserAgentHeader() async {
|
||||
|
||||
@@ -35,6 +35,7 @@ enum AppSettingsEnum<T> {
|
||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
|
||||
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
|
||||
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
|
||||
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
|
||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
|
||||
|
||||
@@ -37,6 +37,7 @@ class SharedLinkService {
|
||||
required bool allowUpload,
|
||||
String? description,
|
||||
String? password,
|
||||
String? slug,
|
||||
String? albumId,
|
||||
List<String>? assetIds,
|
||||
DateTime? expiresAt,
|
||||
@@ -54,6 +55,7 @@ class SharedLinkService {
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
slug: slug,
|
||||
);
|
||||
} else if (assetIds != null) {
|
||||
dto = SharedLinkCreateDto(
|
||||
@@ -64,6 +66,7 @@ class SharedLinkService {
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
slug: slug,
|
||||
assetIds: assetIds,
|
||||
);
|
||||
}
|
||||
@@ -88,6 +91,7 @@ class SharedLinkService {
|
||||
bool? changeExpiry = false,
|
||||
String? description,
|
||||
String? password,
|
||||
String? slug,
|
||||
DateTime? expiresAt,
|
||||
}) async {
|
||||
try {
|
||||
@@ -100,6 +104,7 @@ class SharedLinkService {
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
slug: slug,
|
||||
changeExpiryTime: changeExpiry,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -20,9 +20,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
@@ -42,6 +44,7 @@ class ActionButtonContext {
|
||||
final bool isCasting;
|
||||
final TimelineOrigin timelineOrigin;
|
||||
final ThemeData? originalTheme;
|
||||
final int selectedCount;
|
||||
|
||||
const ActionButtonContext({
|
||||
required this.asset,
|
||||
@@ -56,6 +59,7 @@ class ActionButtonContext {
|
||||
this.isCasting = false,
|
||||
this.timelineOrigin = TimelineOrigin.main,
|
||||
this.originalTheme,
|
||||
this.selectedCount = 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +69,9 @@ enum ActionButtonType {
|
||||
share,
|
||||
shareLink,
|
||||
cast,
|
||||
setAlbumCover,
|
||||
similarPhotos,
|
||||
setProfilePicture,
|
||||
viewInTimeline,
|
||||
download,
|
||||
upload,
|
||||
@@ -134,6 +140,11 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null,
|
||||
ActionButtonType.setAlbumCover =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null && //
|
||||
context.selectedCount == 1,
|
||||
ActionButtonType.unstack =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -146,6 +157,10 @@ enum ActionButtonType {
|
||||
ActionButtonType.similarPhotos =>
|
||||
!context.isInLockedView && //
|
||||
context.asset is RemoteAsset,
|
||||
ActionButtonType.setProfilePicture =>
|
||||
!context.isInLockedView && //
|
||||
context.asset is RemoteAsset && //
|
||||
context.isOwner,
|
||||
ActionButtonType.openInfo => true,
|
||||
ActionButtonType.viewInTimeline =>
|
||||
context.timelineOrigin != TimelineOrigin.main &&
|
||||
@@ -213,6 +228,12 @@ enum ActionButtonType {
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.setAlbumCover => SetAlbumCoverActionButton(
|
||||
albumId: context.currentAlbum!.id,
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||
@@ -220,12 +241,17 @@ enum ActionButtonType {
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.setProfilePicture => SetProfilePictureActionButton(
|
||||
asset: context.asset,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.openInfo => BaseActionButton(
|
||||
label: 'info'.tr(),
|
||||
iconData: Icons.info_outline,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
menuItem: true,
|
||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||
onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()),
|
||||
),
|
||||
ActionButtonType.viewInTimeline => BaseActionButton(
|
||||
label: 'view_in_timeline'.tr(),
|
||||
@@ -251,7 +277,7 @@ enum ActionButtonType {
|
||||
int get kebabMenuGroup => switch (this) {
|
||||
// 0: info
|
||||
ActionButtonType.openInfo => 0,
|
||||
// 10: move,remove, and delete
|
||||
// 10: move, remove, and delete
|
||||
ActionButtonType.trash => 10,
|
||||
ActionButtonType.deletePermanent => 10,
|
||||
ActionButtonType.removeFromLockFolder => 10,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Converts a Flutter [Image] widget to a [Uint8List] in PNG format.
|
||||
///
|
||||
/// This function resolves the image stream and converts it to byte data.
|
||||
/// Returns a [Future] that completes with the image bytes or completes with an error
|
||||
/// if the conversion fails.
|
||||
Future<Uint8List> imageToUint8List(Image image) async {
|
||||
final Completer<Uint8List> completer = Completer();
|
||||
image.image
|
||||
.resolve(const ImageConfiguration())
|
||||
.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) {
|
||||
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
} else {
|
||||
completer.completeError('Failed to convert image to bytes');
|
||||
}
|
||||
});
|
||||
}, onError: (exception, stackTrace) => completer.completeError(exception)),
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
@@ -30,11 +30,10 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 21;
|
||||
const int targetVersion = 22;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -100,6 +99,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||
await Store.put(StoreKey.needBetaMigration, true);
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
sealed class Option<T> {
|
||||
const Option();
|
||||
|
||||
const factory Option.some(T value) = Some<T>;
|
||||
|
||||
const factory Option.none() = None<T>;
|
||||
|
||||
factory Option.fromNullable(T? value) => value != null ? Some(value) : None<T>();
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
bool get isSome => this is Some<T>;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
bool get isNone => this is None<T>;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
T? get unwrapOrNull => switch (this) {
|
||||
Some(:final value) => value,
|
||||
None() => null,
|
||||
};
|
||||
|
||||
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||
Some(:final value) => onSome(value),
|
||||
None() => onNone(),
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => switch (this) {
|
||||
Some(:final value) => 'Some($value)',
|
||||
None() => 'None',
|
||||
};
|
||||
}
|
||||
|
||||
final class Some<T> extends Option<T> {
|
||||
final T value;
|
||||
|
||||
const Some(this.value);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Some<T> && other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
}
|
||||
|
||||
final class None<T> extends Option<T> {
|
||||
const None();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is None<T>;
|
||||
|
||||
@override
|
||||
int get hashCode => 0;
|
||||
}
|
||||
|
||||
extension ObjectOptionExtension<T> on T? {
|
||||
Option<T> toOption() => Option.fromNullable(this);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget {
|
||||
prefixIcon: user != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
||||
child: UserCircleAvatar(user: user, size: 30),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: Padding(
|
||||
|
||||
@@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget {
|
||||
child: Icon(Icons.thumb_up, color: context.primaryColor),
|
||||
)
|
||||
: isBottomSheet
|
||||
? UserCircleAvatar(user: activity.user, size: 30, radius: 15)
|
||||
? UserCircleAvatar(user: activity.user, size: 30)
|
||||
: UserCircleAvatar(user: activity.user),
|
||||
title: _ActivityTitle(
|
||||
userName: activity.user.name,
|
||||
|
||||
@@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget {
|
||||
// avatar (hidden for own messages)
|
||||
Widget avatar = const SizedBox.shrink();
|
||||
if (!isOwn) {
|
||||
avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14);
|
||||
avatar = UserCircleAvatar(user: activity.user, size: 28);
|
||||
}
|
||||
|
||||
// Thumbnail with tappable behavior and optional heart overlay
|
||||
|
||||
@@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget {
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true),
|
||||
child: UserCircleAvatar(user: sharedUsers[index], size: 36, hasBorder: true),
|
||||
);
|
||||
}),
|
||||
itemCount: sharedUsers.length,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class TrashDeleteDialog extends StatelessWidget {
|
||||
const TrashDeleteDialog({super.key, required this.count});
|
||||
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
title: Text(context.t.permanently_delete),
|
||||
content: ImmichFormattedText(context.t.permanently_delete_assets_prompt(count: count)),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: () => context.pop(false),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.surfaceDim,
|
||||
foregroundColor: context.primaryColor,
|
||||
),
|
||||
child: Text(context.t.cancel, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
|
||||
child: FilledButton(
|
||||
onPressed: () => context.pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
foregroundColor: context.colorScheme.onErrorContainer,
|
||||
),
|
||||
child: Text(context.t.delete, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: UnconstrainedBox(
|
||||
child: AnimatedOpacity(
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
iconSize: 32,
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
icon: isFinished
|
||||
? Icon(Icons.replay, color: iconColor)
|
||||
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
return Center(
|
||||
child: UnconstrainedBox(
|
||||
child: AnimatedOpacity(
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
iconSize: 32,
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
icon: isFinished
|
||||
? Icon(Icons.replay, color: iconColor)
|
||||
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -52,7 +52,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)),
|
||||
IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: Icon(Icons.close, size: 20, color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Padding(
|
||||
@@ -154,15 +157,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
"backup_controller_page_server_storage",
|
||||
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
).tr(),
|
||||
Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge),
|
||||
LinearProgressIndicator(
|
||||
minHeight: 10.0,
|
||||
value: percentage,
|
||||
@@ -264,13 +264,13 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
const AppBarProfileInfoBox(),
|
||||
const Divider(height: 3),
|
||||
Divider(thickness: 4, color: context.colorScheme.surfaceContainer),
|
||||
buildStorageInformation(),
|
||||
const Divider(height: 3),
|
||||
Divider(thickness: 4, color: context.colorScheme.surfaceContainer),
|
||||
const AppBarServerInfo(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final userImage = UserCircleAvatar(radius: 22, size: 44, user: user);
|
||||
final userImage = UserCircleAvatar(size: 44, user: user, hasBorder: true);
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
|
||||
return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20));
|
||||
|
||||
@@ -38,7 +38,7 @@ class AppBarServerInfo extends HookConsumerWidget {
|
||||
const divider = Divider(thickness: 1);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -109,7 +109,7 @@ class _ServerInfoItem extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w500,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user