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:
|
||||
|
||||
- [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/)
|
||||
- [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
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.11.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz",
|
||||
"integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==",
|
||||
"version": "20.11.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz",
|
||||
"integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"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) {
|
||||
final peopleProvider =
|
||||
ref.watch(assetPeopleNotifierProvider(asset).notifier);
|
||||
final people = ref.watch(assetPeopleNotifierProvider(asset));
|
||||
final double imageSize = math.min(context.width / 3, 150);
|
||||
final people = ref
|
||||
.watch(assetPeopleNotifierProvider(asset))
|
||||
.value
|
||||
?.where((p) => !p.isHidden);
|
||||
final double imageSize = math.min(context.width / 3, 120);
|
||||
|
||||
showPersonNameEditModel(
|
||||
String personId,
|
||||
@ -40,14 +43,13 @@ class ExifPeople extends ConsumerWidget {
|
||||
});
|
||||
}
|
||||
|
||||
if (people.value?.isEmpty ?? true) {
|
||||
if (people?.isEmpty ?? true) {
|
||||
// Empty list or loading
|
||||
return Container();
|
||||
}
|
||||
|
||||
final curatedPeople = people.value
|
||||
?.map((p) => CuratedContent(id: p.id, label: p.name))
|
||||
.toList() ??
|
||||
final curatedPeople =
|
||||
people?.map((p) => CuratedContent(id: p.id, label: p.name)).toList() ??
|
||||
[];
|
||||
|
||||
return Column(
|
||||
|
@ -5,11 +5,9 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
class AvailableAlbum {
|
||||
final AssetPathEntity albumEntity;
|
||||
final DateTime? lastBackup;
|
||||
final Uint8List? thumbnailData;
|
||||
AvailableAlbum({
|
||||
required this.albumEntity,
|
||||
this.lastBackup,
|
||||
this.thumbnailData,
|
||||
});
|
||||
|
||||
AvailableAlbum copyWith({
|
||||
@ -20,7 +18,6 @@ class AvailableAlbum {
|
||||
return AvailableAlbum(
|
||||
albumEntity: albumEntity ?? this.albumEntity,
|
||||
lastBackup: lastBackup ?? this.lastBackup,
|
||||
thumbnailData: thumbnailData ?? this.thumbnailData,
|
||||
);
|
||||
}
|
||||
|
||||
@ -34,7 +31,7 @@ class AvailableAlbum {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
|
||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
|
@ -234,34 +234,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
final assetCountInAlbum = await album.assetCountAsync;
|
||||
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
|
||||
// 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);
|
||||
|
||||
final List<BackupAlbum> excludedBackupAlbums =
|
||||
|
@ -11,17 +11,16 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class AlbumInfoCard extends HookConsumerWidget {
|
||||
final Uint8List? imageData;
|
||||
final AvailableAlbum albumInfo;
|
||||
final AvailableAlbum album;
|
||||
|
||||
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
|
||||
const AlbumInfoCard({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isSelected =
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||
final bool isExcluded =
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
|
||||
ColorFilter selectedFilter = ColorFilter.mode(
|
||||
@ -82,9 +81,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isSelected) {
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||
} else {
|
||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||
}
|
||||
},
|
||||
onDoubleTap: () {
|
||||
@ -92,13 +91,11 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
|
||||
if (isExcluded) {
|
||||
// Remove from exclude album list
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||
} else {
|
||||
// Add to exclude album list
|
||||
|
||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
||||
if (album.id == 'isAll' || album.name == 'Recents') {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Cannot exclude album contains all assets',
|
||||
@ -108,9 +105,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.addExcludedAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
@ -136,14 +131,12 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
children: [
|
||||
ColorFiltered(
|
||||
colorFilter: buildImageFilter(),
|
||||
child: Image(
|
||||
child: const Image(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
image: imageData != null
|
||||
? MemoryImage(imageData!)
|
||||
: const AssetImage(
|
||||
image: AssetImage(
|
||||
'assets/immich-logo.png',
|
||||
) as ImageProvider,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
@ -168,7 +161,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
albumInfo.name,
|
||||
album.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: context.primaryColor,
|
||||
@ -182,7 +175,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
snapshot.data.toString() +
|
||||
(albumInfo.isAll
|
||||
(album.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
@ -193,7 +186,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
}
|
||||
return const Text("0");
|
||||
}),
|
||||
future: albumInfo.assetCount,
|
||||
future: album.assetCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -202,7 +195,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.pushRoute(
|
||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
||||
AlbumPreviewRoute(album: album.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
|
@ -11,47 +11,26 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class AlbumInfoListTile extends HookConsumerWidget {
|
||||
final Uint8List? imageData;
|
||||
final AvailableAlbum albumInfo;
|
||||
final AvailableAlbum album;
|
||||
|
||||
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
|
||||
const AlbumInfoListTile({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isSelected =
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||
final bool isExcluded =
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
|
||||
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);
|
||||
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||
var assetCount = useState(0);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
albumInfo.assetCount.then((value) => assetCount.value = value);
|
||||
album.assetCount.then((value) => assetCount.value = value);
|
||||
return null;
|
||||
},
|
||||
[albumInfo],
|
||||
[album],
|
||||
);
|
||||
|
||||
buildImageFilter() {
|
||||
if (isSelected) {
|
||||
return selectedFilter;
|
||||
} else if (isExcluded) {
|
||||
return excludedFilter;
|
||||
} else {
|
||||
return unselectedFilter;
|
||||
}
|
||||
}
|
||||
|
||||
buildTileColor() {
|
||||
if (isSelected) {
|
||||
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(
|
||||
onDoubleTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isExcluded) {
|
||||
// Remove from exclude album list
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||
} else {
|
||||
// Add to exclude album list
|
||||
|
||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
||||
if (album.id == 'isAll' || album.name == 'Recents') {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Cannot exclude album contains all assets',
|
||||
@ -88,9 +86,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.addExcludedAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
|
||||
}
|
||||
},
|
||||
child: ListTile(
|
||||
@ -99,33 +95,14 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
if (isSelected) {
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||
} else {
|
||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
||||
ref.read(backupProvider.notifier).addAlbumForBackup(album);
|
||||
}
|
||||
},
|
||||
leading: ClipRRect(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
leading: buildIcon(),
|
||||
title: Text(
|
||||
albumInfo.name,
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
@ -135,7 +112,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||
trailing: IconButton(
|
||||
onPressed: () {
|
||||
context.pushRoute(
|
||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
||||
AlbumPreviewRoute(album: album.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
|
@ -43,10 +43,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
((context, index) {
|
||||
var thumbnailData = albums[index].thumbnailData;
|
||||
return AlbumInfoListTile(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: albums[index],
|
||||
album: albums[index],
|
||||
);
|
||||
}),
|
||||
childCount: albums.length,
|
||||
@ -74,10 +72,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: ((context, index) {
|
||||
var thumbnailData = albums[index].thumbnailData;
|
||||
return AlbumInfoCard(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: albums[index],
|
||||
album: albums[index],
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
@ -26,7 +26,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
|
||||
|
||||
final didGetBackupInfo = useState(false);
|
||||
bool hasExclusiveAccess =
|
||||
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||
@ -38,11 +38,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
|
||||
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
|
||||
// have the latest, since the platform channel will not update
|
||||
// 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() {
|
||||
var text = "backup_controller_page_backup_selected".tr();
|
||||
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(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
@ -297,7 +313,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
||||
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:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_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/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_placeholder.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||
|
||||
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ScrollOffsetController _scrollOffsetController =
|
||||
ScrollOffsetController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
final Set<Asset> _selectedAssets =
|
||||
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() {
|
||||
return Set.from(_selectedAssets);
|
||||
}
|
||||
@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
|
||||
void _selectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
if (_dragging) {
|
||||
_draggedAssets.addAll(assets);
|
||||
}
|
||||
_selectedAssets.addAll(assets);
|
||||
_callSelectionListener(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
_selectedAssets.removeAll(
|
||||
assets.where(
|
||||
final assetsToDeselect = assets.where(
|
||||
(a) =>
|
||||
widget.canDeselect ||
|
||||
!(widget.preselectedAssets?.contains(a) ?? false),
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_selectedAssets.removeAll(assetsToDeselect);
|
||||
if (_dragging) {
|
||||
_draggedAssets.removeAll(assetsToDeselect);
|
||||
}
|
||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||
});
|
||||
}
|
||||
@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
_dragAnchorAssetIndex = null;
|
||||
_dragAnchorSectionIndex = null;
|
||||
_draggedAssets.clear();
|
||||
_dragging = false;
|
||||
if (!widget.canDeselect &&
|
||||
widget.preselectedAssets != null &&
|
||||
widget.preselectedAssets!.isNotEmpty) {
|
||||
@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
selectedAssets: _selectedAssets,
|
||||
selectionActive: widget.selectionActive,
|
||||
sectionIndex: index,
|
||||
section: section,
|
||||
margin: widget.margin,
|
||||
renderList: widget.renderList,
|
||||
@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
itemBuilder: _itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
scrollOffsetController: _scrollOffsetController,
|
||||
itemCount: widget.renderList.elements.length +
|
||||
(widget.topWidget != null ? 1 : 0),
|
||||
addRepaintBoundaries: true,
|
||||
@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
if (widget.visibleItemsListener != null) {
|
||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||
}
|
||||
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildAssetGrid(),
|
||||
AssetDragRegion(
|
||||
onStart: _setDragStartIndex,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: _stopDrag,
|
||||
onScroll: _dragDragScroll,
|
||||
onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => controlBottomAppBarNotifier.minimize(),
|
||||
),
|
||||
child: _buildAssetGrid(),
|
||||
),
|
||||
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
||||
_buildMultiSelectIndicator(),
|
||||
],
|
||||
@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget {
|
||||
/// A section for the render grid
|
||||
class _Section extends StatelessWidget {
|
||||
final RenderAssetGridElement section;
|
||||
final int sectionIndex;
|
||||
final Set<Asset> selectedAssets;
|
||||
final bool scrolling;
|
||||
final double margin;
|
||||
@ -377,6 +512,7 @@ class _Section extends StatelessWidget {
|
||||
|
||||
const _Section({
|
||||
required this.section,
|
||||
required this.sectionIndex,
|
||||
required this.scrolling,
|
||||
required this.margin,
|
||||
required this.assetsPerRow,
|
||||
@ -435,6 +571,8 @@ class _Section extends StatelessWidget {
|
||||
)
|
||||
: _AssetRow(
|
||||
key: ValueKey(i),
|
||||
rowStartIndex: i * assetsPerRow,
|
||||
sectionIndex: sectionIndex,
|
||||
assets: assetsToRender.nestedSlice(
|
||||
i * assetsPerRow,
|
||||
min((i + 1) * assetsPerRow, section.count),
|
||||
@ -522,6 +660,8 @@ class _Title extends StatelessWidget {
|
||||
/// The row of assets
|
||||
class _AssetRow extends StatelessWidget {
|
||||
final List<Asset> assets;
|
||||
final int rowStartIndex;
|
||||
final int sectionIndex;
|
||||
final Set<Asset> selectedAssets;
|
||||
final int absoluteOffset;
|
||||
final double width;
|
||||
@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget {
|
||||
|
||||
const _AssetRow({
|
||||
super.key,
|
||||
required this.rowStartIndex,
|
||||
required this.sectionIndex,
|
||||
required this.assets,
|
||||
required this.absoluteOffset,
|
||||
required this.width,
|
||||
@ -594,6 +736,9 @@ class _AssetRow extends StatelessWidget {
|
||||
bottom: margin,
|
||||
right: last ? 0.0 : margin,
|
||||
),
|
||||
child: AssetIndexWrapper(
|
||||
rowIndex: rowStartIndex + index,
|
||||
sectionIndex: sectionIndex,
|
||||
child: ThumbnailImage(
|
||||
asset: asset,
|
||||
index: absoluteOffset + index,
|
||||
@ -607,6 +752,7 @@ class _AssetRow extends StatelessWidget {
|
||||
heroOffset: heroOffset,
|
||||
showStack: showStack,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.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/ui/drag_sheet.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()? onFavorite;
|
||||
final void Function()? onArchive;
|
||||
@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
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(
|
||||
Function(bool) deleteCb, {
|
||||
@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
controller: scrollController,
|
||||
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
||||
minChildSize: bottomPadding,
|
||||
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
||||
|
@ -23,7 +23,7 @@ class CuratedPeopleRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const imageSize = 85.0;
|
||||
const imageSize = 80.0;
|
||||
|
||||
// Guard empty [content]
|
||||
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:immich_mobile/extensions/build_context_extensions.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/user_circle_avatar.dart';
|
||||
|
||||
@ -26,6 +27,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final bool isEnableAutoBackup =
|
||||
backupState.backgroundBackup || backupState.autoBackup;
|
||||
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
||||
final immichLogo = ref.watch(immichLogoProvider);
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
const widgetSize = 30.0;
|
||||
@ -152,14 +154,29 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
builder: (BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
height: 30,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final today = DateTime.now();
|
||||
if (today.month == 4 && today.day == 1) {
|
||||
if (immichLogo.value == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
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 { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
|
||||
import { getKey } from '$lib/utils';
|
||||
export let asset: AssetResponseDto;
|
||||
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
@ -14,7 +14,7 @@
|
||||
};
|
||||
</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
|
||||
size={'sm'}
|
||||
rounded={false}
|
||||
|
@ -83,8 +83,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label for="timezone">Timezone</label>
|
||||
<Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." />
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
id="settings-timezone"
|
||||
label="Timezone"
|
||||
options={timezones}
|
||||
placeholder="Search timezone..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialogue>
|
||||
|
@ -11,48 +11,93 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
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 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 selectedOption: ComboBoxOption | undefined;
|
||||
export let placeholder = '';
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dropdown autocomplete list should be visible.
|
||||
*/
|
||||
let isOpen = false;
|
||||
let inputFocused = false;
|
||||
/**
|
||||
* Keeps track of whether the combobox is actively being used.
|
||||
*/
|
||||
let isActive = false;
|
||||
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()));
|
||||
|
||||
$: {
|
||||
searchQuery = selectedOption ? selectedOption.label : '';
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: ComboBoxOption | undefined;
|
||||
click: void;
|
||||
}>();
|
||||
|
||||
const handleClick = () => {
|
||||
searchQuery = '';
|
||||
const activate = () => {
|
||||
isActive = true;
|
||||
openDropdown();
|
||||
};
|
||||
|
||||
const deactivate = () => {
|
||||
searchQuery = selectedOption ? selectedOption.label : '';
|
||||
isActive = false;
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
isOpen = true;
|
||||
inputFocused = true;
|
||||
dispatch('click');
|
||||
};
|
||||
|
||||
let handleOutClick = () => {
|
||||
// In rare cases it's possible for the input to still have focus and
|
||||
// outclick to fire.
|
||||
if (!inputFocused) {
|
||||
const closeDropdown = () => {
|
||||
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' });
|
||||
};
|
||||
|
||||
let handleSelect = (option: ComboBoxOption) => {
|
||||
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);
|
||||
isOpen = false;
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
@ -62,30 +107,80 @@
|
||||
};
|
||||
</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>
|
||||
{#if isOpen}
|
||||
{#if isActive}
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<div class="dark:text-immich-dark-fg/75">
|
||||
<Icon path={mdiMagnify} />
|
||||
<Icon path={mdiMagnify} ariaHidden={true} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
{id}
|
||||
{placeholder}
|
||||
role="combobox"
|
||||
aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={id}
|
||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||
class:!pl-8={isOpen}
|
||||
autocomplete="off"
|
||||
class:!pl-8={isActive}
|
||||
class:!rounded-b-none={isOpen}
|
||||
class:cursor-pointer={!isOpen}
|
||||
value={isOpen ? '' : selectedOption?.label || ''}
|
||||
on:input={(e) => (searchQuery = e.currentTarget.value)}
|
||||
on:focus={handleClick}
|
||||
on:blur={() => (inputFocused = false)}
|
||||
class:cursor-pointer={!isActive}
|
||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||
id={inputId}
|
||||
on:click={activate}
|
||||
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
|
||||
@ -95,37 +190,51 @@
|
||||
>
|
||||
{#if selectedOption}
|
||||
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
|
||||
<Icon path={mdiClose} />
|
||||
<Icon path={mdiClose} ariaLabel="Clear value" />
|
||||
</IconButton>
|
||||
{:else if !isOpen}
|
||||
<Icon path={mdiUnfoldMoreHorizontal} />
|
||||
<Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
<ul
|
||||
role="listbox"
|
||||
id={listboxId}
|
||||
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}
|
||||
<div class="px-4 py-2 font-medium">No results</div>
|
||||
{/if}
|
||||
{#each filteredOptions as option (option.label)}
|
||||
{@const selected = option.label === selectedOption?.label}
|
||||
<button
|
||||
type="button"
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={selectedIndex === 0}
|
||||
aria-disabled={true}
|
||||
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"
|
||||
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}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -2,8 +2,10 @@
|
||||
import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
|
||||
import logoLightUrl from '$lib/assets/immich-logo-inline-light.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 { colorTheme } from '$lib/stores/preferences.store';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@ -14,11 +16,17 @@
|
||||
|
||||
export let noText = false;
|
||||
export let draggable = false;
|
||||
|
||||
const today = DateTime.now().toLocal();
|
||||
</script>
|
||||
|
||||
{#if today.month === 4 && today.day === 1}
|
||||
<img src="data:image/png;base64, {alternativeLogo}" alt="Immich Logo" class="h-20" {draggable} />
|
||||
{:else}
|
||||
<img
|
||||
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 { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { shortcut } from '$lib/utils/shortcut';
|
||||
|
||||
export let value = '';
|
||||
export let grayTheme: boolean;
|
||||
@ -84,7 +85,16 @@
|
||||
};
|
||||
</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
|
||||
draggable="false"
|
||||
autocomplete="off"
|
||||
@ -118,6 +128,12 @@
|
||||
bind:this={input}
|
||||
on:click={onFocusIn}
|
||||
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">
|
||||
|
@ -40,24 +40,24 @@
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
||||
<Combobox
|
||||
id="search-camera-make"
|
||||
options={toComboBoxOptions(makes)}
|
||||
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
||||
id="camera-make"
|
||||
label="Make"
|
||||
on:select={({ detail }) => (filters.make = detail?.value)}
|
||||
options={toComboBoxOptions(makes)}
|
||||
placeholder="Search camera make..."
|
||||
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
||||
<Combobox
|
||||
id="search-camera-model"
|
||||
options={toComboBoxOptions(models)}
|
||||
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
||||
id="camera-model"
|
||||
label="Model"
|
||||
on:select={({ detail }) => (filters.model = detail?.value)}
|
||||
options={toComboBoxOptions(models)}
|
||||
placeholder="Search camera model..."
|
||||
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -62,35 +62,35 @@
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
||||
<Combobox
|
||||
id="search-place-country"
|
||||
options={toComboBoxOptions(countries)}
|
||||
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
||||
id="location-country"
|
||||
label="Country"
|
||||
on:select={({ detail }) => (filters.country = detail?.value)}
|
||||
options={toComboBoxOptions(countries)}
|
||||
placeholder="Search country..."
|
||||
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
||||
<Combobox
|
||||
id="search-place-state"
|
||||
options={toComboBoxOptions(states)}
|
||||
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
||||
id="location-state"
|
||||
label="State"
|
||||
on:select={({ detail }) => (filters.state = detail?.value)}
|
||||
options={toComboBoxOptions(states)}
|
||||
placeholder="Search state..."
|
||||
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
||||
<Combobox
|
||||
id="search-place-city"
|
||||
options={toComboBoxOptions(cities)}
|
||||
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
||||
id="location-city"
|
||||
label="City"
|
||||
on:select={({ detail }) => (filters.city = detail?.value)}
|
||||
options={toComboBoxOptions(cities)}
|
||||
placeholder="Search city..."
|
||||
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { fly } from 'svelte/transition';
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
|
||||
export let id: string;
|
||||
export let title: string;
|
||||
export let comboboxPlaceholder: string;
|
||||
export let subtitle = '';
|
||||
@ -32,6 +33,9 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Combobox
|
||||
{id}
|
||||
label={title}
|
||||
hideLabel={true}
|
||||
{selectedOption}
|
||||
{options}
|
||||
placeholder={comboboxPlaceholder}
|
||||
|
@ -86,6 +86,7 @@
|
||||
{#if $locale !== undefined}
|
||||
<div class="ml-4">
|
||||
<SettingCombobox
|
||||
id="custom-locale"
|
||||
comboboxPlaceholder="Searching locales..."
|
||||
{selectedOption}
|
||||
options={getAllLanguages()}
|
||||
|
Loading…
x
Reference in New Issue
Block a user