mirror of
https://github.com/immich-app/immich.git
synced 2026-07-06 12:36:55 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc99ddd053 | |||
| 947095b453 | |||
| fef42a2cc9 | |||
| 24a7419844 | |||
| 1f3998cda8 |
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user