mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 21:35:24 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8c01fe953 | |||
| 4a8c3b60be | |||
| 2190aa72a8 | |||
| d21cb28526 | |||
| 5c33eb3204 | |||
| 53e4ae7fb2 | |||
| 47bf83813d | |||
| bd0fc2cc86 | |||
| e98201232a | |||
| 0388c534ed | |||
| 137687bc0f | |||
| 4ac4781bfb |
@@ -259,17 +259,6 @@ describe('/search', () => {
|
||||
assets: [assetHeic],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: "should search city ('')",
|
||||
deferred: () => ({
|
||||
dto: {
|
||||
city: '',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
includeNull: true,
|
||||
},
|
||||
assets: [assetLast],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: 'should search city (null)',
|
||||
deferred: () => ({
|
||||
@@ -291,18 +280,6 @@ describe('/search', () => {
|
||||
assets: [assetDensity],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: "should search state ('')",
|
||||
deferred: () => ({
|
||||
dto: {
|
||||
state: '',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
withExif: true,
|
||||
includeNull: true,
|
||||
},
|
||||
assets: [assetLast, assetNotocactus],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: 'should search state (null)',
|
||||
deferred: () => ({
|
||||
@@ -324,17 +301,6 @@ describe('/search', () => {
|
||||
assets: [assetFalcon],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: "should search country ('')",
|
||||
deferred: () => ({
|
||||
dto: {
|
||||
country: '',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
includeNull: true,
|
||||
},
|
||||
assets: [assetLast],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: 'should search country (null)',
|
||||
deferred: () => ({
|
||||
|
||||
@@ -22,5 +22,3 @@ enum AssetDateAggregation { start, end }
|
||||
enum SlideshowLook { contain, cover, blurredBackground }
|
||||
|
||||
enum SlideshowDirection { forward, backward, shuffle }
|
||||
|
||||
enum PartnerDirection { sharedBy, sharedWith }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -137,7 +138,7 @@ class RemoteAlbumService {
|
||||
Future<RemoteAlbum> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
String? description,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -365,11 +366,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
bool? download;
|
||||
bool? upload;
|
||||
bool? meta;
|
||||
String? desc;
|
||||
String? password;
|
||||
var password = const Optional<String?>.absent();
|
||||
var description = const Optional<String?>.absent();
|
||||
String? slug;
|
||||
DateTime? expiry;
|
||||
bool? changeExpiry;
|
||||
var expiry = const Optional<DateTime?>.absent();
|
||||
|
||||
if (allowDownload.value != existingLink!.allowDownload) {
|
||||
download = allowDownload.value;
|
||||
@@ -383,12 +383,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
meta = showMetadata.value;
|
||||
}
|
||||
|
||||
if (descriptionController.text != existingLink!.description) {
|
||||
desc = descriptionController.text;
|
||||
if (descriptionController.text != (existingLink!.description ?? '')) {
|
||||
description = descriptionController.text.isEmpty
|
||||
? const Optional.present(null)
|
||||
: Optional.present(descriptionController.text);
|
||||
}
|
||||
|
||||
if (passwordController.text != existingLink!.password) {
|
||||
password = passwordController.text;
|
||||
if (passwordController.text != (existingLink!.password ?? '')) {
|
||||
password = passwordController.text.isEmpty
|
||||
? const Optional.present(null)
|
||||
: Optional.present(passwordController.text);
|
||||
}
|
||||
|
||||
if (slugController.text != (existingLink!.slug ?? "")) {
|
||||
@@ -399,8 +403,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
|
||||
final newExpiry = expiryAfter.value;
|
||||
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
|
||||
expiry = newExpiry;
|
||||
changeExpiry = true;
|
||||
expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc());
|
||||
}
|
||||
|
||||
await ref
|
||||
@@ -410,11 +413,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
showMeta: meta,
|
||||
allowDownload: download,
|
||||
allowUpload: upload,
|
||||
description: desc,
|
||||
description: description,
|
||||
password: password,
|
||||
slug: slug,
|
||||
expiresAt: expiry?.toUtc(),
|
||||
changeExpiry: changeExpiry,
|
||||
expiresAt: expiry,
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
|
||||
@RoutePage()
|
||||
class RemoteAlbumPage extends ConsumerStatefulWidget {
|
||||
@@ -247,10 +248,13 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
||||
try {
|
||||
final newTitle = titleController.text.trim();
|
||||
final newDescription = descriptionController.text.trim();
|
||||
final description = newDescription.isEmpty
|
||||
? const Optional<String?>.present(null)
|
||||
: Optional<String?>.present(newDescription);
|
||||
|
||||
await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: description);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -153,7 +154,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
String? description,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,7 +71,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
String albumId,
|
||||
UserDto owner, {
|
||||
String? name,
|
||||
String? description,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
@@ -86,7 +86,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
albumId,
|
||||
UpdateAlbumDto(
|
||||
albumName: name == null ? const Optional.absent() : Optional.present(name),
|
||||
description: description == null ? const Optional.absent() : Optional.present(description),
|
||||
description: description,
|
||||
albumThumbnailAssetId: thumbnailAssetId == null
|
||||
? const Optional.absent()
|
||||
: Optional.present(thumbnailAssetId),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -88,11 +88,10 @@ class SharedLinkService {
|
||||
required bool? showMeta,
|
||||
required bool? allowDownload,
|
||||
required bool? allowUpload,
|
||||
bool? changeExpiry = false,
|
||||
String? description,
|
||||
String? password,
|
||||
Optional<String?> password = const Optional.absent(),
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? slug,
|
||||
DateTime? expiresAt,
|
||||
Optional<DateTime?> expiresAt = const Optional.absent(),
|
||||
}) async {
|
||||
try {
|
||||
final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
|
||||
@@ -101,11 +100,10 @@ class SharedLinkService {
|
||||
showMetadata: showMeta == null ? const Optional.absent() : Optional.present(showMeta),
|
||||
allowDownload: allowDownload == null ? const Optional.absent() : Optional.present(allowDownload),
|
||||
allowUpload: allowUpload == null ? const Optional.absent() : Optional.present(allowUpload),
|
||||
expiresAt: expiresAt == null ? const Optional.absent() : Optional.present(expiresAt),
|
||||
description: description == null ? const Optional.absent() : Optional.present(description),
|
||||
password: password == null ? const Optional.absent() : Optional.present(password),
|
||||
password: password,
|
||||
description: description,
|
||||
expiresAt: expiresAt,
|
||||
slug: slug == null ? const Optional.absent() : Optional.present(slug),
|
||||
changeExpiryTime: changeExpiry == null ? const Optional.absent() : Optional.present(changeExpiry),
|
||||
),
|
||||
);
|
||||
if (responseDto != null) {
|
||||
|
||||
+1
-18
@@ -15,7 +15,6 @@ class SharedLinkEditDto {
|
||||
SharedLinkEditDto({
|
||||
this.allowDownload = const Optional.absent(),
|
||||
this.allowUpload = const Optional.absent(),
|
||||
this.changeExpiryTime = const Optional.absent(),
|
||||
this.description = const Optional.absent(),
|
||||
this.expiresAt = const Optional.absent(),
|
||||
this.password = const Optional.absent(),
|
||||
@@ -41,15 +40,6 @@ class SharedLinkEditDto {
|
||||
///
|
||||
Optional<bool?> allowUpload;
|
||||
|
||||
/// Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<bool?> changeExpiryTime;
|
||||
|
||||
/// Link description
|
||||
Optional<String?> description;
|
||||
|
||||
@@ -75,7 +65,6 @@ class SharedLinkEditDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
|
||||
other.allowDownload == allowDownload &&
|
||||
other.allowUpload == allowUpload &&
|
||||
other.changeExpiryTime == changeExpiryTime &&
|
||||
other.description == description &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.password == password &&
|
||||
@@ -87,7 +76,6 @@ class SharedLinkEditDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(allowDownload == null ? 0 : allowDownload!.hashCode) +
|
||||
(allowUpload == null ? 0 : allowUpload!.hashCode) +
|
||||
(changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(expiresAt == null ? 0 : expiresAt!.hashCode) +
|
||||
(password == null ? 0 : password!.hashCode) +
|
||||
@@ -95,7 +83,7 @@ class SharedLinkEditDto {
|
||||
(slug == null ? 0 : slug!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, slug=$slug]';
|
||||
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, slug=$slug]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -107,10 +95,6 @@ class SharedLinkEditDto {
|
||||
final value = this.allowUpload.value;
|
||||
json[r'allowUpload'] = value;
|
||||
}
|
||||
if (this.changeExpiryTime.isPresent) {
|
||||
final value = this.changeExpiryTime.value;
|
||||
json[r'changeExpiryTime'] = value;
|
||||
}
|
||||
if (this.description.isPresent) {
|
||||
final value = this.description.value;
|
||||
json[r'description'] = value;
|
||||
@@ -147,7 +131,6 @@ class SharedLinkEditDto {
|
||||
return SharedLinkEditDto(
|
||||
allowDownload: json.containsKey(r'allowDownload') ? Optional.present(mapValueOfType<bool>(json, r'allowDownload')) : const Optional.absent(),
|
||||
allowUpload: json.containsKey(r'allowUpload') ? Optional.present(mapValueOfType<bool>(json, r'allowUpload')) : const Optional.absent(),
|
||||
changeExpiryTime: json.containsKey(r'changeExpiryTime') ? Optional.present(mapValueOfType<bool>(json, r'changeExpiryTime')) : const Optional.absent(),
|
||||
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
|
||||
expiresAt: json.containsKey(r'expiresAt') ? Optional.present(mapDateTime(json, r'expiresAt', 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))$/')) : const Optional.absent(),
|
||||
password: json.containsKey(r'password') ? Optional.present(mapValueOfType<String>(json, r'password')) : const Optional.absent(),
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22197,10 +22197,6 @@
|
||||
"description": "Allow uploads",
|
||||
"type": "boolean"
|
||||
},
|
||||
"changeExpiryTime": {
|
||||
"description": "Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"description": "Link description",
|
||||
"nullable": true,
|
||||
|
||||
@@ -2192,8 +2192,6 @@ export type SharedLinkEditDto = {
|
||||
allowDownload?: boolean;
|
||||
/** Allow uploads */
|
||||
allowUpload?: boolean;
|
||||
/** Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. */
|
||||
changeExpiryTime?: boolean;
|
||||
/** Link description */
|
||||
description?: string | null;
|
||||
/** Expiration date */
|
||||
|
||||
@@ -53,16 +53,6 @@ describe(PersonController.name, () => {
|
||||
await request(ctx.getHttpServer()).post('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should map an empty color to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people', () => {
|
||||
@@ -153,12 +143,6 @@ describe(PersonController.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (false)', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
|
||||
@@ -63,11 +63,5 @@ describe(TagController.name, () => {
|
||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow setting a null color via an empty string', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,20 +9,22 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||
import { asBirthDateString, asDateString } from 'src/utils/date';
|
||||
import { transformFaceBoundingBox } from 'src/utils/transform';
|
||||
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
|
||||
import { hexColor, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const PersonCreateSchema = z
|
||||
.object({
|
||||
name: z.string().optional().describe('Person name'),
|
||||
// Note: the mobile app cannot currently set the birth date to null.
|
||||
birthDate: emptyStringToNull(z.string().meta({ format: 'date' }).nullable())
|
||||
birthDate: z
|
||||
.string()
|
||||
.meta({ format: 'date' })
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine((val) => (val ? new Date(val) <= new Date() : true), { error: 'Birth date cannot be in the future' })
|
||||
.describe('Person date of birth'),
|
||||
isHidden: z.boolean().optional().describe('Person visibility (hidden)'),
|
||||
isFavorite: z.boolean().optional().describe('Mark as favorite'),
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Person color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Person color (hex)'),
|
||||
})
|
||||
.meta({ id: 'PersonCreateDto' });
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import { isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
@@ -23,12 +23,12 @@ const BaseSearchSchema = z.object({
|
||||
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
|
||||
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
|
||||
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
|
||||
city: emptyStringToNull(z.string().nullable()).optional().describe('Filter by city name'),
|
||||
state: emptyStringToNull(z.string().nullable()).optional().describe('Filter by state/province name'),
|
||||
country: emptyStringToNull(z.string().nullable()).optional().describe('Filter by country name'),
|
||||
make: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera make'),
|
||||
model: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera model'),
|
||||
lensModel: emptyStringToNull(z.string().nullable()).optional().describe('Filter by lens model'),
|
||||
city: z.string().nullable().optional().describe('Filter by city name'),
|
||||
state: z.string().nullable().optional().describe('Filter by state/province name'),
|
||||
country: z.string().nullable().optional().describe('Filter by country name'),
|
||||
make: z.string().nullable().optional().describe('Filter by camera make'),
|
||||
model: z.string().nullable().optional().describe('Filter by camera model'),
|
||||
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
|
||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkTypeSchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const SharedLinkSearchSchema = z
|
||||
@@ -23,9 +23,9 @@ const SharedLinkCreateSchema = z
|
||||
type: SharedLinkTypeSchema,
|
||||
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs (for individual assets)'),
|
||||
albumId: z.uuidv4().optional().describe('Album ID (for album sharing)'),
|
||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
||||
description: z.string().nullable().optional().describe('Link description'),
|
||||
password: z.string().nullable().optional().describe('Link password'),
|
||||
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date').default(null).optional(),
|
||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
|
||||
@@ -35,19 +35,13 @@ const SharedLinkCreateSchema = z
|
||||
|
||||
const SharedLinkEditSchema = z
|
||||
.object({
|
||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
||||
description: z.string().nullable().optional().describe('Link description'),
|
||||
password: z.string().nullable().optional().describe('Link password'),
|
||||
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
|
||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||
allowDownload: z.boolean().optional().describe('Allow downloads'),
|
||||
showMetadata: z.boolean().optional().describe('Show metadata'),
|
||||
changeExpiryTime: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.',
|
||||
),
|
||||
})
|
||||
.meta({ id: 'SharedLinkEditDto' });
|
||||
|
||||
|
||||
@@ -2,20 +2,20 @@ import { createZodDto } from 'nestjs-zod';
|
||||
import { Tag } from 'src/database';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { emptyStringToNull, hexColor } from 'src/validation';
|
||||
import { hexColor } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const TagCreateSchema = z
|
||||
.object({
|
||||
name: z.string().describe('Tag name'),
|
||||
parentId: z.uuidv4().nullish().describe('Parent tag ID'),
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||
})
|
||||
.meta({ id: 'TagCreateDto' });
|
||||
|
||||
const TagUpdateSchema = z
|
||||
.object({
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||
})
|
||||
.meta({ id: 'TagUpdateDto' });
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { pinCodeRegex } from 'src/dtos/auth.dto';
|
||||
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export const UserUpdateMeSchema = z
|
||||
@@ -80,10 +80,7 @@ export const UserAdminCreateSchema = z
|
||||
password: z.string().describe('User password'),
|
||||
name: z.string().describe('User name'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
||||
.optional()
|
||||
.describe('PIN code')
|
||||
.meta({ example: '123456' }),
|
||||
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
|
||||
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
|
||||
@@ -98,10 +95,7 @@ const UserAdminUpdateSchema = z
|
||||
.object({
|
||||
email: toEmail.optional().describe('User email'),
|
||||
password: z.string().optional().describe('User password'),
|
||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
||||
.optional()
|
||||
.describe('PIN code')
|
||||
.meta({ example: '123456' }),
|
||||
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||
name: z.string().optional().describe('User name'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||
|
||||
@@ -24,7 +24,7 @@ import { DB } from 'src/schema';
|
||||
import { immich_uuid_v7 } from 'src/schema/functions';
|
||||
import { ExtensionVersion, VectorExtension } from 'src/types';
|
||||
import { vectorIndexQuery } from 'src/utils/database';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export let cachedVectorExtension: VectorExtension | undefined;
|
||||
export async function getVectorExtension(runner: Kysely<DB>): Promise<VectorExtension> {
|
||||
@@ -292,7 +292,13 @@ export class DatabaseRepository {
|
||||
`.execute(this.db);
|
||||
|
||||
const dimSize = rows[0]?.dimsize;
|
||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
||||
if (
|
||||
!z
|
||||
.int()
|
||||
.min(1)
|
||||
.max(2 ** 16)
|
||||
.safeParse(dimSize).success
|
||||
) {
|
||||
this.logger.warn(`Could not retrieve dimension size of column '${column}' in table '${table}', assuming 512`);
|
||||
return 512;
|
||||
}
|
||||
@@ -300,7 +306,13 @@ export class DatabaseRepository {
|
||||
}
|
||||
|
||||
async setDimensionSize(dimSize: number): Promise<void> {
|
||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
||||
if (
|
||||
!z
|
||||
.int()
|
||||
.min(1)
|
||||
.max(2 ** 16)
|
||||
.safeParse(dimSize).success
|
||||
) {
|
||||
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export interface SearchAssetIdOptions {
|
||||
checksum?: Buffer;
|
||||
@@ -278,7 +278,7 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
|
||||
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
|
||||
if (!z.int().min(1).max(1000).safeParse(pagination.size).success) {
|
||||
throw new Error(`Invalid value for 'size': ${pagination.size}`);
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
|
||||
if (!isValidInteger(numResults, { min: 1, max: 1000 })) {
|
||||
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
|
||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ export class SharedLinkService extends BaseService {
|
||||
userId: auth.user.id,
|
||||
description: dto.description,
|
||||
password: dto.password,
|
||||
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
|
||||
expiresAt: dto.expiresAt,
|
||||
allowUpload: dto.allowUpload,
|
||||
allowDownload: dto.allowDownload,
|
||||
showExif: dto.showMetadata,
|
||||
|
||||
@@ -125,11 +125,6 @@ const FilenameParamSchema = z.object({
|
||||
|
||||
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
|
||||
|
||||
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
|
||||
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
|
||||
return Number.isInteger(value) && value >= min && value <= max;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified email validation
|
||||
* Converts email strings to lowercase and validates against HTML5 email regex
|
||||
@@ -251,16 +246,4 @@ export const hexColor = z
|
||||
.regex(hexColorRegex)
|
||||
.transform((val) => (val.startsWith('#') ? val : `#${val}`));
|
||||
|
||||
/**
|
||||
* Transform empty strings to null. Inner schema passed to this function must accept null.
|
||||
* @docs https://zod.dev/api?id=preprocess
|
||||
* @example emptyStringToNull(z.string().nullable()).optional() // [encouraged] final schema is optional
|
||||
* @example emptyStringToNull(z.string().nullable()) // [encouraged] same as the one above, but final schema is not optional
|
||||
* @example emptyStringToNull(z.string().nullish()) // [discouraged] same as the one above, might be confusing
|
||||
* @example emptyStringToNull(z.string().optional()) // fails: string schema rejects null
|
||||
* @example emptyStringToNull(z.string().nullable()).nullish() // [discouraged] passes, null is duplicated. use the first example instead
|
||||
*/
|
||||
export const emptyStringToNull = <T extends z.ZodTypeAny>(schema: T) =>
|
||||
z.preprocess((val) => (val === '' ? null : val), schema);
|
||||
|
||||
export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
slot="media"
|
||||
src={assetFileUrl}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
disablePictureInPicture
|
||||
|
||||
Reference in New Issue
Block a user