mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(mobile): Improve timeline performance on mobile - experimental (#710)
This commit is contained in:
		
							parent
							
								
									8ede738396
								
							
						
					
					
						commit
						28bf497a0b
					
				@ -165,5 +165,10 @@
 | 
			
		||||
  "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
 | 
			
		||||
  "version_announcement_overlay_text_2": "please take your time to visit the ",
 | 
			
		||||
  "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
 | 
			
		||||
  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
 | 
			
		||||
  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
 | 
			
		||||
  "experimental_settings_title": "Experimental",
 | 
			
		||||
  "experimental_settings_subtitle": "Use at your own risk!",
 | 
			
		||||
  "experimental_settings_new_asset_list_title": "Enable experimental photo grid",
 | 
			
		||||
  "experimental_settings_new_asset_list_subtitle": "Work in progress",
 | 
			
		||||
  "settings_require_restart": "Please restart Immich to apply this setting"
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,91 @@
 | 
			
		||||
import 'dart:math';
 | 
			
		||||
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
enum RenderAssetGridElementType {
 | 
			
		||||
  assetRow,
 | 
			
		||||
  dayTitle,
 | 
			
		||||
  monthTitle;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class RenderAssetGridRow {
 | 
			
		||||
  final List<AssetResponseDto> assets;
 | 
			
		||||
 | 
			
		||||
  RenderAssetGridRow(this.assets);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class RenderAssetGridElement {
 | 
			
		||||
  final RenderAssetGridElementType type;
 | 
			
		||||
  final RenderAssetGridRow? assetRow;
 | 
			
		||||
  final String? title;
 | 
			
		||||
  final int? month;
 | 
			
		||||
  final int? year;
 | 
			
		||||
  final List<AssetResponseDto>? relatedAssetList;
 | 
			
		||||
 | 
			
		||||
  RenderAssetGridElement(
 | 
			
		||||
    this.type, {
 | 
			
		||||
    this.assetRow,
 | 
			
		||||
    this.title,
 | 
			
		||||
    this.month,
 | 
			
		||||
    this.year,
 | 
			
		||||
    this.relatedAssetList,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final renderListProvider = StateProvider((ref) {
 | 
			
		||||
  var assetGroups = ref.watch(assetGroupByDateTimeProvider);
 | 
			
		||||
  var settings = ref.watch(appSettingsServiceProvider);
 | 
			
		||||
 | 
			
		||||
  final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
 | 
			
		||||
 | 
			
		||||
  List<RenderAssetGridElement> elements = [];
 | 
			
		||||
  DateTime? lastDate;
 | 
			
		||||
 | 
			
		||||
  assetGroups.forEach((groupName, assets) {
 | 
			
		||||
    final date = DateTime.parse(groupName);
 | 
			
		||||
 | 
			
		||||
    if (lastDate == null || lastDate!.month != date.month) {
 | 
			
		||||
      elements.add(
 | 
			
		||||
        RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
 | 
			
		||||
            title: groupName, month: date.month, year: date.year),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add group title
 | 
			
		||||
    elements.add(
 | 
			
		||||
      RenderAssetGridElement(
 | 
			
		||||
        RenderAssetGridElementType.dayTitle,
 | 
			
		||||
        title: groupName,
 | 
			
		||||
        month: date.month,
 | 
			
		||||
        year: date.year,
 | 
			
		||||
        relatedAssetList: assets,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Add rows
 | 
			
		||||
    int cursor = 0;
 | 
			
		||||
    while (cursor < assets.length) {
 | 
			
		||||
      int rowElements = min(assets.length - cursor, assetsPerRow);
 | 
			
		||||
 | 
			
		||||
      final rowElement = RenderAssetGridElement(
 | 
			
		||||
        RenderAssetGridElementType.assetRow,
 | 
			
		||||
        month: date.month,
 | 
			
		||||
        year: date.year,
 | 
			
		||||
        assetRow: RenderAssetGridRow(
 | 
			
		||||
          assets.sublist(cursor, cursor + rowElements),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      elements.add(rowElement);
 | 
			
		||||
      cursor += rowElements;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    lastDate = date;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return elements;
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										107
									
								
								mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,107 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
class DailyTitleText extends ConsumerWidget {
 | 
			
		||||
  const DailyTitleText({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required this.isoDate,
 | 
			
		||||
    required this.assetGroup,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  final String isoDate;
 | 
			
		||||
  final List<AssetResponseDto> assetGroup;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var currentYear = DateTime.now().year;
 | 
			
		||||
    var groupYear = DateTime.parse(isoDate).year;
 | 
			
		||||
    var formatDateTemplate = currentYear == groupYear
 | 
			
		||||
        ? "daily_title_text_date".tr()
 | 
			
		||||
        : "daily_title_text_date_year".tr();
 | 
			
		||||
    var dateText =
 | 
			
		||||
        DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
 | 
			
		||||
    var isMultiSelectEnable =
 | 
			
		||||
        ref.watch(homePageStateProvider).isMultiSelectEnable;
 | 
			
		||||
    var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
 | 
			
		||||
    var selectedItems = ref.watch(homePageStateProvider).selectedItems;
 | 
			
		||||
 | 
			
		||||
    void _handleTitleIconClick() {
 | 
			
		||||
      if (isMultiSelectEnable &&
 | 
			
		||||
          selectedDateGroup.contains(dateText) &&
 | 
			
		||||
          selectedDateGroup.length == 1 &&
 | 
			
		||||
          selectedItems.length <= assetGroup.length) {
 | 
			
		||||
        // Multi select is active - click again on the icon while it is the only active group -> disable multi select
 | 
			
		||||
        ref.watch(homePageStateProvider.notifier).disableMultiSelect();
 | 
			
		||||
      } else if (isMultiSelectEnable &&
 | 
			
		||||
          selectedDateGroup.contains(dateText) &&
 | 
			
		||||
          selectedItems.length != assetGroup.length) {
 | 
			
		||||
        // Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .removeSelectedDateGroup(dateText);
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .removeMultipleSelectedItem(assetGroup);
 | 
			
		||||
      } else if (isMultiSelectEnable &&
 | 
			
		||||
          selectedDateGroup.contains(dateText) &&
 | 
			
		||||
          selectedDateGroup.length > 1) {
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .removeSelectedDateGroup(dateText);
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .removeMultipleSelectedItem(assetGroup);
 | 
			
		||||
      } else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .addSelectedDateGroup(dateText);
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .addMultipleSelectedItems(assetGroup);
 | 
			
		||||
      } else {
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .enableMultiSelect(assetGroup.toSet());
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(homePageStateProvider.notifier)
 | 
			
		||||
            .addSelectedDateGroup(dateText);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Padding(
 | 
			
		||||
      padding: const EdgeInsets.only(
 | 
			
		||||
        top: 29.0,
 | 
			
		||||
        bottom: 29.0,
 | 
			
		||||
        left: 12.0,
 | 
			
		||||
        right: 12.0,
 | 
			
		||||
      ),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        children: [
 | 
			
		||||
          Text(
 | 
			
		||||
            dateText,
 | 
			
		||||
            style: const TextStyle(
 | 
			
		||||
              fontSize: 14,
 | 
			
		||||
              fontWeight: FontWeight.bold,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          const Spacer(),
 | 
			
		||||
          GestureDetector(
 | 
			
		||||
            onTap: _handleTitleIconClick,
 | 
			
		||||
            child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
 | 
			
		||||
                ? Icon(
 | 
			
		||||
                    Icons.check_circle_rounded,
 | 
			
		||||
                    color: Theme.of(context).primaryColor,
 | 
			
		||||
                  )
 | 
			
		||||
                : const Icon(
 | 
			
		||||
                    Icons.check_circle_outline_rounded,
 | 
			
		||||
                    color: Colors.grey,
 | 
			
		||||
                  ),
 | 
			
		||||
          )
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,536 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 | 
			
		||||
 | 
			
		||||
/// Build the Scroll Thumb and label using the current configuration
 | 
			
		||||
typedef ScrollThumbBuilder = Widget Function(
 | 
			
		||||
  Color backgroundColor,
 | 
			
		||||
  Animation<double> thumbAnimation,
 | 
			
		||||
  Animation<double> labelAnimation,
 | 
			
		||||
  double height, {
 | 
			
		||||
  Text? labelText,
 | 
			
		||||
  BoxConstraints? labelConstraints,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/// Build a Text widget using the current scroll offset
 | 
			
		||||
typedef LabelTextBuilder = Text Function(int item);
 | 
			
		||||
 | 
			
		||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
 | 
			
		||||
/// for quick navigation of the BoxScrollView.
 | 
			
		||||
class DraggableScrollbar extends StatefulWidget {
 | 
			
		||||
  /// The view that will be scrolled with the scroll thumb
 | 
			
		||||
  final ScrollablePositionedList child;
 | 
			
		||||
 | 
			
		||||
  final ItemPositionsListener itemPositionsListener;
 | 
			
		||||
 | 
			
		||||
  /// A function that builds a thumb using the current configuration
 | 
			
		||||
  final ScrollThumbBuilder scrollThumbBuilder;
 | 
			
		||||
 | 
			
		||||
  /// The height of the scroll thumb
 | 
			
		||||
  final double heightScrollThumb;
 | 
			
		||||
 | 
			
		||||
  /// The background color of the label and thumb
 | 
			
		||||
  final Color backgroundColor;
 | 
			
		||||
 | 
			
		||||
  /// The amount of padding that should surround the thumb
 | 
			
		||||
  final EdgeInsetsGeometry? padding;
 | 
			
		||||
 | 
			
		||||
  /// Determines how quickly the scrollbar will animate in and out
 | 
			
		||||
  final Duration scrollbarAnimationDuration;
 | 
			
		||||
 | 
			
		||||
  /// How long should the thumb be visible before fading out
 | 
			
		||||
  final Duration scrollbarTimeToFade;
 | 
			
		||||
 | 
			
		||||
  /// Build a Text widget from the current offset in the BoxScrollView
 | 
			
		||||
  final LabelTextBuilder? labelTextBuilder;
 | 
			
		||||
 | 
			
		||||
  /// Determines box constraints for Container displaying label
 | 
			
		||||
  final BoxConstraints? labelConstraints;
 | 
			
		||||
 | 
			
		||||
  /// The ScrollController for the BoxScrollView
 | 
			
		||||
  final ItemScrollController controller;
 | 
			
		||||
 | 
			
		||||
  /// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
 | 
			
		||||
  final bool alwaysVisibleScrollThumb;
 | 
			
		||||
 | 
			
		||||
  final Function(bool scrolling) scrollStateListener;
 | 
			
		||||
 | 
			
		||||
  DraggableScrollbar.semicircle({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    Key? scrollThumbKey,
 | 
			
		||||
    this.alwaysVisibleScrollThumb = false,
 | 
			
		||||
    required this.child,
 | 
			
		||||
    required this.controller,
 | 
			
		||||
    required this.itemPositionsListener,
 | 
			
		||||
    required this.scrollStateListener,
 | 
			
		||||
    this.heightScrollThumb = 48.0,
 | 
			
		||||
    this.backgroundColor = Colors.white,
 | 
			
		||||
    this.padding,
 | 
			
		||||
    this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
 | 
			
		||||
    this.scrollbarTimeToFade = const Duration(milliseconds: 600),
 | 
			
		||||
    this.labelTextBuilder,
 | 
			
		||||
    this.labelConstraints,
 | 
			
		||||
  })  : assert(child.scrollDirection == Axis.vertical),
 | 
			
		||||
        scrollThumbBuilder = _thumbSemicircleBuilder(
 | 
			
		||||
          heightScrollThumb * 0.6,
 | 
			
		||||
          scrollThumbKey,
 | 
			
		||||
          alwaysVisibleScrollThumb,
 | 
			
		||||
        ),
 | 
			
		||||
        super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  DraggableScrollbarState createState() => DraggableScrollbarState();
 | 
			
		||||
 | 
			
		||||
  static buildScrollThumbAndLabel({
 | 
			
		||||
    required Widget scrollThumb,
 | 
			
		||||
    required Color backgroundColor,
 | 
			
		||||
    required Animation<double>? thumbAnimation,
 | 
			
		||||
    required Animation<double>? labelAnimation,
 | 
			
		||||
    required Text? labelText,
 | 
			
		||||
    required BoxConstraints? labelConstraints,
 | 
			
		||||
    required bool alwaysVisibleScrollThumb,
 | 
			
		||||
  }) {
 | 
			
		||||
    var scrollThumbAndLabel = labelText == null
 | 
			
		||||
        ? scrollThumb
 | 
			
		||||
        : Row(
 | 
			
		||||
            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
            children: [
 | 
			
		||||
              ScrollLabel(
 | 
			
		||||
                animation: labelAnimation,
 | 
			
		||||
                backgroundColor: backgroundColor,
 | 
			
		||||
                constraints: labelConstraints,
 | 
			
		||||
                child: labelText,
 | 
			
		||||
              ),
 | 
			
		||||
              scrollThumb,
 | 
			
		||||
            ],
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
    if (alwaysVisibleScrollThumb) {
 | 
			
		||||
      return scrollThumbAndLabel;
 | 
			
		||||
    }
 | 
			
		||||
    return SlideFadeTransition(
 | 
			
		||||
      animation: thumbAnimation!,
 | 
			
		||||
      child: scrollThumbAndLabel,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static ScrollThumbBuilder _thumbSemicircleBuilder(
 | 
			
		||||
    double width,
 | 
			
		||||
    Key? scrollThumbKey,
 | 
			
		||||
    bool alwaysVisibleScrollThumb,
 | 
			
		||||
  ) {
 | 
			
		||||
    return (
 | 
			
		||||
      Color backgroundColor,
 | 
			
		||||
      Animation<double> thumbAnimation,
 | 
			
		||||
      Animation<double> labelAnimation,
 | 
			
		||||
      double height, {
 | 
			
		||||
      Text? labelText,
 | 
			
		||||
      BoxConstraints? labelConstraints,
 | 
			
		||||
    }) {
 | 
			
		||||
      final scrollThumb = CustomPaint(
 | 
			
		||||
        key: scrollThumbKey,
 | 
			
		||||
        foregroundPainter: ArrowCustomPainter(Colors.white),
 | 
			
		||||
        child: Material(
 | 
			
		||||
          elevation: 4.0,
 | 
			
		||||
          color: backgroundColor,
 | 
			
		||||
          borderRadius: BorderRadius.only(
 | 
			
		||||
            topLeft: Radius.circular(height),
 | 
			
		||||
            bottomLeft: Radius.circular(height),
 | 
			
		||||
            topRight: const Radius.circular(4.0),
 | 
			
		||||
            bottomRight: const Radius.circular(4.0),
 | 
			
		||||
          ),
 | 
			
		||||
          child: Container(
 | 
			
		||||
            constraints: BoxConstraints.tight(Size(width, height)),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return buildScrollThumbAndLabel(
 | 
			
		||||
        scrollThumb: scrollThumb,
 | 
			
		||||
        backgroundColor: backgroundColor,
 | 
			
		||||
        thumbAnimation: thumbAnimation,
 | 
			
		||||
        labelAnimation: labelAnimation,
 | 
			
		||||
        labelText: labelText,
 | 
			
		||||
        labelConstraints: labelConstraints,
 | 
			
		||||
        alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
 | 
			
		||||
      );
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ScrollLabel extends StatelessWidget {
 | 
			
		||||
  final Animation<double>? animation;
 | 
			
		||||
  final Color backgroundColor;
 | 
			
		||||
  final Text child;
 | 
			
		||||
 | 
			
		||||
  final BoxConstraints? constraints;
 | 
			
		||||
  static const BoxConstraints _defaultConstraints =
 | 
			
		||||
      BoxConstraints.tightFor(width: 72.0, height: 28.0);
 | 
			
		||||
 | 
			
		||||
  const ScrollLabel({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required this.child,
 | 
			
		||||
    required this.animation,
 | 
			
		||||
    required this.backgroundColor,
 | 
			
		||||
    this.constraints = _defaultConstraints,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return FadeTransition(
 | 
			
		||||
      opacity: animation!,
 | 
			
		||||
      child: Container(
 | 
			
		||||
        margin: const EdgeInsets.only(right: 12.0),
 | 
			
		||||
        child: Material(
 | 
			
		||||
          elevation: 4.0,
 | 
			
		||||
          color: backgroundColor,
 | 
			
		||||
          borderRadius: const BorderRadius.all(Radius.circular(16.0)),
 | 
			
		||||
          child: Container(
 | 
			
		||||
            constraints: constraints ?? _defaultConstraints,
 | 
			
		||||
            alignment: Alignment.center,
 | 
			
		||||
            child: child,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class DraggableScrollbarState extends State<DraggableScrollbar>
 | 
			
		||||
    with TickerProviderStateMixin {
 | 
			
		||||
  late double _barOffset;
 | 
			
		||||
  late bool _isDragInProcess;
 | 
			
		||||
  late int _currentItem;
 | 
			
		||||
 | 
			
		||||
  late AnimationController _thumbAnimationController;
 | 
			
		||||
  late Animation<double> _thumbAnimation;
 | 
			
		||||
  late AnimationController _labelAnimationController;
 | 
			
		||||
  late Animation<double> _labelAnimation;
 | 
			
		||||
  Timer? _fadeoutTimer;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _barOffset = 0.0;
 | 
			
		||||
    _isDragInProcess = false;
 | 
			
		||||
    _currentItem = 0;
 | 
			
		||||
 | 
			
		||||
    _thumbAnimationController = AnimationController(
 | 
			
		||||
      vsync: this,
 | 
			
		||||
      duration: widget.scrollbarAnimationDuration,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _thumbAnimation = CurvedAnimation(
 | 
			
		||||
      parent: _thumbAnimationController,
 | 
			
		||||
      curve: Curves.fastOutSlowIn,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _labelAnimationController = AnimationController(
 | 
			
		||||
      vsync: this,
 | 
			
		||||
      duration: widget.scrollbarAnimationDuration,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _labelAnimation = CurvedAnimation(
 | 
			
		||||
      parent: _labelAnimationController,
 | 
			
		||||
      curve: Curves.fastOutSlowIn,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _thumbAnimationController.dispose();
 | 
			
		||||
    _labelAnimationController.dispose();
 | 
			
		||||
    _fadeoutTimer?.cancel();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  double get barMaxScrollExtent =>
 | 
			
		||||
      (context.size?.height ?? 0) - widget.heightScrollThumb;
 | 
			
		||||
 | 
			
		||||
  double get barMinScrollExtent => 0;
 | 
			
		||||
 | 
			
		||||
  int get maxItemCount => widget.child.itemCount;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    Text? labelText;
 | 
			
		||||
    if (widget.labelTextBuilder != null && _isDragInProcess) {
 | 
			
		||||
      int numberOfItems = widget.child.itemCount;
 | 
			
		||||
 | 
			
		||||
      labelText = widget.labelTextBuilder!(_currentItem);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return LayoutBuilder(
 | 
			
		||||
      builder: (BuildContext context, BoxConstraints constraints) {
 | 
			
		||||
        //print("LayoutBuilder constraints=$constraints");
 | 
			
		||||
 | 
			
		||||
        return NotificationListener<ScrollNotification>(
 | 
			
		||||
          onNotification: (ScrollNotification notification) {
 | 
			
		||||
            changePosition(notification);
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
          child: Stack(
 | 
			
		||||
            children: <Widget>[
 | 
			
		||||
              RepaintBoundary(
 | 
			
		||||
                child: widget.child,
 | 
			
		||||
              ),
 | 
			
		||||
              RepaintBoundary(
 | 
			
		||||
                child: GestureDetector(
 | 
			
		||||
                  onVerticalDragStart: _onVerticalDragStart,
 | 
			
		||||
                  onVerticalDragUpdate: _onVerticalDragUpdate,
 | 
			
		||||
                  onVerticalDragEnd: _onVerticalDragEnd,
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    alignment: Alignment.topRight,
 | 
			
		||||
                    margin: EdgeInsets.only(top: _barOffset),
 | 
			
		||||
                    padding: widget.padding,
 | 
			
		||||
                    child: widget.scrollThumbBuilder(
 | 
			
		||||
                      widget.backgroundColor,
 | 
			
		||||
                      _thumbAnimation,
 | 
			
		||||
                      _labelAnimation,
 | 
			
		||||
                      widget.heightScrollThumb,
 | 
			
		||||
                      labelText: labelText,
 | 
			
		||||
                      labelConstraints: widget.labelConstraints,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // scroll bar has received notification that it's view was scrolled
 | 
			
		||||
  // so it should also changes his position
 | 
			
		||||
  // but only if it isn't dragged
 | 
			
		||||
  changePosition(ScrollNotification notification) {
 | 
			
		||||
    if (_isDragInProcess) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setState(() {
 | 
			
		||||
      int firstItemIndex =
 | 
			
		||||
          widget.itemPositionsListener.itemPositions.value.first.index;
 | 
			
		||||
 | 
			
		||||
      if (notification is ScrollUpdateNotification) {
 | 
			
		||||
        _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
 | 
			
		||||
 | 
			
		||||
        if (_barOffset < barMinScrollExtent) {
 | 
			
		||||
          _barOffset = barMinScrollExtent;
 | 
			
		||||
        }
 | 
			
		||||
        if (_barOffset > barMaxScrollExtent) {
 | 
			
		||||
          _barOffset = barMaxScrollExtent;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (notification is ScrollUpdateNotification ||
 | 
			
		||||
          notification is OverscrollNotification) {
 | 
			
		||||
        if (_thumbAnimationController.status != AnimationStatus.forward) {
 | 
			
		||||
          _thumbAnimationController.forward();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (itemPos < maxItemCount) {
 | 
			
		||||
          _currentItem = itemPos;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _fadeoutTimer?.cancel();
 | 
			
		||||
        _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
 | 
			
		||||
          _thumbAnimationController.reverse();
 | 
			
		||||
          _labelAnimationController.reverse();
 | 
			
		||||
          _fadeoutTimer = null;
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onVerticalDragStart(DragStartDetails details) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _isDragInProcess = true;
 | 
			
		||||
      _labelAnimationController.forward();
 | 
			
		||||
      _fadeoutTimer?.cancel();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    widget.scrollStateListener(true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int get itemPos {
 | 
			
		||||
    int numberOfItems = widget.child.itemCount;
 | 
			
		||||
    return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _jumpToBarPos() {
 | 
			
		||||
    if (itemPos > maxItemCount - 1) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _currentItem = itemPos;
 | 
			
		||||
 | 
			
		||||
    widget.controller.jumpTo(
 | 
			
		||||
      index: itemPos,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Timer? dragHaltTimer;
 | 
			
		||||
  int lastTimerPos = 0;
 | 
			
		||||
 | 
			
		||||
  void _onVerticalDragUpdate(DragUpdateDetails details) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      if (_thumbAnimationController.status != AnimationStatus.forward) {
 | 
			
		||||
        _thumbAnimationController.forward();
 | 
			
		||||
      }
 | 
			
		||||
      if (_isDragInProcess) {
 | 
			
		||||
        _barOffset += details.delta.dy;
 | 
			
		||||
 | 
			
		||||
        if (_barOffset < barMinScrollExtent) {
 | 
			
		||||
          _barOffset = barMinScrollExtent;
 | 
			
		||||
        }
 | 
			
		||||
        if (_barOffset > barMaxScrollExtent) {
 | 
			
		||||
          _barOffset = barMaxScrollExtent;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (itemPos != lastTimerPos) {
 | 
			
		||||
          lastTimerPos = itemPos;
 | 
			
		||||
          dragHaltTimer?.cancel();
 | 
			
		||||
          widget.scrollStateListener(true);
 | 
			
		||||
 | 
			
		||||
          dragHaltTimer = Timer(
 | 
			
		||||
            const Duration(milliseconds: 200),
 | 
			
		||||
                () {
 | 
			
		||||
              widget.scrollStateListener(false);
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _jumpToBarPos();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onVerticalDragEnd(DragEndDetails details) {
 | 
			
		||||
    _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
 | 
			
		||||
      _thumbAnimationController.reverse();
 | 
			
		||||
      _labelAnimationController.reverse();
 | 
			
		||||
      _fadeoutTimer = null;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _jumpToBarPos();
 | 
			
		||||
      _isDragInProcess = false;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    widget.scrollStateListener(false);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Draws 2 triangles like arrow up and arrow down
 | 
			
		||||
class ArrowCustomPainter extends CustomPainter {
 | 
			
		||||
  Color color;
 | 
			
		||||
 | 
			
		||||
  ArrowCustomPainter(this.color);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void paint(Canvas canvas, Size size) {
 | 
			
		||||
    final paint = Paint()..color = color;
 | 
			
		||||
    const width = 12.0;
 | 
			
		||||
    const height = 8.0;
 | 
			
		||||
    final baseX = size.width / 2;
 | 
			
		||||
    final baseY = size.height / 2;
 | 
			
		||||
 | 
			
		||||
    canvas.drawPath(
 | 
			
		||||
      _trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
 | 
			
		||||
      paint,
 | 
			
		||||
    );
 | 
			
		||||
    canvas.drawPath(
 | 
			
		||||
      _trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
 | 
			
		||||
      paint,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Path _trianglePath(Offset o, double width, double height, bool isUp) {
 | 
			
		||||
    return Path()
 | 
			
		||||
      ..moveTo(o.dx, o.dy)
 | 
			
		||||
      ..lineTo(o.dx + width, o.dy)
 | 
			
		||||
      ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
 | 
			
		||||
      ..close();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
///This cut 2 lines in arrow shape
 | 
			
		||||
class ArrowClipper extends CustomClipper<Path> {
 | 
			
		||||
  @override
 | 
			
		||||
  Path getClip(Size size) {
 | 
			
		||||
    Path path = Path();
 | 
			
		||||
    path.lineTo(0.0, size.height);
 | 
			
		||||
    path.lineTo(size.width, size.height);
 | 
			
		||||
    path.lineTo(size.width, 0.0);
 | 
			
		||||
    path.lineTo(0.0, 0.0);
 | 
			
		||||
    path.close();
 | 
			
		||||
 | 
			
		||||
    double arrowWidth = 8.0;
 | 
			
		||||
    double startPointX = (size.width - arrowWidth) / 2;
 | 
			
		||||
    double startPointY = size.height / 2 - arrowWidth / 2;
 | 
			
		||||
    path.moveTo(startPointX, startPointY);
 | 
			
		||||
    path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
 | 
			
		||||
    path.lineTo(startPointX + arrowWidth, startPointY);
 | 
			
		||||
    path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
 | 
			
		||||
    path.lineTo(
 | 
			
		||||
      startPointX + arrowWidth / 2,
 | 
			
		||||
      startPointY - arrowWidth / 2 + 1.0,
 | 
			
		||||
    );
 | 
			
		||||
    path.lineTo(startPointX, startPointY + 1.0);
 | 
			
		||||
    path.close();
 | 
			
		||||
 | 
			
		||||
    startPointY = size.height / 2 + arrowWidth / 2;
 | 
			
		||||
    path.moveTo(startPointX + arrowWidth, startPointY);
 | 
			
		||||
    path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
 | 
			
		||||
    path.lineTo(startPointX, startPointY);
 | 
			
		||||
    path.lineTo(startPointX, startPointY - 1.0);
 | 
			
		||||
    path.lineTo(
 | 
			
		||||
      startPointX + arrowWidth / 2,
 | 
			
		||||
      startPointY + arrowWidth / 2 - 1.0,
 | 
			
		||||
    );
 | 
			
		||||
    path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
 | 
			
		||||
    path.close();
 | 
			
		||||
 | 
			
		||||
    return path;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SlideFadeTransition extends StatelessWidget {
 | 
			
		||||
  final Animation<double> animation;
 | 
			
		||||
  final Widget child;
 | 
			
		||||
 | 
			
		||||
  const SlideFadeTransition({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required this.animation,
 | 
			
		||||
    required this.child,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AnimatedBuilder(
 | 
			
		||||
      animation: animation,
 | 
			
		||||
      builder: (context, child) =>
 | 
			
		||||
          animation.value == 0.0 ? const SizedBox() : child!,
 | 
			
		||||
      child: SlideTransition(
 | 
			
		||||
        position: Tween(
 | 
			
		||||
          begin: const Offset(0.3, 0.0),
 | 
			
		||||
          end: const Offset(0.0, 0.0),
 | 
			
		||||
        ).animate(animation),
 | 
			
		||||
        child: FadeTransition(
 | 
			
		||||
          opacity: animation,
 | 
			
		||||
          child: child,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										166
									
								
								mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,166 @@
 | 
			
		||||
import 'dart:math';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/cupertino.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/src/widgets/framework.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 | 
			
		||||
 | 
			
		||||
import '../thumbnail_image.dart';
 | 
			
		||||
 | 
			
		||||
class ImmichAssetGrid extends HookConsumerWidget {
 | 
			
		||||
  final ItemScrollController _itemScrollController = ItemScrollController();
 | 
			
		||||
  final ItemPositionsListener _itemPositionsListener =
 | 
			
		||||
      ItemPositionsListener.create();
 | 
			
		||||
 | 
			
		||||
  final List<RenderAssetGridElement> renderList;
 | 
			
		||||
  final int assetsPerRow;
 | 
			
		||||
  final double margin;
 | 
			
		||||
  final bool showStorageIndicator;
 | 
			
		||||
 | 
			
		||||
  ImmichAssetGrid({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.renderList,
 | 
			
		||||
    required this.assetsPerRow,
 | 
			
		||||
    required this.showStorageIndicator,
 | 
			
		||||
    this.margin = 5.0,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  List<AssetResponseDto> get _assets {
 | 
			
		||||
    return renderList
 | 
			
		||||
        .map((e) {
 | 
			
		||||
          if (e.type == RenderAssetGridElementType.assetRow) {
 | 
			
		||||
            return e.assetRow!.assets;
 | 
			
		||||
          } else {
 | 
			
		||||
            return List<AssetResponseDto>.empty();
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .flattened
 | 
			
		||||
        .toList();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  double _getItemSize(BuildContext context) {
 | 
			
		||||
    return MediaQuery.of(context).size.width / assetsPerRow -
 | 
			
		||||
        margin * (assetsPerRow - 1) / assetsPerRow;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildThumbnailOrPlaceholder(
 | 
			
		||||
      AssetResponseDto asset, bool placeholder) {
 | 
			
		||||
    if (placeholder) {
 | 
			
		||||
      return const DecoratedBox(
 | 
			
		||||
        decoration: BoxDecoration(color: Colors.grey),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return ThumbnailImage(
 | 
			
		||||
      asset: asset,
 | 
			
		||||
      assetList: _assets,
 | 
			
		||||
      showStorageIndicator: showStorageIndicator,
 | 
			
		||||
      useGrayBoxPlaceholder: true,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildAssetRow(
 | 
			
		||||
      BuildContext context, RenderAssetGridRow row, bool scrolling) {
 | 
			
		||||
    double size = _getItemSize(context);
 | 
			
		||||
 | 
			
		||||
    return Row(
 | 
			
		||||
      key: Key("asset-row-${row.assets.first.id}"),
 | 
			
		||||
      children: row.assets.map((AssetResponseDto asset) {
 | 
			
		||||
        bool last = asset == row.assets.last;
 | 
			
		||||
 | 
			
		||||
        return Container(
 | 
			
		||||
          key: Key("asset-${asset.id}"),
 | 
			
		||||
          width: size,
 | 
			
		||||
          height: size,
 | 
			
		||||
          margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
 | 
			
		||||
          child: _buildThumbnailOrPlaceholder(asset, scrolling),
 | 
			
		||||
        );
 | 
			
		||||
      }).toList(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildTitle(
 | 
			
		||||
      BuildContext context, String title, List<AssetResponseDto> assets) {
 | 
			
		||||
    return DailyTitleText(
 | 
			
		||||
      isoDate: title,
 | 
			
		||||
      assetGroup: assets,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildMonthTitle(BuildContext context, String title) {
 | 
			
		||||
    var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
 | 
			
		||||
        .format(DateTime.parse(title));
 | 
			
		||||
 | 
			
		||||
    return Padding(
 | 
			
		||||
      key: Key("month-$title"),
 | 
			
		||||
      padding: const EdgeInsets.only(left: 12.0, top: 32),
 | 
			
		||||
      child: Text(
 | 
			
		||||
        monthTitleText,
 | 
			
		||||
        style: TextStyle(
 | 
			
		||||
          fontSize: 26,
 | 
			
		||||
          fontWeight: FontWeight.bold,
 | 
			
		||||
          color: Theme.of(context).textTheme.headline1?.color,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
 | 
			
		||||
    final item = renderList[position];
 | 
			
		||||
 | 
			
		||||
    if (item.type == RenderAssetGridElementType.dayTitle) {
 | 
			
		||||
      return _buildTitle(c, item.title!, item.relatedAssetList!);
 | 
			
		||||
    } else if (item.type == RenderAssetGridElementType.monthTitle) {
 | 
			
		||||
      return _buildMonthTitle(c, item.title!);
 | 
			
		||||
    } else if (item.type == RenderAssetGridElementType.assetRow) {
 | 
			
		||||
      return _buildAssetRow(c, item.assetRow!, scrolling);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return const Text("Invalid widget type!");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Text _labelBuilder(int pos) {
 | 
			
		||||
    return Text(
 | 
			
		||||
      "${renderList[pos].month} / ${renderList[pos].year}",
 | 
			
		||||
      style: const TextStyle(
 | 
			
		||||
        color: Colors.white,
 | 
			
		||||
        fontWeight: FontWeight.bold,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final scrolling = useState(false);
 | 
			
		||||
 | 
			
		||||
    void dragScrolling(bool active) {
 | 
			
		||||
      scrolling.value = active;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget itemBuilder(BuildContext c, int position) {
 | 
			
		||||
      return _itemBuilder(c, position, scrolling.value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return DraggableScrollbar.semicircle(
 | 
			
		||||
        scrollStateListener: dragScrolling,
 | 
			
		||||
        itemPositionsListener: _itemPositionsListener,
 | 
			
		||||
        controller: _itemScrollController,
 | 
			
		||||
        backgroundColor: Theme.of(context).hintColor,
 | 
			
		||||
        labelTextBuilder: _labelBuilder,
 | 
			
		||||
        scrollbarAnimationDuration: const Duration(seconds: 1),
 | 
			
		||||
        scrollbarTimeToFade: const Duration(seconds: 4),
 | 
			
		||||
        child: ScrollablePositionedList.builder(
 | 
			
		||||
          itemBuilder: itemBuilder,
 | 
			
		||||
          itemPositionsListener: _itemPositionsListener,
 | 
			
		||||
          itemScrollController: _itemScrollController,
 | 
			
		||||
          itemCount: renderList.length,
 | 
			
		||||
        ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -33,35 +33,11 @@ class ImageGrid extends ConsumerWidget {
 | 
			
		||||
          var assetType = assetGroup[index].type;
 | 
			
		||||
          return GestureDetector(
 | 
			
		||||
            onTap: () {},
 | 
			
		||||
            child: Stack(
 | 
			
		||||
              children: [
 | 
			
		||||
                ThumbnailImage(
 | 
			
		||||
            child: ThumbnailImage(
 | 
			
		||||
              asset: assetGroup[index],
 | 
			
		||||
              assetList: sortedAssetGroup,
 | 
			
		||||
              showStorageIndicator: showStorageIndicator,
 | 
			
		||||
            ),
 | 
			
		||||
                if (assetType != AssetTypeEnum.IMAGE)
 | 
			
		||||
                  Positioned(
 | 
			
		||||
                    top: 5,
 | 
			
		||||
                    right: 5,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text(
 | 
			
		||||
                          assetGroup[index].duration.toString().substring(0, 7),
 | 
			
		||||
                          style: const TextStyle(
 | 
			
		||||
                            color: Colors.white,
 | 
			
		||||
                            fontSize: 10,
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Icon(
 | 
			
		||||
                          Icons.play_circle_outline_rounded,
 | 
			
		||||
                          color: Colors.white,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
        childCount: assetGroup.length,
 | 
			
		||||
 | 
			
		||||
@ -15,12 +15,14 @@ class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final List<AssetResponseDto> assetList;
 | 
			
		||||
  final bool showStorageIndicator;
 | 
			
		||||
  final bool useGrayBoxPlaceholder;
 | 
			
		||||
 | 
			
		||||
  const ThumbnailImage({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required this.asset,
 | 
			
		||||
    required this.assetList,
 | 
			
		||||
    this.showStorageIndicator = true,
 | 
			
		||||
    this.useGrayBoxPlaceholder = false,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -102,13 +104,19 @@ class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
                  "Authorization": "Bearer ${box.get(accessTokenKey)}"
 | 
			
		||||
                },
 | 
			
		||||
                fadeInDuration: const Duration(milliseconds: 250),
 | 
			
		||||
                progressIndicatorBuilder: (context, url, downloadProgress) =>
 | 
			
		||||
                    Transform.scale(
 | 
			
		||||
                progressIndicatorBuilder: (context, url, downloadProgress) {
 | 
			
		||||
                  if (useGrayBoxPlaceholder) {
 | 
			
		||||
                    return const DecoratedBox(
 | 
			
		||||
                      decoration: BoxDecoration(color: Colors.grey),
 | 
			
		||||
                    );
 | 
			
		||||
                  }
 | 
			
		||||
                  return Transform.scale(
 | 
			
		||||
                    scale: 0.2,
 | 
			
		||||
                    child: CircularProgressIndicator(
 | 
			
		||||
                      value: downloadProgress.progress,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
                errorWidget: (context, url, error) {
 | 
			
		||||
                  debugPrint("Error getting thumbnail $url = $error");
 | 
			
		||||
                  CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
 | 
			
		||||
@ -139,7 +147,27 @@ class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
                  color: Colors.white,
 | 
			
		||||
                  size: 18,
 | 
			
		||||
                ),
 | 
			
		||||
              )
 | 
			
		||||
              ),
 | 
			
		||||
            if (asset.type != AssetTypeEnum.IMAGE)
 | 
			
		||||
              Positioned(
 | 
			
		||||
                top: 5,
 | 
			
		||||
                right: 5,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(
 | 
			
		||||
                      asset.duration.toString().substring(0, 7),
 | 
			
		||||
                      style: const TextStyle(
 | 
			
		||||
                        color: Colors.white,
 | 
			
		||||
                        fontSize: 10,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Icon(
 | 
			
		||||
                      Icons.play_circle_outline_rounded,
 | 
			
		||||
                      color: Colors.white,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,14 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_grid.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
 | 
			
		||||
@ -25,6 +27,8 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final appSettingService = ref.watch(appSettingsServiceProvider);
 | 
			
		||||
 | 
			
		||||
    var renderList = ref.watch(renderListProvider);
 | 
			
		||||
 | 
			
		||||
    ScrollController scrollController = useScrollController();
 | 
			
		||||
    var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
 | 
			
		||||
    List<Widget> imageGridGroup = [];
 | 
			
		||||
@ -120,6 +124,31 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
              );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _buildAssetGrid() {
 | 
			
		||||
        if (appSettingService
 | 
			
		||||
            .getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
 | 
			
		||||
          return ImmichAssetGrid(
 | 
			
		||||
              renderList: renderList,
 | 
			
		||||
              assetsPerRow:
 | 
			
		||||
              appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
 | 
			
		||||
              showStorageIndicator: appSettingService
 | 
			
		||||
                  .getSetting(AppSettingsEnum.storageIndicator),
 | 
			
		||||
            );
 | 
			
		||||
        } else {
 | 
			
		||||
          return DraggableScrollbar.semicircle(
 | 
			
		||||
            backgroundColor: Theme.of(context).hintColor,
 | 
			
		||||
            controller: scrollController,
 | 
			
		||||
            heightScrollThumb: 48.0,
 | 
			
		||||
            child: CustomScrollView(
 | 
			
		||||
              controller: scrollController,
 | 
			
		||||
              slivers: [
 | 
			
		||||
                ...imageGridGroup,
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return SafeArea(
 | 
			
		||||
        bottom: !isMultiSelectEnable,
 | 
			
		||||
        top: !isMultiSelectEnable,
 | 
			
		||||
@ -132,17 +161,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
 | 
			
		||||
              child: DraggableScrollbar.semicircle(
 | 
			
		||||
                backgroundColor: Theme.of(context).hintColor,
 | 
			
		||||
                controller: scrollController,
 | 
			
		||||
                heightScrollThumb: 48.0,
 | 
			
		||||
                child: CustomScrollView(
 | 
			
		||||
                  controller: scrollController,
 | 
			
		||||
                  slivers: [
 | 
			
		||||
                    ...imageGridGroup,
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              child: _buildAssetGrid(),
 | 
			
		||||
            ),
 | 
			
		||||
            if (isMultiSelectEnable) ...[
 | 
			
		||||
              _buildSelectedItemCountIndicator(),
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,8 @@ enum AppSettingsEnum<T> {
 | 
			
		||||
  storageIndicator<bool>("storageIndicator", true),
 | 
			
		||||
  thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
 | 
			
		||||
  imageCacheSize<int>("imageCacheSize", 350),
 | 
			
		||||
  albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200);
 | 
			
		||||
  albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
 | 
			
		||||
  useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
 | 
			
		||||
 | 
			
		||||
  const AppSettingsEnum(this.hiveKey, this.defaultValue);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,80 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
 | 
			
		||||
class ExperimentalSettings extends HookConsumerWidget {
 | 
			
		||||
  const ExperimentalSettings({
 | 
			
		||||
    Key? key,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final appSettingService = ref.watch(appSettingsServiceProvider);
 | 
			
		||||
 | 
			
		||||
    final useExperimentalAssetGrid = useState(false);
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
        useExperimentalAssetGrid.value = appSettingService
 | 
			
		||||
            .getSetting(AppSettingsEnum.useExperimentalAssetGrid);
 | 
			
		||||
        return null;
 | 
			
		||||
      },
 | 
			
		||||
      [],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    void changeUseExperimentalAssetGrid(bool status) {
 | 
			
		||||
      useExperimentalAssetGrid.value = status;
 | 
			
		||||
      appSettingService.setSetting(
 | 
			
		||||
        AppSettingsEnum.useExperimentalAssetGrid,
 | 
			
		||||
        status,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      ImmichToast.show(
 | 
			
		||||
        context: context,
 | 
			
		||||
        msg: "settings_require_restart".tr(),
 | 
			
		||||
        gravity: ToastGravity.BOTTOM,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return ExpansionTile(
 | 
			
		||||
      textColor: Theme.of(context).primaryColor,
 | 
			
		||||
      title: const Text(
 | 
			
		||||
        'experimental_settings_title',
 | 
			
		||||
        style: TextStyle(
 | 
			
		||||
          fontWeight: FontWeight.bold,
 | 
			
		||||
        ),
 | 
			
		||||
      ).tr(),
 | 
			
		||||
      subtitle: const Text(
 | 
			
		||||
        'experimental_settings_subtitle',
 | 
			
		||||
        style: TextStyle(
 | 
			
		||||
          fontSize: 13,
 | 
			
		||||
        ),
 | 
			
		||||
      ).tr(),
 | 
			
		||||
      children: [
 | 
			
		||||
        SwitchListTile.adaptive(
 | 
			
		||||
          activeColor: Theme.of(context).primaryColor,
 | 
			
		||||
          title: const Text(
 | 
			
		||||
            "experimental_settings_new_asset_list_title",
 | 
			
		||||
            style: TextStyle(
 | 
			
		||||
              fontSize: 12,
 | 
			
		||||
              fontWeight: FontWeight.bold,
 | 
			
		||||
            ),
 | 
			
		||||
          ).tr(),
 | 
			
		||||
          subtitle: const Text(
 | 
			
		||||
            "experimental_settings_new_asset_list_subtitle",
 | 
			
		||||
            style: TextStyle(
 | 
			
		||||
              fontSize: 12,
 | 
			
		||||
            ),
 | 
			
		||||
          ).tr(),
 | 
			
		||||
          value: useExperimentalAssetGrid.value,
 | 
			
		||||
          onChanged: changeUseExperimentalAssetGrid,
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
 | 
			
		||||
@ -42,6 +43,7 @@ class SettingsPage extends HookConsumerWidget {
 | 
			
		||||
              const ThemeSetting(),
 | 
			
		||||
              const AssetListSettings(),
 | 
			
		||||
              if (Platform.isAndroid) const NotificationSetting(),
 | 
			
		||||
              const ExperimentalSettings(),
 | 
			
		||||
            ],
 | 
			
		||||
          ).toList(),
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ class ImmichToast {
 | 
			
		||||
    ToastType toastType = ToastType.info,
 | 
			
		||||
    ToastGravity gravity = ToastGravity.TOP,
 | 
			
		||||
  }) {
 | 
			
		||||
    final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 | 
			
		||||
    final fToast = FToast();
 | 
			
		||||
    fToast.init(context);
 | 
			
		||||
 | 
			
		||||
@ -49,7 +50,7 @@ class ImmichToast {
 | 
			
		||||
        padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
 | 
			
		||||
        decoration: BoxDecoration(
 | 
			
		||||
          borderRadius: BorderRadius.circular(5.0),
 | 
			
		||||
          color: Colors.grey[50],
 | 
			
		||||
          color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
 | 
			
		||||
          border: Border.all(
 | 
			
		||||
            color: Colors.black12,
 | 
			
		||||
            width: 1,
 | 
			
		||||
 | 
			
		||||
@ -868,6 +868,13 @@ packages:
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.27.3"
 | 
			
		||||
  scrollable_positioned_list:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: scrollable_positioned_list
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.3.4"
 | 
			
		||||
  share_plus:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
 | 
			
		||||
@ -43,6 +43,7 @@ dependencies:
 | 
			
		||||
  easy_localization: ^3.0.1
 | 
			
		||||
  share_plus: ^4.0.10
 | 
			
		||||
  flutter_displaymode: ^0.4.0
 | 
			
		||||
  scrollable_positioned_list: ^0.3.4
 | 
			
		||||
 | 
			
		||||
  path: ^1.8.1
 | 
			
		||||
  path_provider: ^2.0.11
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user