From 28bf497a0bdf306083d4a2f715fb80e60343aa8b Mon Sep 17 00:00:00 2001 From: Matthias Rupp Date: Wed, 28 Sep 2022 18:30:38 +0200 Subject: [PATCH] feat(mobile): Improve timeline performance on mobile - experimental (#710) --- mobile/assets/i18n/en-US.json | 7 +- .../home_page_render_list_provider.dart | 91 +++ .../ui/asset_list_v2/daily_title_text.dart | 107 ++++ .../draggable_scrollbar_custom.dart | 536 ++++++++++++++++++ .../ui/asset_list_v2/immich_asset_grid.dart | 166 ++++++ mobile/lib/modules/home/ui/image_grid.dart | 32 +- .../lib/modules/home/ui/thumbnail_image.dart | 44 +- mobile/lib/modules/home/views/home_page.dart | 41 +- .../services/app_settings.service.dart | 3 +- .../experimental_settings.dart | 80 +++ .../modules/settings/views/settings_page.dart | 2 + mobile/lib/shared/ui/immich_toast.dart | 3 +- mobile/pubspec.lock | 7 + mobile/pubspec.yaml | 1 + 14 files changed, 1070 insertions(+), 50 deletions(-) create mode 100644 mobile/lib/modules/home/providers/home_page_render_list_provider.dart create mode 100644 mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart create mode 100644 mobile/lib/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart create mode 100644 mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart create mode 100644 mobile/lib/modules/settings/ui/experimental_settings/experimental_settings.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 0ff5bc8412..c29e842faf 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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" } \ No newline at end of file diff --git a/mobile/lib/modules/home/providers/home_page_render_list_provider.dart b/mobile/lib/modules/home/providers/home_page_render_list_provider.dart new file mode 100644 index 0000000000..2058b65158 --- /dev/null +++ b/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 assets; + + RenderAssetGridRow(this.assets); +} + +class RenderAssetGridElement { + final RenderAssetGridElementType type; + final RenderAssetGridRow? assetRow; + final String? title; + final int? month; + final int? year; + final List? 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 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; +}); diff --git a/mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart b/mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart new file mode 100644 index 0000000000..e16bea2b30 --- /dev/null +++ b/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 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, + ), + ) + ], + ), + ); + } +} diff --git a/mobile/lib/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart b/mobile/lib/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart new file mode 100644 index 0000000000..6b7821c8e7 --- /dev/null +++ b/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 thumbAnimation, + Animation 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? thumbAnimation, + required Animation? 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 thumbAnimation, + Animation 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? 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 + with TickerProviderStateMixin { + late double _barOffset; + late bool _isDragInProcess; + late int _currentItem; + + late AnimationController _thumbAnimationController; + late Animation _thumbAnimation; + late AnimationController _labelAnimationController; + late Animation _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( + onNotification: (ScrollNotification notification) { + changePosition(notification); + return false; + }, + child: Stack( + children: [ + 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 { + @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 oldClipper) => false; +} + +class SlideFadeTransition extends StatelessWidget { + final Animation 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, + ), + ), + ); + } +} diff --git a/mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart new file mode 100644 index 0000000000..7845de07b2 --- /dev/null +++ b/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 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 get _assets { + return renderList + .map((e) { + if (e.type == RenderAssetGridElementType.assetRow) { + return e.assetRow!.assets; + } else { + return List.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 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, + )); + } +} diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart index 30ad9b3938..f7efe613d2 100644 --- a/mobile/lib/modules/home/ui/image_grid.dart +++ b/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, ), ); }, diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index 2b8853222c..1eb165f62f 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -15,12 +15,14 @@ class ThumbnailImage extends HookConsumerWidget { final AssetResponseDto asset; final List 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, + ), + ], + ), + ), ], ), ), diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 46a6c29d74..a1d8f46b2f 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/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 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(), diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart index 5eee76dca6..469e6a0d43 100644 --- a/mobile/lib/modules/settings/services/app_settings.service.dart +++ b/mobile/lib/modules/settings/services/app_settings.service.dart @@ -10,7 +10,8 @@ enum AppSettingsEnum { storageIndicator("storageIndicator", true), thumbnailCacheSize("thumbnailCacheSize", 10000), imageCacheSize("imageCacheSize", 350), - albumThumbnailCacheSize("albumThumbnailCacheSize", 200); + albumThumbnailCacheSize("albumThumbnailCacheSize", 200), + useExperimentalAssetGrid("useExperimentalAssetGrid", false); const AppSettingsEnum(this.hiveKey, this.defaultValue); diff --git a/mobile/lib/modules/settings/ui/experimental_settings/experimental_settings.dart b/mobile/lib/modules/settings/ui/experimental_settings/experimental_settings.dart new file mode 100644 index 0000000000..2043d0d24b --- /dev/null +++ b/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, + ), + ], + ); + } +} diff --git a/mobile/lib/modules/settings/views/settings_page.dart b/mobile/lib/modules/settings/views/settings_page.dart index 84264ed57b..4176629dd2 100644 --- a/mobile/lib/modules/settings/views/settings_page.dart +++ b/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(), ], diff --git a/mobile/lib/shared/ui/immich_toast.dart b/mobile/lib/shared/ui/immich_toast.dart index a4caca4d82..80cac0ce96 100644 --- a/mobile/lib/shared/ui/immich_toast.dart +++ b/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, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index b5feac2f85..46376b615a 100644 --- a/mobile/pubspec.lock +++ b/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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 26e3d0ff9d..ae400643c2 100644 --- a/mobile/pubspec.yaml +++ b/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