forked from Cutlery/immich
Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job
This commit is contained in:
commit
104ffdd7d5
@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](
|
|||||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||||
|
|
||||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/)
|
||||||
- [Okta](https://www.okta.com/openid-connect/)
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
|
|
||||||
|
6
e2e/package-lock.json
generated
6
e2e/package-lock.json
generated
@ -1158,9 +1158,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.11.25",
|
"version": "20.11.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz",
|
||||||
"integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==",
|
"integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
|
1
mobile/assets/immich-logo.json
Normal file
1
mobile/assets/immich-logo.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -22,8 +22,11 @@ class ExifPeople extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final peopleProvider =
|
final peopleProvider =
|
||||||
ref.watch(assetPeopleNotifierProvider(asset).notifier);
|
ref.watch(assetPeopleNotifierProvider(asset).notifier);
|
||||||
final people = ref.watch(assetPeopleNotifierProvider(asset));
|
final people = ref
|
||||||
final double imageSize = math.min(context.width / 3, 150);
|
.watch(assetPeopleNotifierProvider(asset))
|
||||||
|
.value
|
||||||
|
?.where((p) => !p.isHidden);
|
||||||
|
final double imageSize = math.min(context.width / 3, 120);
|
||||||
|
|
||||||
showPersonNameEditModel(
|
showPersonNameEditModel(
|
||||||
String personId,
|
String personId,
|
||||||
@ -40,15 +43,14 @@ class ExifPeople extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (people.value?.isEmpty ?? true) {
|
if (people?.isEmpty ?? true) {
|
||||||
// Empty list or loading
|
// Empty list or loading
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
|
|
||||||
final curatedPeople = people.value
|
final curatedPeople =
|
||||||
?.map((p) => CuratedContent(id: p.id, label: p.name))
|
people?.map((p) => CuratedContent(id: p.id, label: p.name)).toList() ??
|
||||||
.toList() ??
|
[];
|
||||||
[];
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
@ -5,11 +5,9 @@ import 'package:photo_manager/photo_manager.dart';
|
|||||||
class AvailableAlbum {
|
class AvailableAlbum {
|
||||||
final AssetPathEntity albumEntity;
|
final AssetPathEntity albumEntity;
|
||||||
final DateTime? lastBackup;
|
final DateTime? lastBackup;
|
||||||
final Uint8List? thumbnailData;
|
|
||||||
AvailableAlbum({
|
AvailableAlbum({
|
||||||
required this.albumEntity,
|
required this.albumEntity,
|
||||||
this.lastBackup,
|
this.lastBackup,
|
||||||
this.thumbnailData,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
AvailableAlbum copyWith({
|
AvailableAlbum copyWith({
|
||||||
@ -20,7 +18,6 @@ class AvailableAlbum {
|
|||||||
return AvailableAlbum(
|
return AvailableAlbum(
|
||||||
albumEntity: albumEntity ?? this.albumEntity,
|
albumEntity: albumEntity ?? this.albumEntity,
|
||||||
lastBackup: lastBackup ?? this.lastBackup,
|
lastBackup: lastBackup ?? this.lastBackup,
|
||||||
thumbnailData: thumbnailData ?? this.thumbnailData,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,7 +31,7 @@ class AvailableAlbum {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
|
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
|
@ -234,33 +234,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
for (AssetPathEntity album in albums) {
|
for (AssetPathEntity album in albums) {
|
||||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
final assetCountInAlbum = await album.assetCountAsync;
|
availableAlbums.add(availableAlbum);
|
||||||
if (assetCountInAlbum > 0) {
|
|
||||||
final assetList = await album.getAssetListPaged(page: 0, size: 1);
|
|
||||||
|
|
||||||
// Even though we check assetCountInAlbum to make sure that there are assets in album
|
albumMap[album.id] = album;
|
||||||
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
|
|
||||||
if (assetList.isEmpty) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final thumbnailAsset = assetList.first;
|
|
||||||
try {
|
|
||||||
final thumbnailData = await thumbnailAsset
|
|
||||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
|
||||||
availableAlbum =
|
|
||||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
|
||||||
} catch (e, stack) {
|
|
||||||
log.severe(
|
|
||||||
"Failed to get thumbnail for album ${album.name}",
|
|
||||||
e,
|
|
||||||
stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
availableAlbums.add(availableAlbum);
|
|
||||||
|
|
||||||
albumMap[album.id] = album;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
state = state.copyWith(availableAlbums: availableAlbums);
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
|
|
||||||
|
@ -11,17 +11,16 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
class AlbumInfoCard extends HookConsumerWidget {
|
class AlbumInfoCard extends HookConsumerWidget {
|
||||||
final Uint8List? imageData;
|
final AvailableAlbum album;
|
||||||
final AvailableAlbum albumInfo;
|
|
||||||
|
|
||||||
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
|
const AlbumInfoCard({super.key, required this.album});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bool isSelected =
|
final bool isSelected =
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
|
|
||||||
ColorFilter selectedFilter = ColorFilter.mode(
|
ColorFilter selectedFilter = ColorFilter.mode(
|
||||||
@ -82,9 +81,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
} else {
|
} else {
|
||||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDoubleTap: () {
|
onDoubleTap: () {
|
||||||
@ -92,13 +91,11 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
// Remove from exclude album list
|
// Remove from exclude album list
|
||||||
ref
|
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.removeExcludedAlbumForBackup(albumInfo);
|
|
||||||
} else {
|
} else {
|
||||||
// Add to exclude album list
|
// Add to exclude album list
|
||||||
|
|
||||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
if (album.id == 'isAll' || album.name == 'Recents') {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'Cannot exclude album contains all assets',
|
msg: 'Cannot exclude album contains all assets',
|
||||||
@ -108,9 +105,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.addExcludedAlbumForBackup(albumInfo);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Card(
|
child: Card(
|
||||||
@ -136,14 +131,12 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ColorFiltered(
|
ColorFiltered(
|
||||||
colorFilter: buildImageFilter(),
|
colorFilter: buildImageFilter(),
|
||||||
child: Image(
|
child: const Image(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
image: imageData != null
|
image: AssetImage(
|
||||||
? MemoryImage(imageData!)
|
'assets/immich-logo.png',
|
||||||
: const AssetImage(
|
),
|
||||||
'assets/immich-logo.png',
|
|
||||||
) as ImageProvider,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -168,7 +161,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
albumInfo.name,
|
album.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: context.primaryColor,
|
color: context.primaryColor,
|
||||||
@ -182,7 +175,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return Text(
|
return Text(
|
||||||
snapshot.data.toString() +
|
snapshot.data.toString() +
|
||||||
(albumInfo.isAll
|
(album.isAll
|
||||||
? " (${'backup_all'.tr()})"
|
? " (${'backup_all'.tr()})"
|
||||||
: ""),
|
: ""),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -193,7 +186,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
return const Text("0");
|
return const Text("0");
|
||||||
}),
|
}),
|
||||||
future: albumInfo.assetCount,
|
future: album.assetCount,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -202,7 +195,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
AlbumPreviewRoute(album: album.albumEntity),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
@ -11,47 +11,26 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
class AlbumInfoListTile extends HookConsumerWidget {
|
class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
final Uint8List? imageData;
|
final AvailableAlbum album;
|
||||||
final AvailableAlbum albumInfo;
|
|
||||||
|
|
||||||
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
|
const AlbumInfoListTile({super.key, required this.album});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bool isSelected =
|
final bool isSelected =
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
|
|
||||||
ColorFilter selectedFilter = ColorFilter.mode(
|
|
||||||
context.primaryColor.withAlpha(100),
|
|
||||||
BlendMode.darken,
|
|
||||||
);
|
|
||||||
ColorFilter excludedFilter =
|
|
||||||
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
|
||||||
ColorFilter unselectedFilter =
|
|
||||||
const ColorFilter.mode(Colors.black, BlendMode.color);
|
|
||||||
|
|
||||||
var assetCount = useState(0);
|
var assetCount = useState(0);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
albumInfo.assetCount.then((value) => assetCount.value = value);
|
album.assetCount.then((value) => assetCount.value = value);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[albumInfo],
|
[album],
|
||||||
);
|
);
|
||||||
|
|
||||||
buildImageFilter() {
|
|
||||||
if (isSelected) {
|
|
||||||
return selectedFilter;
|
|
||||||
} else if (isExcluded) {
|
|
||||||
return excludedFilter;
|
|
||||||
} else {
|
|
||||||
return unselectedFilter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTileColor() {
|
buildTileColor() {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return context.isDarkTheme
|
return context.isDarkTheme
|
||||||
@ -66,19 +45,38 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildIcon() {
|
||||||
|
if (isSelected) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExcluded) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.remove_circle_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Icon(
|
||||||
|
Icons.circle,
|
||||||
|
color: context.isDarkTheme ? Colors.grey[400] : Colors.black45,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onDoubleTap: () {
|
onDoubleTap: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
// Remove from exclude album list
|
// Remove from exclude album list
|
||||||
ref
|
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.removeExcludedAlbumForBackup(albumInfo);
|
|
||||||
} else {
|
} else {
|
||||||
// Add to exclude album list
|
// Add to exclude album list
|
||||||
|
|
||||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
if (album.id == 'isAll' || album.name == 'Recents') {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'Cannot exclude album contains all assets',
|
msg: 'Cannot exclude album contains all assets',
|
||||||
@ -88,9 +86,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
|
||||||
.read(backupProvider.notifier)
|
|
||||||
.addExcludedAlbumForBackup(albumInfo);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@ -99,33 +95,14 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
} else {
|
} else {
|
||||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: ClipRRect(
|
leading: buildIcon(),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
width: 80,
|
|
||||||
child: ColorFiltered(
|
|
||||||
colorFilter: buildImageFilter(),
|
|
||||||
child: Image(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
image: imageData != null
|
|
||||||
? MemoryImage(imageData!)
|
|
||||||
: const AssetImage(
|
|
||||||
'assets/immich-logo.png',
|
|
||||||
) as ImageProvider,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
title: Text(
|
||||||
albumInfo.name,
|
album.name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@ -135,7 +112,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
|||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
AlbumPreviewRoute(album: album.albumEntity),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
@ -43,10 +43,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
((context, index) {
|
((context, index) {
|
||||||
var thumbnailData = albums[index].thumbnailData;
|
|
||||||
return AlbumInfoListTile(
|
return AlbumInfoListTile(
|
||||||
imageData: thumbnailData,
|
album: albums[index],
|
||||||
albumInfo: albums[index],
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
childCount: albums.length,
|
childCount: albums.length,
|
||||||
@ -74,10 +72,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: ((context, index) {
|
itemBuilder: ((context, index) {
|
||||||
var thumbnailData = albums[index].thumbnailData;
|
|
||||||
return AlbumInfoCard(
|
return AlbumInfoCard(
|
||||||
imageData: thumbnailData,
|
album: albums[index],
|
||||||
albumInfo: albums[index],
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
@ -26,7 +26,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
BackUpState backupState = ref.watch(backupProvider);
|
BackUpState backupState = ref.watch(backupProvider);
|
||||||
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
|
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
|
||||||
|
final didGetBackupInfo = useState(false);
|
||||||
bool hasExclusiveAccess =
|
bool hasExclusiveAccess =
|
||||||
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||||
@ -38,11 +38,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
|
|
||||||
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
|
|
||||||
ref.watch(backupProvider.notifier).getBackupInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the background settings information just to make sure we
|
// Update the background settings information just to make sure we
|
||||||
// have the latest, since the platform channel will not update
|
// have the latest, since the platform channel will not update
|
||||||
// automatically
|
// automatically
|
||||||
@ -58,6 +53,18 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
if (backupState.backupProgress == BackUpProgressEnum.idle &&
|
||||||
|
!didGetBackupInfo.value) {
|
||||||
|
ref.watch(backupProvider.notifier).getBackupInfo();
|
||||||
|
didGetBackupInfo.value = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[backupState.backupProgress],
|
||||||
|
);
|
||||||
|
|
||||||
Widget buildSelectedAlbumName() {
|
Widget buildSelectedAlbumName() {
|
||||||
var text = "backup_controller_page_backup_selected".tr();
|
var text = "backup_controller_page_backup_selected".tr();
|
||||||
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
@ -235,6 +242,15 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildLoadingIndicator() {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 42.0),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@ -297,7 +313,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
||||||
buildBackupButton(),
|
buildBackupButton(),
|
||||||
]
|
]
|
||||||
: [buildFolderSelectionTile()],
|
: [
|
||||||
|
buildFolderSelectionTile(),
|
||||||
|
if (!didGetBackupInfo.value) buildLoadingIndicator(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
223
mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart
Normal file
223
mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// Based on https://stackoverflow.com/a/52625182
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
|
class AssetDragRegion extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
final void Function(AssetIndex valueKey)? onStart;
|
||||||
|
final void Function(AssetIndex valueKey)? onAssetEnter;
|
||||||
|
final void Function()? onEnd;
|
||||||
|
final void Function()? onScrollStart;
|
||||||
|
final void Function(ScrollDirection direction)? onScroll;
|
||||||
|
|
||||||
|
const AssetDragRegion({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onStart,
|
||||||
|
this.onAssetEnter,
|
||||||
|
this.onEnd,
|
||||||
|
this.onScrollStart,
|
||||||
|
this.onScroll,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
State createState() => _AssetDragRegionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetDragRegionState extends State<AssetDragRegion> {
|
||||||
|
late AssetIndex? assetUnderPointer;
|
||||||
|
late AssetIndex? anchorAsset;
|
||||||
|
|
||||||
|
// Scroll related state
|
||||||
|
static const double scrollOffset = 0.10;
|
||||||
|
double? topScrollOffset;
|
||||||
|
double? bottomScrollOffset;
|
||||||
|
Timer? scrollTimer;
|
||||||
|
late bool scrollNotified;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
assetUnderPointer = null;
|
||||||
|
anchorAsset = null;
|
||||||
|
scrollNotified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
topScrollOffset = null;
|
||||||
|
bottomScrollOffset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
scrollTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RawGestureDetector(
|
||||||
|
gestures: {
|
||||||
|
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
|
||||||
|
_CustomLongPressGestureRecognizer>(
|
||||||
|
() => _CustomLongPressGestureRecognizer(),
|
||||||
|
_registerCallbacks,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
|
||||||
|
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
|
||||||
|
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
|
||||||
|
recognizer.onLongPressUp = _onLongPressEnd;
|
||||||
|
recognizer.onLongPressCancel = _onLongPressEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetIndex? _getValueKeyAtPositon(Offset position) {
|
||||||
|
final box = context.findAncestorRenderObjectOfType<RenderBox>();
|
||||||
|
if (box == null) return null;
|
||||||
|
|
||||||
|
final hitTestResult = BoxHitTestResult();
|
||||||
|
final local = box.globalToLocal(position);
|
||||||
|
if (!box.hitTest(hitTestResult, position: local)) return null;
|
||||||
|
|
||||||
|
return (hitTestResult.path
|
||||||
|
.firstWhereOrNull((hit) => hit.target is _AssetIndexProxy)
|
||||||
|
?.target as _AssetIndexProxy?)
|
||||||
|
?.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressStart(LongPressStartDetails event) {
|
||||||
|
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
|
||||||
|
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
|
||||||
|
final height = context.size?.height;
|
||||||
|
if (height != null &&
|
||||||
|
(topScrollOffset == null || bottomScrollOffset == null)) {
|
||||||
|
topScrollOffset = height * scrollOffset;
|
||||||
|
bottomScrollOffset = height - topScrollOffset!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initialHit = _getValueKeyAtPositon(event.globalPosition);
|
||||||
|
anchorAsset = initialHit;
|
||||||
|
if (initialHit == null) return;
|
||||||
|
|
||||||
|
if (anchorAsset != null) {
|
||||||
|
widget.onStart?.call(anchorAsset!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressEnd() {
|
||||||
|
scrollNotified = false;
|
||||||
|
scrollTimer?.cancel();
|
||||||
|
widget.onEnd?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressMove(LongPressMoveUpdateDetails event) {
|
||||||
|
if (anchorAsset == null) return;
|
||||||
|
if (topScrollOffset == null || bottomScrollOffset == null) return;
|
||||||
|
|
||||||
|
final currentDy = event.localPosition.dy;
|
||||||
|
|
||||||
|
if (currentDy > bottomScrollOffset!) {
|
||||||
|
scrollTimer ??= Timer.periodic(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
(_) => widget.onScroll?.call(ScrollDirection.forward),
|
||||||
|
);
|
||||||
|
} else if (currentDy < topScrollOffset!) {
|
||||||
|
scrollTimer ??= Timer.periodic(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
(_) => widget.onScroll?.call(ScrollDirection.reverse),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scrollTimer?.cancel();
|
||||||
|
scrollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition);
|
||||||
|
if (currentlyTouchingAsset == null) return;
|
||||||
|
|
||||||
|
if (assetUnderPointer != currentlyTouchingAsset) {
|
||||||
|
if (!scrollNotified) {
|
||||||
|
scrollNotified = true;
|
||||||
|
widget.onScrollStart?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onAssetEnter?.call(currentlyTouchingAsset);
|
||||||
|
assetUnderPointer = currentlyTouchingAsset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
|
||||||
|
@override
|
||||||
|
void rejectGesture(int pointer) {
|
||||||
|
acceptGesture(pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer-single-widget-per-file
|
||||||
|
class AssetIndexWrapper extends SingleChildRenderObjectWidget {
|
||||||
|
final int rowIndex;
|
||||||
|
final int sectionIndex;
|
||||||
|
|
||||||
|
const AssetIndexWrapper({
|
||||||
|
required Widget super.child,
|
||||||
|
required this.rowIndex,
|
||||||
|
required this.sectionIndex,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AssetIndexProxy createRenderObject(BuildContext context) {
|
||||||
|
return _AssetIndexProxy(
|
||||||
|
index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context,
|
||||||
|
_AssetIndexProxy renderObject,
|
||||||
|
) {
|
||||||
|
renderObject.index =
|
||||||
|
AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetIndexProxy extends RenderProxyBox {
|
||||||
|
AssetIndex index;
|
||||||
|
|
||||||
|
_AssetIndexProxy({
|
||||||
|
required this.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetIndex {
|
||||||
|
final int rowIndex;
|
||||||
|
final int sectionIndex;
|
||||||
|
|
||||||
|
const AssetIndex({
|
||||||
|
required this.rowIndex,
|
||||||
|
required this.sectionIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant AssetIndex other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode;
|
||||||
|
}
|
@ -5,12 +5,15 @@ import 'dart:math';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||||||
|
|
||||||
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
|
final ScrollOffsetController _scrollOffsetController =
|
||||||
|
ScrollOffsetController();
|
||||||
final ItemPositionsListener _itemPositionsListener =
|
final ItemPositionsListener _itemPositionsListener =
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
final Set<Asset> _selectedAssets =
|
final Set<Asset> _selectedAssets =
|
||||||
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||||
|
|
||||||
|
bool _dragging = false;
|
||||||
|
int? _dragAnchorAssetIndex;
|
||||||
|
int? _dragAnchorSectionIndex;
|
||||||
|
final Set<Asset> _draggedAssets =
|
||||||
|
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||||
|
|
||||||
Set<Asset> _getSelectedAssets() {
|
Set<Asset> _getSelectedAssets() {
|
||||||
return Set.from(_selectedAssets);
|
return Set.from(_selectedAssets);
|
||||||
}
|
}
|
||||||
@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
|
|
||||||
void _selectAssets(List<Asset> assets) {
|
void _selectAssets(List<Asset> assets) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
if (_dragging) {
|
||||||
|
_draggedAssets.addAll(assets);
|
||||||
|
}
|
||||||
_selectedAssets.addAll(assets);
|
_selectedAssets.addAll(assets);
|
||||||
_callSelectionListener(true);
|
_callSelectionListener(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deselectAssets(List<Asset> assets) {
|
void _deselectAssets(List<Asset> assets) {
|
||||||
|
final assetsToDeselect = assets.where(
|
||||||
|
(a) =>
|
||||||
|
widget.canDeselect ||
|
||||||
|
!(widget.preselectedAssets?.contains(a) ?? false),
|
||||||
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAssets.removeAll(
|
_selectedAssets.removeAll(assetsToDeselect);
|
||||||
assets.where(
|
if (_dragging) {
|
||||||
(a) =>
|
_draggedAssets.removeAll(assetsToDeselect);
|
||||||
widget.canDeselect ||
|
}
|
||||||
!(widget.preselectedAssets?.contains(a) ?? false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
void _deselectAll() {
|
void _deselectAll() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAssets.clear();
|
_selectedAssets.clear();
|
||||||
|
_dragAnchorAssetIndex = null;
|
||||||
|
_dragAnchorSectionIndex = null;
|
||||||
|
_draggedAssets.clear();
|
||||||
|
_dragging = false;
|
||||||
if (!widget.canDeselect &&
|
if (!widget.canDeselect &&
|
||||||
widget.preselectedAssets != null &&
|
widget.preselectedAssets != null &&
|
||||||
widget.preselectedAssets!.isNotEmpty) {
|
widget.preselectedAssets!.isNotEmpty) {
|
||||||
@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
showStorageIndicator: widget.showStorageIndicator,
|
showStorageIndicator: widget.showStorageIndicator,
|
||||||
selectedAssets: _selectedAssets,
|
selectedAssets: _selectedAssets,
|
||||||
selectionActive: widget.selectionActive,
|
selectionActive: widget.selectionActive,
|
||||||
|
sectionIndex: index,
|
||||||
section: section,
|
section: section,
|
||||||
margin: widget.margin,
|
margin: widget.margin,
|
||||||
renderList: widget.renderList,
|
renderList: widget.renderList,
|
||||||
@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
itemBuilder: _itemBuilder,
|
itemBuilder: _itemBuilder,
|
||||||
itemPositionsListener: _itemPositionsListener,
|
itemPositionsListener: _itemPositionsListener,
|
||||||
itemScrollController: _itemScrollController,
|
itemScrollController: _itemScrollController,
|
||||||
|
scrollOffsetController: _scrollOffsetController,
|
||||||
itemCount: widget.renderList.elements.length +
|
itemCount: widget.renderList.elements.length +
|
||||||
(widget.topWidget != null ? 1 : 0),
|
(widget.topWidget != null ? 1 : 0),
|
||||||
addRepaintBoundaries: true,
|
addRepaintBoundaries: true,
|
||||||
@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
if (widget.visibleItemsListener != null) {
|
if (widget.visibleItemsListener != null) {
|
||||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||||
}
|
}
|
||||||
|
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setDragStartIndex(AssetIndex index) {
|
||||||
|
setState(() {
|
||||||
|
_dragAnchorAssetIndex = index.rowIndex;
|
||||||
|
_dragAnchorSectionIndex = index.sectionIndex;
|
||||||
|
_dragging = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopDrag() {
|
||||||
|
setState(() {
|
||||||
|
_dragging = false;
|
||||||
|
_draggedAssets.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dragDragScroll(ScrollDirection direction) {
|
||||||
|
_scrollOffsetController.animateScroll(
|
||||||
|
offset: direction == ScrollDirection.forward ? 175 : -175,
|
||||||
|
duration: const Duration(milliseconds: 125),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragAssetEnter(AssetIndex index) {
|
||||||
|
if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dragAnchorSectionIndex = _dragAnchorSectionIndex!;
|
||||||
|
final dragAnchorAssetIndex = _dragAnchorAssetIndex!;
|
||||||
|
|
||||||
|
late final int startSectionIndex;
|
||||||
|
late final int startSectionAssetIndex;
|
||||||
|
late final int endSectionIndex;
|
||||||
|
late final int endSectionAssetIndex;
|
||||||
|
|
||||||
|
if (index.sectionIndex < dragAnchorSectionIndex) {
|
||||||
|
startSectionIndex = index.sectionIndex;
|
||||||
|
startSectionAssetIndex = index.rowIndex;
|
||||||
|
endSectionIndex = dragAnchorSectionIndex;
|
||||||
|
endSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
} else if (index.sectionIndex > dragAnchorSectionIndex) {
|
||||||
|
startSectionIndex = dragAnchorSectionIndex;
|
||||||
|
startSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
endSectionIndex = index.sectionIndex;
|
||||||
|
endSectionAssetIndex = index.rowIndex;
|
||||||
|
} else {
|
||||||
|
startSectionIndex = dragAnchorSectionIndex;
|
||||||
|
endSectionIndex = dragAnchorSectionIndex;
|
||||||
|
|
||||||
|
// If same section, assign proper start / end asset Index
|
||||||
|
if (dragAnchorAssetIndex < index.rowIndex) {
|
||||||
|
startSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
endSectionAssetIndex = index.rowIndex;
|
||||||
|
} else {
|
||||||
|
startSectionAssetIndex = index.rowIndex;
|
||||||
|
endSectionAssetIndex = dragAnchorAssetIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedAssets = <Asset>{};
|
||||||
|
var currentSectionIndex = startSectionIndex;
|
||||||
|
while (currentSectionIndex < endSectionIndex) {
|
||||||
|
final section =
|
||||||
|
widget.renderList.elements.elementAtOrNull(currentSectionIndex);
|
||||||
|
if (section == null) continue;
|
||||||
|
|
||||||
|
final sectionAssets =
|
||||||
|
widget.renderList.loadAssets(section.offset, section.count);
|
||||||
|
|
||||||
|
if (currentSectionIndex == startSectionIndex) {
|
||||||
|
selectedAssets.addAll(
|
||||||
|
sectionAssets.slice(startSectionAssetIndex, sectionAssets.length),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedAssets.addAll(sectionAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSectionIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final section = widget.renderList.elements.elementAtOrNull(endSectionIndex);
|
||||||
|
if (section != null) {
|
||||||
|
final sectionAssets =
|
||||||
|
widget.renderList.loadAssets(section.offset, section.count);
|
||||||
|
if (startSectionIndex == endSectionIndex) {
|
||||||
|
selectedAssets.addAll(
|
||||||
|
sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedAssets.addAll(
|
||||||
|
sectionAssets.slice(0, endSectionAssetIndex + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_deselectAssets(_draggedAssets.toList());
|
||||||
|
_draggedAssets.clear();
|
||||||
|
_draggedAssets.addAll(selectedAssets);
|
||||||
|
_selectAssets(_draggedAssets.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
|
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildAssetGrid(),
|
AssetDragRegion(
|
||||||
|
onStart: _setDragStartIndex,
|
||||||
|
onAssetEnter: _handleDragAssetEnter,
|
||||||
|
onEnd: _stopDrag,
|
||||||
|
onScroll: _dragDragScroll,
|
||||||
|
onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => controlBottomAppBarNotifier.minimize(),
|
||||||
|
),
|
||||||
|
child: _buildAssetGrid(),
|
||||||
|
),
|
||||||
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
||||||
_buildMultiSelectIndicator(),
|
_buildMultiSelectIndicator(),
|
||||||
],
|
],
|
||||||
@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget {
|
|||||||
/// A section for the render grid
|
/// A section for the render grid
|
||||||
class _Section extends StatelessWidget {
|
class _Section extends StatelessWidget {
|
||||||
final RenderAssetGridElement section;
|
final RenderAssetGridElement section;
|
||||||
|
final int sectionIndex;
|
||||||
final Set<Asset> selectedAssets;
|
final Set<Asset> selectedAssets;
|
||||||
final bool scrolling;
|
final bool scrolling;
|
||||||
final double margin;
|
final double margin;
|
||||||
@ -377,6 +512,7 @@ class _Section extends StatelessWidget {
|
|||||||
|
|
||||||
const _Section({
|
const _Section({
|
||||||
required this.section,
|
required this.section,
|
||||||
|
required this.sectionIndex,
|
||||||
required this.scrolling,
|
required this.scrolling,
|
||||||
required this.margin,
|
required this.margin,
|
||||||
required this.assetsPerRow,
|
required this.assetsPerRow,
|
||||||
@ -435,6 +571,8 @@ class _Section extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: _AssetRow(
|
: _AssetRow(
|
||||||
key: ValueKey(i),
|
key: ValueKey(i),
|
||||||
|
rowStartIndex: i * assetsPerRow,
|
||||||
|
sectionIndex: sectionIndex,
|
||||||
assets: assetsToRender.nestedSlice(
|
assets: assetsToRender.nestedSlice(
|
||||||
i * assetsPerRow,
|
i * assetsPerRow,
|
||||||
min((i + 1) * assetsPerRow, section.count),
|
min((i + 1) * assetsPerRow, section.count),
|
||||||
@ -522,6 +660,8 @@ class _Title extends StatelessWidget {
|
|||||||
/// The row of assets
|
/// The row of assets
|
||||||
class _AssetRow extends StatelessWidget {
|
class _AssetRow extends StatelessWidget {
|
||||||
final List<Asset> assets;
|
final List<Asset> assets;
|
||||||
|
final int rowStartIndex;
|
||||||
|
final int sectionIndex;
|
||||||
final Set<Asset> selectedAssets;
|
final Set<Asset> selectedAssets;
|
||||||
final int absoluteOffset;
|
final int absoluteOffset;
|
||||||
final double width;
|
final double width;
|
||||||
@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget {
|
|||||||
|
|
||||||
const _AssetRow({
|
const _AssetRow({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.rowStartIndex,
|
||||||
|
required this.sectionIndex,
|
||||||
required this.assets,
|
required this.assets,
|
||||||
required this.absoluteOffset,
|
required this.absoluteOffset,
|
||||||
required this.width,
|
required this.width,
|
||||||
@ -594,18 +736,22 @@ class _AssetRow extends StatelessWidget {
|
|||||||
bottom: margin,
|
bottom: margin,
|
||||||
right: last ? 0.0 : margin,
|
right: last ? 0.0 : margin,
|
||||||
),
|
),
|
||||||
child: ThumbnailImage(
|
child: AssetIndexWrapper(
|
||||||
asset: asset,
|
rowIndex: rowStartIndex + index,
|
||||||
index: absoluteOffset + index,
|
sectionIndex: sectionIndex,
|
||||||
loadAsset: renderList.loadAsset,
|
child: ThumbnailImage(
|
||||||
totalAssets: renderList.totalAssets,
|
asset: asset,
|
||||||
multiselectEnabled: selectionActive,
|
index: absoluteOffset + index,
|
||||||
isSelected: isSelectionActive && selectedAssets.contains(asset),
|
loadAsset: renderList.loadAsset,
|
||||||
onSelect: () => onSelect?.call(asset),
|
totalAssets: renderList.totalAssets,
|
||||||
onDeselect: () => onDeselect?.call(asset),
|
multiselectEnabled: selectionActive,
|
||||||
showStorageIndicator: showStorageIndicator,
|
isSelected: isSelectionActive && selectedAssets.contains(asset),
|
||||||
heroOffset: heroOffset,
|
onSelect: () => onSelect?.call(asset),
|
||||||
showStack: showStack,
|
onDeselect: () => onDeselect?.call(asset),
|
||||||
|
showStorageIndicator: showStorageIndicator,
|
||||||
|
heroOffset: heroOffset,
|
||||||
|
showStack: showStack,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||||
@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
|||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
|
||||||
|
|
||||||
class ControlBottomAppBar extends ConsumerWidget {
|
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
|
||||||
|
|
||||||
|
class ControlBottomAppBarNotifier with ChangeNotifier {
|
||||||
|
void minimize() {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ControlBottomAppBar extends HookConsumerWidget {
|
||||||
final void Function(bool shareLocal) onShare;
|
final void Function(bool shareLocal) onShare;
|
||||||
final void Function()? onFavorite;
|
final void Function()? onFavorite;
|
||||||
final void Function()? onArchive;
|
final void Function()? onArchive;
|
||||||
@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
const bottomPadding = 0.20;
|
const bottomPadding = 0.20;
|
||||||
|
final scrollController = useDraggableScrollController();
|
||||||
|
|
||||||
|
void minimize() {
|
||||||
|
scrollController.animateTo(
|
||||||
|
bottomPadding,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
controlBottomAppBarNotifier.addListener(minimize);
|
||||||
|
return () {
|
||||||
|
controlBottomAppBarNotifier.removeListener(minimize);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
void showForceDeleteDialog(
|
void showForceDeleteDialog(
|
||||||
Function(bool) deleteCb, {
|
Function(bool) deleteCb, {
|
||||||
@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
|
controller: scrollController,
|
||||||
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
||||||
minChildSize: bottomPadding,
|
minChildSize: bottomPadding,
|
||||||
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
||||||
|
@ -23,7 +23,7 @@ class CuratedPeopleRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const imageSize = 85.0;
|
const imageSize = 80.0;
|
||||||
|
|
||||||
// Guard empty [content]
|
// Guard empty [content]
|
||||||
if (content.isEmpty) {
|
if (content.isEmpty) {
|
||||||
|
13
mobile/lib/shared/providers/immich_logo_provider.dart
Normal file
13
mobile/lib/shared/providers/immich_logo_provider.dart
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'immich_logo_provider.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<Uint8List> immichLogo(ImmichLogoRef ref) async {
|
||||||
|
final json = await rootBundle.loadString('assets/immich-logo.json');
|
||||||
|
final j = jsonDecode(json);
|
||||||
|
return base64Decode(j['content']);
|
||||||
|
}
|
24
mobile/lib/shared/providers/immich_logo_provider.g.dart
generated
Normal file
24
mobile/lib/shared/providers/immich_logo_provider.g.dart
generated
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'immich_logo_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd';
|
||||||
|
|
||||||
|
/// See also [immichLogo].
|
||||||
|
@ProviderFor(immichLogo)
|
||||||
|
final immichLogoProvider = AutoDisposeFutureProvider<Uint8List>.internal(
|
||||||
|
immichLogo,
|
||||||
|
name: r'immichLogoProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$immichLogoHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef ImmichLogoRef = AutoDisposeFutureProviderRef<Uint8List>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/immich_logo_provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
|
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
|
||||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final bool isEnableAutoBackup =
|
final bool isEnableAutoBackup =
|
||||||
backupState.backgroundBackup || backupState.autoBackup;
|
backupState.backgroundBackup || backupState.autoBackup;
|
||||||
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
||||||
|
final immichLogo = ref.watch(immichLogoProvider);
|
||||||
final user = Store.tryGet(StoreKey.currentUser);
|
final user = Store.tryGet(StoreKey.currentUser);
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
const widgetSize = 30.0;
|
const widgetSize = 30.0;
|
||||||
@ -152,14 +154,29 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Builder(
|
||||||
padding: const EdgeInsets.only(top: 3),
|
builder: (context) {
|
||||||
height: 30,
|
final today = DateTime.now();
|
||||||
child: Image.asset(
|
if (today.month == 4 && today.day == 1) {
|
||||||
context.isDarkTheme
|
if (immichLogo.value == null) {
|
||||||
? 'assets/immich-logo-inline-dark.png'
|
return const SizedBox.shrink();
|
||||||
: 'assets/immich-logo-inline-light.png',
|
}
|
||||||
),
|
return Image.memory(
|
||||||
|
immichLogo.value!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
height: 80,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 3.0),
|
||||||
|
child: Image.asset(
|
||||||
|
height: 30,
|
||||||
|
context.isDarkTheme
|
||||||
|
? 'assets/immich-logo-inline-dark.png'
|
||||||
|
: 'assets/immich-logo-inline-light.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
1
web/src/lib/assets/immich-logo.json
Normal file
1
web/src/lib/assets/immich-logo.json
Normal file
File diff suppressed because one or more lines are too long
@ -2,11 +2,11 @@
|
|||||||
import { serveFile, type AssetResponseDto } from '@immich/sdk';
|
import { serveFile, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
|
import { getKey } from '$lib/utils';
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
|
|
||||||
const loadAssetData = async () => {
|
const loadAssetData = async () => {
|
||||||
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false });
|
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
|
||||||
return URL.createObjectURL(data);
|
return URL.createObjectURL(data);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
||||||
<Button
|
<Button
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
rounded={false}
|
rounded={false}
|
||||||
|
@ -83,8 +83,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col w-full mt-2">
|
<div class="flex flex-col w-full mt-2">
|
||||||
<label for="timezone">Timezone</label>
|
<Combobox
|
||||||
<Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." />
|
bind:selectedOption
|
||||||
|
id="settings-timezone"
|
||||||
|
label="Timezone"
|
||||||
|
options={timezones}
|
||||||
|
placeholder="Search timezone..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ConfirmDialogue>
|
</ConfirmDialogue>
|
||||||
|
@ -11,48 +11,93 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { clickOutside } from '$lib/utils/click-outside';
|
|
||||||
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, tick } from 'svelte';
|
||||||
import IconButton from '../elements/buttons/icon-button.svelte';
|
import IconButton from '../elements/buttons/icon-button.svelte';
|
||||||
|
import type { FormEventHandler } from 'svelte/elements';
|
||||||
|
import { shortcuts } from '$lib/utils/shortcut';
|
||||||
|
import { clickOutside } from '$lib/utils/click-outside';
|
||||||
|
|
||||||
export let id: string | undefined = undefined;
|
/**
|
||||||
|
* Unique identifier for the combobox.
|
||||||
|
*/
|
||||||
|
export let id: string;
|
||||||
|
export let label: string;
|
||||||
|
export let hideLabel = false;
|
||||||
export let options: ComboBoxOption[] = [];
|
export let options: ComboBoxOption[] = [];
|
||||||
export let selectedOption: ComboBoxOption | undefined;
|
export let selectedOption: ComboBoxOption | undefined;
|
||||||
export let placeholder = '';
|
export let placeholder = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether or not the dropdown autocomplete list should be visible.
|
||||||
|
*/
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let inputFocused = false;
|
/**
|
||||||
|
* Keeps track of whether the combobox is actively being used.
|
||||||
|
*/
|
||||||
|
let isActive = false;
|
||||||
let searchQuery = selectedOption?.label || '';
|
let searchQuery = selectedOption?.label || '';
|
||||||
|
let selectedIndex: number | undefined;
|
||||||
|
let optionRefs: HTMLElement[] = [];
|
||||||
|
const inputId = `combobox-${id}`;
|
||||||
|
const listboxId = `listbox-${id}`;
|
||||||
|
|
||||||
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
$: {
|
||||||
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
select: ComboBoxOption | undefined;
|
select: ComboBoxOption | undefined;
|
||||||
click: void;
|
click: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const handleClick = () => {
|
const activate = () => {
|
||||||
searchQuery = '';
|
isActive = true;
|
||||||
|
openDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivate = () => {
|
||||||
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
|
isActive = false;
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
inputFocused = true;
|
|
||||||
dispatch('click');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let handleOutClick = () => {
|
const closeDropdown = () => {
|
||||||
// In rare cases it's possible for the input to still have focus and
|
|
||||||
// outclick to fire.
|
|
||||||
if (!inputFocused) {
|
|
||||||
isOpen = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let handleSelect = (option: ComboBoxOption) => {
|
|
||||||
selectedOption = option;
|
|
||||||
dispatch('select', option);
|
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
|
selectedIndex = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const incrementSelectedIndex = async (increment: number) => {
|
||||||
|
if (filteredOptions.length === 0) {
|
||||||
|
selectedIndex = 0;
|
||||||
|
} else if (selectedIndex === undefined) {
|
||||||
|
selectedIndex = increment === 1 ? 0 : filteredOptions.length - 1;
|
||||||
|
} else {
|
||||||
|
selectedIndex = (selectedIndex + increment + filteredOptions.length) % filteredOptions.length;
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
optionRefs[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput: FormEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
openDropdown();
|
||||||
|
searchQuery = event.currentTarget.value;
|
||||||
|
selectedIndex = undefined;
|
||||||
|
optionRefs[0]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
let onSelect = (option: ComboBoxOption) => {
|
||||||
|
selectedOption = option;
|
||||||
|
searchQuery = option.label;
|
||||||
|
dispatch('select', option);
|
||||||
|
closeDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
@ -62,30 +107,80 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}>
|
<label class="text-sm text-black dark:text-white" class:sr-only={hideLabel} for={inputId}>{label}</label>
|
||||||
|
<div
|
||||||
|
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||||
|
use:clickOutside={{ onOutclick: deactivate }}
|
||||||
|
on:focusout={(e) => {
|
||||||
|
if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) {
|
||||||
|
deactivate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{#if isOpen}
|
{#if isActive}
|
||||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<div class="dark:text-immich-dark-fg/75">
|
<div class="dark:text-immich-dark-fg/75">
|
||||||
<Icon path={mdiMagnify} />
|
<Icon path={mdiMagnify} ariaHidden={true} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
{id}
|
|
||||||
{placeholder}
|
{placeholder}
|
||||||
role="combobox"
|
aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls={listboxId}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-controls={id}
|
autocomplete="off"
|
||||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
class:!pl-8={isActive}
|
||||||
class:!pl-8={isOpen}
|
|
||||||
class:!rounded-b-none={isOpen}
|
class:!rounded-b-none={isOpen}
|
||||||
class:cursor-pointer={!isOpen}
|
class:cursor-pointer={!isActive}
|
||||||
value={isOpen ? '' : selectedOption?.label || ''}
|
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||||
on:input={(e) => (searchQuery = e.currentTarget.value)}
|
id={inputId}
|
||||||
on:focus={handleClick}
|
on:click={activate}
|
||||||
on:blur={() => (inputFocused = false)}
|
on:focus={activate}
|
||||||
|
on:input={onInput}
|
||||||
|
role="combobox"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
use:shortcuts={[
|
||||||
|
{
|
||||||
|
shortcut: { key: 'ArrowUp' },
|
||||||
|
onShortcut: () => {
|
||||||
|
openDropdown();
|
||||||
|
void incrementSelectedIndex(-1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'ArrowDown' },
|
||||||
|
onShortcut: () => {
|
||||||
|
openDropdown();
|
||||||
|
void incrementSelectedIndex(1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'ArrowDown', alt: true },
|
||||||
|
onShortcut: () => {
|
||||||
|
openDropdown();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'Enter' },
|
||||||
|
onShortcut: () => {
|
||||||
|
if (selectedIndex !== undefined && filteredOptions.length > 0) {
|
||||||
|
onSelect(filteredOptions[selectedIndex]);
|
||||||
|
}
|
||||||
|
closeDropdown();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortcut: { key: 'Escape' },
|
||||||
|
onShortcut: () => {
|
||||||
|
closeDropdown();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -95,37 +190,51 @@
|
|||||||
>
|
>
|
||||||
{#if selectedOption}
|
{#if selectedOption}
|
||||||
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
|
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
|
||||||
<Icon path={mdiClose} />
|
<Icon path={mdiClose} ariaLabel="Clear value" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{:else if !isOpen}
|
{:else if !isOpen}
|
||||||
<Icon path={mdiUnfoldMoreHorizontal} />
|
<Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isOpen}
|
<ul
|
||||||
<div
|
role="listbox"
|
||||||
role="listbox"
|
id={listboxId}
|
||||||
transition:fly={{ duration: 250 }}
|
transition:fly={{ duration: 250 }}
|
||||||
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
|
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10"
|
||||||
>
|
class:border={isOpen}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if isOpen}
|
||||||
{#if filteredOptions.length === 0}
|
{#if filteredOptions.length === 0}
|
||||||
<div class="px-4 py-2 font-medium">No results</div>
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
{/if}
|
<li
|
||||||
{#each filteredOptions as option (option.label)}
|
role="option"
|
||||||
{@const selected = option.label === selectedOption?.label}
|
aria-selected={selectedIndex === 0}
|
||||||
<button
|
aria-disabled={true}
|
||||||
type="button"
|
class:bg-gray-100={selectedIndex === 0}
|
||||||
|
class:dark:bg-gray-700={selectedIndex === 0}
|
||||||
|
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default"
|
||||||
|
id={`${listboxId}-${0}`}
|
||||||
|
on:click={() => closeDropdown()}
|
||||||
|
>
|
||||||
|
No results
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#each filteredOptions as option, index (option.label)}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<li
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
|
bind:this={optionRefs[index]}
|
||||||
|
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
|
||||||
|
id={`${listboxId}-${index}`}
|
||||||
|
on:click={() => onSelect(option)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
|
||||||
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
|
||||||
class:bg-gray-300={selected}
|
|
||||||
class:dark:bg-gray-600={selected}
|
|
||||||
on:click={() => handleSelect(option)}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,8 +2,10 @@
|
|||||||
import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
|
import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
|
||||||
import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg';
|
import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg';
|
||||||
import logoNoText from '$lib/assets/immich-logo.svg';
|
import logoNoText from '$lib/assets/immich-logo.svg';
|
||||||
|
import { content as alternativeLogo } from '$lib/assets/immich-logo.json';
|
||||||
import { Theme } from '$lib/constants';
|
import { Theme } from '$lib/constants';
|
||||||
import { colorTheme } from '$lib/stores/preferences.store';
|
import { colorTheme } from '$lib/stores/preferences.store';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@ -14,11 +16,17 @@
|
|||||||
|
|
||||||
export let noText = false;
|
export let noText = false;
|
||||||
export let draggable = false;
|
export let draggable = false;
|
||||||
|
|
||||||
|
const today = DateTime.now().toLocal();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
{#if today.month === 4 && today.day === 1}
|
||||||
src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
|
<img src="data:image/png;base64, {alternativeLogo}" alt="Immich Logo" class="h-20" {draggable} />
|
||||||
alt="Immich Logo"
|
{:else}
|
||||||
{draggable}
|
<img
|
||||||
{...$$restProps}
|
src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
|
||||||
/>
|
alt="Immich Logo"
|
||||||
|
{draggable}
|
||||||
|
{...$$restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { shortcut } from '$lib/utils/shortcut';
|
||||||
|
|
||||||
export let value = '';
|
export let value = '';
|
||||||
export let grayTheme: boolean;
|
export let grayTheme: boolean;
|
||||||
@ -84,7 +85,16 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
|
<svelte:window
|
||||||
|
use:shortcut={{
|
||||||
|
shortcut: { key: 'Escape' },
|
||||||
|
onShortcut: () => {
|
||||||
|
onFocusOut();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}>
|
||||||
<form
|
<form
|
||||||
draggable="false"
|
draggable="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@ -118,6 +128,12 @@
|
|||||||
bind:this={input}
|
bind:this={input}
|
||||||
on:click={onFocusIn}
|
on:click={onFocusIn}
|
||||||
disabled={showFilter}
|
disabled={showFilter}
|
||||||
|
use:shortcut={{
|
||||||
|
shortcut: { key: 'Escape' },
|
||||||
|
onShortcut: () => {
|
||||||
|
onFocusOut();
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
|
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
|
||||||
|
@ -40,24 +40,24 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-camera-make"
|
id="camera-make"
|
||||||
options={toComboBoxOptions(makes)}
|
label="Make"
|
||||||
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.make = detail?.value)}
|
on:select={({ detail }) => (filters.make = detail?.value)}
|
||||||
|
options={toComboBoxOptions(makes)}
|
||||||
placeholder="Search camera make..."
|
placeholder="Search camera make..."
|
||||||
|
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-camera-model"
|
id="camera-model"
|
||||||
options={toComboBoxOptions(models)}
|
label="Model"
|
||||||
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.model = detail?.value)}
|
on:select={({ detail }) => (filters.model = detail?.value)}
|
||||||
|
options={toComboBoxOptions(models)}
|
||||||
placeholder="Search camera model..."
|
placeholder="Search camera model..."
|
||||||
|
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,35 +62,35 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-place-country"
|
id="location-country"
|
||||||
options={toComboBoxOptions(countries)}
|
label="Country"
|
||||||
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.country = detail?.value)}
|
on:select={({ detail }) => (filters.country = detail?.value)}
|
||||||
|
options={toComboBoxOptions(countries)}
|
||||||
placeholder="Search country..."
|
placeholder="Search country..."
|
||||||
|
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-place-state"
|
id="location-state"
|
||||||
options={toComboBoxOptions(states)}
|
label="State"
|
||||||
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.state = detail?.value)}
|
on:select={({ detail }) => (filters.state = detail?.value)}
|
||||||
|
options={toComboBoxOptions(states)}
|
||||||
placeholder="Search state..."
|
placeholder="Search state..."
|
||||||
|
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
id="search-place-city"
|
id="location-city"
|
||||||
options={toComboBoxOptions(cities)}
|
label="City"
|
||||||
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
|
||||||
on:select={({ detail }) => (filters.city = detail?.value)}
|
on:select={({ detail }) => (filters.city = detail?.value)}
|
||||||
|
options={toComboBoxOptions(cities)}
|
||||||
placeholder="Search city..."
|
placeholder="Search city..."
|
||||||
|
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
|
||||||
|
export let id: string;
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let comboboxPlaceholder: string;
|
export let comboboxPlaceholder: string;
|
||||||
export let subtitle = '';
|
export let subtitle = '';
|
||||||
@ -32,6 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Combobox
|
<Combobox
|
||||||
|
{id}
|
||||||
|
label={title}
|
||||||
|
hideLabel={true}
|
||||||
{selectedOption}
|
{selectedOption}
|
||||||
{options}
|
{options}
|
||||||
placeholder={comboboxPlaceholder}
|
placeholder={comboboxPlaceholder}
|
||||||
|
@ -86,6 +86,7 @@
|
|||||||
{#if $locale !== undefined}
|
{#if $locale !== undefined}
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<SettingCombobox
|
<SettingCombobox
|
||||||
|
id="custom-locale"
|
||||||
comboboxPlaceholder="Searching locales..."
|
comboboxPlaceholder="Searching locales..."
|
||||||
{selectedOption}
|
{selectedOption}
|
||||||
options={getAllLanguages()}
|
options={getAllLanguages()}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user