Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] a54e6852fc chore(deps): update node.js to v24.16.0 2026-06-04 12:05:29 +00:00
52 changed files with 517 additions and 1453 deletions
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.16.0
+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.example.com`)
traefik.http.routers.immich.rule: Host(`immich.your-domain.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',
email: 'immich@example.com.com',
name: 'Immich Admin',
storageLabel: 'admin',
externalPath: null,
+1 -1
View File
@@ -17,7 +17,7 @@ services:
ports:
- "8888:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
PGADMIN_DEFAULT_PASSWORD: strong-password
volumes:
- pgadmin-data:/var/lib/pgadmin
-2
View File
@@ -699,7 +699,6 @@
"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",
@@ -1690,7 +1689,6 @@
"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",
+1 -1
View File
@@ -15,7 +15,7 @@ config_roots = [
]
[tools]
node = "24.15.0"
node = "24.16.0"
pnpm = "10.33.4"
terragrunt = "1.0.3"
opentofu = "1.11.6"
@@ -47,44 +47,18 @@ 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 when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PermissionStatus.ofRaw(it.toInt())
}
}
else -> super.readValueOfType(type, buffer)
}
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is PermissionStatus -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
else -> super.writeValue(stream, value)
}
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)
@@ -98,21 +72,6 @@ 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,26 +1,13 @@
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()
+1 -81
View File
@@ -11,24 +11,6 @@ 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]
}
@@ -64,57 +46,8 @@ 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)
@@ -122,23 +55,10 @@ protocol PermissionApi {
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { PermissionApiPigeonCodec.shared }
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
/// 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,10 +1,6 @@
import Foundation
class PermissionApiImpl: PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus {
return PermissionStatus.granted;
}
func hasManageMediaPermission() throws -> Bool {
return false
}
-2
View File
@@ -22,5 +22,3 @@ enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
enum PartnerDirection { sharedBy, sharedWith }
-122
View File
@@ -237,125 +237,3 @@ 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);
}
+36 -27
View File
@@ -1,42 +1,51 @@
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:stream_transform/stream_transform.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class PartnerService {
final UserRepository _userRepository;
final PartnerRepository _partnerRepository;
class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository;
final PartnerApiRepository _partnerApiRepository;
const PartnerService(this._userRepository, this._partnerRepository, this._partnerApiRepository);
const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
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>> getSharedWith(String userId) {
return _driftPartnerRepository.getSharedWith(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>> getSharedBy(String userId) {
return _driftPartnerRepository.getSharedBy(userId);
}
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<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> delete({required String sharedById, required String sharedWithId}) async {
await _partnerApiRepository.delete(sharedWithId);
await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId);
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);
}
}
-15
View File
@@ -1,15 +0,0 @@
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);
@@ -1,62 +1,106 @@
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 PartnerRepository {
class DriftPartnerRepository extends DriftDatabaseRepository {
final Drift _db;
const PartnerRepository(this._db);
const DriftPartnerRepository(this._db) : super(_db);
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();
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));
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();
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<void> create({required String sharedById, required String sharedWithId, bool inTimeline = false}) =>
_db.partnerEntity.insertOnConflictUpdate(
PartnerEntityCompanion(
sharedById: Value(sharedById),
sharedWithId: Value(sharedWithId),
inTimeline: Value(inTimeline),
),
);
// 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> 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)));
return query.map((user) {
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false);
}).get();
}
Future<void> delete({required String sharedById, required String sharedWithId}) =>
(_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.go();
// 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));
Partner _resultToPartner(TypedResult result) {
final user = result.readTable(_db.userEntity);
final partner = result.readTable(_db.partnerEntity);
return mapToPartner(user, partner);
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));
}
}
@@ -2,17 +2,9 @@ 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;
+5 -138
View File
@@ -8,7 +8,6 @@ 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';
@@ -16,16 +15,11 @@ 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()
@@ -168,7 +162,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
),
),
},
const _BackupFooter(),
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
],
],
),
@@ -179,137 +177,6 @@ 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();
@@ -0,0 +1,139 @@
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()]))),
);
}
}
@@ -1,200 +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/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,8 +26,6 @@ Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName,
return replyList.firstOrNull;
}
enum PermissionStatus { granted, denied, permanentlyDenied }
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -35,9 +33,6 @@ 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);
}
@@ -46,9 +41,6 @@ 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);
}
@@ -68,25 +60,6 @@ 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,13 +7,12 @@ 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/providers/user.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_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';
@@ -328,23 +327,12 @@ 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(sharedWithPartnerProvider);
final partnerSharedWithAsync = ref.watch(driftSharedWithPartnerProvider);
final partners = partnerSharedWithAsync.valueOrNull ?? [];
return SliverPadding(
@@ -399,9 +387,9 @@ class _QuickAccessButtonList extends ConsumerWidget {
'partners'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const PartnerRoute()),
onTap: () => context.pushRoute(const DriftPartnerRoute()),
),
_PartnerList(partners: partners.toList()),
_PartnerList(partners: partners),
],
),
),
@@ -413,7 +401,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
class _PartnerList extends StatelessWidget {
const _PartnerList({required this.partners});
final List<Partner> partners;
final List<PartnerUserDto> partners;
@override
Widget build(BuildContext context) {
@@ -433,7 +421,7 @@ class _PartnerList extends StatelessWidget {
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
leading: PartnerUserAvatar(partner: partner),
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 Partner partner;
final PartnerUserDto partner;
const DriftPartnerDetailPage({super.key, required this.partner});
@@ -39,7 +39,7 @@ class DriftPartnerDetailPage extends StatelessWidget {
}
class _InfoBox extends ConsumerStatefulWidget {
final Partner partner;
final PartnerUserDto partner;
const _InfoBox({required this.partner});
@@ -63,9 +63,7 @@ class _InfoBoxState extends ConsumerState<_InfoBox> {
}
try {
await ref
.read(partnerServiceProvider)
.update(sharedById: widget.partner.id, sharedWithId: user.id, inTimeline: !_inTimeline);
await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline(widget.partner.id, user.id);
setState(() {
_inTimeline = !_inTimeline;
@@ -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.userId, required this.name});
const PartnerUserAvatar({super.key, required this.partner});
final String userId;
final String name;
final PartnerUserDto partner;
@override
Widget build(BuildContext context) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image";
final nameFirstLetter = name.isNotEmpty ? name[0] : "";
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${partner.id}/profile-image";
final nameFirstLetter = partner.name.isNotEmpty ? partner.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/permission.provider.dart';
import 'package:immich_mobile/providers/notification_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';
@@ -0,0 +1,86 @@
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,16 +1,15 @@
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(
@@ -20,12 +19,13 @@ final userServiceProvider = Provider(
),
);
final partnerRepositoryProvider = Provider<PartnerRepository>((ref) => PartnerRepository(ref.watch(driftProvider)));
final partnerServiceProvider = Provider<PartnerService>(
(ref) => PartnerService(
ref.watch(userRepositoryProvider),
ref.watch(partnerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
),
/// Drifts
final driftPartnerRepositoryProvider = Provider<DriftPartnerRepository>(
(ref) => DriftPartnerRepository(ref.watch(driftProvider)),
);
final driftPartnerServiceProvider = Provider<DriftPartnerService>(
(ref) => DriftPartnerService(ref.watch(driftPartnerRepositoryProvider), ref.watch(partnerApiRepositoryProvider)),
);
final partnerUsersProvider = NotifierProvider<PartnerNotifier, List<PartnerUserDto>>(PartnerNotifier.new);
@@ -1,9 +1,6 @@
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> {
@@ -42,26 +39,3 @@ 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/partner.page.dart';
import 'package:immich_mobile/pages/library/partner/drift_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_added.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_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: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPartnerRoute.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 Partner partner,
required PartnerUserDto partner,
List<PageRouteInfo>? children,
}) : super(
DriftPartnerDetailRoute.name,
@@ -851,7 +851,7 @@ class DriftPartnerDetailRouteArgs {
final Key? key;
final Partner partner;
final PartnerUserDto partner;
@override
String toString() {
@@ -869,6 +869,22 @@ 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> {
@@ -1440,22 +1456,6 @@ 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/permission.provider.dart';
import 'package:immich_mobile/providers/notification_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';
+15 -8
View File
@@ -38,8 +38,15 @@ run = "dart run build_runner watch --delete-conflicting-outputs"
alias = "pigeon"
description = "Generate pigeon platform code"
run = [
"ls pigeon/*.dart | xargs -n1 -P4 -I{} dart run pigeon --input {}",
"dart format lib/platform/",
"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",
]
[tasks."codegen:translation"]
@@ -142,10 +149,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" },
]
+1 -5
View File
@@ -1,12 +1,10 @@
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(includeErrorClass: false),
swiftOptions: SwiftOptions(),
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
dartOptions: DartOptions(),
@@ -15,8 +13,6 @@ enum PermissionStatus { granted, denied, permanentlyDenied }
)
@HostApi()
abstract class PermissionApi {
PermissionStatus isIgnoringBatteryOptimizations();
bool hasManageMediaPermission();
@async
-3
View File
@@ -1,9 +1,6 @@
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,4 +1,3 @@
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';
@@ -12,5 +11,3 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
@@ -2,7 +2,6 @@ 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';
@@ -12,7 +11,6 @@ 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';
@@ -46,10 +44,6 @@ 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 {}
@@ -1,100 +0,0 @@
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,7 +8,6 @@ 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';
@@ -69,18 +68,6 @@ 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
@@ -1,31 +0,0 @@
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);
}
}
@@ -1,110 +0,0 @@
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 ?? .none,
backupSelection: 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: .image,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
@@ -1,19 +0,0 @@
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(),
);
}
}
@@ -1,26 +0,0 @@
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,
);
}
}
+14 -30
View File
@@ -5,30 +5,26 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
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 {
class UnitMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = MockNativeSyncApi();
RepositoryMocks() {
_registerFallbacks();
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,
),
);
}
void reset() {
@@ -38,15 +34,3 @@ class RepositoryMocks {
mocktail.reset(nativeApi);
}
}
class ServiceMocks {
final partner = MockPartnerService();
ServiceMocks() {
_registerFallbacks();
}
void reset() {
mocktail.reset(partner);
}
}
@@ -1,105 +0,0 @@
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);
});
});
}
@@ -1,68 +0,0 @@
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 = RepositoryMocks();
final mocks = UnitMocks();
setUp(() {
sut = HashService(
+15 -5
View File
@@ -43,7 +43,9 @@ 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);
@@ -52,7 +54,9 @@ 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);
@@ -61,7 +65,9 @@ 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);
@@ -70,7 +76,9 @@ 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);
@@ -79,7 +87,9 @@ 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 versions correctly', () {
test('Compares equal versons correctly', () {
final v1 = SemVer.fromString('1.2.3');
final v2 = SemVer.fromString('1.2.3');
expect(v1 == v2, isTrue);
@@ -476,18 +476,16 @@
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<OnEvents {onAssetUpdate} />
<svelte:document
bind:fullscreenElement
use:shortcuts={[
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
]}
/>
<svelte:document bind:fullscreenElement />
<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,7 +6,6 @@
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 {
@@ -45,7 +44,6 @@
} 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';
@@ -64,13 +62,12 @@
let { data }: Props = $props();
let { id, enabled, name, description, trigger } = $derived(data.workflow);
let steps = $state(data.workflow.steps.map((step) => ({ ...step, id: generateId() })));
let steps = $state(data.workflow.steps);
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 });
@@ -80,23 +77,20 @@
name !== savedWorkflow.name ||
description !== savedWorkflow.description ||
!isEqual(trigger, savedWorkflow.trigger) ||
!isEqual(
steps.map(({ id: _, ...step }) => step),
savedWorkflow.steps,
),
!isEqual(steps, savedWorkflow.steps),
);
const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps.push({ ...step, id: generateId() });
steps.push(step);
}
};
const handleInsertStep = async (index: number) => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps = [...steps.slice(0, index), { ...step, id: generateId() }, ...steps.slice(index)];
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
}
};
@@ -108,53 +102,20 @@
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
if (result) {
steps[index] = { ...result, id: generateId() };
steps[index] = result;
}
};
const handleDrop = (event: DragEvent) => {
if (!event.dataTransfer || !dragSourceId) {
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const ghostIndex = steps.findIndex(({ id }) => id === 'ghost');
if (ghostIndex === -1) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const from = steps.findIndex(({ id }) => id === dragSourceId);
const next = [...steps];
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' });
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
@@ -170,7 +131,7 @@
name = content.name;
description = content.description;
trigger = content.trigger;
steps = cloneDeep(content.steps).map((step) => ({ ...step, id: generateId() }));
steps = cloneDeep(content.steps);
};
const onClose = () => goto(Route.workflows());
@@ -253,8 +214,6 @@
});
});
$effect(() => console.log(steps));
const { Download, Duplicate, CopyJson, Delete } = $derived(
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
);
@@ -385,20 +344,15 @@
</CardHeader>
</Card>
{#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={handleDrop}
onDragStart={(event) => (dragSourceId = event.dataTransfer?.getData('text/plain'))}
/>
</div>
{#each steps as step, index (step.method + index)}
<WorkflowStepCard
{step}
{index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDrop={handleDrop}
/>
{/each}
<Button
@@ -4,6 +4,7 @@
// 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) {
@@ -19,7 +20,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,
@@ -34,18 +35,15 @@
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
type Props = {
step: WorkflowStepDto & { id: string };
step: WorkflowStepDto;
index: number;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDragOver: (index: number, event: DragEvent, boundingRect: DOMRect) => void;
onDrop: (event: DragEvent) => void;
onDragEnd: (event: DragEvent) => void;
onDragStart: (event: DragEvent) => void;
onDrop: (index: number, event: DragEvent) => void;
};
let { step, index, onEdit, onDelete, onInsertBefore, onDragOver, onDrop, onDragEnd, onDragStart }: Props = $props();
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
@@ -53,12 +51,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);
@@ -95,7 +93,7 @@
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', step.id);
event.dataTransfer.setData('text/plain', String(index));
mount(WorkflowStepDragImage, {
target: document.body,
@@ -109,23 +107,31 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(event);
};
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
event.preventDefault();
if (isGhost) {
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
isDropTarget = true;
onDragOver(index, event, event.currentTarget.getBoundingClientRect());
event.preventDefault();
const from = Number(event.dataTransfer.getData('text/plain'));
if (from === index) {
return;
}
onDrop(index, event);
};
const handleDragEnd = (event: DragEvent) => {
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
};
const handleDragEnd = () => {
dragImage?.remove();
dragImage = undefined;
isDropTarget = false;
onDragEnd(event);
};
</script>
@@ -151,13 +157,13 @@
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)}
ondrop={onDrop}
ondrop={(event) => handleDrop(index, event)}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: isGhost
: hoverDrag
? 'border-dashed border-primary'
: ''}"
>
@@ -168,6 +174,8 @@
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')}