Add stacked image preview with subtle top peek

This commit is contained in:
laurenspriem
2025-09-04 10:49:37 +05:30
parent fb0179b081
commit b5febe9ba4
3 changed files with 154 additions and 108 deletions

View File

@@ -93,9 +93,11 @@ lib/ui/pages/library_culling/
- [x] Use circular undo icon as specified in feature plan
- [x] Double pressing the image in card should zoom in to image by pushing the `DetailPage` with hero animation (check `similar_images_page.dart` for example).
- [ ] Put the next image in the group to the right of the current image, slightly more opaque and slightly darkern/opaque and just the left most side, because I still want to use most space for the current image. After swiping the current image, the next image is animated (fast and smooth) from that position on the right to the center position as new current image.
- [x] Stack next image behind current image with darkening/opacity, peeking from top. Shows full image preview that animates forward when current is swiped.
- [ ] Make the undo button animate nicely to the previous photo, instead of this flicker. Think hard on how to do this animation.
- [ ] Animate going from last image in group to first image in next group
- [ ] Bug: when only having a single group, finishing it, and then canceling the delete, the complete checkmark animation stays on screen. Which is fine, but it doesn't disappear on pressing the undo button.
- [ ] Pressing the undo button when nothing is decided in current group should navigate the user to the last group with changes and undo a change there.
- [ ] Better placement of the instagram-like progress dots
## Remaining Tasks (Optional)

View File

@@ -205,7 +205,8 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
showChoiceDialog(
context,
title: AppLocalizations.of(context).deleteAllInGroup,
body: AppLocalizations.of(context).allImagesMarkedForDeletion(count: groupSize),
body: AppLocalizations.of(context)
.allImagesMarkedForDeletion(count: groupSize),
firstButtonLabel: AppLocalizations.of(context).confirm,
secondButtonLabel: AppLocalizations.of(context).reviewAgain,
isCritical: true,
@@ -245,9 +246,9 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
context,
title: AppLocalizations.of(context).deletePhotos,
body: AppLocalizations.of(context).deletePhotosBody(
count: filesToDelete.length.toString(),
size: formatBytes(totalSize),
),
count: filesToDelete.length.toString(),
size: formatBytes(totalSize),
),
firstButtonLabel: AppLocalizations.of(context).delete,
isCritical: true,
firstButtonOnTap: () async {
@@ -305,7 +306,7 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
break;
}
}
// Reset the controller to ensure clean state
controller.dispose();
controller = CardSwiperController();
@@ -368,13 +369,13 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
Widget _buildProgressDots(theme) {
final totalImages = currentGroupFiles.length;
if (totalImages == 0) return const SizedBox.shrink();
// Limit dots to max 10 for readability
const maxDots = 10;
final showAllDots = totalImages <= maxDots;
return SizedBox(
height: 8,
child: Row(
@@ -384,20 +385,20 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
showAllDots ? totalImages : maxDots,
(index) {
// For collapsed view, calculate which image this dot represents
final imageIndex = showAllDots
? index
: (index * totalImages / maxDots).floor();
final imageIndex =
showAllDots ? index : (index * totalImages / maxDots).floor();
final decision = decisions[currentGroupFiles[imageIndex]];
final isCurrent = showAllDots
final isCurrent = showAllDots
? index == currentImageIndex
: imageIndex <= currentImageIndex &&
(index == maxDots - 1 ||
((index + 1) * totalImages / maxDots).floor() > currentImageIndex);
: imageIndex <= currentImageIndex &&
(index == maxDots - 1 ||
((index + 1) * totalImages / maxDots).floor() >
currentImageIndex);
Color dotColor;
double dotSize = 6;
if (decision == SwipeDecision.delete) {
dotColor = theme.warning700;
} else if (decision == SwipeDecision.keep) {
@@ -408,7 +409,7 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
} else {
dotColor = theme.strokeFaint;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
width: dotSize,
@@ -423,7 +424,7 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
),
);
}
Future<void> _deleteFilesLogic(
Set<EnteFile> filesToDelete,
bool createSymlink,
@@ -517,9 +518,9 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
title: Text(AppLocalizations.of(context).congratulations),
content: Text(
AppLocalizations.of(context).deletedPhotosWithSize(
count: deletedCount.toString(),
size: formatBytes(totalSize),
),
count: deletedCount.toString(),
size: formatBytes(totalSize),
),
),
actions: [
TextButton(
@@ -568,7 +569,8 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
child: GestureDetector(
onTap: _showCompletionDialog,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8,),
decoration: BoxDecoration(
color: theme.warning500.withAlpha((0.1 * 255).toInt()),
borderRadius: BorderRadius.circular(8),
@@ -583,10 +585,11 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context).deleteWithCount(count: totalDeletionCount),
AppLocalizations.of(context)
.deleteWithCount(count: totalDeletionCount),
style: getEnteTextTheme(context).smallBold.copyWith(
color: theme.warning500,
),
color: theme.warning500,
),
),
],
),
@@ -625,62 +628,99 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
children: [
// Use a unique key for each group to force rebuild
CardSwiper(
key: ValueKey('swiper_${currentGroupIndex}_$currentImageIndex'),
controller: controller,
cardsCount:
currentGroupFiles.length - currentImageIndex,
numberOfCardsDisplayed: 1,
backCardOffset: const Offset(0, 0),
padding: const EdgeInsets.all(20.0),
cardBuilder: (
context,
index,
percentThresholdX,
percentThresholdY,
) {
final fileIndex = currentImageIndex + index;
if (fileIndex >= currentGroupFiles.length) {
// Return a placeholder container instead of SizedBox.shrink()
return Container(
decoration: BoxDecoration(
color: theme.backgroundBase,
borderRadius: BorderRadius.circular(16),
),
);
}
key: ValueKey(
'swiper_${currentGroupIndex}_$currentImageIndex',),
controller: controller,
cardsCount: currentGroupFiles.length -
currentImageIndex,
numberOfCardsDisplayed:
(currentGroupFiles.length -
currentImageIndex) >=
2
? 2
: 1, // Show 2 cards only if available
backCardOffset: const Offset(
0, -50,), // Very subtle peek from top
padding: const EdgeInsets.all(20.0),
cardBuilder: (
context,
index,
percentThresholdX,
percentThresholdY,
) {
final fileIndex = currentImageIndex + index;
if (fileIndex >= currentGroupFiles.length) {
// Return a placeholder container instead of SizedBox.shrink()
return Container(
decoration: BoxDecoration(
color: theme.backgroundBase,
borderRadius:
BorderRadius.circular(16),
),
);
}
final file = currentGroupFiles[fileIndex];
final file = currentGroupFiles[fileIndex];
final isBackCard = index >
0; // Check if this is the back card
// Calculate swipe progress for overlay effects
final swipeProgress = percentThresholdX / 100;
final isSwipingLeft = swipeProgress < -0.1;
final isSwipingRight = swipeProgress > 0.1;
// Calculate swipe progress for overlay effects
final swipeProgress =
percentThresholdX / 100;
final isSwipingLeft = swipeProgress < -0.1;
final isSwipingRight = swipeProgress > 0.1;
return SwipeablePhotoCard(
key: ValueKey(file.uploadedFileID ?? file.localID),
file: file,
swipeProgress: swipeProgress,
isSwipingLeft: isSwipingLeft,
isSwipingRight: isSwipingRight,
);
},
onSwipe: (previousIndex, currentIndex, direction) {
final decision =
direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
// Wrap back card with darkening/opacity effect
Widget card = SwipeablePhotoCard(
key: ValueKey(
file.uploadedFileID ?? file.localID,),
file: file,
swipeProgress:
isBackCard ? 0 : swipeProgress,
isSwipingLeft:
isBackCard ? false : isSwipingLeft,
isSwipingRight:
isBackCard ? false : isSwipingRight,
showFileInfo:
!isBackCard, // Hide file info for back card
);
// Handle the swipe decision
_handleSwipeDecision(decision);
// Apply darkening to the back card (no clipping, show full image)
if (isBackCard) {
card = ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withValues(
alpha: 0.4,), // Darken the preview
BlendMode.darken,
),
child: Opacity(
opacity:
0.6, // Make it more transparent
child: card,
),
);
}
return true;
},
onEnd: () {
// All cards in current group have been swiped
// This is handled in _handleSwipeDecision when reaching last card
},
isDisabled: false,
threshold: 50,
return card;
},
onSwipe:
(previousIndex, currentIndex, direction) {
final decision =
direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
// Handle the swipe decision
_handleSwipeDecision(decision);
return true;
},
onEnd: () {
// All cards in current group have been swiped
// This is handled in _handleSwipeDecision when reaching last card
},
isDisabled: false,
threshold: 50,
),
// Minimal celebration overlay
@@ -694,7 +734,7 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
color: theme.primary500,
)
.animate(
controller: _celebrationController,
controller: _celebrationController,
)
.scaleXY(
begin: 0.5,
@@ -712,7 +752,8 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
],
)
: Center(
child: Text(AppLocalizations.of(context).noImagesSelected),
child:
Text(AppLocalizations.of(context).noImagesSelected),
),
),
// Action buttons at bottom
@@ -720,7 +761,7 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
padding: const EdgeInsets.only(
left: 32,
right: 32,
bottom: 48,
bottom: 48,
top: 8,
),
child: Row(
@@ -764,8 +805,8 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
child: Center(
child: Icon(
Icons.close,
color: currentFile != null
? theme.warning700
color: currentFile != null
? theme.warning700
: theme.strokeFainter,
size: 32,
),
@@ -822,8 +863,8 @@ class _SwipeCullingPageState extends State<SwipeCullingPage>
child: Center(
child: Icon(
Icons.thumb_up_outlined,
color: currentFile != null
? theme.primary700
color: currentFile != null
? theme.primary700
: theme.strokeFainter,
size: 32,
),

View File

@@ -17,6 +17,7 @@ class SwipeablePhotoCard extends StatefulWidget {
final double swipeProgress;
final bool isSwipingLeft;
final bool isSwipingRight;
final bool showFileInfo;
const SwipeablePhotoCard({
super.key,
@@ -24,6 +25,7 @@ class SwipeablePhotoCard extends StatefulWidget {
this.swipeProgress = 0.0,
this.isSwipingLeft = false,
this.isSwipingRight = false,
this.showFileInfo = true,
});
@override
@@ -239,29 +241,30 @@ class _SwipeablePhotoCardState extends State<SwipeablePhotoCard> {
child: imageWidget,
),
),
// File info directly below the image
Container(
width: maxWidth,
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
fileName,
style: textTheme.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
fileSize,
style: textTheme.small.copyWith(color: theme.textMuted),
textAlign: TextAlign.center,
),
],
// File info directly below the image (only if showFileInfo is true)
if (widget.showFileInfo)
Container(
width: maxWidth,
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
fileName,
style: textTheme.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
fileSize,
style: textTheme.small.copyWith(color: theme.textMuted),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
),