fix(mobile): prevent spam taps in memories viewer (#32869)

* fix(mobile): improve memory tap navigation

* fix(mobile): preserve memory animations and simplify tap locking
This commit is contained in:
Stellan Sun authored and Codex committed 2026-11-06 21:35:53 +03:00
1 parent b3414d7595
commit a5678e0d24
2 files changed
+246 -141

No files matched your search

+174 -141
View File
@@ -39,6 +39,10 @@ class MemoryPage extends HookConsumerWidget {
final assetProgress = useState("${currentAssetPage.value + 1}|${currentMemory.value.assets.length}");
const bgColor = Colors.black;
final currentAsset = useState<RemoteAsset?>(null);
final isTapScrolling = useState(false);
const nextPageDelta = 1;
const previousPageDelta = -1;
/// The list of all of the asset page controllers
final memoryAssetPageControllers = List.generate(memories.length, (i) => usePageController());
@@ -55,14 +59,40 @@ class MemoryPage extends HookConsumerWidget {
};
}, const []);
void unlockTapScrollingFlag() {
if (context.mounted) {
isTapScrolling.value = false;
}
}
bool animateToPage(PageController controller, int pageDelta, Curve curve) {
if (isTapScrolling.value) {
return false;
}
// Lock only when clicking to trigger page flipping, gesture sliding does not lock
isTapScrolling.value = true;
unawaited(
controller
.animateToPage(
controller.page!.round() + pageDelta,
duration: const Duration(milliseconds: 500),
curve: curve,
)
.whenComplete(unlockTapScrollingFlag),
);
return true;
}
void toNextMemory() {
unawaited(memoryPageController.nextPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn));
animateToPage(memoryPageController, nextPageDelta, Curves.easeIn);
}
void toPreviousMemory() {
if (currentMemoryIndex.value > 0) {
// Move to the previous memory page
unawaited(memoryPageController.previousPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn));
if (!animateToPage(memoryPageController, previousPageDelta, Curves.easeIn)) {
return;
}
// Wait for the next frame to ensure the page is built
SchedulerBinding.instance.addPostFrameCallback((_) {
@@ -89,7 +119,7 @@ class MemoryPage extends HookConsumerWidget {
// Go to the next asset
final PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
unawaited(controller.nextPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500)));
animateToPage(controller, nextPageDelta, Curves.easeInOut);
} else {
// Go to the next memory since we are at the end of our assets
toNextMemory();
@@ -101,7 +131,7 @@ class MemoryPage extends HookConsumerWidget {
// Go to the previous asset
final PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
unawaited(controller.previousPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500)));
animateToPage(controller, previousPageDelta, Curves.easeInOut);
} else {
// Go to the previous memory since we are at the end of our assets
toPreviousMemory();
@@ -209,155 +239,158 @@ class MemoryPage extends HookConsumerWidget {
child: Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
scrollDirection: Axis.vertical,
controller: memoryPageController,
onPageChanged: (pageNumber) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
if (pageNumber < memories.length) {
currentMemoryIndex.value = pageNumber;
currentMemory.value = memories[pageNumber];
child: AbsorbPointer(
absorbing: isTapScrolling.value,
child: PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
scrollDirection: Axis.vertical,
controller: memoryPageController,
onPageChanged: (pageNumber) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
if (pageNumber < memories.length) {
currentMemoryIndex.value = pageNumber;
currentMemory.value = memories[pageNumber];
WidgetsBinding.instance.addPostFrameCallback((_) {
MemoryPage.setMemory(ref, memories[pageNumber]);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
MemoryPage.setMemory(ref, memories[pageNumber]);
});
// Update currentAsset to the first asset of the new memory
if (memories[pageNumber].assets.isNotEmpty) {
currentAsset.value = memories[pageNumber].assets.first;
// Update currentAsset to the first asset of the new memory
if (memories[pageNumber].assets.isNotEmpty) {
currentAsset.value = memories[pageNumber].assets.first;
}
}
}
currentAssetPage.value = 0;
currentAssetPage.value = 0;
updateProgressText();
},
itemCount: memories.length + 1,
itemBuilder: (context, mIndex) {
// Build last page
if (mIndex == memories.length) {
return MemoryEpilogue(
onStartOver: () => memoryPageController.animateToPage(
0,
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
),
);
}
updateProgressText();
},
itemCount: memories.length + 1,
itemBuilder: (context, mIndex) {
// Build last page
if (mIndex == memories.length) {
return MemoryEpilogue(
onStartOver: () => memoryPageController.animateToPage(
0,
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
),
);
}
// Build horizontal page
final assetController = memoryAssetPageControllers[mIndex];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 24.0, top: 8.0, bottom: 2.0),
child: AnimatedBuilder(
// Build horizontal page
final assetController = memoryAssetPageControllers[mIndex];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 24.0, top: 8.0, bottom: 2.0),
child: AnimatedBuilder(
animation: assetController,
builder: (context, child) {
double value = 0.0;
if (assetController.hasClients) {
// We can only access [page] if this has clients
value = assetController.page ?? 0;
}
return MemoryProgressIndicator(
ticks: memories[mIndex].assets.length,
value: (value + 1) / memories[mIndex].assets.length,
);
},
),
),
Expanded(
child: Stack(
children: [
PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
controller: assetController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Stack(
children: [
ColoredBox(
color: Colors.black,
child: MemoryCard(
asset: asset,
memory: memories[mIndex],
showTitle: index == 0,
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
),
),
Positioned.fill(
child: Row(
children: [
// Left side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toPreviousAsset(index);
},
),
),
// Right side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toNextAsset(index);
},
),
),
],
),
),
],
);
},
),
Positioned(
top: 8,
left: 8,
child: MaterialButton(
minWidth: 0,
onPressed: () {
// auto_route doesn't invoke pop scope, so
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
unawaited(context.maybePop());
unawaited(restoreEdgeToEdge());
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
elevation: 0,
child: const Icon(Icons.close_rounded, color: Colors.white),
),
),
if (currentAsset.value != null && currentAsset.value!.isVideo)
Positioned(
bottom: 24,
right: 32,
child: Icon(Icons.videocam_outlined, color: Colors.grey[200]),
),
],
),
),
AnimatedBuilder(
animation: assetController,
builder: (context, child) {
double value = 0.0;
int assetIndex = 0;
if (assetController.hasClients) {
// We can only access [page] if this has clients
value = assetController.page ?? 0;
assetIndex = (assetController.page ?? 0).round().clamp(0, memories[mIndex].assets.length - 1);
}
return MemoryProgressIndicator(
ticks: memories[mIndex].assets.length,
value: (value + 1) / memories[mIndex].assets.length,
);
return MemoryBottomInfo(memory: memories[mIndex], asset: memories[mIndex].assets[assetIndex]);
},
),
),
Expanded(
child: Stack(
children: [
PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
controller: assetController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Stack(
children: [
ColoredBox(
color: Colors.black,
child: MemoryCard(
asset: asset,
memory: memories[mIndex],
showTitle: index == 0,
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
),
),
Positioned.fill(
child: Row(
children: [
// Left side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toPreviousAsset(index);
},
),
),
// Right side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toNextAsset(index);
},
),
),
],
),
),
],
);
},
),
Positioned(
top: 8,
left: 8,
child: MaterialButton(
minWidth: 0,
onPressed: () {
// auto_route doesn't invoke pop scope, so
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
unawaited(context.maybePop());
unawaited(restoreEdgeToEdge());
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
elevation: 0,
child: const Icon(Icons.close_rounded, color: Colors.white),
),
),
if (currentAsset.value != null && currentAsset.value!.isVideo)
Positioned(
bottom: 24,
right: 32,
child: Icon(Icons.videocam_outlined, color: Colors.grey[200]),
),
],
),
),
AnimatedBuilder(
animation: assetController,
builder: (context, child) {
int assetIndex = 0;
if (assetController.hasClients) {
assetIndex = (assetController.page ?? 0).round().clamp(0, memories[mIndex].assets.length - 1);
}
return MemoryBottomInfo(memory: memories[mIndex], asset: memories[mIndex].assets[assetIndex]);
},
),
],
);
},
],
);
},
),
),
),
),
@@ -0,0 +1,72 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/presentation/pages/memory.page.dart';
import 'package:mocktail/mocktail.dart';
import '../../service.mocks.dart';
import '../factories/remote_asset_factory.dart';
import 'presentation_context.dart';
void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() async => await context.dispose());
Memory newMemory({required MemoryTypeEnum type, required DateTime memoryAt, String? personName}) => Memory(
id: 'memory-${memoryAt.toIso8601String()}',
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
ownerId: 'user-1',
type: type,
data: MemoryData(year: memoryAt.year, personName: personName),
isSaved: false,
memoryAt: memoryAt,
assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create(), RemoteAssetFactory.create()],
);
Future<void> pumpPage(WidgetTester tester, List<Memory> memories) =>
tester.pumpTestWidget(context, MemoryPage(memories: memories, memoryIndex: 0), expectSettle: false);
group('MemoryPage', () {
testWidgets('Ignore repeated clicks during the animation triggered by clicking, '
'and allow page flipping to continue after the end', (tester) async {
final Memory memory = newMemory(type: MemoryTypeEnum.onThisDay, memoryAt: DateTime(2025, 10, 7));
final MockAssetService assetService = context.service.asset.service;
when(() => assetService.watchAsset(memory.assets[0])).thenAnswer((_) => const Stream<BaseAsset?>.empty());
when(() => assetService.watchAsset(memory.assets[1])).thenAnswer((_) => const Stream<BaseAsset?>.empty());
when(() => assetService.watchAsset(memory.assets[2])).thenAnswer((_) => const Stream<BaseAsset?>.empty());
await pumpPage(tester, [memory]);
await tester.pump();
await tester.tapAt(const Offset(760.0, 500.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.tapAt(const Offset(760.0, 500.0));
await tester.pumpAndSettle();
final finder = find.byWidgetPredicate(
(widget) => widget is PageView && widget.scrollDirection == Axis.horizontal,
);
final pageView = tester.widget<PageView>(finder);
final controller = pageView.controller!;
expect(controller.page, 1);
// normal process
await tester.pump();
await tester.tapAt(const Offset(760.0, 500.0));
await tester.pumpAndSettle();
expect(controller.page, 2);
await tester.pump(const Duration(milliseconds: 2000));
});
});
}