1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-24 10:37:28 +02:00

feat(mobile): partner sharing (#2541)

* feat(mobile): partner sharing

* getAllAssets for other users

* i18n

* fix tests

* try to fix web tests

* shared with/by confusion

* error logging

* guard against outdated server version
This commit is contained in:
Fynn Petersen-Frey 2023-05-25 05:52:43 +02:00 committed by GitHub
parent 1613ae9185
commit bcc2c34eef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 873 additions and 213 deletions

View File

@ -257,6 +257,15 @@
"sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "Create shared album",
"sharing_silver_appbar_share_partner": "Share with partner",
"partner_page_title": "Partner",
"partner_page_no_more_users": "No more users to add",
"partner_page_empty_message": "Your photos are not yet shared with any partner.",
"partner_page_shared_to_title": "Shared to",
"partner_page_select_partner": "Select partner",
"partner_page_add_partner": "Add partner",
"partner_page_partner_add_failed": "Failed to add partner",
"partner_page_stop_sharing_title": "Stop sharing your photos?",
"partner_page_stop_sharing_content": "{} will no longer be able to access your photos.",
"tab_controller_nav_library": "Library",
"tab_controller_nav_photos": "Photos",
"tab_controller_nav_search": "Search",

View File

@ -20,6 +20,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
@ -89,6 +90,7 @@ Future<Isar> loadDb() async {
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
],
directory: dir.path,
maxSizeMiB: 256,

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
@ -73,7 +74,9 @@ final sharedAlbumProvider =
});
final sharedAlbumDetailProvider =
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
StreamProvider.family<Album, int>((ref, albumId) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
await for (final a in sharedAlbumService.watchAlbum(albumId)) {

View File

@ -2,8 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
final suggestedSharedUsersProvider =
FutureProvider.autoDispose<List<User>>((ref) {
final otherUsersProvider = FutureProvider.autoDispose<List<User>>((ref) {
UserService userService = ref.watch(userServiceProvider);
return userService.getUsersInDb();

View File

@ -1,85 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
class SharingSliverAppBar extends StatelessWidget {
const SharingSliverAppBar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverAppBar(
centerTitle: true,
floating: false,
pinned: true,
snap: false,
automaticallyImplyLeading: false,
title: Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(50.0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ElevatedButton.icon(
onPressed: () {
AutoRouter.of(context)
.push(CreateAlbumRoute(isSharedAlbum: true));
},
icon: const Icon(
Icons.photo_album_outlined,
size: 20,
),
label: const Text(
"sharing_silver_appbar_create_shared_album",
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
// color: Theme.of(context).primaryColor,
),
).tr(),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: ElevatedButton.icon(
onPressed: null,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
size: 20,
),
label: const Text(
"sharing_silver_appbar_share_partner",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
maxLines: 1,
).tr(),
),
),
)
],
),
),
),
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({
@ -21,7 +22,8 @@ class AssetSelectionPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final renderList = ref.watch(remoteAssetsProvider);
final currentUser = ref.watch(currentUserProvider);
final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
final selected = useState<Set<Asset>>(existingAssets);
final selectionEnabledHook = useState(true);

View File

@ -17,7 +17,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<User>> suggestedShareUsers =
ref.watch(suggestedSharedUsersProvider);
ref.watch(otherUsersProvider);
final sharedUsersList = useState<Set<User>>({});
addNewUsersHandler() {

View File

@ -20,8 +20,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsersList = useState<Set<User>>({});
AsyncValue<List<User>> suggestedShareUsers =
ref.watch(suggestedSharedUsersProvider);
final suggestedShareUsers = ref.watch(otherUsersProvider);
createSharedAlbum() async {
var newAlbum =

View File

@ -5,10 +5,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart' as store;
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class SharingPage extends HookConsumerWidget {
@ -17,7 +18,8 @@ class SharingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
final userId = store.Store.get(store.StoreKey.currentUser).id;
final userId = ref.watch(currentUserProvider)?.id;
final partner = ref.watch(partnerSharedWithProvider);
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
useEffect(
@ -63,8 +65,7 @@ class SharingPage extends HookConsumerWidget {
final isOwner = album.ownerId == userId;
return ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ImmichImage(
@ -93,7 +94,8 @@ class SharingPage extends HookConsumerWidget {
)
: album.ownerName != null
? Text(
'album_thumbnail_shared_by'.tr(args: [album.ownerName!]),
'album_thumbnail_shared_by'
.tr(args: [album.ownerName!]),
style: const TextStyle(
fontSize: 12.0,
),
@ -110,6 +112,75 @@ class SharingPage extends HookConsumerWidget {
);
}
buildTopBottons() {
return Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
AutoRouter.of(context)
.push(CreateAlbumRoute(isSharedAlbum: true));
},
icon: const Icon(
Icons.photo_album_outlined,
size: 20,
),
label: const Text(
"sharing_silver_appbar_create_shared_album",
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
).tr(),
),
),
const SizedBox(width: 12.0),
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
AutoRouter.of(context).push(const PartnerRoute()),
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
size: 20,
),
label: const Text(
"sharing_silver_appbar_share_partner",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
maxLines: 1,
).tr(),
),
)
],
),
);
}
AppBar buildAppBar() {
return AppBar(
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
);
}
buildEmptyListIndication() {
return SliverToBoxAdapter(
child: Padding(
@ -123,7 +194,6 @@ class SharingPage extends HookConsumerWidget {
width: 0.5,
),
),
// color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Column(
@ -160,11 +230,27 @@ class SharingPage extends HookConsumerWidget {
}
return Scaffold(
appBar: buildAppBar(),
body: CustomScrollView(
slivers: [
const SharingSliverAppBar(),
SliverToBoxAdapter(child: buildTopBottons()),
if (partner.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 4),
sliver: SliverToBoxAdapter(
child: const Text(
"partner_page_title",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
),
if (partner.isNotEmpty) PartnerList(partner: partner),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
padding: EdgeInsets.only(
left: 12,
right: 12,
top: partner.isEmpty ? 0 : 16,
),
sliver: SliverToBoxAdapter(
child: const Text(
"sharing_page_album",

View File

@ -3,16 +3,18 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
final archiveProvider = StreamProvider<RenderList>((ref) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.ownerIdEqualTo(user.isarId)
.isArchivedEqualTo(true)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);

View File

@ -4,9 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/shared/models/store.dart' as store;
class DescriptionInput extends HookConsumerWidget {
DescriptionInput({
@ -25,9 +25,10 @@ class DescriptionInput extends HookConsumerWidget {
final focusNode = useFocusNode();
final isFocus = useState(false);
final isTextEmpty = useState(controller.text.isEmpty);
final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier);
final descriptionProvider =
ref.watch(assetDescriptionProvider(asset).notifier);
final description = ref.watch(assetDescriptionProvider(asset));
final owner = store.Store.get(store.StoreKey.currentUser);
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
controller.text = description;
@ -67,7 +68,7 @@ class DescriptionInput extends HookConsumerWidget {
}
return TextField(
enabled: owner.isarId == asset.ownerId,
enabled: owner?.isarId == asset.ownerId,
focusNode: focusNode,
onTap: () => isFocus.value = true,
onChanged: (value) {

View File

@ -3,16 +3,18 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.ownerIdEqualTo(user.isarId)
.isFavoriteEqualTo(true)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);

View File

@ -1,47 +1,16 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
class DeleteDialog extends ConsumerWidget {
class DeleteDialog extends ConfirmDialog {
final Function onDelete;
const DeleteDialog({Key? key, required this.onDelete}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return AlertDialog(
// backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("delete_dialog_title").tr(),
content: const Text("delete_dialog_alert").tr(),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
"delete_dialog_cancel",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
).tr(),
),
TextButton(
onPressed: () {
onDelete();
Navigator.of(context).pop();
},
child: Text(
"delete_dialog_ok",
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
),
).tr(),
),
],
);
}
const DeleteDialog({Key? key, required this.onDelete})
: super(
key: key,
title: "delete_dialog_title",
content: "delete_dialog_alert",
cancel: "delete_dialog_cancel",
ok: "delete_dialog_ok",
onOk: onDelete,
);
}

View File

@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@ -38,6 +39,7 @@ class HomePage extends HookConsumerWidget {
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider);
final currentUser = ref.watch(currentUserProvider);
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
@ -300,7 +302,7 @@ class HomePage extends HookConsumerWidget {
bottom: false,
child: Stack(
children: [
ref.watch(assetsProvider).when(
ref.watch(assetsProvider(currentUser?.isarId)).when(
data: (data) => data.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(

View File

@ -0,0 +1,50 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
PartnerSharedWithNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
query.findAll().then((partners) => state = partners);
query.watch().listen((partners) => state = partners);
}
}
final partnerSharedWithProvider =
StateNotifierProvider<PartnerSharedWithNotifier, List<User>>((ref) {
return PartnerSharedWithNotifier(ref.watch(dbProvider));
});
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
PartnerSharedByNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedByEqualTo(true);
query.findAll().then((partners) => state = partners);
streamSub = query.watch().listen((partners) => state = partners);
}
late final StreamSubscription<List<User>> streamSub;
@override
void dispose() {
streamSub.cancel();
super.dispose();
}
}
final partnerSharedByProvider =
StateNotifierProvider<PartnerSharedByNotifier, List<User>>((ref) {
return PartnerSharedByNotifier(ref.watch(dbProvider));
});
final partnerAvailableProvider =
FutureProvider.autoDispose<List<User>>((ref) async {
final otherUsers = await ref.watch(otherUsersProvider.future);
final currentPartners = ref.watch(partnerSharedByProvider);
final available = Set<User>.of(otherUsers);
available.removeAll(currentPartners);
return available.toList();
});

View File

@ -0,0 +1,72 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
final partnerServiceProvider = Provider(
(ref) => PartnerService(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
),
);
enum PartnerDirection {
sharedWith("shared-with"),
sharedBy("shared-by");
const PartnerDirection(
this._value,
);
final String _value;
}
class PartnerService {
final ApiService _apiService;
final Isar _db;
final Logger _log = Logger("PartnerService");
PartnerService(this._apiService, this._db);
Future<List<User>?> getPartners(PartnerDirection direction) async {
try {
final userDtos =
await _apiService.partnerApi.getPartners(direction._value);
if (userDtos != null) {
return userDtos.map((u) => User.fromDto(u)).toList();
}
} catch (e) {
_log.warning("failed to get partners for direction $direction:\n$e");
}
return null;
}
Future<bool> removePartner(User partner) async {
try {
await _apiService.partnerApi.removePartner(partner.id);
partner.isPartnerSharedBy = false;
await _db.writeTxn(() => _db.users.put(partner));
} catch (e) {
_log.warning("failed to remove partner ${partner.id}:\n$e");
return false;
}
return true;
}
Future<bool> addPartner(User partner) async {
try {
final dto = await _apiService.partnerApi.createPartner(partner.id);
if (dto != null) {
partner.isPartnerSharedBy = true;
await _db.writeTxn(() => _db.users.put(partner));
return true;
}
} catch (e) {
_log.warning("failed to add partner ${partner.id}:\n$e");
}
return false;
}
}

View File

@ -0,0 +1,30 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/user_avatar.dart';
class PartnerList extends HookConsumerWidget {
const PartnerList({Key? key, required this.partner}) : super(key: key);
final List<User> partner;
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverList(
delegate:
SliverChildBuilderDelegate(listEntry, childCount: partner.length),
);
}
Widget listEntry(BuildContext context, int index) {
final User p = partner[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
leading: userAvatar(context, p, radius: 30),
title: Text("${p.firstName} ${p.lastName}"),
onTap: () => AutoRouter.of(context).push(PartnerDetailRoute(partner: p)),
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class PartnerDetailPage extends HookConsumerWidget {
const PartnerDetailPage({Key? key, required this.partner}) : super(key: key);
final User partner;
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(assetsProvider(partner.isarId));
return Scaffold(
appBar: AppBar(
title: Text("${partner.firstName} ${partner.lastName}"),
elevation: 0,
centerTitle: false,
),
body: assets.when(
data: (renderList) => renderList.isEmpty
? Padding(
padding: const EdgeInsets.all(16),
child: Text(
"It seems ${partner.firstName} does not have any photos...\n"
"Or your server version does not match the app version."),
)
: ImmichAssetGrid(
renderList: renderList,
onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
),
error: (e, _) => Text("Error loading partners:\n$e"),
loading: () => const Center(child: ImmichLoadingIndicator()),
),
);
}
}

View File

@ -0,0 +1,160 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/modules/partner/services/partner.service.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/user_avatar.dart';
class PartnerPage extends HookConsumerWidget {
const PartnerPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<User> partners = ref.watch(partnerSharedByProvider);
final availableUsers = ref.watch(partnerAvailableProvider);
addNewUsersHandler() async {
final users = availableUsers.value;
if (users == null || users.isEmpty) {
ImmichToast.show(
context: context,
msg: "partner_page_no_more_users".tr(),
);
return;
}
final selectedUser = await showDialog<User>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (User u in users)
SimpleDialogOption(
onPressed: () => Navigator.pop(context, u),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: userAvatar(context, u),
),
Text("${u.firstName} ${u.lastName}"),
],
),
)
],
);
},
);
if (selectedUser != null) {
final ok =
await ref.read(partnerServiceProvider).addPartner(selectedUser);
if (ok) {
ref.invalidate(partnerSharedByProvider);
} else {
ImmichToast.show(
context: context,
msg: "partner_page_partner_add_failed".tr(),
toastType: ToastType.error,
);
}
}
}
onDeleteUser(User u) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "partner_page_stop_sharing_title",
content:
"partner_page_stop_sharing_content".tr(args: [u.firstName]),
onOk: () => ref.read(partnerServiceProvider).removePartner(u),
);
},
);
}
buildUserList(List<User> users) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: const Text(
"partner_page_shared_to_title",
style: TextStyle(
fontSize: 14,
color: Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
if (users.isNotEmpty)
ListView.builder(
shrinkWrap: true,
itemCount: users.length,
itemBuilder: ((context, index) {
return ListTile(
leading: userAvatar(context, users[index]),
title: Text(
users[index].email,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
trailing: IconButton(
icon: const Icon(Icons.person_remove),
onPressed: () => onDeleteUser(users[index]),
),
);
}),
),
if (users.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text(
"partner_page_empty_message",
style: TextStyle(fontSize: 14),
).tr(),
),
ElevatedButton.icon(
onPressed: availableUsers.whenOrNull(
data: (data) => addNewUsersHandler,
),
icon: const Icon(Icons.person_add),
label: const Text("partner_page_add_partner").tr(),
),
],
),
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partner_page_title").tr(),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed:
availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: "partner_page_add_partner".tr(),
)
],
),
body: buildUserList(partners),
);
}
}

View File

@ -6,6 +6,8 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
import 'package:immich_mobile/modules/partner/views/partner_page.dart';
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
@ -35,6 +37,7 @@ import 'package:immich_mobile/routing/duplicate_guard.dart';
import 'package:immich_mobile/routing/gallery_permission_guard.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@ -136,6 +139,8 @@ part 'router.gr.dart';
DuplicateGuard,
],
),
AutoRoute(page: PartnerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: PartnerDetailPage, guards: [AuthGuard, DuplicateGuard])
],
)
class AppRouter extends _$AppRouter {

View File

@ -256,6 +256,22 @@ class _$AppRouter extends RootStackRouter {
child: const ArchivePage(),
);
},
PartnerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const PartnerPage(),
);
},
PartnerDetailRoute.name: (routeData) {
final args = routeData.argsAs<PartnerDetailRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: PartnerDetailPage(
key: args.key,
partner: args.partner,
),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@ -523,6 +539,22 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
PartnerRoute.name,
path: '/partner-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
PartnerDetailRoute.name,
path: '/partner-detail-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@ -1113,6 +1145,52 @@ class ArchiveRoute extends PageRouteInfo<void> {
static const String name = 'ArchiveRoute';
}
/// generated route for
/// [PartnerPage]
class PartnerRoute extends PageRouteInfo<void> {
const PartnerRoute()
: super(
PartnerRoute.name,
path: '/partner-page',
);
static const String name = 'PartnerRoute';
}
/// generated route for
/// [PartnerDetailPage]
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
PartnerDetailRoute({
Key? key,
required User partner,
}) : super(
PartnerDetailRoute.name,
path: '/partner-detail-page',
args: PartnerDetailRouteArgs(
key: key,
partner: partner,
),
);
static const String name = 'PartnerDetailRoute';
}
class PartnerDetailRouteArgs {
const PartnerDetailRouteArgs({
this.key,
required this.partner,
});
final Key? key;
final User partner;
@override
String toString() {
return 'PartnerDetailRouteArgs{key: $key, partner: $partner}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@ -87,8 +87,8 @@ class Album {
remoteId == other.remoteId &&
localId == other.localId &&
name == other.name &&
createdAt == other.createdAt &&
modifiedAt == other.modifiedAt &&
createdAt.isAtSameMomentAs(other.createdAt) &&
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
shared == other.shared &&
owner.value == other.owner.value &&
thumbnail.value == other.thumbnail.value &&

View File

@ -179,9 +179,9 @@ class Asset {
localId == other.localId &&
deviceId == other.deviceId &&
ownerId == other.ownerId &&
fileCreatedAt == other.fileCreatedAt &&
fileModifiedAt == other.fileModifiedAt &&
updatedAt == other.updatedAt &&
fileCreatedAt.isAtSameMomentAs(other.fileCreatedAt) &&
fileModifiedAt.isAtSameMomentAs(other.fileModifiedAt) &&
updatedAt.isAtSameMomentAs(other.updatedAt) &&
durationInSeconds == other.durationInSeconds &&
type == other.type &&
width == other.width &&

View File

@ -0,0 +1,13 @@
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'etag.g.dart';
@Collection(inheritance: false)
class ETag {
ETag({required this.id, this.value});
Id get isarId => fastHash(id);
@Index(unique: true, replace: true, type: IndexType.hash)
String id;
String? value;
}

Binary file not shown.

View File

@ -14,6 +14,8 @@ class User {
required this.firstName,
required this.lastName,
required this.isAdmin,
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
});
Id get isarId => fastHash(id);
@ -26,6 +28,8 @@ class User {
email = dto.email,
firstName = dto.firstName,
lastName = dto.lastName,
isPartnerSharedBy = false,
isPartnerSharedWith = false,
isAdmin = dto.isAdmin;
@Index(unique: true, replace: false, type: IndexType.hash)
@ -34,6 +38,8 @@ class User {
String email;
String firstName;
String lastName;
bool isPartnerSharedBy;
bool isPartnerSharedWith;
bool isAdmin;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@ -44,10 +50,12 @@ class User {
bool operator ==(other) {
if (other is! User) return false;
return id == other.id &&
updatedAt == other.updatedAt &&
updatedAt.isAtSameMomentAs(other.updatedAt) &&
email == other.email &&
firstName == other.firstName &&
lastName == other.lastName &&
isPartnerSharedBy == other.isPartnerSharedBy &&
isPartnerSharedWith == other.isPartnerSharedWith &&
isAdmin == other.isAdmin;
}
@ -59,5 +67,7 @@ class User {
email.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
isAdmin.hashCode;
}

Binary file not shown.

View File

@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
@ -10,6 +11,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@ -23,6 +25,7 @@ class AssetsState {}
class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService;
final AlbumService _albumService;
final UserService _userService;
final SyncService _syncService;
final Isar _db;
final log = Logger('AssetNotifier');
@ -32,6 +35,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
AssetNotifier(
this._assetService,
this._albumService,
this._userService,
this._syncService,
this._db,
) : super(AssetsState());
@ -51,6 +55,12 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
await _userService.refreshUsers();
final List<User> partners =
await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll();
for (User u in partners) {
await _assetService.refreshRemoteAssets(u);
}
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getAllAssetInProgress = false;
@ -147,6 +157,7 @@ final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
);
@ -161,12 +172,14 @@ final assetDetailProvider =
}
});
final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
final assetsProvider =
StreamProvider.family<RenderList, int?>((ref, userId) async* {
if (userId == null) return;
final query = ref
.watch(dbProvider)
.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.ownerIdEqualTo(userId)
.isArchivedEqualTo(false)
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
@ -179,14 +192,15 @@ final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
});
final remoteAssetsProvider =
StreamProvider.autoDispose<RenderList>((ref) async* {
StreamProvider.family<RenderList, int?>((ref, userId) async* {
if (userId == null) return;
final query = ref
.watch(dbProvider)
.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.ownerIdEqualTo(userId)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =

View File

@ -0,0 +1,26 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
class CurrentUserProvider extends StateNotifier<User?> {
CurrentUserProvider() : super(null) {
state = Store.tryGet(StoreKey.currentUser);
streamSub =
Store.watch(StoreKey.currentUser).listen((user) => state = user);
}
late final StreamSubscription<User?> streamSub;
@override
void dispose() {
streamSub.cancel();
super.dispose();
}
}
final currentUserProvider =
StateNotifierProvider<CurrentUserProvider, User?>((ref) {
return CurrentUserProvider();
});

View File

@ -16,6 +16,7 @@ class ApiService {
late AssetApi assetApi;
late SearchApi searchApi;
late ServerInfoApi serverInfoApi;
late PartnerApi partnerApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -37,6 +38,7 @@ class ApiService {
assetApi = AssetApi(_apiClient);
serverInfoApi = ServerInfoApi(_apiClient);
searchApi = SearchApi(_apiClient);
partnerApi = PartnerApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View File

@ -3,8 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@ -36,37 +38,47 @@ class AssetService {
/// Checks the server for updated assets and updates the local database if
/// required. Returns `true` if there were any changes.
Future<bool> refreshRemoteAssets() async {
Future<bool> refreshRemoteAssets([User? user]) async {
user ??= Store.get(StoreKey.currentUser);
final Stopwatch sw = Stopwatch()..start();
final int numOwnedRemoteAssets = await _db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.ownerIdEqualTo(user!.isarId)
.count();
final bool changes = await _syncService.syncRemoteAssetsToDb(
() async => (await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0))
?.map(Asset.remote)
.toList(),
user,
() async => (await _getRemoteAssets(
hasCache: numOwnedRemoteAssets > 0,
user: user!,
)),
);
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Returns `null` if the server state did not change, else list of assets
Future<List<AssetResponseDto>?> _getRemoteAssets({
Future<List<Asset>?> _getRemoteAssets({
required bool hasCache,
required User user,
}) async {
try {
final etag = hasCache ? Store.tryGet(StoreKey.assetETag) : null;
final etag = hasCache ? _db.eTags.getByIdSync(user.id)?.value : null;
final (List<AssetResponseDto>? assets, String? newETag) =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
await _apiService.assetApi
.getAllAssetsWithETag(eTag: etag, userId: user.id);
if (assets == null) {
return null;
} else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
log.warning("Make sure that server and app versions match!"
" The server returned assets for user ${assets.first.ownerId}"
" while requesting assets of user ${user.id}");
return null;
} else if (newETag != etag) {
Store.put(StoreKey.assetETag, newETag);
_db.writeTxn(() => _db.eTags.put(ETag(id: user.id, value: newETag)));
}
return assets;
return assets.map(Asset.remote).toList();
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
return null;

View File

@ -40,7 +40,9 @@ class SyncService {
dbUsers,
compare: (User a, User b) => a.id.compareTo(b.id),
both: (User a, User b) {
if (!a.updatedAt.isAtSameMomentAs(b.updatedAt)) {
if (!a.updatedAt.isAtSameMomentAs(b.updatedAt) ||
a.isPartnerSharedBy != b.isPartnerSharedBy ||
a.isPartnerSharedWith != b.isPartnerSharedWith) {
toUpsert.add(a);
return true;
}
@ -61,9 +63,10 @@ class SyncService {
/// Syncs remote assets owned by the logged-in user to the DB
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(
User user,
FutureOr<List<Asset>?> Function() loadAssets,
) =>
_lock.run(() => _syncRemoteAssetsToDb(loadAssets));
_lock.run(() => _syncRemoteAssetsToDb(user, loadAssets));
/// Syncs remote albums to the database
/// returns `true` if there were any changes
@ -149,13 +152,13 @@ class SyncService {
/// Syncs remote assets to the databas
/// returns `true` if there were any changes
Future<bool> _syncRemoteAssetsToDb(
User user,
FutureOr<List<Asset>?> Function() loadAssets,
) async {
final List<Asset>? remote = await loadAssets();
if (remote == null) {
return false;
}
final User user = Store.get(StoreKey.currentUser);
final List<Asset> inDb = await _db.assets
.filter()
.ownerIdEqualTo(user.isarId)
@ -349,10 +352,19 @@ class SyncService {
);
} else if (album.shared) {
final User user = Store.get(StoreKey.currentUser);
// delete assets in DB unless they belong to this user or are part of some other shared album
deleteCandidates.addAll(
await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(),
);
// delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner
final userIds = await _db.users
.filter()
.isPartnerSharedWithEqualTo(true)
.isarIdProperty()
.findAll();
userIds.add(user.isarId);
final orphanedAssets = await album.assets
.filter()
.not()
.anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id))
.findAll();
deleteCandidates.addAll(orphanedAssets);
}
try {
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));

View File

@ -1,16 +1,19 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/modules/partner/services/partner.service.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final userServiceProvider = Provider(
@ -18,6 +21,7 @@ final userServiceProvider = Provider(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref.watch(syncServiceProvider),
ref.watch(partnerServiceProvider),
),
);
@ -25,15 +29,22 @@ class UserService {
final ApiService _apiService;
final Isar _db;
final SyncService _syncService;
final PartnerService _partnerService;
final Logger _log = Logger("UserService");
UserService(this._apiService, this._db, this._syncService);
UserService(
this._apiService,
this._db,
this._syncService,
this._partnerService,
);
Future<List<User>?> _getAllUsers({required bool isAll}) async {
try {
final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromDto).toList();
} catch (e) {
debugPrint("Error [getAllUsersInfo] ${e.toString()}");
_log.warning("Failed get all users:\n$e");
return null;
}
}
@ -62,16 +73,45 @@ class UserService {
),
);
} catch (e) {
debugPrint("Error [uploadProfileImage] ${e.toString()}");
_log.warning("Failed to upload profile image:\n$e");
return null;
}
}
Future<bool> refreshUsers() async {
final List<User>? users = await _getAllUsers(isAll: true);
if (users == null) {
final List<User>? sharedBy =
await _partnerService.getPartners(PartnerDirection.sharedBy);
final List<User>? sharedWith =
await _partnerService.getPartners(PartnerDirection.sharedWith);
if (users == null || sharedBy == null || sharedWith == null) {
_log.warning("Failed to refresh users");
return false;
}
users.sortBy((u) => u.id);
sharedBy.sortBy((u) => u.id);
sharedWith.sortBy((u) => u.id);
diffSortedListsSync(
users,
sharedBy,
compare: (User a, User b) => a.id.compareTo(b.id),
both: (User a, User b) => a.isPartnerSharedBy = true,
onlyFirst: (_) {},
onlySecond: (_) {},
);
diffSortedListsSync(
users,
sharedWith,
compare: (User a, User b) => a.id.compareTo(b.id),
both: (User a, User b) => a.isPartnerSharedWith = true,
onlyFirst: (_) {},
onlySecond: (_) {},
);
return _syncService.syncUsersFromServer(users);
}
}

View File

@ -0,0 +1,54 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ConfirmDialog extends ConsumerWidget {
final Function onOk;
final String title;
final String content;
final String cancel;
final String ok;
const ConfirmDialog({
Key? key,
required this.onOk,
required this.title,
required this.content,
this.cancel = "delete_dialog_cancel",
this.ok = "backup_controller_page_background_battery_info_ok",
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Text(title).tr(),
content: Text(content).tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
cancel,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
).tr(),
),
TextButton(
onPressed: () {
onOk();
Navigator.of(context).pop();
},
child: Text(
ok,
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
),
).tr(),
),
],
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
Widget userAvatar(BuildContext context, User u, {double? radius}) {
final url =
"${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}";
return CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
foregroundImage: CachedNetworkImageProvider(
url,
headers: {"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"},
cacheKey: "user-${u.id}-profile",
),
// silence errors if user has no profile image, use initials as fallback
onForegroundImageError: (exception, stackTrace) {},
child: Text((u.firstName[0] + u.lastName[0]).toUpperCase()),
);
}

View File

@ -1,7 +1,9 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
Future<void> clearAssetsAndAlbums(Isar db) async {
@ -10,5 +12,7 @@ Future<void> clearAssetsAndAlbums(Isar db) async {
await db.assets.clear();
await db.exifInfos.clear();
await db.albums.clear();
await db.eTags.clear();
await db.users.clear();
});
}

View File

@ -14,9 +14,11 @@ extension WithETag on AssetApi {
/// ETag of data already cached on the client
Future<(List<AssetResponseDto>? assets, String? eTag)> getAllAssetsWithETag({
String? eTag,
String? userId,
}) async {
final response = await getAllAssetsWithHttpInfo(
ifNoneMatch: eTag,
userId: userId,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -52,6 +52,14 @@ void main() {
group('Test SyncService grouped', () {
late final Isar db;
final owner = User(
id: "1",
updatedAt: DateTime.now(),
email: "a@b.c",
firstName: "first",
lastName: "last",
isAdmin: false,
);
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
await Isar.initializeIsarCore(download: true);
@ -59,17 +67,7 @@ void main() {
ImmichLogger();
db.writeTxnSync(() => db.clearSync());
Store.init(db);
await Store.put(
StoreKey.currentUser,
User(
id: "1",
updatedAt: DateTime.now(),
email: "a@b.c",
firstName: "first",
lastName: "last",
isAdmin: false,
),
);
await Store.put(StoreKey.currentUser, owner);
});
final List<Asset> initialAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
@ -92,7 +90,7 @@ void main() {
makeAsset(localId: "1", remoteId: "1-1"),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
expect(c1, false);
expect(db.assets.countSync(), 5);
});
@ -108,7 +106,7 @@ void main() {
makeAsset(localId: "1", remoteId: "3-1", deviceId: 3),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 7);
});
@ -124,19 +122,19 @@ void main() {
makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 8);
final bool c2 = await s.syncRemoteAssetsToDb(() => remoteAssets);
final bool c2 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
expect(c2, false);
expect(db.assets.countSync(), 8);
remoteAssets.removeAt(4);
final bool c3 = await s.syncRemoteAssetsToDb(() => remoteAssets);
final bool c3 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
expect(c3, true);
expect(db.assets.countSync(), 7);
remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2));
remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2));
final bool c4 = await s.syncRemoteAssetsToDb(() => remoteAssets);
final bool c4 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
expect(c4, true);
expect(db.assets.countSync(), 9);
});

View File

@ -150,7 +150,10 @@ export class AssetService {
}
public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
if (dto.userId && dto.userId !== authUser.id) {
await this.checkUserAccess(authUser, dto.userId);
}
const assets = await this._assetRepository.getAllByUserId(dto.userId || authUser.id, dto);
return assets.map((asset) => mapAsset(asset));
}

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class AssetSearchDto {
@ -18,4 +19,9 @@ export class AssetSearchDto {
@IsOptional()
@IsNumber()
skip?: number;
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
}

View File

@ -2853,6 +2853,15 @@
"operationId": "getAllAssets",
"description": "Get all AssetEntity belong to the user",
"parameters": [
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,

View File

@ -4599,6 +4599,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
},
/**
* Get all AssetEntity belong to the user
* @param {string} [userId]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
@ -4606,7 +4607,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -4628,6 +4629,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
if (isFavorite !== undefined) {
localVarQueryParameter['isFavorite'] = isFavorite;
}
@ -5551,6 +5556,7 @@ export const AssetApiFp = function(configuration?: Configuration) {
},
/**
* Get all AssetEntity belong to the user
* @param {string} [userId]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
@ -5558,8 +5564,8 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options);
async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -5837,6 +5843,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
},
/**
* Get all AssetEntity belong to the user
* @param {string} [userId]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
@ -5844,8 +5851,8 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(axios, basePath));
getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(axios, basePath));
},
/**
*
@ -6124,6 +6131,7 @@ export class AssetApi extends BaseAPI {
/**
* Get all AssetEntity belong to the user
* @param {string} [userId]
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
@ -6132,8 +6140,8 @@ export class AssetApi extends BaseAPI {
* @throws {RequiredError}
* @memberof AssetApi
*/
public getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
public getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -30,7 +30,7 @@
const getFavoriteCount = async () => {
try {
const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
const { data: assets } = await api.assetApi.getAllAssets(undefined, true, undefined);
return {
favorites: assets.length

View File

@ -24,7 +24,7 @@
onMount(async () => {
try {
const { data: assets } = await api.assetApi.getAllAssets(undefined, true);
const { data: assets } = await api.assetApi.getAllAssets(undefined, undefined, true);
$archivedAsset = assets;
} catch {
handleError(Error, 'Unable to load archived assets');

View File

@ -20,7 +20,7 @@
onMount(async () => {
try {
const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
const { data: assets } = await api.assetApi.getAllAssets(undefined, true, undefined);
favorites = assets;
} catch {
handleError(Error, 'Unable to load favorites');