fix(mobile): rewrite slideshow controller system (#30771)
* fix(mobile): rewrite slideshow controller system * Normalize playback status changes * Improve preloading assets * Added controller and widget tests * Minor fixes from PR comments * More minor fixes * Move slideshow test after rename
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -14,8 +12,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_progress_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_slide.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
@@ -35,38 +34,74 @@ class SlideshowPage extends ConsumerStatefulWidget {
|
||||
ConsumerState<SlideshowPage> createState() => _SlideshowPageState();
|
||||
}
|
||||
|
||||
class _SlideshowPageState extends ConsumerState<SlideshowPage> with SingleTickerProviderStateMixin {
|
||||
static const double _kenBurnsZoom = 0.1;
|
||||
|
||||
late SlideshowConfig _config;
|
||||
class _SlideshowPageState extends ConsumerState<SlideshowPage>
|
||||
with TickerProviderStateMixin
|
||||
implements SlideshowDelegate {
|
||||
late final SlideshowController _slideshow;
|
||||
late final PageController _pageController;
|
||||
late final Stopwatch _stopwatch;
|
||||
late Timer _timer;
|
||||
late int _index;
|
||||
late int _nextIndex;
|
||||
bool _paused = false;
|
||||
bool _showAppBar = false;
|
||||
|
||||
late final AnimationController _crossfadeController;
|
||||
late final Animation<double> _crossfadeOpacity;
|
||||
int? _crossfadeFromIndex;
|
||||
int? _crossfadeToIndex;
|
||||
int _zoomCycle = 0;
|
||||
late final AnimationController _fade;
|
||||
late final Animation<double> _fadeOut;
|
||||
|
||||
/// While non-null, a frozen copy of this slide is fading out over the live page.
|
||||
int? _fadingSlideIndex;
|
||||
|
||||
bool _showAppBar = false;
|
||||
bool _disableAnimations = false;
|
||||
|
||||
SlideshowConfig get _config => ref.read(appConfigProvider).slideshow;
|
||||
|
||||
BaseAsset? _assetAt(int index) => widget.timeline.getAssetSafe(index);
|
||||
|
||||
BaseAsset? _videoAt(int index) {
|
||||
final asset = _assetAt(index);
|
||||
|
||||
if (asset == null || asset.isImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
VideoPlayerState _videoState(BaseAsset asset) => ref.read(videoPlayerProvider(asset.id));
|
||||
|
||||
VideoPlayerNotifier? _videoNotifier(int index) {
|
||||
final video = _videoAt(index);
|
||||
|
||||
if (video == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ref.read(videoPlayerProvider(video.id).notifier);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
|
||||
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
|
||||
_pageController = PageController(initialPage: _index);
|
||||
_crossfadeController = AnimationController(vsync: this, duration: Durations.extralong2);
|
||||
_crossfadeOpacity = Tween<double>(begin: 1.0, end: 0.0).animate(_crossfadeController);
|
||||
_stopwatch = Stopwatch();
|
||||
_createTimer();
|
||||
_updateNextIndex();
|
||||
ref.listenManual(appConfigProvider.select((s) => s.slideshow), _onConfigChanged);
|
||||
final assetIndex = asset != null ? widget.timeline.getIndex(asset.heroTag) : null;
|
||||
final initialIndex = assetIndex ?? 0;
|
||||
|
||||
_pageController = PageController(initialPage: initialIndex);
|
||||
_fade = AnimationController(vsync: this, duration: Durations.extralong2);
|
||||
_fadeOut = _fade.drive(Tween(begin: 1.0, end: 0.0));
|
||||
|
||||
_slideshow = SlideshowController(
|
||||
vsync: this,
|
||||
slideDuration: Duration(seconds: _config.duration),
|
||||
initialIndex: initialIndex,
|
||||
delegate: this,
|
||||
);
|
||||
|
||||
ref.listenManual(appConfigProvider.select((s) => s.slideshow), (previous, next) {
|
||||
_slideshow.slideDuration = Duration(seconds: next.duration);
|
||||
|
||||
// A new direction or repeat change can cause a different next slide
|
||||
_slideshow.recalculateNextIndex();
|
||||
});
|
||||
|
||||
_slideshow.goToNextSlide();
|
||||
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive));
|
||||
unawaited(WakelockPlus.enable());
|
||||
@@ -80,194 +115,112 @@ class _SlideshowPageState extends ConsumerState<SlideshowPage> with SingleTicker
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_slideshow.dispose();
|
||||
_fade.dispose();
|
||||
_pageController.dispose();
|
||||
_crossfadeController.dispose();
|
||||
|
||||
unawaited(WakelockPlus.disable());
|
||||
unawaited(restoreEdgeToEdge());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _play() {
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (asset.isImage) {
|
||||
_createTimer();
|
||||
} else if (ref.read(videoPlayerProvider(asset.id)).status == VideoPlaybackStatus.paused) {
|
||||
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).play());
|
||||
} else {
|
||||
unawaited(_nextPage());
|
||||
@override
|
||||
int? nextIndexAfter(int index) {
|
||||
if (widget.timeline.totalAssets == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
|
||||
setState(() {
|
||||
_paused = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (!asset.isImage) {
|
||||
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).pause());
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _onConfigChanged(SlideshowConfig? previous, SlideshowConfig next) {
|
||||
if (_config == next) {
|
||||
return;
|
||||
}
|
||||
|
||||
final durationChanged = _config.duration != next.duration;
|
||||
_config = next;
|
||||
_updateNextIndex();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
if (durationChanged && !_paused && asset?.isImage == true) {
|
||||
_timer.cancel();
|
||||
_createTimer();
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _updateNextIndex() {
|
||||
_nextIndex = switch (_config.direction) {
|
||||
SlideshowDirection.forward => _index + 1,
|
||||
SlideshowDirection.backward => _index - 1,
|
||||
var next = switch (_config.direction) {
|
||||
SlideshowDirection.forward => index + 1,
|
||||
SlideshowDirection.backward => index - 1,
|
||||
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
|
||||
};
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
unawaited(widget.timeline.preloadAssets(_nextIndex));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _nextPage() async {
|
||||
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
|
||||
if (_config.repeat) {
|
||||
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
|
||||
await widget.timeline.preloadAssets(wrapped);
|
||||
_pageController.jumpToPage(wrapped);
|
||||
} else {
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
if (next < 0 || next >= widget.timeline.totalAssets) {
|
||||
// Out of bounds
|
||||
if (!_config.repeat) {
|
||||
// Don't wrap. End of slideshow
|
||||
return null;
|
||||
}
|
||||
return;
|
||||
|
||||
// Do wrap
|
||||
next = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
|
||||
}
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
await widget.timeline.preloadAssets(_nextIndex);
|
||||
if (!widget.timeline.hasRange(next, 1)) {
|
||||
// Async preload this index. We don't want to wait on it, just get it started so the asset is more likely to be ready in time
|
||||
unawaited(widget.timeline.preloadAssets(next));
|
||||
}
|
||||
|
||||
_crossFadeToPage(_nextIndex);
|
||||
return next;
|
||||
}
|
||||
|
||||
void _crossFadeToPage(int page) {
|
||||
if (_disableAnimations) {
|
||||
_pageController.jumpToPage(page);
|
||||
return;
|
||||
@override
|
||||
Duration? videoProgressOf(int index) {
|
||||
final video = _videoAt(index);
|
||||
|
||||
if (video == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final previousIndex = _index;
|
||||
_pageController.jumpToPage(page);
|
||||
setState(() {
|
||||
_crossfadeFromIndex = previousIndex;
|
||||
_crossfadeToIndex = page;
|
||||
});
|
||||
unawaited(
|
||||
_crossfadeController.forward(from: 0.0).whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_crossfadeFromIndex = null;
|
||||
_crossfadeToIndex = null;
|
||||
});
|
||||
return _videoState(video).position;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isVideoCompleted(int index) {
|
||||
final video = _videoAt(index);
|
||||
|
||||
return video != null && _videoState(video).status == VideoPlaybackStatus.completed;
|
||||
}
|
||||
|
||||
@override
|
||||
void onPlaybackChanged(int index, bool playing) {
|
||||
final video = _videoNotifier(index);
|
||||
|
||||
unawaited(playing ? video?.play() : video?.pause());
|
||||
}
|
||||
|
||||
@override
|
||||
void onShowSlide(int index, int prevIndex) {
|
||||
unawaited(() async {
|
||||
if (index == prevIndex) {
|
||||
// Showing the same slide again. Don't need to animate
|
||||
if (isVideoCompleted(index)) {
|
||||
unawaited(_videoNotifier(index)?.restart());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
_slideshow.didCompleteShowSlide(index);
|
||||
|
||||
final Widget child;
|
||||
if (isIncoming && asset?.isImage == true) {
|
||||
child = _getPhotoView(context, index);
|
||||
} else {
|
||||
final zoomOut = isIncoming ? _zoomCycle.isOdd : _zoomCycle.isEven;
|
||||
final zoom = isIncoming ? (zoomOut ? 1.0 : 0.0) : (zoomOut ? 0.0 : 1.0);
|
||||
child = _getCrossfadeChild(context, index, zoom);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), child],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getCrossfadeChild(BuildContext context, int index, double zoom) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final scale = _config.look == SlideshowLook.cover
|
||||
? PhotoViewComputedScale.covered
|
||||
: PhotoViewComputedScale.contained;
|
||||
|
||||
return PhotoView(
|
||||
imageProvider: getFullImageProvider(asset, size: context.sizeData),
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + zoom * _kenBurnsZoom),
|
||||
controller: PhotoViewController(),
|
||||
);
|
||||
}
|
||||
|
||||
void _createTimer() {
|
||||
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
unawaited(_nextPage());
|
||||
});
|
||||
|
||||
_stopwatch.start();
|
||||
}
|
||||
|
||||
void _pageChanged(int page) {
|
||||
final asset = widget.timeline.getAssetSafe(page)!;
|
||||
|
||||
setState(() {
|
||||
_index = page;
|
||||
_zoomCycle++;
|
||||
|
||||
if (!asset.isImage) {
|
||||
_paused = false;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
if (!widget.timeline.hasRange(index, 1)) {
|
||||
// If it wasn't already loaded by [nextIndexAfter], make sure we have the new asset synchronously
|
||||
await widget.timeline.preloadAssets(index);
|
||||
}
|
||||
|
||||
if (!_paused && asset.isImage) {
|
||||
_createTimer();
|
||||
}
|
||||
if (!mounted || _slideshow.currentIndex != prevIndex) {
|
||||
// User triggered another slide while we waited on preload
|
||||
return;
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
_pageController.jumpToPage(index);
|
||||
|
||||
// didCompleteShowSlide will be called by onPageChanged, so we don't need to call it in any of these branches
|
||||
if (_disableAnimations) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _fadingSlideIndex = prevIndex);
|
||||
|
||||
unawaited(
|
||||
_fade.forward(from: 0.0).whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() => _fadingSlideIndex = null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}());
|
||||
}
|
||||
|
||||
Future<void> _onTapUp() async {
|
||||
@@ -280,314 +233,116 @@ class _SlideshowPageState extends ConsumerState<SlideshowPage> with SingleTicker
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getProgressBar(BuildContext context) {
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
/// Zoom for the current Ken Burns cycle, moving from 0 -> 1, or 1 -> 0
|
||||
Animation<double> get _zoom {
|
||||
if (_disableAnimations) {
|
||||
return const AlwaysStoppedAnimation(0.0);
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
return _SlideshowProgressBar(
|
||||
key: Key(_index.toString()),
|
||||
durationMs: _config.duration * 1000,
|
||||
elapsedMs: _stopwatch.elapsedMilliseconds,
|
||||
paused: _paused,
|
||||
color: context.colorScheme.primary,
|
||||
);
|
||||
} else {
|
||||
return _VideoProgressBar(asset: asset);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getBlur(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
),
|
||||
return _slideshow.progress.drive(
|
||||
_slideshow.shouldZoomOut ? Tween(begin: 1.0, end: 0.0) : Tween(begin: 0.0, end: 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getPhotoView(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
Widget _buildSlide(int index, SlideshowLook look) {
|
||||
final asset = _assetAt(index);
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
final scale = _config.look == SlideshowLook.cover
|
||||
? PhotoViewComputedScale.covered
|
||||
: PhotoViewComputedScale.contained;
|
||||
final isCurrent = _index == index;
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
return SlideshowSlide(
|
||||
asset: asset,
|
||||
index: index,
|
||||
look: look,
|
||||
zoom: _zoom,
|
||||
isCurrent: _slideshow.currentIndex == index,
|
||||
onTapUp: _onTapUp,
|
||||
onCompleted: _slideshow.didCompleteVideo,
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: initialScale,
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
);
|
||||
|
||||
if (_disableAnimations) {
|
||||
return buildPhotoView(scale);
|
||||
}
|
||||
|
||||
final zoomOut = _zoomCycle.isOdd;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
tween: Tween<double>(
|
||||
begin: progress,
|
||||
end: _paused
|
||||
? progress
|
||||
: zoomOut
|
||||
? 0.0
|
||||
: 1.0,
|
||||
),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
|
||||
);
|
||||
} else {
|
||||
return _VideoChild(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
scale: scale,
|
||||
imageProvider: imageProvider,
|
||||
onTapUp: _onTapUp,
|
||||
onCompleted: _nextPage,
|
||||
);
|
||||
/// The outgoing slide, frozen in its last position
|
||||
Widget _buildFadingSlide(int index, SlideshowLook look) {
|
||||
final asset = _assetAt(index);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SlideshowSlide.frozen(asset: asset, index: index, look: look, zoom: _slideshow.shouldZoomOut ? 1.0 : 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showAppBar,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showAppBar ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: Column(
|
||||
children: [
|
||||
AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text(context.t.slideshow),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _paused ? _play : _pause,
|
||||
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
_pause();
|
||||
unawaited(context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer)));
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
_getProgressBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _pageChanged,
|
||||
itemBuilder: (context, index) => Stack(
|
||||
children: [
|
||||
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
|
||||
_getPhotoView(context, index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_crossfadeFromIndex != null && _crossfadeToIndex != null)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
final config = ref.watch(appConfigProvider.select((s) => s.slideshow));
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: _slideshow,
|
||||
builder: (context, _) {
|
||||
final currentAsset = _assetAt(_slideshow.currentIndex);
|
||||
final progressBar = currentAsset != null
|
||||
? SlideshowProgressBar(asset: currentAsset, progress: _slideshow.progress)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showAppBar,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showAppBar ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: Column(
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
FadeTransition(
|
||||
opacity: _crossfadeController,
|
||||
child: _getCrossfadeLayer(context, _crossfadeToIndex!, isIncoming: true),
|
||||
),
|
||||
FadeTransition(
|
||||
opacity: _crossfadeOpacity,
|
||||
child: _getCrossfadeLayer(context, _crossfadeFromIndex!, isIncoming: false),
|
||||
AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text(context.t.slideshow),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _slideshow.paused ? _slideshow.resume : _slideshow.pause,
|
||||
icon: Icon(_slideshow.paused ? Icons.play_arrow : Icons.pause),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (!_slideshow.paused) {
|
||||
_slideshow.pause();
|
||||
}
|
||||
unawaited(context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer)));
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
progressBar,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoChild extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final bool isCurrent;
|
||||
final PhotoViewComputedScale scale;
|
||||
final ImageProvider imageProvider;
|
||||
final VoidCallback onTapUp;
|
||||
final VoidCallback onCompleted;
|
||||
|
||||
const _VideoChild({
|
||||
required this.asset,
|
||||
required this.isCurrent,
|
||||
required this.scale,
|
||||
required this.imageProvider,
|
||||
required this.onTapUp,
|
||||
required this.onCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) {
|
||||
if (status == VideoPlaybackStatus.completed) {
|
||||
if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) {
|
||||
onCompleted();
|
||||
}
|
||||
} else if (status == VideoPlaybackStatus.playing) {
|
||||
unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false));
|
||||
}
|
||||
});
|
||||
|
||||
return PhotoView.customChild(
|
||||
onTapUp: (_, _, _) => onTapUp(),
|
||||
disableScaleGestures: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale,
|
||||
child: NativeVideoViewer(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoProgressBar extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const _VideoProgressBar({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position));
|
||||
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: BorderRadius.zero,
|
||||
minHeight: 5,
|
||||
value: position.inMilliseconds / asset.duration.inMilliseconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar for image slides, driven by an explicit [AnimationController].
|
||||
///
|
||||
/// [TweenAnimationBuilder] creates its controller internally with the default
|
||||
/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system
|
||||
/// "reduce motion" setting is on (flutter/flutter#164287). This owns its
|
||||
/// controller so it can use [AnimationBehavior.preserve] and animate at the real
|
||||
/// slide duration regardless of that setting.
|
||||
class _SlideshowProgressBar extends StatefulWidget {
|
||||
final int durationMs;
|
||||
final int elapsedMs;
|
||||
final bool paused;
|
||||
final Color color;
|
||||
|
||||
const _SlideshowProgressBar({
|
||||
super.key,
|
||||
required this.durationMs,
|
||||
required this.elapsedMs,
|
||||
required this.paused,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState();
|
||||
}
|
||||
|
||||
class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: widget.durationMs),
|
||||
animationBehavior: AnimationBehavior.preserve,
|
||||
)..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0);
|
||||
if (!widget.paused) {
|
||||
unawaited(_controller.forward());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_SlideshowProgressBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.durationMs != oldWidget.durationMs) {
|
||||
_controller.duration = Duration(milliseconds: widget.durationMs);
|
||||
}
|
||||
if (widget.paused != oldWidget.paused) {
|
||||
widget.paused ? _controller.stop() : _controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => LinearProgressIndicator(
|
||||
color: widget.color,
|
||||
borderRadius: BorderRadius.zero,
|
||||
minHeight: 5,
|
||||
value: _controller.value,
|
||||
),
|
||||
),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _slideshow.didCompleteShowSlide,
|
||||
itemBuilder: (context, index) => _buildSlide(index, config.look),
|
||||
),
|
||||
),
|
||||
if (_fadingSlideIndex != null)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: FadeTransition(opacity: _fadeOut, child: _buildFadingSlide(_fadingSlideIndex!, config.look)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
final bool showControls;
|
||||
final Widget image;
|
||||
|
||||
/// Overrides the user's configured loop video setting
|
||||
final bool? loopOverride;
|
||||
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
@@ -32,6 +35,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
this.loopOverride,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -278,7 +282,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
|
||||
// Grab refs to prevent reading after dispose
|
||||
final loopVideo = ref.read(appConfigProvider).viewer.loopVideo;
|
||||
final loopVideo = widget.loopOverride ?? ref.read(appConfigProvider).viewer.loopVideo;
|
||||
final localNotifier = _notifier;
|
||||
|
||||
await localNotifier.load(source);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The shared instance behavior between individual slideshow components
|
||||
abstract interface class SlideshowDelegate {
|
||||
/// Provides the index of the slide to display after the slide at [index]. A returned `null` index indicates the slideshow should terminate "next"
|
||||
int? nextIndexAfter(int index);
|
||||
|
||||
/// Provides the playback position of the video slide at [index], or null if the slide is not a video
|
||||
Duration? videoProgressOf(int index);
|
||||
|
||||
/// Provides the completion state of the video slide at [index], or false if the slide is not a video
|
||||
bool isVideoCompleted(int index);
|
||||
|
||||
/// Called when the slideshow starts or stops playing
|
||||
void onPlaybackChanged(int index, bool playing);
|
||||
|
||||
/// Called to indicate the slide corresponding to [index] should be displayed, transitioning from [prevIndex]
|
||||
///
|
||||
/// **NOTE:** The receiver MUST call [SlideshowController.didCompleteShowSlide] when the slide became "ready"
|
||||
void onShowSlide(int index, int prevIndex);
|
||||
}
|
||||
|
||||
/// Manages Flutter slideshow rendering
|
||||
///
|
||||
/// After constructing, call [goToNextSlide] to begin the slideshow
|
||||
class SlideshowController extends ChangeNotifier {
|
||||
final SlideshowDelegate delegate;
|
||||
|
||||
late final AnimationController _animationController;
|
||||
|
||||
/// The index of the currently displayed slide
|
||||
int _currentIndex;
|
||||
|
||||
/// The index of the expected next displayed slide
|
||||
int? _nextIndex;
|
||||
|
||||
bool _paused = false;
|
||||
bool _shouldZoomOut = true;
|
||||
|
||||
/// The last recorded video playback position
|
||||
Duration _lastVideoPosition = Duration.zero;
|
||||
|
||||
SlideshowController({
|
||||
required TickerProvider vsync,
|
||||
required Duration slideDuration,
|
||||
required int initialIndex,
|
||||
required this.delegate,
|
||||
}) : _currentIndex = initialIndex {
|
||||
_animationController =
|
||||
AnimationController(
|
||||
vsync: vsync,
|
||||
duration: slideDuration,
|
||||
// This `AnimationController` serves as the actual slideshow timer, so we must ignore reduce motion
|
||||
animationBehavior: AnimationBehavior.preserve,
|
||||
)..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_onAnimationElapsed();
|
||||
}
|
||||
});
|
||||
|
||||
// We want to go to the first slide
|
||||
_nextIndex = initialIndex;
|
||||
}
|
||||
|
||||
/// The slide currently on screen
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
/// The slide to be displayed next. Null if the slideshow will end after this slide
|
||||
int? get nextIndex => _nextIndex;
|
||||
|
||||
/// True when the user paused or the slideshow has completed
|
||||
bool get paused => _paused;
|
||||
|
||||
/// Ken Burns zoom animation direction
|
||||
///
|
||||
/// Each slide transitions from zooming in/out to out/in
|
||||
bool get shouldZoomOut => _shouldZoomOut;
|
||||
|
||||
/// The slideshow clock/progress indicator. Its value, [0.0, 1.0] represents the progress through the duration of the current slide
|
||||
Animation<double> get progress => _animationController;
|
||||
|
||||
/// The display duration of a single slide. Setting a new duration mid-animation will continue from the current percentage completion at the new pace
|
||||
set slideDuration(Duration duration) {
|
||||
_animationController.duration = duration;
|
||||
|
||||
if (!_paused && _animationController.isAnimating) {
|
||||
unawaited(_animationController.forward());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Stops the slideshow at its current position
|
||||
void pause() {
|
||||
_paused = true;
|
||||
_animationController.stop();
|
||||
|
||||
delegate.onPlaybackChanged(_currentIndex, false);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Resume the slideshow from its current position
|
||||
void resume() {
|
||||
_paused = false;
|
||||
notifyListeners();
|
||||
|
||||
if (delegate.isVideoCompleted(_currentIndex)) {
|
||||
goToNextSlide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_animationController.isCompleted) {
|
||||
// If slide hit the end, restart it from 0
|
||||
_animationController.value = 0.0;
|
||||
}
|
||||
|
||||
unawaited(_animationController.forward());
|
||||
|
||||
delegate.onPlaybackChanged(_currentIndex, true);
|
||||
}
|
||||
|
||||
/// Immediately transitions to the previously determined next slide
|
||||
///
|
||||
/// If there is no [_nextIndex], pauses the slideshow
|
||||
void goToNextSlide() {
|
||||
_animationController.stop();
|
||||
|
||||
final targetIndex = _nextIndex;
|
||||
|
||||
if (targetIndex == null) {
|
||||
_paused = true;
|
||||
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
delegate.onShowSlide(targetIndex, _currentIndex);
|
||||
}
|
||||
|
||||
/// Indicates the slide at [index] is now displayed
|
||||
void didCompleteShowSlide(int index) {
|
||||
_currentIndex = index;
|
||||
_nextIndex = delegate.nextIndexAfter(index);
|
||||
|
||||
_shouldZoomOut = !_shouldZoomOut;
|
||||
|
||||
if (delegate.videoProgressOf(index) != null) {
|
||||
// Visiting a video immediately starts playback (as part of NativeVideoPlayer)
|
||||
// We do not want to unpause outside of videos
|
||||
_paused = false;
|
||||
}
|
||||
|
||||
_startSlideTimer();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Indicates the currently displayed video slide finished playback
|
||||
void didCompleteVideo() {
|
||||
if (!_paused) {
|
||||
goToNextSlide();
|
||||
}
|
||||
}
|
||||
|
||||
/// Recalculates the next slide index
|
||||
void recalculateNextIndex() {
|
||||
_nextIndex = delegate.nextIndexAfter(_currentIndex);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Begin the timer for the current slide
|
||||
void _startSlideTimer() {
|
||||
_lastVideoPosition = delegate.videoProgressOf(_currentIndex) ?? Duration.zero;
|
||||
|
||||
if (_paused) {
|
||||
_animationController.value = 0.0;
|
||||
} else {
|
||||
unawaited(_animationController.forward(from: 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
void _onAnimationElapsed() {
|
||||
final videoPosition = delegate.videoProgressOf(_currentIndex);
|
||||
|
||||
if (videoPosition != null && videoPosition != _lastVideoPosition) {
|
||||
// Video progress has been made and thus is not stalled
|
||||
_lastVideoPosition = videoPosition;
|
||||
|
||||
// Restart the slide timer in case the video stalls in the future
|
||||
unawaited(_animationController.forward(from: 0.0));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
goToNextSlide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
|
||||
/// Bar indicating current progress through a given slide's alloted display time
|
||||
class SlideshowProgressBar extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final Animation<double> progress;
|
||||
|
||||
const SlideshowProgressBar({super.key, required this.asset, required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
|
||||
if (asset.isImage) {
|
||||
return AnimatedBuilder(animation: progress, builder: (context, _) => _bar(context, progress.value));
|
||||
}
|
||||
|
||||
final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position));
|
||||
return _bar(context, position.inMilliseconds / asset.duration.inMilliseconds);
|
||||
}
|
||||
|
||||
Widget _bar(BuildContext context, double value) {
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: BorderRadius.zero,
|
||||
minHeight: 5,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
void _noop() {}
|
||||
|
||||
/// A single slideshow slide
|
||||
class SlideshowSlide extends StatelessWidget {
|
||||
static const double _kenBurnsZoomMultiplier = 0.1;
|
||||
|
||||
final BaseAsset asset;
|
||||
|
||||
final int index;
|
||||
final SlideshowLook look;
|
||||
final Animation<double> zoom;
|
||||
|
||||
final bool isCurrent;
|
||||
final bool frozen;
|
||||
|
||||
final VoidCallback onTapUp;
|
||||
final VoidCallback onCompleted;
|
||||
|
||||
const SlideshowSlide({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.index,
|
||||
required this.look,
|
||||
required this.zoom,
|
||||
required this.isCurrent,
|
||||
required this.onTapUp,
|
||||
required this.onCompleted,
|
||||
}) : frozen = false;
|
||||
|
||||
/// A static slide frozen at a given zoom level for use transitions
|
||||
SlideshowSlide.frozen({super.key, required this.asset, required this.index, required this.look, required double zoom})
|
||||
: zoom = AlwaysStoppedAnimation(zoom),
|
||||
isCurrent = false,
|
||||
frozen = true,
|
||||
onTapUp = _noop,
|
||||
onCompleted = _noop;
|
||||
|
||||
PhotoViewComputedScale get _scale =>
|
||||
look == SlideshowLook.cover ? PhotoViewComputedScale.covered : PhotoViewComputedScale.contained;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget content = asset.isImage || frozen
|
||||
? ScaleTransition(
|
||||
scale: zoom.drive(Tween(begin: 1.0, end: 1.0 + _kenBurnsZoomMultiplier)),
|
||||
child: PhotoView(
|
||||
imageProvider: getFullImageProvider(asset, size: context.sizeData),
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: _scale,
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => onTapUp(),
|
||||
),
|
||||
)
|
||||
: _SlideshowVideo(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
scale: _scale,
|
||||
imageProvider: getFullImageProvider(asset, size: context.sizeData),
|
||||
onTapUp: onTapUp,
|
||||
onCompleted: onCompleted,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (look == SlideshowLook.blurredBackground) _BlurredBackground(asset: asset),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlurredBackground extends StatelessWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const _BlurredBackground({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideshowVideo extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
final bool isCurrent;
|
||||
final PhotoViewComputedScale scale;
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
|
||||
final VoidCallback onTapUp;
|
||||
final VoidCallback onCompleted;
|
||||
|
||||
const _SlideshowVideo({
|
||||
required this.asset,
|
||||
required this.isCurrent,
|
||||
required this.scale,
|
||||
required this.imageProvider,
|
||||
required this.onTapUp,
|
||||
required this.onCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) {
|
||||
if (status == VideoPlaybackStatus.completed) {
|
||||
if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) {
|
||||
onCompleted();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return PhotoView.customChild(
|
||||
onTapUp: (_, _, _) => onTapUp(),
|
||||
disableScaleGestures: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale,
|
||||
child: NativeVideoViewer(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
// Disable video looping
|
||||
loopOverride: false,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/pages/slideshow.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_slide.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
|
||||
import '../../unit/factories/remote_asset_factory.dart';
|
||||
import '../../unit/presentation/presentation_context.dart';
|
||||
|
||||
/// A VideoPlayerNotifier that records play/pause events
|
||||
class FakeVideoPlayer extends VideoPlayerNotifier {
|
||||
/// Every call made against this video, in order
|
||||
final calls = <String>[];
|
||||
|
||||
void emit(VideoPlayerState next) => state = next;
|
||||
|
||||
@override
|
||||
Future<void> play() async => calls.add('play');
|
||||
|
||||
@override
|
||||
Future<void> pause() async => calls.add('pause');
|
||||
}
|
||||
|
||||
const slideDuration = Duration(seconds: 3);
|
||||
|
||||
/// A small step to precisely test event timing
|
||||
const tick = Duration(milliseconds: 1);
|
||||
|
||||
List<BaseAsset> images(int count) => List.generate(count, (_) => RemoteAssetFactory.create());
|
||||
|
||||
TimelineService timelineOf(List<BaseAsset> assets, {TimelineAssetSource? source}) => TimelineService((
|
||||
assetSource: source ?? (index, count) async => assets.sublist(index, math.min(index + count, assets.length)),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
/// A timeline whose last asset is still loading
|
||||
TimelineService partlyLoaded(List<BaseAsset> assets, {Completer<void>? stall}) {
|
||||
var loadCount = 0;
|
||||
|
||||
return timelineOf(
|
||||
assets,
|
||||
source: (index, count) async {
|
||||
loadCount += 1;
|
||||
|
||||
if (loadCount != 1) {
|
||||
await stall?.future;
|
||||
}
|
||||
|
||||
return assets.sublist(index, math.min(index + count, loadCount == 1 ? assets.length - 1 : assets.length));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PresentationContext ctx;
|
||||
|
||||
setUp(() async => ctx = await PresentationContext.create());
|
||||
|
||||
tearDown(() async => ctx.dispose());
|
||||
|
||||
void slideshowTest(
|
||||
String description,
|
||||
Future<void> Function(WidgetTester tester) body, {
|
||||
TimelineService Function()? timeline,
|
||||
SlideshowDirection direction = SlideshowDirection.forward,
|
||||
bool repeat = true,
|
||||
bool disableAnimations = false,
|
||||
List<Override> overrides = const [],
|
||||
}) {
|
||||
testWidgets(description, (tester) async {
|
||||
if (disableAnimations) {
|
||||
tester.binding.platformDispatcher.accessibilityFeaturesTestValue = const FakeAccessibilityFeatures(
|
||||
disableAnimations: true,
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ctx,
|
||||
SlideshowPage(timeline: (timeline ?? () => timelineOf(images(3)))()),
|
||||
expectSettle: false,
|
||||
overrides: [
|
||||
appConfigProvider.overrideWithValue(
|
||||
AppConfig(
|
||||
slideshow: SlideshowConfig(duration: 3, look: .contain, direction: direction, repeat: repeat),
|
||||
),
|
||||
),
|
||||
...overrides,
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
await body(tester);
|
||||
});
|
||||
}
|
||||
|
||||
/// The delegate for this page
|
||||
SlideshowDelegate delegateOf(WidgetTester tester) =>
|
||||
tester.state<ConsumerState<SlideshowPage>>(find.byType(SlideshowPage)) as SlideshowDelegate;
|
||||
|
||||
/// The currently visible slide index
|
||||
int visibleIndex(WidgetTester tester) =>
|
||||
tester.widgetList<SlideshowSlide>(find.byType(SlideshowSlide)).firstWhere((slide) => slide.isCurrent).index;
|
||||
|
||||
/// The current value of the Ken Burns zoom for a particular slide
|
||||
double currentZoomOf(WidgetTester tester, int index) => tester
|
||||
.widget<SlideshowSlide>(find.byWidgetPredicate((w) => w is SlideshowSlide && w.index == index && !w.frozen))
|
||||
.zoom
|
||||
.value;
|
||||
|
||||
double? progressIndicatorProgress(WidgetTester tester) =>
|
||||
tester.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator)).value;
|
||||
|
||||
final fadingSlide = find.byWidgetPredicate((w) => w is SlideshowSlide && w.frozen);
|
||||
|
||||
Future<void> tapScreen(WidgetTester tester) async {
|
||||
await tester.tap(find.byType(PageView));
|
||||
|
||||
// The app bar does stuff in post frame, so we need to run twice
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
group('basic rendering', () {
|
||||
slideshowTest('should show the first slide only', (tester) async {
|
||||
expect(visibleIndex(tester), 0);
|
||||
expect(fadingSlide, findsNothing);
|
||||
});
|
||||
|
||||
slideshowTest('should zoom the slide for its duration', (tester) async {
|
||||
expect(currentZoomOf(tester, 0), 0.0);
|
||||
|
||||
await tester.pump(slideDuration * 0.5);
|
||||
// 50% through duration we should see 50% of the zoom completed
|
||||
expect(currentZoomOf(tester, 0), closeTo(0.5, 0.01));
|
||||
});
|
||||
|
||||
slideshowTest('should reverse the zoom direction when moving to the next slide', (tester) async {
|
||||
await tester.pump(slideDuration + tick);
|
||||
await tester.pump();
|
||||
|
||||
// The previous slide zoomed in, so this one starts zoomed in and will zoom out
|
||||
expect(currentZoomOf(tester, 1), closeTo(1.0, 0.01));
|
||||
|
||||
await tester.pump(slideDuration * 0.3);
|
||||
expect(currentZoomOf(tester, 1), closeTo(0.7, 0.01));
|
||||
});
|
||||
|
||||
slideshowTest('should show the next slide once the duration passes', (tester) async {
|
||||
await tester.pump(slideDuration - tick);
|
||||
expect(visibleIndex(tester), 0);
|
||||
|
||||
await tester.pump(tick * 2);
|
||||
expect(visibleIndex(tester), 1);
|
||||
});
|
||||
|
||||
slideshowTest('should fade the outgoing slide over the incoming one', (tester) async {
|
||||
await tester.pump(slideDuration + tick);
|
||||
await tester.pump();
|
||||
|
||||
// The old slide covers the new one, then is removed
|
||||
expect(tester.widget<SlideshowSlide>(fadingSlide).index, 0);
|
||||
expect(visibleIndex(tester), 1);
|
||||
|
||||
final fade = find.ancestor(of: fadingSlide, matching: find.byType(FadeTransition)).first;
|
||||
expect(tester.widget<FadeTransition>(fade).opacity.value, 1.0);
|
||||
|
||||
await tester.pump(Durations.extralong2 * 0.5);
|
||||
expect(tester.widget<FadeTransition>(fade).opacity.value, lessThan(1.0));
|
||||
|
||||
await tester.pump(Durations.extralong2);
|
||||
expect(fadingSlide, findsNothing);
|
||||
});
|
||||
|
||||
slideshowTest('should not fade when animations are disabled', (tester) async {
|
||||
await tester.pump(slideDuration + tick);
|
||||
await tester.pump();
|
||||
|
||||
expect(visibleIndex(tester), 1);
|
||||
expect(fadingSlide, findsNothing);
|
||||
|
||||
expect(currentZoomOf(tester, 1), 0.0);
|
||||
|
||||
await tester.pump(slideDuration * 0.5);
|
||||
expect(currentZoomOf(tester, 1), 0.0);
|
||||
}, disableAnimations: true);
|
||||
|
||||
slideshowTest('should show a loading indicator for a slide that isnt ready', (tester) async {
|
||||
await tester.drag(find.byType(PageView), const Offset(-800, 0));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(ImmichLoadingIndicator), findsOneWidget);
|
||||
}, timeline: () => partlyLoaded(images(2), stall: Completer<void>()));
|
||||
});
|
||||
|
||||
group('interactions', () {
|
||||
slideshowTest('should advance the progress bar', (tester) async {
|
||||
expect(progressIndicatorProgress(tester), 0.0);
|
||||
|
||||
await tester.pump(slideDuration * 0.45);
|
||||
expect(progressIndicatorProgress(tester), closeTo(0.45, 0.01));
|
||||
});
|
||||
|
||||
slideshowTest('should show/hide the AppBar', (tester) async {
|
||||
final appBar = find.byType(AnimatedOpacity).first;
|
||||
|
||||
expect(tester.widget<AnimatedOpacity>(appBar).opacity, 0.0);
|
||||
|
||||
await tapScreen(tester);
|
||||
expect(tester.widget<AnimatedOpacity>(appBar).opacity, 1.0);
|
||||
|
||||
await tapScreen(tester);
|
||||
expect(tester.widget<AnimatedOpacity>(appBar).opacity, 0.0);
|
||||
});
|
||||
|
||||
slideshowTest('should stop slide movement and progress bar when paused', (tester) async {
|
||||
await tester.pump(slideDuration * 0.5);
|
||||
|
||||
// Show AppBar and pause the app
|
||||
await tapScreen(tester);
|
||||
await tester.tap(find.byIcon(Icons.pause));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
|
||||
|
||||
final held = progressIndicatorProgress(tester);
|
||||
await tester.pump(slideDuration);
|
||||
expect(progressIndicatorProgress(tester), held);
|
||||
expect(visibleIndex(tester), 0);
|
||||
});
|
||||
|
||||
slideshowTest('should resume slide from its paused position', (tester) async {
|
||||
await tester.pump(slideDuration * 0.64);
|
||||
|
||||
await tapScreen(tester);
|
||||
await tester.tap(find.byIcon(Icons.pause));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.play_arrow));
|
||||
|
||||
// Resuming restarts the ticker, so this frame is its new baseline and elapses nothing
|
||||
await tester.pump();
|
||||
|
||||
// We should have precisely 36% left before transition
|
||||
await tester.pump(slideDuration * 0.36 - tick);
|
||||
expect(visibleIndex(tester), 0);
|
||||
|
||||
await tester.pump(tick * 2);
|
||||
expect(visibleIndex(tester), 1);
|
||||
});
|
||||
|
||||
late Completer<void> arrived;
|
||||
|
||||
slideshowTest('should properly handle an async load the user navigated away from', (tester) async {
|
||||
await tester.pump(slideDuration + tick);
|
||||
expect(visibleIndex(tester), 1);
|
||||
|
||||
// This slide will get stuck
|
||||
await tester.pump(slideDuration + tick);
|
||||
|
||||
// Since the slide is stuck, the user swipes back to the first asset
|
||||
await tester.drag(find.byType(PageView), const Offset(800, 0));
|
||||
await tester.pump();
|
||||
expect(visibleIndex(tester), 0);
|
||||
|
||||
// The stuck slide loads, but it doesn't matter
|
||||
arrived.complete();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(visibleIndex(tester), 0);
|
||||
}, timeline: () => partlyLoaded(images(3), stall: arrived = Completer<void>()));
|
||||
|
||||
slideshowTest('should start the slide timer when navigating to a new slide', (tester) async {
|
||||
await tester.pump(slideDuration * 0.75);
|
||||
|
||||
await tester.drag(find.byType(PageView), const Offset(-800, 0));
|
||||
await tester.pump();
|
||||
|
||||
expect(visibleIndex(tester), 1);
|
||||
expect(progressIndicatorProgress(tester), 0.0);
|
||||
|
||||
await tester.pump(slideDuration * 0.35);
|
||||
expect(progressIndicatorProgress(tester), closeTo(0.35, 0.01));
|
||||
});
|
||||
});
|
||||
|
||||
group('delegate API', () {
|
||||
final video = RemoteAssetFactory.create(type: .video);
|
||||
|
||||
late FakeVideoPlayer player;
|
||||
|
||||
final playing = [videoPlayerProvider(video.id).overrideWith((ref) => player)];
|
||||
|
||||
TimelineService withVideo() => timelineOf([video, ...images(1)]);
|
||||
|
||||
setUp(() => player = FakeVideoPlayer());
|
||||
|
||||
slideshowTest('should wrap past end', (tester) async {
|
||||
expect(delegateOf(tester).nextIndexAfter(0), 1);
|
||||
expect(delegateOf(tester).nextIndexAfter(2), 0);
|
||||
});
|
||||
|
||||
slideshowTest('should wrap past start', (tester) async {
|
||||
expect(delegateOf(tester).nextIndexAfter(2), 1);
|
||||
expect(delegateOf(tester).nextIndexAfter(0), 2);
|
||||
}, direction: .backward);
|
||||
|
||||
slideshowTest('should end the slideshow at the last slide when repeat is disabled', (tester) async {
|
||||
expect(delegateOf(tester).nextIndexAfter(1), 2);
|
||||
expect(delegateOf(tester).nextIndexAfter(2), null);
|
||||
}, repeat: false);
|
||||
|
||||
slideshowTest(
|
||||
'should end the slideshow at the first slide when running backwards',
|
||||
(tester) async {
|
||||
expect(delegateOf(tester).nextIndexAfter(1), 0);
|
||||
expect(delegateOf(tester).nextIndexAfter(0), null);
|
||||
},
|
||||
repeat: false,
|
||||
direction: .backward,
|
||||
);
|
||||
|
||||
slideshowTest('should end the slideshow when there is nothing to show', (tester) async {
|
||||
expect(delegateOf(tester).nextIndexAfter(0), null);
|
||||
}, timeline: () => timelineOf(const []));
|
||||
|
||||
slideshowTest(
|
||||
'should provide video playback position, but not image positions',
|
||||
(tester) async {
|
||||
player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: .zero, status: .playing));
|
||||
|
||||
expect(delegateOf(tester).videoProgressOf(0), const Duration(seconds: 2));
|
||||
expect(delegateOf(tester).videoProgressOf(1), null);
|
||||
},
|
||||
timeline: withVideo,
|
||||
overrides: playing,
|
||||
);
|
||||
|
||||
slideshowTest(
|
||||
'should report when videos complete',
|
||||
(tester) async {
|
||||
expect(delegateOf(tester).isVideoCompleted(0), false);
|
||||
|
||||
player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: .zero, status: .completed));
|
||||
expect(delegateOf(tester).isVideoCompleted(0), true);
|
||||
expect(delegateOf(tester).isVideoCompleted(1), false);
|
||||
},
|
||||
timeline: withVideo,
|
||||
overrides: playing,
|
||||
);
|
||||
|
||||
slideshowTest(
|
||||
'should pause and play the video along with the slideshow',
|
||||
(tester) async {
|
||||
delegateOf(tester).onPlaybackChanged(0, false);
|
||||
delegateOf(tester).onPlaybackChanged(0, true);
|
||||
expect(player.calls, ['pause', 'play']);
|
||||
|
||||
// An image does nothing here
|
||||
delegateOf(tester).onPlaybackChanged(1, true);
|
||||
expect(player.calls, ['pause', 'play']);
|
||||
},
|
||||
timeline: withVideo,
|
||||
overrides: playing,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart';
|
||||
|
||||
const slideDuration = Duration(seconds: 5);
|
||||
const tick = Duration(milliseconds: 1);
|
||||
|
||||
class FakeSlideshow implements SlideshowDelegate {
|
||||
FakeSlideshow({this.nextIndex = _nextInOrder}) {
|
||||
controller = SlideshowController(
|
||||
vsync: const TestVSync(),
|
||||
slideDuration: slideDuration,
|
||||
initialIndex: 0,
|
||||
delegate: this,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _nextInOrder(int index) => index + 1;
|
||||
|
||||
int? Function(int index) nextIndex;
|
||||
|
||||
/// Video slide index -> playback position. Image slides do not appear in this map at all
|
||||
final videos = <int, Duration>{};
|
||||
final completedVideos = <int>{};
|
||||
|
||||
/// Test action log
|
||||
final events = <String>[];
|
||||
|
||||
late final SlideshowController controller;
|
||||
|
||||
@override
|
||||
int? nextIndexAfter(int index) => nextIndex(index);
|
||||
|
||||
@override
|
||||
Duration? videoProgressOf(int index) => videos[index];
|
||||
|
||||
@override
|
||||
bool isVideoCompleted(int index) => completedVideos.contains(index);
|
||||
|
||||
@override
|
||||
void onPlaybackChanged(int index, bool playing) => events.add(playing ? 'play' : 'pause');
|
||||
|
||||
@override
|
||||
void onShowSlide(int index, int prevIndex) {
|
||||
events.add('show $index');
|
||||
|
||||
controller.didCompleteShowSlide(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs [body] against a freshly started slideshow
|
||||
void slideshowTest(
|
||||
String description,
|
||||
Future<void> Function(WidgetTester tester, FakeSlideshow show) body, {
|
||||
int? Function(int index)? nextIndex,
|
||||
}) {
|
||||
testWidgets(description, (tester) async {
|
||||
final show = FakeSlideshow(nextIndex: nextIndex ?? FakeSlideshow._nextInOrder);
|
||||
|
||||
// Go to first slide
|
||||
show.controller.goToNextSlide();
|
||||
|
||||
// The slideshow's ticker takes its baseline on this frame, so a test can jump the clock straight away
|
||||
await tester.pump();
|
||||
|
||||
try {
|
||||
await body(tester, show);
|
||||
} finally {
|
||||
// The slideshow ticks forever. All tests must stop it, otherwise it will leak into subsequent tests
|
||||
// This cannot appear in tearDown due to invariants being called outside of "render"
|
||||
show.controller.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
slideshowTest('should advance once the slide duration passes', (tester, show) async {
|
||||
await tester.pump(slideDuration - tick);
|
||||
expect(show.events, ['show 0']);
|
||||
|
||||
await tester.pump(tick * 2);
|
||||
expect(show.events, ['show 0', 'show 1']);
|
||||
});
|
||||
|
||||
slideshowTest('should show the slide the delegate provides next', (tester, show) async {
|
||||
await tester.pump(slideDuration + tick);
|
||||
|
||||
expect(show.events, ['show 0', 'show 7']);
|
||||
}, nextIndex: (_) => 7);
|
||||
|
||||
slideshowTest('should pause when there is no next slide', (tester, show) async {
|
||||
await tester.pump(slideDuration + tick);
|
||||
|
||||
expect(show.events, ['show 0']);
|
||||
expect(show.controller.paused, isTrue);
|
||||
}, nextIndex: (_) => null);
|
||||
|
||||
slideshowTest('should stop the clock when paused', (tester, show) async {
|
||||
show.controller.pause();
|
||||
|
||||
expect(show.events, ['show 0', 'pause']);
|
||||
|
||||
await tester.pump(slideDuration * 2);
|
||||
|
||||
expect(show.events, ['show 0', 'pause']);
|
||||
});
|
||||
|
||||
slideshowTest('should continue the current slide when resumed', (tester, show) async {
|
||||
await tester.pump(slideDuration * 0.75);
|
||||
|
||||
show.controller.pause();
|
||||
show.controller.resume();
|
||||
|
||||
// Resuming restarts the ticker, so this frame is its new baseline and elapses nothing
|
||||
await tester.pump();
|
||||
|
||||
// A quarter of the slide is left
|
||||
await tester.pump(slideDuration * 0.25 - tick);
|
||||
expect(show.events, ['show 0', 'pause', 'play']);
|
||||
|
||||
await tester.pump(tick * 2);
|
||||
expect(show.events, ['show 0', 'pause', 'play', 'show 1']);
|
||||
});
|
||||
|
||||
slideshowTest('should not watchdog modify a video that is successfully playing back', (tester, show) async {
|
||||
show.videos[0] = Duration.zero;
|
||||
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
// Simulate progress
|
||||
show.videos[0] = slideDuration * i;
|
||||
|
||||
await tester.pump(slideDuration + tick);
|
||||
}
|
||||
|
||||
expect(show.events, ['show 0']);
|
||||
});
|
||||
|
||||
slideshowTest('should advance past a stalled video once the slide duration passes', (tester, show) async {
|
||||
show.videos[0] = Duration.zero;
|
||||
|
||||
await tester.pump(slideDuration - tick);
|
||||
expect(show.events, ['show 0']);
|
||||
|
||||
await tester.pump(tick * 2);
|
||||
expect(show.events, ['show 0', 'show 1']);
|
||||
});
|
||||
|
||||
slideshowTest('should advance immediately when a video ends', (tester, show) async {
|
||||
show.videos[0] = Duration.zero;
|
||||
|
||||
show.controller.didCompleteVideo();
|
||||
|
||||
expect(show.events, ['show 0', 'show 1']);
|
||||
});
|
||||
|
||||
slideshowTest('should not move when a video ends while paused', (tester, show) async {
|
||||
show.videos[0] = Duration.zero;
|
||||
show.controller.pause();
|
||||
|
||||
show.controller.didCompleteVideo();
|
||||
|
||||
expect(show.events, ['show 0', 'pause']);
|
||||
});
|
||||
|
||||
slideshowTest('should move to next slide when a finished video is resumed', (tester, show) async {
|
||||
show.videos[0] = Duration.zero;
|
||||
show.controller.pause();
|
||||
show.completedVideos.add(0);
|
||||
|
||||
expect(show.events, ['show 0', 'pause']);
|
||||
|
||||
show.controller.resume();
|
||||
|
||||
expect(show.events, ['show 0', 'pause', 'show 1']);
|
||||
});
|
||||
|
||||
slideshowTest('should restart the timer on manual navigation to another slide', (tester, show) async {
|
||||
await tester.pump(slideDuration * 0.60);
|
||||
|
||||
// Widget informs us of a slide change
|
||||
show.controller.didCompleteShowSlide(2);
|
||||
|
||||
// A new slide restarts the ticker, so this frame is its new baseline and elapses nothing
|
||||
await tester.pump();
|
||||
|
||||
// The new slide gets a full slide duration
|
||||
await tester.pump(slideDuration - tick);
|
||||
expect(show.events, ['show 0']);
|
||||
|
||||
await tester.pump(tick * 2);
|
||||
expect(show.events, ['show 0', 'show 3']);
|
||||
});
|
||||
|
||||
slideshowTest('should pick up a new next slide when recalculating', (tester, show) async {
|
||||
expect(show.controller.currentIndex, 0);
|
||||
expect(show.controller.nextIndex, 1);
|
||||
|
||||
show.nextIndex = (_) => 7;
|
||||
show.controller.recalculateNextIndex();
|
||||
|
||||
expect(show.controller.nextIndex, 7);
|
||||
});
|
||||
}
|
||||
@@ -178,6 +178,7 @@ class ServiceMocks {
|
||||
}
|
||||
|
||||
void _stubAssetService() {
|
||||
when(asset.getAsset).thenAnswer((_) async => null);
|
||||
when(asset.update).thenAnswer((_) async {});
|
||||
when(asset.stack).thenAnswer((_) async {});
|
||||
when(asset.unstack).thenAnswer((_) async {});
|
||||
@@ -343,6 +344,9 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
|
||||
}
|
||||
|
||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<BaseAsset?> Function() get getAsset =>
|
||||
() => service.getAsset(any());
|
||||
|
||||
Future<void> Function() get update =>
|
||||
() => service.update(
|
||||
any(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
@@ -90,6 +91,12 @@ class PresentationContext {
|
||||
|
||||
void setup() {
|
||||
when(service.user.tryGetMyUser).thenReturn(currentUser);
|
||||
|
||||
// Handle system chrome messages
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
@@ -99,7 +106,15 @@ class PresentationContext {
|
||||
}
|
||||
|
||||
extension PumpPresentationWidget on WidgetTester {
|
||||
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
|
||||
/// Renders the UI from the given [widget]
|
||||
///
|
||||
/// Provide [expectSettle] `false` for a component that infinitely animates
|
||||
Future<void> pumpTestWidget(
|
||||
PresentationContext context,
|
||||
Widget widget, {
|
||||
List<Override> overrides = const [],
|
||||
bool expectSettle = true,
|
||||
}) async {
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
@@ -127,7 +142,12 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
),
|
||||
),
|
||||
);
|
||||
await pumpAndSettle();
|
||||
|
||||
if (expectSettle) {
|
||||
await pumpAndSettle();
|
||||
} else {
|
||||
await pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pumpTestAction(
|
||||
|
||||
Reference in New Issue
Block a user