Compare commits

...

5 Commits

4 changed files with 52 additions and 65 deletions
@@ -106,7 +106,7 @@ class _FixedSegmentRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final recommendDeferredLoading = ref.watch(timelineStateProvider.select((s) => s.recommendDeferredLoading));
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
@@ -119,7 +119,7 @@ class _FixedSegmentRow extends ConsumerWidget {
); );
} }
if (isScrubbing) { if (recommendDeferredLoading) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
@@ -11,7 +11,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection; import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
@@ -89,8 +88,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
bool _isDragging = false; bool _isDragging = false;
List<_Segment> _segments = []; List<_Segment> _segments = [];
int _monthCount = 0; int _monthCount = 0;
DateTime? _currentScrubberDate;
Debouncer? _scrubberDebouncer;
late AnimationController _thumbAnimationController; late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer; Timer? _fadeOutTimer;
@@ -145,7 +142,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_thumbAnimationController.dispose(); _thumbAnimationController.dispose();
_labelAnimationController.dispose(); _labelAnimationController.dispose();
_fadeOutTimer?.cancel(); _fadeOutTimer?.cancel();
_scrubberDebouncer?.dispose();
super.dispose(); super.dispose();
} }
@@ -189,24 +185,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
return false; return false;
} }
void _onScrubberDateChanged(DateTime date) {
if (_currentScrubberDate != date) {
// Date changed, immediately set scrubbing to true
_currentScrubberDate = date;
ref.read(timelineStateProvider.notifier).setScrubbing(true);
// Initialize debouncer if needed
_scrubberDebouncer ??= Debouncer(interval: const Duration(milliseconds: 50));
// Debounce setting scrubbing to false
_scrubberDebouncer!.run(() {
if (_currentScrubberDate == date) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
}
});
}
}
void _onDragStart(DragStartDetails _) { void _onDragStart(DragStartDetails _) {
setState(() { setState(() {
_isDragging = true; _isDragging = true;
@@ -237,11 +215,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (_lastLabel != label) { if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label; _lastLabel = label;
// Notify timeline state of the new scrubber date position
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
_onScrubberDateChanged(nearestMonthSegment.date);
}
} }
} }
@@ -349,13 +322,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_isDragging = false; _isDragging = false;
}); });
ref.read(timelineStateProvider.notifier).setScrubbing(false);
// Reset scrubber tracking when drag ends
_currentScrubberDate = null;
_scrubberDebouncer?.dispose();
_scrubberDebouncer = null;
_resetThumbTimer(); _resetThumbTimer();
} }
@@ -50,37 +50,43 @@ class TimelineArgs {
} }
class TimelineState { class TimelineState {
final bool isScrubbing;
final bool isScrolling; final bool isScrolling;
const TimelineState({this.isScrubbing = false, this.isScrolling = false}); /// Indicates whether the timeline is scrolling beyond some configured "high" speed,
/// such as when programmatically scrolling to the top or a really fast user fling
final bool recommendDeferredLoading;
bool get isInteracting => isScrubbing || isScrolling; const TimelineState({this.isScrolling = false, this.recommendDeferredLoading = false});
bool get isInteracting => isScrolling || recommendDeferredLoading;
@override @override
bool operator ==(covariant TimelineState other) { bool operator ==(covariant TimelineState other) {
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling; return isScrolling == other.isScrolling && recommendDeferredLoading == other.recommendDeferredLoading;
} }
@override @override
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode; int get hashCode => isScrolling.hashCode ^ recommendDeferredLoading.hashCode;
TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) { TimelineState copyWith({bool? isScrolling, bool? recommendDeferredLoading}) {
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling); return TimelineState(
isScrolling: isScrolling ?? this.isScrolling,
recommendDeferredLoading: recommendDeferredLoading ?? this.recommendDeferredLoading,
);
} }
} }
class TimelineStateNotifier extends Notifier<TimelineState> { class TimelineStateNotifier extends Notifier<TimelineState> {
void setScrubbing(bool isScrubbing) {
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) { void setScrolling(bool isScrolling) {
state = state.copyWith(isScrolling: isScrolling); state = state.copyWith(isScrolling: isScrolling);
} }
void setRecommendDeferredLoading(bool recommendDeferredLoading) {
state = state.copyWith(recommendDeferredLoading: recommendDeferredLoading);
}
@override @override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false); TimelineState build() => const TimelineState(isScrolling: false, recommendDeferredLoading: false);
} }
// This provider watches the buckets from the timeline service & args and serves the segments. // This provider watches the buckets from the timeline service & args and serves the segments.
@@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
@@ -150,6 +151,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
double _baseScaleFactor = 3.0; double _baseScaleFactor = 3.0;
int? _restoreAssetIndex; int? _restoreAssetIndex;
final Debouncer _fastScrollDebouncer = Debouncer(interval: const Duration(milliseconds: 100));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -182,11 +185,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
switch (event) { switch (event) {
case ScrollToTopEvent(): case ScrollToTopEvent():
{ {
final timelineState = ref.read(timelineStateProvider.notifier); _scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
} }
case ScrollToDateEvent scrollToDateEvent: case ScrollToDateEvent scrollToDateEvent:
@@ -246,13 +245,31 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void dispose() { void dispose() {
_fastScrollDebouncer.dispose();
_scrollController.dispose(); _scrollController.dispose();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); super.dispose();
} }
/// Track whether the timeline is moving fast enough to defer per-row asset loading
bool _onScrollVelocityNotification(ScrollNotification notification) {
// Only consider the primary timeline ScrollView (no nested views) and update events
if (notification.depth != 0 || notification is! ScrollUpdateNotification) {
return false;
}
// Use Flutter's built in fast velocity tracking
if (_scrollController.position.recommendDeferredLoading(context)) {
ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(true);
// We cannot rely on scroll end events, as the timeline scrubber jumps from position
// to position, resulting in large spikes in velocity followed by low velocity
_fastScrollDebouncer.run(() => ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(false));
}
return false;
}
void _scrollToDate(DateTime date) { void _scrollToDate(DateTime date) {
final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider); final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) { asyncSegments.whenData((segments) {
// Find the segment that contains assets from the target date // Find the segment that contains assets from the target date
@@ -279,16 +296,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
if (fallbackSegment != null) { if (fallbackSegment != null) {
// Scroll to the segment with a small offset to show the header // Scroll to the segment with a small offset to show the header
final targetOffset = fallbackSegment.startOffset - 50; final targetOffset = fallbackSegment.startOffset - 50;
timelineState.setScrubbing(true); _scrollController.animateTo(
_scrollController targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
.animateTo( duration: const Duration(milliseconds: 500),
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent), curve: Curves.easeInOut,
duration: const Duration(milliseconds: 500), );
curve: Curves.easeInOut,
)
.whenComplete(() => timelineState.setScrubbing(false));
} else {
timelineState.setScrubbing(false);
} }
}); });
} }
@@ -480,7 +492,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
timeline, NotificationListener<ScrollNotification>(
onNotification: _onScrollVelocityNotification,
child: timeline,
),
if (isBottomWidgetVisible) if (isBottomWidgetVisible)
Positioned( Positioned(
top: MediaQuery.paddingOf(context).top, top: MediaQuery.paddingOf(context).top,