소스 검색

feat(mobile): Improve timeline performance on mobile - experimental (#710)

Matthias Rupp 2 년 전
부모
커밋
28bf497a0b

+ 6 - 1
mobile/assets/i18n/en-US.json

@@ -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"
 }

+ 91 - 0
mobile/lib/modules/home/providers/home_page_render_list_provider.dart

@@ -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 - 0
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart

@@ -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,
+                  ),
+          )
+        ],
+      ),
+    );
+  }
+}

+ 536 - 0
mobile/lib/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart

@@ -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 - 0
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart

@@ -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,
+        ));
+  }
+}

+ 4 - 28
mobile/lib/modules/home/ui/image_grid.dart

@@ -33,34 +33,10 @@ class ImageGrid extends ConsumerWidget {
           var assetType = assetGroup[index].type;
           return GestureDetector(
             onTap: () {},
-            child: Stack(
-              children: [
-                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,
-                        ),
-                      ],
-                    ),
-                  ),
-              ],
+            child: ThumbnailImage(
+              asset: assetGroup[index],
+              assetList: sortedAssetGroup,
+              showStorageIndicator: showStorageIndicator,
             ),
           );
         },

+ 36 - 8
mobile/lib/modules/home/ui/thumbnail_image.dart

@@ -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(
-                  scale: 0.2,
-                  child: CircularProgressIndicator(
-                    value: downloadProgress.progress,
-                  ),
-                ),
+                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,
+                    ),
+                  ],
+                ),
+              ),
           ],
         ),
       ),

+ 30 - 11
mobile/lib/modules/home/views/home_page.dart

@@ -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(),

+ 2 - 1
mobile/lib/modules/settings/services/app_settings.service.dart

@@ -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);
 

+ 80 - 0
mobile/lib/modules/settings/ui/experimental_settings/experimental_settings.dart

@@ -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,
+        ),
+      ],
+    );
+  }
+}

+ 2 - 0
mobile/lib/modules/settings/views/settings_page.dart

@@ -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(),
         ],

+ 2 - 1
mobile/lib/shared/ui/immich_toast.dart

@@ -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,

+ 7 - 0
mobile/pubspec.lock

@@ -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:

+ 1 - 0
mobile/pubspec.yaml

@@ -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