Compare commits

..

6 Commits

Author SHA1 Message Date
Daniel Dietzler 0a01f5a9ca chore: workflow drag and drop improvements 2026-06-04 18:48:20 +02:00
Daniel Dietzler d7999ce1d1 feat: workflows drag and drop enhancements (#28764) 2026-06-04 10:46:51 -05:00
shenlong 6b0fd89cd2 refactor: partner-page (#28783)
* refactor: partner-page

* cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-04 09:05:45 -04:00
Daniel Dietzler 4b0adb7a1e fix: stack arrow navigation when not directly navigating to an asset (#28828) 2026-06-04 14:49:12 +02:00
shenlong de70d19d20 feat: show notification and battery optimization warning (#26610)
* feat: show notification and battery optimization warning

* cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-04 12:24:39 +00:00
bo0tzz 7155bb1e80 chore: fix up docs placeholders (#28814) 2026-06-04 08:19:40 -04:00
92 changed files with 1461 additions and 17988 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ services:
traefik.enable: true
# increase readingTimeouts for the entrypoint used here
traefik.http.routers.immich.entrypoints: websecure
traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
traefik.http.routers.immich.rule: Host(`immich.example.com`)
traefik.http.services.immich.loadbalancer.server.port: 2283
```
+1 -1
View File
@@ -90,7 +90,7 @@ immich-admin list-users
[
{
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
email: 'immich@example.com.com',
email: 'immich@example.com',
name: 'Immich Admin',
storageLabel: 'admin',
externalPath: null,
+1 -1
View File
@@ -17,7 +17,7 @@ services:
ports:
- "8888:80"
environment:
PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: strong-password
volumes:
- pgadmin-data:/var/lib/pgadmin
+2
View File
@@ -699,6 +699,7 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -1689,6 +1690,7 @@
"not_selected": "Not selected",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -47,18 +47,44 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
enum class PermissionStatus(val raw: Int) {
GRANTED(0),
DENIED(1),
PERMANENTLY_DENIED(2);
companion object {
fun ofRaw(raw: Int): PermissionStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PermissionStatus.ofRaw(it.toInt())
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
when (value) {
is PermissionStatus -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun isIgnoringBatteryOptimizations(): PermissionStatus
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
@@ -72,6 +98,21 @@ interface PermissionApi {
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isIgnoringBatteryOptimizations())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
@@ -1,13 +1,26 @@
package app.alextran.immich.permission
import android.content.Context
import android.os.PowerManager
import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
private val ctx: Context = context.applicationContext
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
private val powerManager =
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
return PermissionStatus.GRANTED
}
return PermissionStatus.DENIED
}
override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission()
File diff suppressed because it is too large Load Diff
+81 -1
View File
@@ -11,6 +11,24 @@ import Foundation
#error("Unsupported platform.")
#endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
@@ -46,8 +64,57 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
return value as! T?
}
enum PermissionStatus: Int {
case granted = 0
case denied = 1
case permanentlyDenied = 2
}
private class PermissionApiPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return PermissionStatus(rawValue: enumResultAsInt)
}
return nil
default:
return super.readValue(ofType: type)
}
}
}
private class PermissionApiPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PermissionStatus {
super.writeByte(129)
super.writeValue(value.rawValue)
} else {
super.writeValue(value)
}
}
}
private class PermissionApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return PermissionApiPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return PermissionApiPigeonCodecWriter(data: data)
}
}
class PermissionApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = PermissionApiPigeonCodec(readerWriter: PermissionApiPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
@@ -55,10 +122,23 @@ protocol PermissionApi {
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
static var codec: FlutterStandardMessageCodec { PermissionApiPigeonCodec.shared }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let isIgnoringBatteryOptimizationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
isIgnoringBatteryOptimizationsChannel.setMessageHandler { _, reply in
do {
let result = try api.isIgnoringBatteryOptimizations()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
isIgnoringBatteryOptimizationsChannel.setMessageHandler(nil)
}
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
@@ -1,6 +1,10 @@
import Foundation
class PermissionApiImpl: PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus {
return PermissionStatus.granted;
}
func hasManageMediaPermission() throws -> Bool {
return false
}
+2
View File
@@ -22,3 +22,5 @@ enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
enum PartnerDirection { sharedBy, sharedWith }
-128
View File
@@ -1,128 +0,0 @@
class Ocr {
final String id;
final String assetId;
final double x1;
final double y1;
final double x2;
final double y2;
final double x3;
final double y3;
final double x4;
final double y4;
final double boxScore;
final double textScore;
final String text;
final bool isVisible;
const Ocr({
required this.id,
required this.assetId,
required this.x1,
required this.y1,
required this.x2,
required this.y2,
required this.x3,
required this.y3,
required this.x4,
required this.y4,
required this.boxScore,
required this.textScore,
required this.text,
required this.isVisible,
});
Ocr copyWith({
String? id,
String? assetId,
double? x1,
double? y1,
double? x2,
double? y2,
double? x3,
double? y3,
double? x4,
double? y4,
double? boxScore,
double? textScore,
String? text,
bool? isVisible,
}) {
return Ocr(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
x1: x1 ?? this.x1,
y1: y1 ?? this.y1,
x2: x2 ?? this.x2,
y2: y2 ?? this.y2,
x3: x3 ?? this.x3,
y3: y3 ?? this.y3,
x4: x4 ?? this.x4,
y4: y4 ?? this.y4,
boxScore: boxScore ?? this.boxScore,
textScore: textScore ?? this.textScore,
text: text ?? this.text,
isVisible: isVisible ?? this.isVisible,
);
}
@override
String toString() {
return '''Ocr {
id: $id,
assetId: $assetId,
x1: $x1,
y1: $y1,
x2: $x2,
y2: $y2,
x3: $x3,
y3: $y3,
x4: $x4,
y4: $y4,
boxScore: $boxScore,
textScore: $textScore,
text: $text,
isVisible: $isVisible
}''';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is Ocr &&
other.id == id &&
other.assetId == assetId &&
other.x1 == x1 &&
other.y1 == y1 &&
other.x2 == x2 &&
other.y2 == y2 &&
other.x3 == x3 &&
other.y3 == y3 &&
other.x4 == x4 &&
other.y4 == y4 &&
other.boxScore == boxScore &&
other.textScore == textScore &&
other.text == text &&
other.isVisible == isVisible;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
x1.hashCode ^
y1.hashCode ^
x2.hashCode ^
y2.hashCode ^
x3.hashCode ^
y3.hashCode ^
x4.hashCode ^
y4.hashCode ^
boxScore.hashCode ^
textScore.hashCode ^
text.hashCode ^
isVisible.hashCode;
}
}
+122
View File
@@ -237,3 +237,125 @@ class PartnerUserDto {
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
}
}
class User {
final String id;
final String name;
final String email;
final DateTime profileChangedAt;
final bool hasProfileImage;
final AvatarColor? avatarColor;
const User({
required this.id,
required this.name,
required this.email,
required this.profileChangedAt,
required this.hasProfileImage,
this.avatarColor = AvatarColor.primary,
});
@override
String toString() {
return 'User(id: $id, name: $name, email: $email, profileChangedAt: $profileChangedAt, hasProfileImage: $hasProfileImage, avatarColor: $avatarColor)';
}
@override
bool operator ==(covariant User other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.name == name &&
other.email == email &&
other.profileChangedAt == profileChangedAt &&
other.hasProfileImage == hasProfileImage &&
other.avatarColor == avatarColor;
}
@override
int get hashCode => Object.hash(id, name, email, profileChangedAt, hasProfileImage, avatarColor);
}
class AuthUser extends User {
final bool isAdmin;
final String? pinCode;
final int? quotaSizeInBytes;
final int quotaUsageInBytes;
const AuthUser({
required super.id,
required super.name,
required super.email,
required super.profileChangedAt,
required super.hasProfileImage,
super.avatarColor,
this.isAdmin = false,
this.pinCode,
this.quotaSizeInBytes = 0,
this.quotaUsageInBytes = 0,
});
@override
String toString() {
return 'AuthUser(user: ${super.toString()}, isAdmin: $isAdmin, pinCode: $pinCode, quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes: $quotaUsageInBytes)';
}
@override
bool operator ==(covariant AuthUser other) {
if (identical(this, other)) {
return true;
}
return super == other &&
other.isAdmin == isAdmin &&
other.pinCode == pinCode &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes;
}
@override
int get hashCode => Object.hash(super.hashCode, isAdmin, pinCode, quotaSizeInBytes, quotaUsageInBytes);
}
class Partner extends User {
final bool inTimeline;
const Partner({
required super.id,
required super.name,
required super.email,
required super.profileChangedAt,
required super.hasProfileImage,
super.avatarColor,
this.inTimeline = false,
});
Partner.fromUser(User user, {this.inTimeline = false})
: super(
id: user.id,
name: user.name,
email: user.email,
profileChangedAt: user.profileChangedAt,
hasProfileImage: user.hasProfileImage,
avatarColor: user.avatarColor,
);
@override
String toString() {
return 'Partner(user: ${super.toString()}, inTimeline: $inTimeline)';
}
@override
bool operator ==(covariant Partner other) {
if (identical(this, other)) {
return true;
}
return super == other && other.inTimeline == inTimeline;
}
@override
int get hashCode => Object.hash(super.hashCode, inTimeline);
}
@@ -1,12 +0,0 @@
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
class OcrService {
final OcrRepository _repository;
const OcrService(this._repository);
Future<List<Ocr>?> get(String assetId) {
return _repository.get(assetId);
}
}
+27 -36
View File
@@ -1,51 +1,42 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:stream_transform/stream_transform.dart';
class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository;
class PartnerService {
final UserRepository _userRepository;
final PartnerRepository _partnerRepository;
final PartnerApiRepository _partnerApiRepository;
const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
const PartnerService(this._userRepository, this._partnerRepository, this._partnerApiRepository);
Future<List<PartnerUserDto>> getSharedWith(String userId) {
return _driftPartnerRepository.getSharedWith(userId);
Stream<Iterable<User>> getCandidates(String userId) {
final userStream = _userRepository.getAll();
final partnerStream = _partnerRepository.search(userId, .sharedBy);
return userStream.combineLatest(partnerStream, (users, partners) {
final partnersSet = partners.map((partner) => partner.id).toSet();
return users.where((user) => user.id != userId && !partnersSet.contains(user.id));
});
}
Future<List<PartnerUserDto>> getSharedBy(String userId) {
return _driftPartnerRepository.getSharedBy(userId);
Stream<Iterable<Partner>> search(String userId, PartnerDirection direction) =>
_partnerRepository.search(userId, direction);
Future<void> update({required String sharedById, required String sharedWithId, required bool inTimeline}) async {
await _partnerApiRepository.update(sharedById, inTimeline: inTimeline);
await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) async {
final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId);
final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId);
final available = otherUsers.where((user) {
return !currentPartners.any((partner) => partner.id == user.id);
}).toList();
return available;
Future<void> create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async {
await _partnerApiRepository.create(sharedWithId);
await _partnerRepository.create(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
if (partner == null) {
dPrint(() => "Partner not found: $partnerId for user: $userId");
return;
}
await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline);
await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
}
Future<void> addPartner(String partnerId, String userId) async {
await _partnerApiRepository.create(partnerId);
await _driftPartnerRepository.create(partnerId, userId);
}
Future<void> removePartner(String partnerId, String userId) async {
await _partnerApiRepository.delete(partnerId);
await _driftPartnerRepository.delete(partnerId, userId);
Future<void> delete({required String sharedById, required String sharedWithId}) async {
await _partnerApiRepository.delete(sharedWithId);
await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId);
}
}
@@ -317,10 +317,6 @@ class SyncStreamService {
return _syncStreamRepository.updateAssetFacesV2(data.cast());
case SyncEntityType.assetFaceDeleteV1:
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
case SyncEntityType.assetOcrV1:
return _syncStreamRepository.updateAssetOcrV1(data.cast());
case SyncEntityType.assetOcrDeleteV1:
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
@@ -1,34 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)')
class AssetOcrEntity extends Table with DriftDefaultsMixin {
const AssetOcrEntity();
TextColumn get id => text()();
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
RealColumn get x1 => real()();
RealColumn get y1 => real()();
RealColumn get x2 => real()();
RealColumn get y2 => real()();
RealColumn get x3 => real()();
RealColumn get y3 => real()();
RealColumn get x4 => real()();
RealColumn get y4 => real()();
RealColumn get boxScore => real()();
RealColumn get textScore => real()();
TextColumn get recognizedText => text()();
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
@override
Set<Column> get primaryKey => {id};
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
User mapToUser(UserEntityData data) => User(
id: data.id,
name: data.name,
email: data.email,
hasProfileImage: data.hasProfileImage,
profileChangedAt: data.profileChangedAt,
avatarColor: data.avatarColor,
);
Partner mapToPartner(UserEntityData user, PartnerEntityData partner) =>
Partner.fromUser(mapToUser(user), inTimeline: partner.inTimeline);
@@ -5,7 +5,6 @@ import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
@@ -14,6 +13,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
@@ -22,7 +22,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
@@ -57,7 +56,6 @@ import 'package:logging/logging.dart';
TrashedLocalAssetEntity,
AssetEditEntity,
SettingsEntity,
AssetOcrEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -100,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 29;
int get schemaVersion => 28;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -284,10 +282,6 @@ class Drift extends $Drift {
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
from28To29: (m, v29) async {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
),
);
@@ -45,11 +45,9 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
as i21;
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
as i22;
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
as i23;
import 'package:drift/internal/modular.dart' as i24;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -96,12 +94,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i22.$SettingsEntityTable settingsEntity = i22.$SettingsEntityTable(
this,
);
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
this,
);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
this,
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -139,7 +134,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
trashedLocalAssetEntity,
assetEditEntity,
settingsEntity,
assetOcrEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -152,7 +146,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i23.idxAssetOcrAssetId,
];
@override
i0.StreamQueryUpdateRules
@@ -342,13 +335,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('asset_ocr_entity', kind: i0.UpdateKind.delete)],
),
]);
@override
i0.DriftDatabaseOptions get options =>
@@ -412,6 +398,4 @@ class $DriftManager {
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$SettingsEntityTableTableManager get settingsEntity =>
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
}
@@ -14631,706 +14631,6 @@ final class Schema28 extends i0.VersionedSchema {
);
}
final class Schema29 extends i0.VersionedSchema {
Schema29({required super.database}) : super(version: 29);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
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 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 idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
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 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 idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
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_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
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_159, _column_177],
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_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
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_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
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_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
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 idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
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 idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
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)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
class Shape51 extends i0.VersionedTable {
Shape51({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<double> get x1 =>
columnsByName['x1']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get y1 =>
columnsByName['y1']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get x2 =>
columnsByName['x2']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get y2 =>
columnsByName['y2']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get x3 =>
columnsByName['x3']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get y3 =>
columnsByName['y3']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get x4 =>
columnsByName['x4']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get y4 =>
columnsByName['y4']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get boxScore =>
columnsByName['box_score']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get textScore =>
columnsByName['text_score']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<String> get recognizedText =>
columnsByName['recognized_text']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isVisible =>
columnsByName['is_visible']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<double> _column_213(String aliasedName) =>
i1.GeneratedColumn<double>(
'x1',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_214(String aliasedName) =>
i1.GeneratedColumn<double>(
'y1',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_215(String aliasedName) =>
i1.GeneratedColumn<double>(
'x2',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_216(String aliasedName) =>
i1.GeneratedColumn<double>(
'y2',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_217(String aliasedName) =>
i1.GeneratedColumn<double>(
'x3',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_218(String aliasedName) =>
i1.GeneratedColumn<double>(
'y3',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_219(String aliasedName) =>
i1.GeneratedColumn<double>(
'x4',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_220(String aliasedName) =>
i1.GeneratedColumn<double>(
'y4',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_221(String aliasedName) =>
i1.GeneratedColumn<double>(
'box_score',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<double> _column_222(String aliasedName) =>
i1.GeneratedColumn<double>(
'text_score',
aliasedName,
false,
type: i1.DriftSqlType.double,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_223(String aliasedName) =>
i1.GeneratedColumn<String>(
'recognized_text',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -15359,7 +14659,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -15498,11 +14797,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from27To28(migrator, schema);
return 28;
case 28:
final schema = Schema29(database: database);
final migrator = i1.Migrator(database, schema);
await from28To29(migrator, schema);
return 29;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -15537,7 +14831,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -15567,6 +14860,5 @@ i1.OnUpgrade stepByStep({
from25To26: from25To26,
from26To27: from26To27,
from27To28: from27To28,
from28To29: from28To29,
),
);
@@ -1,38 +0,0 @@
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:drift/drift.dart';
class OcrRepository extends DriftDatabaseRepository {
final Drift _db;
const OcrRepository(this._db) : super(_db);
Future<List<Ocr>> get(String assetId) async {
final query = _db.select(_db.assetOcrEntity)
..where((row) => row.assetId.equals(assetId) & row.isVisible.equals(true));
final result = await query.get();
return result.map((e) => e.toDto()).toList();
}
}
extension on AssetOcrEntityData {
Ocr toDto() {
return Ocr(
id: id,
assetId: assetId,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
x3: x3,
y3: y3,
x4: x4,
y4: y4,
boxScore: boxScore,
textScore: textScore,
text: recognizedText,
isVisible: isVisible,
);
}
}
@@ -1,106 +1,62 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPartnerRepository extends DriftDatabaseRepository {
class PartnerRepository {
final Drift _db;
const DriftPartnerRepository(this._db) : super(_db);
const PartnerRepository(this._db);
Future<List<PartnerUserDto>> getPartners(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedWithId.equals(userId));
Future<Partner> get({required String sharedById, required String sharedWithId}) =>
(_db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(
_db.partnerEntity.sharedById.equals(sharedById) & _db.partnerEntity.sharedWithId.equals(sharedWithId),
))
.map(_resultToPartner)
.getSingle();
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Stream<Iterable<Partner>> search(String userId, PartnerDirection direction) =>
(_db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(switch (direction) {
.sharedBy => _db.partnerEntity.sharedWithId,
.sharedWith => _db.partnerEntity.sharedById,
}),
),
])..where(
switch (direction) {
.sharedBy => _db.partnerEntity.sharedById,
.sharedWith => _db.partnerEntity.sharedWithId,
}.equals(userId) &
_db.userEntity.id.equals(userId).not(),
))
.map(_resultToPartner)
.watch();
// Get users who we can share our library with
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) {
final query = _db.select(_db.userEntity)..where((row) => row.id.equals(currentUserId).not());
Future<void> create({required String sharedById, required String sharedWithId, bool inTimeline = false}) =>
_db.partnerEntity.insertOnConflictUpdate(
PartnerEntityCompanion(
sharedById: Value(sharedById),
sharedWithId: Value(sharedWithId),
inTimeline: Value(inTimeline),
),
);
return query.map((user) {
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false);
}).get();
}
Future<void> update({required String sharedById, required String sharedWithId, required bool inTimeline}) =>
(_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.write(PartnerEntityCompanion(inTimeline: Value(inTimeline)));
// Get users who are sharing their photos WITH the current user
Future<List<PartnerUserDto>> getSharedWith(String partnerId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedWithId.equals(partnerId));
Future<void> delete({required String sharedById, required String sharedWithId}) =>
(_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.go();
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
// Get users who the current user is sharing their photos TO
Future<List<PartnerUserDto>> getSharedBy(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId)),
])..where(_db.partnerEntity.sharedById.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Future<List<String>> getAllPartnerIds(String userId) async {
// Get users who are sharing with me (sharedWithId = userId)
final sharingWithMeQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedWithId.equals(userId));
final sharingWithMe = await sharingWithMeQuery.map((row) => row.sharedById).get();
// Get users who I am sharing with (sharedById = userId)
final sharingWithThemQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedById.equals(userId));
final sharingWithThem = await sharingWithThemQuery.map((row) => row.sharedWithId).get();
// Combine both lists and remove duplicates
final allPartnerIds = <String>{...sharingWithMe, ...sharingWithThem}.toList();
return allPartnerIds;
}
Future<PartnerUserDto?> getPartner(String partnerId, String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).getSingleOrNull();
}
Future<bool> toggleShowInTimeline(PartnerUserDto partner, String userId) {
return _db.partnerEntity.update().replace(
PartnerEntityCompanion(
sharedById: Value(partner.id),
sharedWithId: Value(userId),
inTimeline: Value(!partner.inTimeline),
),
);
}
Future<int> create(String partnerId, String userId) {
final entity = PartnerEntityCompanion(
sharedById: Value(userId),
sharedWithId: Value(partnerId),
inTimeline: const Value(false),
);
return _db.partnerEntity.insertOne(entity);
}
Future<void> delete(String partnerId, String userId) {
return _db.partnerEntity.deleteWhere((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId));
Partner _resultToPartner(TypedResult result) {
final user = result.readTable(_db.userEntity);
final partner = result.readTable(_db.partnerEntity);
return mapToPartner(user, partner);
}
}
@@ -74,7 +74,6 @@ class SyncApiRepository {
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
? SyncRequestType.assetFacesV2
: SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
],
).toJson(),
);
@@ -205,8 +204,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
SyncEntityType.assetOcrV1: SyncAssetOcrV1.fromJson,
SyncEntityType.assetOcrDeleteV1: SyncAssetOcrDeleteV1.fromJson,
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
};
@@ -12,7 +12,6 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
@@ -70,7 +69,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
await _db.assetOcrEntity.deleteAll();
});
} finally {
// re-enable FK even if the transaction throws, otherwise the connection
@@ -850,52 +848,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetOcrV1(Iterable<SyncAssetOcrV1> data) async {
try {
await _db.batch((batch) {
for (final assetOcr in data) {
final companion = AssetOcrEntityCompanion(
assetId: Value(assetOcr.assetId),
recognizedText: Value(assetOcr.text),
x1: Value(assetOcr.x1),
y1: Value(assetOcr.y1),
x2: Value(assetOcr.x2),
y2: Value(assetOcr.y2),
x3: Value(assetOcr.x3),
y3: Value(assetOcr.y3),
x4: Value(assetOcr.x4),
y4: Value(assetOcr.y4),
boxScore: Value(assetOcr.boxScore),
textScore: Value(assetOcr.textScore),
isVisible: Value(assetOcr.isVisible),
);
batch.insert(
_db.assetOcrEntity,
companion.copyWith(id: Value(assetOcr.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetOcrV1', error, stack);
rethrow;
}
}
Future<void> deleteAssetOcrV1(Iterable<SyncAssetOcrDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final assetOcr in data) {
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id));
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetOcrV1', error, stack);
rethrow;
}
}
Future<void> pruneAssets() async {
try {
await _db.transaction(() async {
@@ -2,9 +2,17 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
class UserRepository {
final Drift _db;
const UserRepository(this._db);
Stream<Iterable<User>> getAll() => _db.select(_db.userEntity).map(mapToUser).watch();
}
class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
+138 -5
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.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/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
),
),
},
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
const _BackupFooter(),
],
],
),
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
}
class _BackupFooter extends ConsumerStatefulWidget {
const _BackupFooter();
@override
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
}
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
}
}
void showPermissionsDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(context.t.notification_permission_dialog_content),
actions: [
ImmichTextButton(
labelText: context.t.cancel,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
ImmichTextButton(
labelText: context.t.settings,
variant: .ghost,
expanded: false,
onPressed: () {
ContextHelper(context).pop();
openAppSettings();
},
),
],
),
);
}
void showBatteryOptimizationInfo() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(context.t.backup_controller_page_background_battery_info_title),
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
actions: [
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_link,
variant: .ghost,
expanded: false,
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
),
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_ok,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final isBackupEnabled = ref.watch(appConfigProvider.select((config) => config.backup.enabled));
final notificationStatus = ref.watch(notificationPermissionProvider);
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
return Column(
children: [
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
if (notificationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.notification_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: () {
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
if (p == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
});
},
),
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
const Divider(indent: 32, endIndent: 32),
if (batteryOptimizationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.battery_optimization_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: showBatteryOptimizationInfo,
),
],
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text(context.t.view_details),
),
],
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@@ -1,139 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftPartnerPage extends HookConsumerWidget {
const DriftPartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider);
addNewUsersHandler() async {
final potentialPartners = potentialPartnersAsync.value;
if (potentialPartners == null || potentialPartners.isEmpty) {
ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr());
return;
}
final selectedUser = await showDialog<PartnerUserDto>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (PartnerUserDto partner in potentialPartners)
SimpleDialogOption(
onPressed: () => context.pop(partner),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: PartnerUserAvatar(partner: partner),
),
Text(partner.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser);
}
}
onDeleteUser(PartnerUserDto partner) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "stop_photo_sharing",
content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': partner.name}),
onOk: () => ref.read(partnerUsersProvider.notifier).removePartner(partner),
);
},
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partners").t(context: context),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: potentialPartnersAsync.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: "add_partner".tr(),
),
],
),
body: _SharedToPartnerList(onAddPartner: addNewUsersHandler, onDeletePartner: onDeleteUser),
);
}
}
class _SharedToPartnerList extends ConsumerWidget {
final VoidCallback onAddPartner;
final Function(PartnerUserDto partner) onDeletePartner;
const _SharedToPartnerList({required this.onAddPartner, required this.onDeletePartner});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partnerAsync = ref.watch(driftSharedByPartnerProvider);
return partnerAsync.when(
data: (partners) {
if (partners.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: onAddPartner,
icon: const Icon(Icons.person_add),
label: const Text("add_partner").tr(),
),
),
],
),
);
}
return ListView.builder(
itemCount: partners.length,
itemBuilder: (context, index) {
final partner = partners[index];
return ListTile(
leading: PartnerUserAvatar(partner: partner),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onDeletePartner(partner)),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))),
);
}
}
@@ -0,0 +1,200 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@visibleForTesting
final candidatesStateProvider = StreamProvider.autoDispose<Iterable<User>>((ref) {
final currentUser = ref.watch(currentUserProvider);
// TODO: Refactor with a route guard to avoid this check in every provider
if (currentUser == null) {
return const Stream.empty();
}
return ref.watch(partnerServiceProvider).getCandidates(currentUser.id);
});
@visibleForTesting
final partnersStateProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
// TODO: Refactor with a route guard to avoid this check in every provider
if (currentUser == null) {
return const Stream.empty();
}
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedBy);
});
Future<void> _addPartner(BuildContext context, WidgetRef ref) async {
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
final currentUser = ref.read(currentUserProvider);
if (selected != null && currentUser != null) {
await ref.read(partnerServiceProvider).create(sharedById: currentUser.id, sharedWithId: selected.id);
}
}
Future<void> _removePartner(BuildContext context, WidgetRef ref, Partner partner) => showDialog(
context: context,
builder: (_) => ConfirmDialog(
title: "stop_photo_sharing",
content: context.t.partner_page_stop_sharing_content(partner: partner.name),
onOk: () {
final currentUser = ref.read(currentUserProvider);
if (currentUser != null) {
ref.read(partnerServiceProvider).delete(sharedById: currentUser.id, sharedWithId: partner.id);
}
},
),
);
@RoutePage()
class PartnerPage extends ConsumerWidget {
const PartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedByAsync = ref.watch(partnersStateProvider);
return Scaffold(
appBar: AppBar(
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: () => _addPartner(context, ref),
icon: const Icon(Icons.person_add),
tooltip: context.t.add_partner,
),
],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(
partners: partners.toList(growable: false),
onAdd: () => _addPartner(context, ref),
onRemove: (partner) => _removePartner(context, ref, partner),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
),
);
}
}
class _EmptyPartners extends StatelessWidget {
const _EmptyPartners({required this.onAdd});
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return Padding(
padding: const .symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: .start,
children: [
Padding(
padding: const .symmetric(vertical: 8),
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
),
Align(
alignment: .center,
child: ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.person_add),
label: Text(context.t.add_partner),
),
),
],
),
);
}
}
@visibleForTesting
class PartnerSharedByList extends StatelessWidget {
const PartnerSharedByList({super.key, required this.partners, required this.onAdd, required this.onRemove});
final List<Partner> partners;
final VoidCallback onAdd;
final ValueChanged<Partner> onRemove;
@override
Widget build(BuildContext context) {
if (partners.isEmpty) {
return _EmptyPartners(onAdd: onAdd);
}
return ListView.builder(
itemCount: partners.length,
itemBuilder: (_, index) {
final partner = partners[index];
return ListTile(
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onRemove(partner)),
);
},
);
}
}
@visibleForTesting
class PartnerSelectionDialog extends ConsumerWidget {
const PartnerSelectionDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final candidatesAsync = ref.watch(candidatesStateProvider);
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: candidatesAsync.when(
data: (candidates) {
final users = candidates.toList();
if (users.isEmpty) {
return [
Padding(
padding: const .symmetric(horizontal: 24, vertical: 8),
child: const Text("partner_page_no_more_users").tr(),
),
];
}
return [
for (final candidate in users)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(candidate),
child: Row(
children: [
Padding(
padding: const .only(right: 8),
child: PartnerUserAvatar(userId: candidate.id, name: candidate.name),
),
Text(candidate.name),
],
),
),
];
},
loading: () => const [
Padding(
padding: .all(24),
child: Center(child: CircularProgressIndicator()),
),
],
error: (error, _) => [
Padding(
padding: const .symmetric(horizontal: 24, vertical: 8),
child: Text(context.t.error_loading_partners(error: error)),
),
],
),
);
}
}
+27
View File
@@ -26,6 +26,8 @@ Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName,
return replyList.firstOrNull;
}
enum PermissionStatus { granted, denied, permanentlyDenied }
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -33,6 +35,9 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PermissionStatus) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else {
super.writeValue(buffer, value);
}
@@ -41,6 +46,9 @@ class _PigeonCodec extends StandardMessageCodec {
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final value = readValue(buffer) as int?;
return value == null ? null : PermissionStatus.values[value];
default:
return super.readValueOfType(type, buffer);
}
@@ -60,6 +68,25 @@ class PermissionApi {
final String pigeonVar_messageChannelSuffix;
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as PermissionStatus;
}
Future<bool> hasManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
@@ -7,12 +7,13 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
@@ -327,12 +328,23 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
}
}
@visibleForTesting
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
// TODO: Refactor with a route guard to avoid this check in every provider
return const .empty();
}
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
});
class _QuickAccessButtonList extends ConsumerWidget {
const _QuickAccessButtonList();
@override
Widget build(BuildContext context, WidgetRef ref) {
final partnerSharedWithAsync = ref.watch(driftSharedWithPartnerProvider);
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
final partners = partnerSharedWithAsync.valueOrNull ?? [];
return SliverPadding(
@@ -387,9 +399,9 @@ class _QuickAccessButtonList extends ConsumerWidget {
'partners'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const DriftPartnerRoute()),
onTap: () => context.pushRoute(const PartnerRoute()),
),
_PartnerList(partners: partners),
_PartnerList(partners: partners.toList()),
],
),
),
@@ -401,7 +413,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
class _PartnerList extends StatelessWidget {
const _PartnerList({required this.partners});
final List<PartnerUserDto> partners;
final List<Partner> partners;
@override
Widget build(BuildContext context) {
@@ -421,7 +433,7 @@ class _PartnerList extends StatelessWidget {
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(partner: partner),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: const Text(
"partner_list_user_photos",
style: TextStyle(fontWeight: FontWeight.w500),
@@ -8,13 +8,13 @@ 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.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@RoutePage()
class DriftPartnerDetailPage extends StatelessWidget {
final PartnerUserDto partner;
final Partner partner;
const DriftPartnerDetailPage({super.key, required this.partner});
@@ -39,7 +39,7 @@ class DriftPartnerDetailPage extends StatelessWidget {
}
class _InfoBox extends ConsumerStatefulWidget {
final PartnerUserDto partner;
final Partner partner;
const _InfoBox({required this.partner});
@@ -63,7 +63,9 @@ class _InfoBoxState extends ConsumerState<_InfoBox> {
}
try {
await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline(widget.partner.id, user.id);
await ref
.read(partnerServiceProvider)
.update(sharedById: widget.partner.id, sharedWithId: user.id, inTimeline: !_inTimeline);
setState(() {
_inTimeline = !_inTimeline;
@@ -14,7 +14,6 @@ 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_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_overlay.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';
@@ -395,7 +394,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
final asset = _asset;
if (asset == null) {
@@ -448,15 +446,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
localFilePath: viewIntentFilePath,
),
),
if (showingOcr && displayAsset.width != null && displayAsset.height != null)
Positioned.fill(
child: OcrOverlay(
asset: displayAsset,
imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()),
viewportSize: Size(viewportWidth, viewportHeight),
controller: _viewController,
),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
@@ -1,343 +0,0 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
class OcrOverlay extends ConsumerStatefulWidget {
final BaseAsset asset;
final Size imageSize;
final Size viewportSize;
final PhotoViewControllerBase? controller;
const OcrOverlay({
super.key,
required this.asset,
required this.imageSize,
required this.viewportSize,
this.controller,
});
@override
ConsumerState<OcrOverlay> createState() => _OcrOverlayState();
}
class _OcrOverlayState extends ConsumerState<OcrOverlay> {
int? _selectedBoxIndex;
// Current transform read from the PhotoView controller.
// Null until the controller has emitted at least one real event or until
// we can seed a reliable value from controller.value on init.
PhotoViewControllerValue? _controllerValue;
StreamSubscription<PhotoViewControllerValue>? _controllerSub;
@override
void initState() {
super.initState();
_attachController(widget.controller);
}
@override
void didUpdateWidget(OcrOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_detachController();
_attachController(widget.controller);
}
}
@override
void dispose() {
_detachController();
super.dispose();
}
void _attachController(PhotoViewControllerBase? controller) {
if (controller == null) {
return;
}
// Seed with the current value only when scaleBoundaries is already set.
// Before the image finishes loading, PhotoView uses childSize = outerSize
// (viewport) as a placeholder, which sets scale = 1.0. That placeholder
// is wrong for any image that doesn't exactly fill the viewport.
// Once scaleBoundaries is set the value is trustworthy (the image has rendered
// at least one frame and setScaleInvisibly has been called with the real
// initial/zoomed scale).
if (controller.scaleBoundaries != null) {
_controllerValue = controller.value;
}
_controllerSub = controller.outputStateStream.listen((value) {
if (mounted) {
setState(() => _controllerValue = value);
}
});
}
void _detachController() {
_controllerSub?.cancel();
_controllerSub = null;
}
@override
Widget build(BuildContext context) {
if (widget.asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final ocrData = ref.watch(ocrAssetProvider((widget.asset as RemoteAsset).id));
return ocrData.when(
data: (data) {
if (data == null || data.isEmpty) {
return const SizedBox.shrink();
}
return _OcrBoxes(
ocrData: data,
controller: widget.controller,
imageSize: widget.imageSize,
viewportSize: widget.viewportSize,
controllerValue: _controllerValue,
selectedBoxIndex: _selectedBoxIndex,
onSelectionChanged: (index) => setState(() => _selectedBoxIndex = index),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
}
class _OcrBoxes extends StatelessWidget {
final List<Ocr> ocrData;
final PhotoViewControllerBase? controller;
final Size imageSize;
final Size viewportSize;
final PhotoViewControllerValue? controllerValue;
final int? selectedBoxIndex;
final ValueChanged<int?> onSelectionChanged;
const _OcrBoxes({
required this.ocrData,
required this.controller,
required this.imageSize,
required this.viewportSize,
required this.controllerValue,
required this.selectedBoxIndex,
required this.onSelectionChanged,
});
@override
Widget build(BuildContext context) {
// Use the actual decoded image size from PhotoView's scaleBoundaries when
// available. The image provider may serve a downscaled preview (e.g. Immich
// serves a ~1440px preview for large originals), so the decoded dimensions
// can differ significantly from the stored asset dimensions. Using the wrong
// size would scale every coordinate by the ratio between the two resolutions.
final resolvedImageSize = controller?.scaleBoundaries?.childSize ?? imageSize;
final scale =
controllerValue?.scale ??
math.min(viewportSize.width / resolvedImageSize.width, viewportSize.height / resolvedImageSize.height);
final position = controllerValue?.position ?? Offset.zero;
final imageWidth = resolvedImageSize.width;
final imageHeight = resolvedImageSize.height;
final viewportWidth = viewportSize.width;
final viewportHeight = viewportSize.height;
// Image center in viewport space, accounting for pan
final cx = viewportWidth / 2 + position.dx;
final cy = viewportHeight / 2 + position.dy;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onSelectionChanged(null),
child: ClipRect(
child: Stack(
children: [
// Fills the viewport so taps outside boxes deselect
SizedBox(width: viewportWidth, height: viewportHeight),
...ocrData.asMap().entries.map((entry) {
final index = entry.key;
final ocr = entry.value;
// Map normalized image coords (01) to viewport space
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
// Bounding rectangle for hit testing and Positioned placement
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
return _OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
);
}),
],
),
),
);
}
}
class _OcrBoxItem extends StatelessWidget {
final Ocr ocr;
final int index;
final bool isSelected;
final List<Offset> points;
final double left;
final double top;
final double width;
final double height;
final double angle;
final double labelDx;
final double labelDy;
final ValueChanged<int?> onSelectionChanged;
const _OcrBoxItem({
super.key,
required this.ocr,
required this.index,
required this.isSelected,
required this.points,
required this.left,
required this.top,
required this.width,
required this.height,
required this.angle,
required this.labelDx,
required this.labelDy,
required this.onSelectionChanged,
});
@override
Widget build(BuildContext context) {
return Positioned(
left: left,
top: top,
child: GestureDetector(
onTap: () => onSelectionChanged(isSelected ? null : index),
behavior: HitTestBehavior.translucent,
child: SizedBox(
width: width,
height: height,
child: Stack(
children: [
CustomPaint(
painter: _OcrBoxPainter(
points: points,
isSelected: isSelected,
colorScheme: context.themeData.colorScheme,
),
size: Size(width, height),
),
if (isSelected)
Positioned(
left: labelDx,
top: labelDy,
child: FractionalTranslation(
translation: const Offset(-0.5, -0.5),
child: Transform.rotate(
angle: angle,
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.all(2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey[800]?.withValues(alpha: 0.4),
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: math.max(50, width), maxHeight: math.max(20, height)),
child: FittedBox(
fit: BoxFit.scaleDown,
child: SelectableText(
ocr.text,
style: TextStyle(
color: Colors.white,
fontSize: math.max(12, height * 0.6),
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
),
),
),
),
],
),
),
),
);
}
}
class _OcrBoxPainter extends CustomPainter {
final List<Offset> points;
final bool isSelected;
final ColorScheme colorScheme;
const _OcrBoxPainter({required this.points, required this.isSelected, required this.colorScheme});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = isSelected ? colorScheme.primary : colorScheme.secondary
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final fillPaint = Paint()
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
..style = PaintingStyle.fill;
final path = Path()
..moveTo(points[0].dx, points[0].dy)
..lineTo(points[1].dx, points[1].dy)
..lineTo(points[2].dx, points[2].dy)
..lineTo(points[3].dx, points[3].dy)
..close();
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(_OcrBoxPainter oldDelegate) {
return oldDelegate.isSelected != isSelected || !listEquals(oldDelegate.points, points);
}
}
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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/ocr.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';
@@ -36,7 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
@@ -48,15 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
final actions = <Widget>[
if (hasOcr)
IconButton(
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
color: showingOcr ? context.primaryColor : null,
),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
@@ -1,19 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
class PartnerUserAvatar extends StatelessWidget {
const PartnerUserAvatar({super.key, required this.partner});
const PartnerUserAvatar({super.key, required this.userId, required this.name});
final PartnerUserDto partner;
final String userId;
final String name;
@override
Widget build(BuildContext context) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${partner.id}/profile-image";
final nameFirstLetter = partner.name.isNotEmpty ? partner.name[0] : "";
final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image";
final nameFirstLetter = name.isNotEmpty ? name[0] : "";
return CircleAvatar(
radius: 16,
backgroundColor: context.primaryColor.withAlpha(50),
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:logging/logging.dart';
@@ -8,7 +8,6 @@ class AssetViewerState {
final bool showingDetails;
final bool showingControls;
final bool isZoomed;
final bool showingOcr;
final BaseAsset? currentAsset;
final int stackIndex;
@@ -17,7 +16,6 @@ class AssetViewerState {
this.showingDetails = false,
this.showingControls = true,
this.isZoomed = false,
this.showingOcr = false,
this.currentAsset,
this.stackIndex = 0,
});
@@ -27,7 +25,6 @@ class AssetViewerState {
bool? showingDetails,
bool? showingControls,
bool? isZoomed,
bool? showingOcr,
BaseAsset? currentAsset,
int? stackIndex,
}) {
@@ -36,7 +33,6 @@ class AssetViewerState {
showingDetails: showingDetails ?? this.showingDetails,
showingControls: showingControls ?? this.showingControls,
isZoomed: isZoomed ?? this.isZoomed,
showingOcr: showingOcr ?? this.showingOcr,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
);
@@ -44,7 +40,7 @@ class AssetViewerState {
@override
String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
}
@override
@@ -60,7 +56,6 @@ class AssetViewerState {
other.showingDetails == showingDetails &&
other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.showingOcr == showingOcr &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@@ -71,7 +66,6 @@ class AssetViewerState {
showingDetails.hashCode ^
showingControls.hashCode ^
isZoomed.hashCode ^
showingOcr.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
}
@@ -96,7 +90,7 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
if (asset == state.currentAsset) {
return;
}
state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false);
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(double opacity) {
@@ -143,10 +137,6 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
}
state = state.copyWith(stackIndex: index);
}
void toggleOcr() {
state = state.copyWith(showingOcr: !state.showingOcr);
}
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
@@ -1,14 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/domain/services/ocr.service.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final ocrRepositoryProvider = Provider<OcrRepository>((ref) => OcrRepository(ref.watch(driftProvider)));
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService(ref.watch(ocrRepositoryProvider)));
final ocrAssetProvider = FutureProvider.autoDispose.family<List<Ocr>?, String>((ref, assetId) async {
final service = ref.watch(ocrServiceProvider);
return service.get(assetId);
});
@@ -1,86 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class PartnerNotifier extends Notifier<List<PartnerUserDto>> {
late DriftPartnerService _driftPartnerService;
@override
List<PartnerUserDto> build() {
_driftPartnerService = ref.read(driftPartnerServiceProvider);
return [];
}
Future<void> _loadPartners() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
state = await _driftPartnerService.getSharedWith(currentUser.id);
}
Future<List<PartnerUserDto>> getPartners(String userId) async {
final partners = await _driftPartnerService.getSharedWith(userId);
state = partners;
return partners;
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
await _driftPartnerService.toggleShowInTimeline(partnerId, userId);
await _loadPartners();
}
Future<void> addPartner(PartnerUserDto partner) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await _driftPartnerService.addPartner(partner.id, currentUser.id);
await _loadPartners();
ref.invalidate(driftAvailablePartnerProvider);
ref.invalidate(driftSharedByPartnerProvider);
}
Future<void> removePartner(PartnerUserDto partner) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await _driftPartnerService.removePartner(partner.id, currentUser.id);
await _loadPartners();
ref.invalidate(driftAvailablePartnerProvider);
ref.invalidate(driftSharedByPartnerProvider);
}
}
final driftAvailablePartnerProvider = FutureProvider.autoDispose<List<PartnerUserDto>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return ref.watch(driftPartnerServiceProvider).getAvailablePartners(currentUser.id);
});
final driftSharedByPartnerProvider = FutureProvider.autoDispose<List<PartnerUserDto>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return ref.watch(driftPartnerServiceProvider).getSharedBy(currentUser.id);
});
final driftSharedWithPartnerProvider = FutureProvider.autoDispose<List<PartnerUserDto>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return ref.watch(driftPartnerServiceProvider).getSharedWith(currentUser.id);
});
@@ -1,15 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider)));
final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi));
final userServiceProvider = Provider(
@@ -19,13 +20,12 @@ final userServiceProvider = Provider(
),
);
/// Drifts
final driftPartnerRepositoryProvider = Provider<DriftPartnerRepository>(
(ref) => DriftPartnerRepository(ref.watch(driftProvider)),
);
final partnerRepositoryProvider = Provider<PartnerRepository>((ref) => PartnerRepository(ref.watch(driftProvider)));
final driftPartnerServiceProvider = Provider<DriftPartnerService>(
(ref) => DriftPartnerService(ref.watch(driftPartnerRepositoryProvider), ref.watch(partnerApiRepositoryProvider)),
final partnerServiceProvider = Provider<PartnerService>(
(ref) => PartnerService(
ref.watch(userRepositoryProvider),
ref.watch(partnerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
),
);
final partnerUsersProvider = NotifierProvider<PartnerNotifier, List<PartnerUserDto>>(PartnerNotifier.new);
@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
@@ -39,3 +42,26 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
(ref) => NotificationPermissionNotifier(),
);
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
BatteryOptimizationNotifier.new,
);
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
Future<PermissionStatus> getBatteryOptimizationPermission() async {
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
state = AsyncValue.data(isIgnoring);
return isIgnoring;
}
@override
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
}
extension on pm.PermissionStatus {
PermissionStatus toStatus() => switch (this) {
pm.PermissionStatus.granted => PermissionStatus.granted,
pm.PermissionStatus.denied => PermissionStatus.denied,
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
};
}
+3 -3
View File
@@ -27,7 +27,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/pages/common/tab_shell.page.dart';
import 'package:immich_mobile/pages/library/folder/folder.page.dart';
import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart';
import 'package:immich_mobile/pages/library/partner/drift_partner.page.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart';
import 'package:immich_mobile/pages/login/change_password.page.dart';
@@ -57,8 +57,8 @@ import 'package:immich_mobile/presentation/pages/drift_people_collection.page.da
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
@@ -176,7 +176,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftPlaceRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPlaceDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftUserSelectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: SyncStatusRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
+18 -18
View File
@@ -827,7 +827,7 @@ class DriftPartnerDetailRoute
extends PageRouteInfo<DriftPartnerDetailRouteArgs> {
DriftPartnerDetailRoute({
Key? key,
required PartnerUserDto partner,
required Partner partner,
List<PageRouteInfo>? children,
}) : super(
DriftPartnerDetailRoute.name,
@@ -851,7 +851,7 @@ class DriftPartnerDetailRouteArgs {
final Key? key;
final PartnerUserDto partner;
final Partner partner;
@override
String toString() {
@@ -869,22 +869,6 @@ class DriftPartnerDetailRouteArgs {
int get hashCode => key.hashCode ^ partner.hashCode;
}
/// generated route for
/// [DriftPartnerPage]
class DriftPartnerRoute extends PageRouteInfo<void> {
const DriftPartnerRoute({List<PageRouteInfo>? children})
: super(DriftPartnerRoute.name, initialChildren: children);
static const String name = 'DriftPartnerRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftPartnerPage();
},
);
}
/// generated route for
/// [DriftPeopleCollectionPage]
class DriftPeopleCollectionRoute extends PageRouteInfo<void> {
@@ -1456,6 +1440,22 @@ class MapLocationPickerRouteArgs {
int get hashCode => key.hashCode ^ initialLatLng.hashCode;
}
/// generated route for
/// [PartnerPage]
class PartnerRoute extends PageRouteInfo<void> {
const PartnerRoute({List<PageRouteInfo>? children})
: super(PartnerRoute.name, initialChildren: children);
static const String name = 'PartnerRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const PartnerPage();
},
);
}
/// generated route for
/// [PinAuthPage]
class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
@@ -2,7 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
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/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:permission_handler/permission_handler.dart';
+8 -15
View File
@@ -38,15 +38,8 @@ run = "dart run build_runner watch --delete-conflicting-outputs"
alias = "pigeon"
description = "Generate pigeon platform code"
run = [
"dart run pigeon --input pigeon/native_sync_api.dart",
"dart run pigeon --input pigeon/local_image_api.dart",
"dart run pigeon --input pigeon/remote_image_api.dart",
"dart run pigeon --input pigeon/background_worker_api.dart",
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart",
"dart run pigeon --input pigeon/view_intent_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
"ls pigeon/*.dart | xargs -n1 -P4 -I{} dart run pigeon --input {}",
"dart format lib/platform/",
]
[tasks."codegen:translation"]
@@ -149,10 +142,10 @@ run = "dcm fix lib"
[tasks.checklist]
run = [
{task = "codegen:pigeon" },
{task = "codegen:dart" },
{task = "codegen:translation" },
{task = "analyze" },
{task = "format" },
{task = "test" },
{ task = "codegen:pigeon" },
{ task = "codegen:dart" },
{ task = "codegen:translation" },
{ task = "analyze" },
{ task = "format" },
{ task = "test" },
]
-2
View File
@@ -581,8 +581,6 @@ Class | Method | HTTP request | Description
- [SyncAssetFaceV2](doc//SyncAssetFaceV2.md)
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
- [SyncAssetOcrDeleteV1](doc//SyncAssetOcrDeleteV1.md)
- [SyncAssetOcrV1](doc//SyncAssetOcrV1.md)
- [SyncAssetV1](doc//SyncAssetV1.md)
- [SyncAssetV2](doc//SyncAssetV2.md)
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
-2
View File
@@ -323,8 +323,6 @@ part 'model/sync_asset_face_v1.dart';
part 'model/sync_asset_face_v2.dart';
part 'model/sync_asset_metadata_delete_v1.dart';
part 'model/sync_asset_metadata_v1.dart';
part 'model/sync_asset_ocr_delete_v1.dart';
part 'model/sync_asset_ocr_v1.dart';
part 'model/sync_asset_v1.dart';
part 'model/sync_asset_v2.dart';
part 'model/sync_auth_user_v1.dart';
-4
View File
@@ -691,10 +691,6 @@ class ApiClient {
return SyncAssetMetadataDeleteV1.fromJson(value);
case 'SyncAssetMetadataV1':
return SyncAssetMetadataV1.fromJson(value);
case 'SyncAssetOcrDeleteV1':
return SyncAssetOcrDeleteV1.fromJson(value);
case 'SyncAssetOcrV1':
return SyncAssetOcrV1.fromJson(value);
case 'SyncAssetV1':
return SyncAssetV1.fromJson(value);
case 'SyncAssetV2':
-120
View File
@@ -1,120 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetOcrDeleteV1 {
/// Returns a new [SyncAssetOcrDeleteV1] instance.
SyncAssetOcrDeleteV1({
required this.assetId,
required this.deletedAt,
required this.id,
});
/// Original asset ID of the deleted OCR entry
String assetId;
/// Timestamp when the OCR entry was deleted
DateTime deletedAt;
/// Audit row ID of the deleted OCR entry
String id;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrDeleteV1 &&
other.assetId == assetId &&
other.deletedAt == deletedAt &&
other.id == id;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(deletedAt.hashCode) +
(id.hashCode);
@override
String toString() => 'SyncAssetOcrDeleteV1[assetId=$assetId, deletedAt=$deletedAt, id=$id]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.deletedAt.millisecondsSinceEpoch
: this.deletedAt.toUtc().toIso8601String();
json[r'id'] = this.id;
return json;
}
/// Returns a new [SyncAssetOcrDeleteV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetOcrDeleteV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetOcrDeleteV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetOcrDeleteV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
id: mapValueOfType<String>(json, r'id')!,
);
}
return null;
}
static List<SyncAssetOcrDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetOcrDeleteV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetOcrDeleteV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetOcrDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetOcrDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetOcrDeleteV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetOcrDeleteV1-objects as value to a dart map
static Map<String, List<SyncAssetOcrDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetOcrDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetOcrDeleteV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'deletedAt',
'id',
};
}
-217
View File
@@ -1,217 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetOcrV1 {
/// Returns a new [SyncAssetOcrV1] instance.
SyncAssetOcrV1({
required this.assetId,
required this.boxScore,
required this.id,
required this.isVisible,
required this.text,
required this.textScore,
required this.x1,
required this.x2,
required this.x3,
required this.x4,
required this.y1,
required this.y2,
required this.y3,
required this.y4,
});
/// Asset ID
String assetId;
/// Confidence score of the bounding box
double boxScore;
/// OCR entry ID
String id;
/// Whether the OCR entry is visible
bool isVisible;
/// Recognized text content
String text;
/// Confidence score of the recognized text
double textScore;
/// Top-left X coordinate (normalized 01)
double x1;
/// Top-right X coordinate (normalized 01)
double x2;
/// Bottom-right X coordinate (normalized 01)
double x3;
/// Bottom-left X coordinate (normalized 01)
double x4;
/// Top-left Y coordinate (normalized 01)
double y1;
/// Top-right Y coordinate (normalized 01)
double y2;
/// Bottom-right Y coordinate (normalized 01)
double y3;
/// Bottom-left Y coordinate (normalized 01)
double y4;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrV1 &&
other.assetId == assetId &&
other.boxScore == boxScore &&
other.id == id &&
other.isVisible == isVisible &&
other.text == text &&
other.textScore == textScore &&
other.x1 == x1 &&
other.x2 == x2 &&
other.x3 == x3 &&
other.x4 == x4 &&
other.y1 == y1 &&
other.y2 == y2 &&
other.y3 == y3 &&
other.y4 == y4;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(boxScore.hashCode) +
(id.hashCode) +
(isVisible.hashCode) +
(text.hashCode) +
(textScore.hashCode) +
(x1.hashCode) +
(x2.hashCode) +
(x3.hashCode) +
(x4.hashCode) +
(y1.hashCode) +
(y2.hashCode) +
(y3.hashCode) +
(y4.hashCode);
@override
String toString() => 'SyncAssetOcrV1[assetId=$assetId, boxScore=$boxScore, id=$id, isVisible=$isVisible, text=$text, textScore=$textScore, x1=$x1, x2=$x2, x3=$x3, x4=$x4, y1=$y1, y2=$y2, y3=$y3, y4=$y4]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'boxScore'] = this.boxScore;
json[r'id'] = this.id;
json[r'isVisible'] = this.isVisible;
json[r'text'] = this.text;
json[r'textScore'] = this.textScore;
json[r'x1'] = this.x1;
json[r'x2'] = this.x2;
json[r'x3'] = this.x3;
json[r'x4'] = this.x4;
json[r'y1'] = this.y1;
json[r'y2'] = this.y2;
json[r'y3'] = this.y3;
json[r'y4'] = this.y4;
return json;
}
/// Returns a new [SyncAssetOcrV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetOcrV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetOcrV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetOcrV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
boxScore: mapValueOfType<double>(json, r'boxScore')!,
id: mapValueOfType<String>(json, r'id')!,
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
text: mapValueOfType<String>(json, r'text')!,
textScore: mapValueOfType<double>(json, r'textScore')!,
x1: mapValueOfType<double>(json, r'x1')!,
x2: mapValueOfType<double>(json, r'x2')!,
x3: mapValueOfType<double>(json, r'x3')!,
x4: mapValueOfType<double>(json, r'x4')!,
y1: mapValueOfType<double>(json, r'y1')!,
y2: mapValueOfType<double>(json, r'y2')!,
y3: mapValueOfType<double>(json, r'y3')!,
y4: mapValueOfType<double>(json, r'y4')!,
);
}
return null;
}
static List<SyncAssetOcrV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetOcrV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetOcrV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetOcrV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetOcrV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetOcrV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetOcrV1-objects as value to a dart map
static Map<String, List<SyncAssetOcrV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetOcrV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetOcrV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'boxScore',
'id',
'isVisible',
'text',
'textScore',
'x1',
'x2',
'x3',
'x4',
'y1',
'y2',
'y3',
'y4',
};
}
-6
View File
@@ -34,8 +34,6 @@ class SyncEntityType {
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1');
static const assetOcrDeleteV1 = SyncEntityType._(r'AssetOcrDeleteV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1');
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
@@ -96,8 +94,6 @@ class SyncEntityType {
assetEditDeleteV1,
assetMetadataV1,
assetMetadataDeleteV1,
assetOcrV1,
assetOcrDeleteV1,
partnerV1,
partnerDeleteV1,
partnerAssetV1,
@@ -193,8 +189,6 @@ class SyncEntityTypeTypeTransformer {
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
case r'AssetOcrV1': return SyncEntityType.assetOcrV1;
case r'AssetOcrDeleteV1': return SyncEntityType.assetOcrDeleteV1;
case r'PartnerV1': return SyncEntityType.partnerV1;
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
-3
View File
@@ -35,7 +35,6 @@ class SyncRequestType {
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
@@ -65,7 +64,6 @@ class SyncRequestType {
assetExifsV1,
assetEditsV1,
assetMetadataV1,
assetOcrV1,
authUsersV1,
memoriesV1,
memoryToAssetsV1,
@@ -130,7 +128,6 @@ class SyncRequestTypeTypeTransformer {
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
case r'AssetOcrV1': return SyncRequestType.assetOcrV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1;
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
+5 -1
View File
@@ -1,10 +1,12 @@
import 'package:pigeon/pigeon.dart';
enum PermissionStatus { granted, denied, permanentlyDenied }
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/permission_api.g.dart',
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
swiftOptions: SwiftOptions(),
swiftOptions: SwiftOptions(includeErrorClass: false),
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
dartOptions: DartOptions(),
@@ -13,6 +15,8 @@ import 'package:pigeon/pigeon.dart';
)
@HostApi()
abstract class PermissionApi {
PermissionStatus isIgnoringBatteryOptimizations();
bool hasManageMediaPermission();
@async
+3
View File
@@ -1,6 +1,9 @@
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
+3
View File
@@ -1,3 +1,4 @@
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
-4
View File
@@ -32,7 +32,6 @@ import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -94,8 +93,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v27.DatabaseAtV27(db);
case 28:
return v28.DatabaseAtV28(db);
case 29:
return v29.DatabaseAtV29(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -130,6 +127,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
26,
27,
28,
29,
];
}
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
@@ -11,6 +12,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
@@ -44,6 +46,10 @@ class MockUploadRepository extends Mock implements UploadRepository {}
class MockSyncMigrationRepository extends Mock implements SyncMigrationRepository {}
class MockUserRepository extends Mock implements UserRepository {}
class MockPartnerRepository extends Mock implements PartnerRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}
@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late PartnerRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = PartnerRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('search', () {
test('sharedBy returns users the current user shares their library to', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id);
final result = await sut.search(me.id, .sharedBy).first;
expect(result.map((partner) => partner.id), unorderedEquals([recipient.id]));
});
test('sharedWith returns users sharing their library with the current user', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id);
final result = await sut.search(me.id, .sharedWith).first;
expect(result.map((partner) => partner.id), unorderedEquals([sharer.id]));
});
test('emits an updated list when a new partner is added', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
final ids = sut.search(me.id, .sharedBy).map((partners) => partners.map((p) => p.id).toList());
final expectation = expectLater(
ids,
emitsInOrder([
isEmpty,
unorderedEquals([recipient.id]),
]),
);
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await expectation;
});
});
group('create', () {
test('inserts a partnership with the current user as the sharer and inTimeline disabled', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
await sut.create(sharedById: me.id, sharedWithId: partner.id);
final result = (await sut.search(me.id, .sharedBy).first).first;
expect(result.id, partner.id);
expect(result.inTimeline, isFalse);
});
});
group('update', () {
test('toggles the inTimeline flag for an existing partnership', () async {
final me = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false);
await sut.update(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true);
final result = await sut.get(sharedById: sharer.id, sharedWithId: me.id);
expect(result.inTimeline, isTrue);
});
});
group('delete', () {
test('removes the partnership the current user shares by', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await sut.delete(sharedById: me.id, sharedWithId: recipient.id);
final rows = await ctx.db.select(ctx.db.partnerEntity).get();
expect(rows, isEmpty);
});
});
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
@@ -68,6 +69,18 @@ class MediumRepositoryContext {
);
}
Future<void> newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) {
return db
.into(db.partnerEntity)
.insert(
PartnerEntityCompanion(
sharedById: .new(sharedById),
sharedWithId: .new(sharedWithId),
inTimeline: .new(inTimeline ?? false),
),
);
}
Future<RemoteAssetEntityData> newRemoteAsset({
String? id,
String? checksum,
+31
View File
@@ -0,0 +1,31 @@
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../api.mocks.dart';
import '../utils.dart';
import 'repository_context.dart';
void _stubPartnerApi(MockPartnerApiRepository api) {
final id = TestUtils.uuid();
final partner = UserDto(id: id, email: '$id@example.com', name: 'name $id', profileChangedAt: TestUtils.now());
registerFallbackValue(Direction.sharedByMe);
when(() => api.getAll(any())).thenAnswer((_) async => const <UserDto>[]);
when(() => api.create(any())).thenAnswer((_) async => partner);
when(() => api.update(any(), inTimeline: any(named: 'inTimeline'))).thenAnswer((_) async => partner);
when(() => api.delete(any())).thenAnswer((_) async {});
}
class MediumServiceContext extends MediumRepositoryContext {
late final UserRepository userRepository = UserRepository(db);
late final PartnerRepository partnerRepository = PartnerRepository(db);
final partnerApi = MockPartnerApiRepository();
MediumServiceContext() {
_stubPartnerApi(partnerApi);
}
}
@@ -0,0 +1,110 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:mocktail/mocktail.dart';
import '../service_context.dart';
void main() {
late MediumServiceContext ctx;
late PartnerService sut;
setUp(() {
ctx = MediumServiceContext();
sut = PartnerService(ctx.userRepository, ctx.partnerRepository, ctx.partnerApi);
});
tearDown(() async {
await ctx.dispose();
});
group('getCandidates', () {
test('returns the other users and excludes the current user', () async {
final me = await ctx.newUser();
final other = await ctx.newUser();
final result = await sut.getCandidates(me.id).first;
expect(result.map((user) => user.id), unorderedEquals([other.id]));
});
test('excludes users the current user already shares with', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
final other = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: partner.id);
final result = await sut.getCandidates(me.id).first;
expect(result.map((user) => user.id), unorderedEquals([other.id]));
});
test('includes users who share with the current user but are not shared with back', () async {
final me = await ctx.newUser();
final inbound = await ctx.newUser();
await ctx.newPartner(sharedById: inbound.id, sharedWithId: me.id);
final result = await sut.getCandidates(me.id).first;
expect(result.map((user) => user.id), unorderedEquals([inbound.id]));
});
test('emits an updated list when the current user adds a partner', () async {
final me = await ctx.newUser();
final a = await ctx.newUser();
final b = await ctx.newUser();
final ids = sut.getCandidates(me.id).map((users) => users.map((user) => user.id).toList());
final expectation = expectLater(
ids,
emitsInOrder([
unorderedEquals([a.id, b.id]),
unorderedEquals([b.id]),
]),
);
await ctx.newPartner(sharedById: me.id, sharedWithId: a.id);
await expectation;
});
});
group('create', () {
test('calls the API then persists the partnership locally', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
await sut.create(sharedById: me.id, sharedWithId: partner.id);
verify(() => ctx.partnerApi.create(partner.id)).called(1);
final shared = await sut.search(me.id, .sharedBy).first;
expect(shared.map((p) => p.id), unorderedEquals([partner.id]));
});
});
group('delete', () {
test('calls the API then removes the partnership locally', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await sut.delete(sharedById: me.id, sharedWithId: recipient.id);
verify(() => ctx.partnerApi.delete(recipient.id)).called(1);
final shared = await sut.search(me.id, .sharedBy).first;
expect(shared, isEmpty);
});
});
group('update', () {
test('calls the API then updates the inTimeline flag locally', () async {
final me = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false);
await sut.update(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true);
verify(() => ctx.partnerApi.update(sharer.id, inTimeline: true)).called(1);
final partner = await ctx.partnerRepository.get(sharedById: sharer.id, sharedWithId: me.id);
expect(partner.inTimeline, isTrue);
});
});
}
@@ -19,7 +19,7 @@ class LocalAlbumFactory {
id: id,
name: name ?? 'local_album_$id',
updatedAt: TestUtils.date(updatedAt),
backupSelection: backupSelection ?? BackupSelection.none,
backupSelection: backupSelection ?? .none,
isIosSharedAlbum: isIosSharedAlbum ?? false,
linkedRemoteAlbumId: linkedRemoteAlbumId,
assetCount: assetCount ?? 10,
@@ -14,7 +14,7 @@ class LocalAssetFactory {
type: AssetType.image,
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
playbackStyle: AssetPlaybackStyle.image,
playbackStyle: .image,
isEdited: false,
);
}
@@ -0,0 +1,19 @@
import 'package:immich_mobile/domain/models/user.model.dart';
import '../../utils.dart';
class PartnerFactory {
const PartnerFactory();
static Partner create({String? id, String? email, String? name, bool? inTimeline}) {
id = TestUtils.uuid(id);
return Partner(
id: id,
email: email ?? '$id@test.com',
name: name ?? 'user_$id',
inTimeline: inTimeline ?? false,
hasProfileImage: false,
profileChangedAt: DateTime.now(),
);
}
}
@@ -0,0 +1,26 @@
import 'package:immich_mobile/domain/models/user.model.dart';
import '../../utils.dart';
class UserFactory {
const UserFactory();
static User create({
String? id,
String? name,
String? email,
DateTime? profileChangedAt,
bool? hasProfileImage,
AvatarColor? avatarColor,
}) {
id = TestUtils.uuid(id);
return User(
id: id,
name: name ?? 'user_$id',
email: email ?? '$id@test.com',
profileChangedAt: TestUtils.date(profileChangedAt),
hasProfileImage: hasProfileImage ?? false,
avatarColor: avatarColor ?? .primary,
);
}
}
+30 -14
View File
@@ -5,26 +5,30 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
class UnitMocks {
void _registerFallbacks() {
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
mocktail.registerFallbackValue(
LocalAsset(
id: '',
name: '',
type: AssetType.image,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
),
);
}
class RepositoryMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = MockNativeSyncApi();
UnitMocks() {
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
mocktail.registerFallbackValue(
LocalAsset(
id: '',
name: '',
type: AssetType.image,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
),
);
RepositoryMocks() {
_registerFallbacks();
}
void reset() {
@@ -34,3 +38,15 @@ class UnitMocks {
mocktail.reset(nativeApi);
}
}
class ServiceMocks {
final partner = MockPartnerService();
ServiceMocks() {
_registerFallbacks();
}
void reset() {
mocktail.reset(partner);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import '../factories/partner_user_factory.dart';
import '../factories/user_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() async => await context.dispose());
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
await tester.pumpTestWidget(PartnerSharedByList(partners: const [], onAdd: () {}, onRemove: (_) {}));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(ElevatedButton, Icons.person_add), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
final partner1 = PartnerFactory.create();
final partner2 = PartnerFactory.create();
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2], onAdd: () {}, onRemove: (_) {}));
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
expect(find.text(partner2.name), findsOneWidget);
expect(find.text(partner2.email), findsOneWidget);
});
testWidgets('invokes onRemovePartner with the tapped partner', (tester) async {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
Partner? removed;
await tester.pumpTestWidget(
PartnerSharedByList(partners: [partner1, partner2], onAdd: () {}, onRemove: (p) => removed = p),
);
await tester.tap(find.byIcon(Icons.person_remove).first);
await tester.pump();
expect(removed, partner1);
});
});
group('PartnerSelectionDialog', () {
final dialogButtonKey = UniqueKey();
Widget dialogWidget({void Function(User?)? onClosed}) {
return Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
onClosed?.call(selected);
},
child: Text(key: dialogButtonKey, 'open'),
),
);
}
List<Override> withCandidates(List<User> candidates) => [
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
final user = UserFactory.create();
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
expect(find.byType(SimpleDialogOption), findsOneWidget);
expect(find.text(user.name), findsOneWidget);
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
expect(find.byType(SimpleDialogOption), findsNothing);
});
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
await tester.tap(find.text(user.name));
await tester.pumpAndSettle();
expect(selected, user);
});
});
}
@@ -0,0 +1,68 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import '../test_utils.dart';
class PresentationContext {
const PresentationContext._();
static const String serverEndpoint = 'http://localhost:3000';
static Drift? _db;
static Future<PresentationContext> create() async {
TestUtils.init();
if (_db == null) {
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint);
_db = db;
}
return const PresentationContext._();
}
Future<void> dispose() async {
// TODO: Dispose the store and database after each test.
// This is currently not possible because the store is a singleton and is used across tests.
// Refactor the store to be created per test to allow proper disposal.
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Material(child: widget),
),
),
),
),
);
await pumpAndSettle();
}
}
@@ -10,7 +10,7 @@ import '../mocks.dart';
void main() {
late HashService sut;
final mocks = UnitMocks();
final mocks = RepositoryMocks();
setUp(() {
sut = HashService(
+5 -15
View File
@@ -43,9 +43,7 @@ void main() {
});
test('should handle a single 90° rotation', () {
final edits = <AssetEdit>[
RotateEdit(RotateParameters(angle: 90)),
];
final edits = <AssetEdit>[RotateEdit(RotateParameters(angle: 90))];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
@@ -54,9 +52,7 @@ void main() {
});
test('should handle a single 180° rotation', () {
final edits = <AssetEdit>[
RotateEdit(RotateParameters(angle: 180)),
];
final edits = <AssetEdit>[RotateEdit(RotateParameters(angle: 180))];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
@@ -65,9 +61,7 @@ void main() {
});
test('should handle a single 270° rotation', () {
final edits = <AssetEdit>[
RotateEdit(RotateParameters(angle: 270)),
];
final edits = <AssetEdit>[RotateEdit(RotateParameters(angle: 270))];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
@@ -76,9 +70,7 @@ void main() {
});
test('should handle a single horizontal mirror', () {
final edits = <AssetEdit>[
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
];
final edits = <AssetEdit>[MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal))];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
@@ -87,9 +79,7 @@ void main() {
});
test('should handle a single vertical mirror', () {
final edits = <AssetEdit>[
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
];
final edits = <AssetEdit>[MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical))];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result);
+1 -1
View File
@@ -16,7 +16,7 @@ void main() {
expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException);
});
test('Compares equal versons correctly', () {
test('Compares equal versions correctly', () {
final v1 = SemVer.fromString('1.2.3');
final v2 = SemVer.fromString('1.2.3');
expect(v1 == v2, isTrue);
-115
View File
@@ -23547,118 +23547,6 @@
],
"type": "object"
},
"SyncAssetOcrDeleteV1": {
"properties": {
"assetId": {
"description": "Original asset ID of the deleted OCR entry",
"type": "string"
},
"deletedAt": {
"description": "Timestamp when the OCR entry was deleted",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"id": {
"description": "Audit row ID of the deleted OCR entry",
"type": "string"
}
},
"required": [
"assetId",
"deletedAt",
"id"
],
"type": "object"
},
"SyncAssetOcrV1": {
"properties": {
"assetId": {
"description": "Asset ID",
"type": "string"
},
"boxScore": {
"description": "Confidence score of the bounding box",
"format": "double",
"type": "number"
},
"id": {
"description": "OCR entry ID",
"type": "string"
},
"isVisible": {
"description": "Whether the OCR entry is visible",
"type": "boolean"
},
"text": {
"description": "Recognized text content",
"type": "string"
},
"textScore": {
"description": "Confidence score of the recognized text",
"format": "double",
"type": "number"
},
"x1": {
"description": "Top-left X coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"x2": {
"description": "Top-right X coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"x3": {
"description": "Bottom-right X coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"x4": {
"description": "Bottom-left X coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"y1": {
"description": "Top-left Y coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"y2": {
"description": "Top-right Y coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"y3": {
"description": "Bottom-right Y coordinate (normalized 01)",
"format": "double",
"type": "number"
},
"y4": {
"description": "Bottom-left Y coordinate (normalized 01)",
"format": "double",
"type": "number"
}
},
"required": [
"assetId",
"boxScore",
"id",
"isVisible",
"text",
"textScore",
"x1",
"x2",
"x3",
"x4",
"y1",
"y2",
"y3",
"y4"
],
"type": "object"
},
"SyncAssetV1": {
"properties": {
"checksum": {
@@ -24040,8 +23928,6 @@
"AssetEditDeleteV1",
"AssetMetadataV1",
"AssetMetadataDeleteV1",
"AssetOcrV1",
"AssetOcrDeleteV1",
"PartnerV1",
"PartnerDeleteV1",
"PartnerAssetV1",
@@ -24364,7 +24250,6 @@
"AssetExifsV1",
"AssetEditsV1",
"AssetMetadataV1",
"AssetOcrV1",
"AuthUsersV1",
"MemoriesV1",
"MemoryToAssetsV1",
-41
View File
@@ -2999,44 +2999,6 @@ export type SyncAssetMetadataV1 = {
[key: string]: any;
};
};
export type SyncAssetOcrDeleteV1 = {
/** Original asset ID of the deleted OCR entry */
assetId: string;
/** Timestamp when the OCR entry was deleted */
deletedAt: string;
/** Audit row ID of the deleted OCR entry */
id: string;
};
export type SyncAssetOcrV1 = {
/** Asset ID */
assetId: string;
/** Confidence score of the bounding box */
boxScore: number;
/** OCR entry ID */
id: string;
/** Whether the OCR entry is visible */
isVisible: boolean;
/** Recognized text content */
text: string;
/** Confidence score of the recognized text */
textScore: number;
/** Top-left X coordinate (normalized 01) */
x1: number;
/** Top-right X coordinate (normalized 01) */
x2: number;
/** Bottom-right X coordinate (normalized 01) */
x3: number;
/** Bottom-left X coordinate (normalized 01) */
x4: number;
/** Top-left Y coordinate (normalized 01) */
y1: number;
/** Top-right Y coordinate (normalized 01) */
y2: number;
/** Bottom-right Y coordinate (normalized 01) */
y3: number;
/** Bottom-left Y coordinate (normalized 01) */
y4: number;
};
export type SyncAssetV1 = {
/** Checksum */
checksum: string;
@@ -7317,8 +7279,6 @@ export enum SyncEntityType {
AssetEditDeleteV1 = "AssetEditDeleteV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
AssetOcrV1 = "AssetOcrV1",
AssetOcrDeleteV1 = "AssetOcrDeleteV1",
PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1",
PartnerAssetV1 = "PartnerAssetV1",
@@ -7379,7 +7339,6 @@ export enum SyncRequestType {
AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetOcrV1 = "AssetOcrV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1",
-17
View File
@@ -444,23 +444,6 @@ export const columns = {
'asset_exif.rating',
'asset_exif.fps',
],
syncAssetOcr: [
'asset_ocr.id',
'asset_ocr.assetId',
'asset_ocr.x1',
'asset_ocr.y1',
'asset_ocr.x2',
'asset_ocr.y2',
'asset_ocr.x3',
'asset_ocr.y3',
'asset_ocr.x4',
'asset_ocr.y4',
'asset_ocr.text',
'asset_ocr.boxScore',
'asset_ocr.textScore',
'asset_ocr.updateId',
'asset_ocr.isVisible',
],
syncAssetEdit: [
'asset_edit.id',
'asset_edit.assetId',
-37
View File
@@ -401,41 +401,6 @@ class SyncMemoryDeleteV1 extends createZodDto(SyncMemoryDeleteV1Schema) {}
class SyncMemoryAssetV1 extends createZodDto(SyncMemoryAssetV1Schema) {}
@ExtraModel()
class SyncMemoryAssetDeleteV1 extends createZodDto(SyncMemoryAssetDeleteV1Schema) {}
const SyncAssetOcrV1Schema = z
.object({
id: z.string().describe('OCR entry ID'),
assetId: z.string().describe('Asset ID'),
x1: z.number().meta({ format: 'double' }).describe('Top-left X coordinate (normalized 01)'),
y1: z.number().meta({ format: 'double' }).describe('Top-left Y coordinate (normalized 01)'),
x2: z.number().meta({ format: 'double' }).describe('Top-right X coordinate (normalized 01)'),
y2: z.number().meta({ format: 'double' }).describe('Top-right Y coordinate (normalized 01)'),
x3: z.number().meta({ format: 'double' }).describe('Bottom-right X coordinate (normalized 01)'),
y3: z.number().meta({ format: 'double' }).describe('Bottom-right Y coordinate (normalized 01)'),
x4: z.number().meta({ format: 'double' }).describe('Bottom-left X coordinate (normalized 01)'),
y4: z.number().meta({ format: 'double' }).describe('Bottom-left Y coordinate (normalized 01)'),
boxScore: z.number().meta({ format: 'double' }).describe('Confidence score of the bounding box'),
textScore: z.number().meta({ format: 'double' }).describe('Confidence score of the recognized text'),
text: z.string().describe('Recognized text content'),
isVisible: z.boolean().describe('Whether the OCR entry is visible'),
})
.meta({ id: 'SyncAssetOcrV1' });
const SyncAssetOcrDeleteV1Schema = z
.object({
id: z.string().describe('Audit row ID of the deleted OCR entry'),
assetId: z.string().describe('Original asset ID of the deleted OCR entry'),
deletedAt: isoDatetimeToDate.describe('Timestamp when the OCR entry was deleted'),
})
.meta({ id: 'SyncAssetOcrDeleteV1' });
@ExtraModel()
class SyncAssetOcrV1 extends createZodDto(SyncAssetOcrV1Schema) {}
@ExtraModel()
class SyncAssetOcrDeleteV1 extends createZodDto(SyncAssetOcrDeleteV1Schema) {}
@ExtraModel()
class SyncStackV1 extends createZodDto(SyncStackV1Schema) {}
@ExtraModel()
@@ -472,8 +437,6 @@ export type SyncItem = {
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
[SyncEntityType.PartnerAssetV2]: SyncAssetV2;
-3
View File
@@ -952,7 +952,6 @@ export enum SyncRequestType {
AssetExifsV1 = 'AssetExifsV1',
AssetEditsV1 = 'AssetEditsV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetOcrV1 = 'AssetOcrV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
@@ -991,8 +990,6 @@ export enum SyncEntityType {
AssetEditDeleteV1 = 'AssetEditDeleteV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
AssetOcrV1 = 'AssetOcrV1',
AssetOcrDeleteV1 = 'AssetOcrDeleteV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
-42
View File
@@ -579,48 +579,6 @@ where
order by
"asset_metadata"."updateId" asc
-- SyncRepository.assetOcr.getDeletes
select
"asset_ocr_audit"."id",
"asset_ocr_audit"."assetId",
"asset_ocr_audit"."deletedAt"
from
"asset_ocr_audit" as "asset_ocr_audit"
left join "asset" on "asset"."id" = "asset_ocr_audit"."assetId"
where
"asset_ocr_audit"."id" < $1
and "asset_ocr_audit"."id" > $2
and "asset"."ownerId" = $3
order by
"asset_ocr_audit"."id" asc
-- SyncRepository.assetOcr.getUpserts
select
"asset_ocr"."id",
"asset_ocr"."assetId",
"asset_ocr"."x1",
"asset_ocr"."y1",
"asset_ocr"."x2",
"asset_ocr"."y2",
"asset_ocr"."x3",
"asset_ocr"."y3",
"asset_ocr"."x4",
"asset_ocr"."y4",
"asset_ocr"."text",
"asset_ocr"."boxScore",
"asset_ocr"."textScore",
"asset_ocr"."updateId",
"asset_ocr"."isVisible"
from
"asset_ocr" as "asset_ocr"
inner join "asset" on "asset"."id" = "asset_ocr"."assetId"
where
"asset_ocr"."updateId" < $1
and "asset_ocr"."updateId" > $2
and "asset"."ownerId" = $3
order by
"asset_ocr"."updateId" asc
-- SyncRepository.authUser.getUpserts
select
"id",
@@ -56,7 +56,6 @@ export class SyncRepository {
assetEdit: AssetEditSync;
assetFace: AssetFaceSync;
assetMetadata: AssetMetadataSync;
assetOcr: AssetOcrSync;
authUser: AuthUserSync;
memory: MemorySync;
memoryToAsset: MemoryToAssetSync;
@@ -80,7 +79,6 @@ export class SyncRepository {
this.assetEdit = new AssetEditSync(this.db);
this.assetFace = new AssetFaceSync(this.db);
this.assetMetadata = new AssetMetadataSync(this.db);
this.assetOcr = new AssetOcrSync(this.db);
this.authUser = new AuthUserSync(this.db);
this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db);
@@ -771,27 +769,3 @@ class AssetMetadataSync extends BaseSync {
.stream();
}
}
class AssetOcrSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getDeletes(options: SyncQueryOptions, userId: string) {
return this.auditQuery('asset_ocr_audit', options)
.select(['asset_ocr_audit.id', 'asset_ocr_audit.assetId', 'asset_ocr_audit.deletedAt'])
.leftJoin('asset', 'asset.id', 'asset_ocr_audit.assetId')
.where('asset.ownerId', '=', userId)
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('asset_ocr_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getUpserts(options: SyncQueryOptions, userId: string) {
return this.upsertQuery('asset_ocr', options)
.select(columns.syncAssetOcr)
.innerJoin('asset', 'asset.id', 'asset_ocr.assetId')
.where('asset.ownerId', '=', userId)
.stream();
}
}
-13
View File
@@ -287,16 +287,3 @@ export const asset_edit_audit = registerFunction({
RETURN NULL;
END`,
});
export const asset_ocr_delete_audit = registerFunction({
name: 'asset_ocr_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO asset_ocr_audit ("assetId")
SELECT "assetId"
FROM OLD;
RETURN NULL;
END`,
});
-5
View File
@@ -11,7 +11,6 @@ import {
asset_delete_audit,
asset_face_audit,
asset_metadata_audit,
asset_ocr_delete_audit,
f_concat_ws,
f_unaccent,
immich_uuid_v7,
@@ -44,7 +43,6 @@ import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@@ -109,7 +107,6 @@ export class ImmichDatabase {
AssetMetadataAuditTable,
AssetJobStatusTable,
AssetOcrTable,
AssetOcrAuditTable,
AssetTable,
AssetFileTable,
AssetExifTable,
@@ -171,7 +168,6 @@ export class ImmichDatabase {
user_metadata_audit,
asset_metadata_audit,
asset_face_audit,
asset_ocr_delete_audit,
];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
@@ -209,7 +205,6 @@ export interface DB {
asset_metadata_audit: AssetMetadataAuditTable;
asset_job_status: AssetJobStatusTable;
asset_ocr: AssetOcrTable;
asset_ocr_audit: AssetOcrAuditTable;
asset_audio: AssetAudioTable;
asset_video: AssetVideoTable;
asset_keyframe: AssetKeyframeTable;
@@ -1,58 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO asset_ocr_audit ("assetId")
SELECT "assetId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "asset_ocr_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"assetId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "asset_ocr_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "asset_ocr_audit_assetId_idx" ON "asset_ocr_audit" ("assetId");`.execute(db);
await sql`CREATE INDEX "asset_ocr_audit_deletedAt_idx" ON "asset_ocr_audit" ("deletedAt");`.execute(db);
await sql`ALTER TABLE "asset_ocr" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "asset_ocr_updateId_idx" ON "asset_ocr" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_ocr_delete_audit"
AFTER DELETE ON "asset_ocr"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION asset_ocr_delete_audit();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_ocr_delete_audit', '{"type":"function","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_ocr_audit (\\"assetId\\")\\n SELECT \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_delete_audit', '{"type":"trigger","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_delete_audit\\"\\n AFTER DELETE ON \\"asset_ocr\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_ocr_delete_audit();"}'::jsonb);`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "asset_ocr_delete_audit" ON "asset_ocr";`.execute(db);
await sql`DROP INDEX "asset_ocr_updateId_idx";`.execute(db);
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TABLE "asset_ocr_audit";`.execute(db);
await sql`DROP FUNCTION asset_ocr_delete_audit;`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_ocr_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_delete_audit';`.execute(db);
}
@@ -1,14 +0,0 @@
import { Column, CreateDateColumn, Generated, Table } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
@Table('asset_ocr_audit')
export class AssetOcrAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Date;
}
+1 -19
View File
@@ -1,22 +1,7 @@
import {
AfterDeleteTrigger,
Column,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
} from '@immich/sql-tools';
import { UpdateIdColumn } from 'src/decorators';
import { asset_ocr_delete_audit } from 'src/schema/functions';
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_ocr')
@AfterDeleteTrigger({
scope: 'statement',
function: asset_ocr_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AssetOcrTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -60,7 +45,4 @@ export class AssetOcrTable {
@Column({ type: 'boolean', default: true })
isVisible!: Generated<boolean>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
-30
View File
@@ -68,7 +68,6 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.AlbumToAssetsV1,
SyncRequestType.AssetExifsV1,
SyncRequestType.AlbumAssetExifsV1,
SyncRequestType.AssetOcrV1,
SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.MemoriesV1,
SyncRequestType.MemoryToAssetsV1,
@@ -189,7 +188,6 @@ export class SyncService extends BaseService {
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
[SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth),
} as const;
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
@@ -220,7 +218,6 @@ export class SyncService extends BaseService {
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetOcr.cleanupAuditTable(pruneThreshold);
}
private needsFullSync(checkpointMap: CheckpointMap) {
@@ -891,33 +888,6 @@ export class SyncService extends BaseService {
}
}
private async syncAssetOcrV1(
options: SyncQueryOptions,
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
) {
const deleteType = SyncEntityType.AssetOcrDeleteV1;
const deletes = this.syncRepository.assetOcr.getDeletes(
{ ...options, ack: checkpointMap[deleteType] },
auth.user.id,
);
for await (const row of deletes) {
send(response, { type: deleteType, ids: [row.id], data: row });
}
const upsertType = SyncEntityType.AssetOcrV1;
const upserts = this.syncRepository.assetOcr.getUpserts(
{ ...options, ack: checkpointMap[upsertType] },
auth.user.id,
);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncCheckpointRepository.upsertAll([
@@ -55,7 +55,6 @@ describe(OcrService.name, () => {
assetId: asset.id,
boxScore: 0.99,
id: expect.any(String),
updateId: expect.any(String),
text: 'Test OCR',
textScore: 0.95,
isVisible: true,
@@ -106,7 +105,6 @@ describe(OcrService.name, () => {
assetId: asset.id,
boxScore: 0.7,
id: expect.any(String),
updateId: expect.any(String),
text: 'One',
textScore: 0.9,
isVisible: true,
@@ -123,7 +121,6 @@ describe(OcrService.name, () => {
assetId: asset.id,
boxScore: 0.67,
id: expect.any(String),
updateId: expect.any(String),
text: 'Two',
textScore: 0.89,
isVisible: true,
@@ -140,7 +137,6 @@ describe(OcrService.name, () => {
assetId: asset.id,
boxScore: 0.65,
id: expect.any(String),
updateId: expect.any(String),
text: 'Three',
textScore: 0.88,
isVisible: true,
@@ -157,7 +153,6 @@ describe(OcrService.name, () => {
assetId: asset.id,
boxScore: 0.62,
id: expect.any(String),
updateId: expect.any(String),
text: 'Four',
textScore: 0.87,
isVisible: true,
@@ -174,7 +169,6 @@ describe(OcrService.name, () => {
assetId: asset.id,
boxScore: 0.6,
id: expect.any(String),
updateId: expect.any(String),
text: 'Five',
textScore: 0.86,
isVisible: true,
@@ -1,379 +0,0 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.AssetOcrV1, () => {
it('should detect and sync new asset OCR', async () => {
const { auth, user, ctx } = await setup();
const ocrRepo = ctx.get(OcrRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ocrRepo.upsert(
asset.id,
[
{
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
],
'Hello World',
);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
type: 'AssetOcrV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]);
});
it('should update asset OCR', async () => {
const { auth, user, ctx } = await setup();
const ocrRepo = ctx.get(OcrRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ocrRepo.upsert(
asset.id,
[
{
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
],
'Hello World',
);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
type: 'AssetOcrV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
// Update OCR data (upsert deletes old entries first, then inserts new ones)
await ocrRepo.upsert(
asset.id,
[
{
assetId: asset.id,
x1: 0.15,
y1: 0.25,
x2: 0.85,
y2: 0.25,
x3: 0.85,
y3: 0.75,
x4: 0.15,
y4: 0.75,
boxScore: 0.98,
textScore: 0.96,
text: 'Updated Text',
isVisible: true,
},
],
'Updated Text',
);
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
// upsert() deletes old entries first, so we expect both delete and upsert events
expect(updatedResponse).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
deletedAt: expect.any(String),
},
type: 'AssetOcrDeleteV1',
},
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
x1: 0.15,
y1: 0.25,
x2: 0.85,
y2: 0.25,
x3: 0.85,
y3: 0.75,
x4: 0.15,
y4: 0.75,
boxScore: 0.98,
textScore: 0.96,
text: 'Updated Text',
isVisible: true,
},
type: 'AssetOcrV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, updatedResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]);
});
it('should update asset OCR visibility flag', async () => {
const { auth, user, ctx } = await setup();
const ocrRepo = ctx.get(OcrRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ocrRepo.upsert(
asset.id,
[
{
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
],
'Hello World',
);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
type: 'AssetOcrV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ocrRepo.upsert(
asset.id,
[
{
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: false,
},
],
'Hello World',
);
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
expect(updatedResponse).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
deletedAt: expect.any(String),
},
type: 'AssetOcrDeleteV1',
},
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: false,
},
type: 'AssetOcrV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, updatedResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]);
});
});
describe(SyncEntityType.AssetOcrDeleteV1, () => {
it('should delete and sync asset OCR', async () => {
const { auth, user, ctx } = await setup();
const ocrRepo = ctx.get(OcrRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ocrRepo.upsert(
asset.id,
[
{
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
],
'Hello World',
);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.2,
x3: 0.9,
y3: 0.8,
x4: 0.1,
y4: 0.8,
boxScore: 0.95,
textScore: 0.92,
text: 'Hello World',
isVisible: true,
},
type: 'AssetOcrV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
// Delete OCR data
await ocrRepo.upsert(asset.id, [], '');
await expect(ctx.syncStream(auth, [SyncRequestType.AssetOcrV1])).resolves.toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
deletedAt: expect.any(String),
id: expect.any(String),
},
type: 'AssetOcrDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});
-2
View File
@@ -201,7 +201,6 @@ const assetSidecarWriteFactory = () => {
const assetOcrFactory = (
ocr: {
id?: string;
updateId?: string;
assetId?: string;
x1?: number;
y1?: number;
@@ -218,7 +217,6 @@ const assetOcrFactory = (
} = {},
) => ({
id: newUuid(),
updateId: newUuidV7(),
assetId: newUuid(),
x1: 0.1,
y1: 0.2,
@@ -476,16 +476,18 @@
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<OnEvents {onAssetUpdate} />
<svelte:document bind:fullscreenElement />
<svelte:document
bind:fullscreenElement
use:shortcuts={[
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
]}
/>
<section
id="immich-asset-viewer"
class="fixed inset-s-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
use:focusTrap
use:shortcuts={[
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
]}
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
@@ -6,6 +6,7 @@
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { generateId } from '$lib/utils/generate-id';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import {
@@ -44,6 +45,7 @@
} from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
@@ -62,12 +64,13 @@
let { data }: Props = $props();
let { id, enabled, name, description, trigger } = $derived(data.workflow);
let steps = $state(data.workflow.steps);
let steps = $state(data.workflow.steps.map((step) => ({ ...step, id: generateId() })));
let savedWorkflow = $state(cloneDeep(data.workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
let dragSourceId: string | undefined;
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
@@ -77,20 +80,23 @@
name !== savedWorkflow.name ||
description !== savedWorkflow.description ||
!isEqual(trigger, savedWorkflow.trigger) ||
!isEqual(steps, savedWorkflow.steps),
!isEqual(
steps.map(({ id: _, ...step }) => step),
savedWorkflow.steps,
),
);
const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps.push(step);
steps.push({ ...step, id: generateId() });
}
};
const handleInsertStep = async (index: number) => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
steps = [...steps.slice(0, index), { ...step, id: generateId() }, ...steps.slice(index)];
}
};
@@ -102,20 +108,53 @@
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
if (result) {
steps[index] = result;
steps[index] = { ...result, id: generateId() };
}
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
const handleDrop = (event: DragEvent) => {
if (!event.dataTransfer || !dragSourceId) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const ghostIndex = steps.findIndex(({ id }) => id === 'ghost');
if (ghostIndex === -1) {
return;
}
const from = steps.findIndex(({ id }) => id === dragSourceId);
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
const [step] = next.splice(from, 1);
next[ghostIndex > from ? ghostIndex - 1 : ghostIndex] = step;
steps = next;
dragSourceId = undefined;
};
const handleDragOver = (index: number, event: DragEvent, boundingRect: DOMRect) => {
if (!event.dataTransfer || !dragSourceId) {
return;
}
const fromIndex = steps.findIndex(({ id }) => dragSourceId === id);
const ghostIndex = steps.findIndex(({ id }) => id === 'ghost');
const shiftedIndex = event.clientY > boundingRect.top + boundingRect.height / 2 ? index + 1 : index;
if (index === fromIndex || shiftedIndex === fromIndex) {
if (ghostIndex !== -1) {
steps.splice(ghostIndex, 1);
}
return;
}
if (
(ghostIndex !== -1 && Math.abs(shiftedIndex - ghostIndex) <= (ghostIndex > shiftedIndex ? 0 : 1)) ||
Math.abs(shiftedIndex - fromIndex) <= (fromIndex > shiftedIndex ? 0 : 1)
) {
return;
}
const next = steps.filter(({ id }) => id !== 'ghost');
next.splice(shiftedIndex, 0, { ...steps[fromIndex], id: 'ghost' });
steps = next;
};
@@ -131,7 +170,7 @@
name = content.name;
description = content.description;
trigger = content.trigger;
steps = cloneDeep(content.steps);
steps = cloneDeep(content.steps).map((step) => ({ ...step, id: generateId() }));
};
const onClose = () => goto(Route.workflows());
@@ -214,6 +253,8 @@
});
});
$effect(() => console.log(steps));
const { Download, Duplicate, CopyJson, Delete } = $derived(
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
);
@@ -344,15 +385,23 @@
</CardHeader>
</Card>
{#each steps as step, index (step.method + index)}
<WorkflowStepCard
{step}
{index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDrop={handleDrop}
/>
{#each steps as step, index (step.id)}
<div class="w-full" animate:flip={{ duration: 120 }}>
<WorkflowStepCard
{step}
{index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={(event) => {
steps = steps.filter(({ id }) => id !== 'ghost');
handleDrop(event);
}}
onDragStart={(event) => (dragSourceId = event.dataTransfer?.getData('text/plain'))}
/>
</div>
{/each}
<Button
@@ -4,7 +4,6 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const albumNameCache = new Map<string, Promise<string>>();
const getAlbumName = (id: string): Promise<string> => {
let albumName = albumNameCache.get(id);
if (!albumName) {
@@ -20,7 +19,7 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { JSONSchemaProperty } from '$lib/types';
import type { WorkflowStepDto } from '@immich/sdk';
import { type WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import {
mdiAutoFix,
@@ -35,15 +34,18 @@
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
type Props = {
step: WorkflowStepDto;
step: WorkflowStepDto & { id: string };
index: number;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void;
onDragOver: (index: number, event: DragEvent, boundingRect: DOMRect) => void;
onDrop: (event: DragEvent) => void;
onDragEnd: (event: DragEvent) => void;
onDragStart: (event: DragEvent) => void;
};
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
let { step, index, onEdit, onDelete, onInsertBefore, onDragOver, onDrop, onDragEnd, onDragStart }: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
@@ -51,12 +53,12 @@
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
const isGhost = $derived(step.id === 'ghost');
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false);
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
@@ -93,7 +95,7 @@
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index));
event.dataTransfer.setData('text/plain', step.id);
mount(WorkflowStepDragImage, {
target: document.body,
@@ -107,31 +109,23 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(event);
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
event.preventDefault();
if (isGhost) {
return;
}
event.preventDefault();
const from = Number(event.dataTransfer.getData('text/plain'));
if (from === index) {
return;
}
onDrop(index, event);
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
onDragOver(index, event, event.currentTarget.getBoundingClientRect());
};
const handleDragEnd = () => {
const handleDragEnd = (event: DragEvent) => {
dragImage?.remove();
dragImage = undefined;
isDropTarget = false;
onDragEnd(event);
};
</script>
@@ -157,13 +151,13 @@
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)}
ondrop={onDrop}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: hoverDrag
: isGhost
? 'border-dashed border-primary'
: ''}"
>
@@ -174,8 +168,6 @@
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
aria-label={$t('drag_to_reorder')}
draggable="true"
onmouseenter={() => (hoverDrag = true)}
onmouseleave={() => (hoverDrag = false)}
ondragstart={(event) => handleDragStart(index, event)}
ondragend={handleDragEnd}
title={$t('drag_to_reorder')}