diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index bd671ca6ba..b836e81305 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -127,184 +128,6 @@ class ImmichAssetGridViewState extends State { assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null; } - Widget _buildThumbnailOrPlaceholder(Asset asset, int index) { - return ThumbnailImage( - asset: asset, - index: index, - loadAsset: widget.renderList.loadAsset, - totalAssets: widget.renderList.totalAssets, - multiselectEnabled: widget.selectionActive, - isSelected: widget.selectionActive && _selectedAssets.contains(asset), - onSelect: () => _selectAssets([asset]), - onDeselect: widget.canDeselect || - widget.preselectedAssets == null || - !widget.preselectedAssets!.contains(asset) - ? () => _deselectAssets([asset]) - : null, - useGrayBoxPlaceholder: true, - showStorageIndicator: widget.showStorageIndicator, - heroOffset: widget.heroOffset, - showStack: widget.showStack, - ); - } - - Widget _buildAssetRow( - Key key, - BuildContext context, - List assets, - int absoluteOffset, - double width, - ) { - // Default: All assets have the same width - final widthDistribution = List.filled(assets.length, 1.0); - - if (widget.dynamicLayout) { - final aspectRatios = - assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); - final meanAspectRatio = aspectRatios.sum / assets.length; - - // 1: mean width - // 0.5: width < mean - threshold - // 1.5: width > mean + threshold - final arConfiguration = aspectRatios.map((e) { - if (e - meanAspectRatio > 0.3) return 1.5; - if (e - meanAspectRatio < -0.3) return 0.5; - return 1.0; - }); - - // Normalize: - final sum = arConfiguration.sum; - widthDistribution.setRange( - 0, - widthDistribution.length, - arConfiguration.map((e) => (e * assets.length) / sum), - ); - } - return Row( - key: key, - children: assets.mapIndexed((int index, Asset asset) { - final bool last = index + 1 == widget.assetsPerRow; - return Container( - key: ValueKey(index), - width: width * widthDistribution[index], - height: width, - margin: EdgeInsets.only( - bottom: widget.margin, - right: last ? 0.0 : widget.margin, - ), - child: _buildThumbnailOrPlaceholder(asset, absoluteOffset + index), - ); - }).toList(), - ); - } - - Widget _buildTitle( - BuildContext context, - String title, - List assets, - ) { - return GroupDividerTitle( - text: title, - multiselectEnabled: widget.selectionActive, - onSelect: () => _selectAssets(assets), - onDeselect: () => _deselectAssets(assets), - selected: _allAssetsSelected(assets), - ); - } - - Widget _buildMonthTitle(BuildContext context, DateTime date) { - final monthFormat = DateTime.now().year == date.year - ? DateFormat.MMMM() - : DateFormat.yMMMM(); - final String title = monthFormat.format(date); - return Padding( - key: Key("month-$title"), - padding: const EdgeInsets.only(left: 12.0, top: 24.0), - child: Text( - title, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w500, - ), - ), - ); - } - - Widget _buildPlaceHolderRow(Key key, int num, double width, double height) { - return Row( - key: key, - children: [ - for (int i = 0; i < num; i++) - Container( - key: ValueKey(i), - width: width, - height: height, - margin: EdgeInsets.only( - bottom: widget.margin, - right: i + 1 == num ? 0.0 : widget.margin, - ), - color: Colors.grey, - ), - ], - ); - } - - Widget _buildSection( - BuildContext context, - RenderAssetGridElement section, - bool scrolling, - ) { - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth / widget.assetsPerRow - - widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow; - final rows = - (section.count + widget.assetsPerRow - 1) ~/ widget.assetsPerRow; - final List assetsToRender = scrolling - ? [] - : widget.renderList.loadAssets(section.offset, section.count); - return Column( - key: ValueKey(section.offset), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (section.type == RenderAssetGridElementType.monthTitle) - _buildMonthTitle(context, section.date), - if (section.type == RenderAssetGridElementType.groupDividerTitle || - section.type == RenderAssetGridElementType.monthTitle) - _buildTitle( - context, - section.title!, - scrolling - ? [] - : widget.renderList - .loadAssets(section.offset, section.totalCount), - ), - for (int i = 0; i < rows; i++) - scrolling - ? _buildPlaceHolderRow( - ValueKey(i), - i + 1 == rows - ? section.count - i * widget.assetsPerRow - : widget.assetsPerRow, - width, - width, - ) - : _buildAssetRow( - ValueKey(i), - context, - assetsToRender.nestedSlice( - i * widget.assetsPerRow, - min((i + 1) * widget.assetsPerRow, section.count), - ), - section.offset + i * widget.assetsPerRow, - width, - ), - ], - ); - }, - ); - } - Widget _itemBuilder(BuildContext c, int position) { int index = position; if (widget.topWidget != null) { @@ -314,8 +137,23 @@ class ImmichAssetGridViewState extends State { index--; } - final item = widget.renderList.elements[index]; - return _buildSection(c, item, _scrolling); + final section = widget.renderList.elements[index]; + return _Section( + showStorageIndicator: widget.showStorageIndicator, + selectedAssets: _selectedAssets, + selectionActive: widget.selectionActive, + section: section, + margin: widget.margin, + renderList: widget.renderList, + assetsPerRow: widget.assetsPerRow, + scrolling: _scrolling, + dynamicLayout: widget.dynamicLayout, + selectAssets: _selectAssets, + deselectAssets: _deselectAssets, + allAssetsSelected: _allAssetsSelected, + showStack: widget.showStack, + heroOffset: widget.heroOffset, + ); } Text _labelBuilder(int pos) { @@ -485,3 +323,292 @@ class ImmichAssetGridViewState extends State { ); } } + +/// A single row of all placeholder widgets +class _PlaceholderRow extends StatelessWidget { + final int number; + final double width; + final double height; + final double margin; + + const _PlaceholderRow({ + super.key, + required this.number, + required this.width, + required this.height, + required this.margin, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (int i = 0; i < number; i++) + ThumbnailPlaceholder( + key: ValueKey(i), + width: width, + height: height, + margin: EdgeInsets.only( + bottom: margin, + right: i + 1 == number ? 0.0 : margin, + ), + ), + ], + ); + } +} + +/// A section for the render grid +class _Section extends StatelessWidget { + final RenderAssetGridElement section; + final Set selectedAssets; + final bool scrolling; + final double margin; + final int assetsPerRow; + final RenderList renderList; + final bool selectionActive; + final bool dynamicLayout; + final Function(List) selectAssets; + final Function(List) deselectAssets; + final bool Function(List) allAssetsSelected; + final bool showStack; + final int heroOffset; + final bool showStorageIndicator; + + const _Section({ + required this.section, + required this.scrolling, + required this.margin, + required this.assetsPerRow, + required this.renderList, + required this.selectionActive, + required this.dynamicLayout, + required this.selectAssets, + required this.deselectAssets, + required this.allAssetsSelected, + required this.selectedAssets, + required this.showStack, + required this.heroOffset, + required this.showStorageIndicator, + }); + + @override + Widget build( + BuildContext context, + ) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth / assetsPerRow - + margin * (assetsPerRow - 1) / assetsPerRow; + final rows = (section.count + assetsPerRow - 1) ~/ assetsPerRow; + final List assetsToRender = scrolling + ? [] + : renderList.loadAssets(section.offset, section.count); + return Column( + key: ValueKey(section.offset), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (section.type == RenderAssetGridElementType.monthTitle) + _MonthTitle(date: section.date), + if (section.type == RenderAssetGridElementType.groupDividerTitle || + section.type == RenderAssetGridElementType.monthTitle) + _Title( + selectionActive: selectionActive, + title: section.title!, + assets: scrolling + ? [] + : renderList.loadAssets(section.offset, section.totalCount), + allAssetsSelected: allAssetsSelected, + selectAssets: selectAssets, + deselectAssets: deselectAssets, + ), + for (int i = 0; i < rows; i++) + scrolling + ? _PlaceholderRow( + key: ValueKey(i), + number: i + 1 == rows + ? section.count - i * assetsPerRow + : assetsPerRow, + width: width, + height: width, + margin: margin, + ) + : _AssetRow( + key: ValueKey(i), + assets: assetsToRender.nestedSlice( + i * assetsPerRow, + min((i + 1) * assetsPerRow, section.count), + ), + absoluteOffset: section.offset + i * assetsPerRow, + width: width, + assetsPerRow: assetsPerRow, + margin: margin, + dynamicLayout: dynamicLayout, + renderList: renderList, + selectedAssets: selectedAssets, + isSelectionActive: selectionActive, + showStack: showStack, + heroOffset: heroOffset, + showStorageIndicator: showStorageIndicator, + selectionActive: selectionActive, + onSelect: (asset) => selectAssets([asset]), + onDeselect: (asset) => deselectAssets([asset]), + ), + ], + ); + }, + ); + } +} + +/// The month title row for a section +class _MonthTitle extends StatelessWidget { + final DateTime date; + + const _MonthTitle({ + required this.date, + }); + + @override + Widget build(BuildContext context) { + final monthFormat = DateTime.now().year == date.year + ? DateFormat.MMMM() + : DateFormat.yMMMM(); + final String title = monthFormat.format(date); + return Padding( + key: Key("month-$title"), + padding: const EdgeInsets.only(left: 12.0, top: 24.0), + child: Text( + title, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} + +/// A title row +class _Title extends StatelessWidget { + final String title; + final List assets; + final bool selectionActive; + final Function(List) selectAssets; + final Function(List) deselectAssets; + final Function(List) allAssetsSelected; + + const _Title({ + required this.title, + required this.assets, + required this.selectionActive, + required this.selectAssets, + required this.deselectAssets, + required this.allAssetsSelected, + }); + + @override + Widget build(BuildContext context) { + return GroupDividerTitle( + text: title, + multiselectEnabled: selectionActive, + onSelect: () => selectAssets(assets), + onDeselect: () => deselectAssets(assets), + selected: allAssetsSelected(assets), + ); + } +} + +/// The row of assets +class _AssetRow extends StatelessWidget { + final List assets; + final Set selectedAssets; + final int absoluteOffset; + final double width; + final bool dynamicLayout; + final double margin; + final int assetsPerRow; + final RenderList renderList; + final bool selectionActive; + final bool showStorageIndicator; + final int heroOffset; + final bool showStack; + final Function(Asset)? onSelect; + final Function(Asset)? onDeselect; + final bool isSelectionActive; + + const _AssetRow({ + super.key, + required this.assets, + required this.absoluteOffset, + required this.width, + required this.dynamicLayout, + required this.margin, + required this.assetsPerRow, + required this.renderList, + required this.selectionActive, + required this.showStorageIndicator, + required this.heroOffset, + required this.showStack, + required this.isSelectionActive, + required this.selectedAssets, + this.onSelect, + this.onDeselect, + }); + + @override + Widget build(BuildContext context) { + // Default: All assets have the same width + final widthDistribution = List.filled(assets.length, 1.0); + + if (dynamicLayout) { + final aspectRatios = + assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); + final meanAspectRatio = aspectRatios.sum / assets.length; + + // 1: mean width + // 0.5: width < mean - threshold + // 1.5: width > mean + threshold + final arConfiguration = aspectRatios.map((e) { + if (e - meanAspectRatio > 0.3) return 1.5; + if (e - meanAspectRatio < -0.3) return 0.5; + return 1.0; + }); + + // Normalize: + final sum = arConfiguration.sum; + widthDistribution.setRange( + 0, + widthDistribution.length, + arConfiguration.map((e) => (e * assets.length) / sum), + ); + } + return Row( + key: key, + children: assets.mapIndexed((int index, Asset asset) { + final bool last = index + 1 == assetsPerRow; + return Container( + width: width * widthDistribution[index], + height: width, + margin: EdgeInsets.only( + bottom: margin, + right: last ? 0.0 : margin, + ), + child: ThumbnailImage( + asset: asset, + index: absoluteOffset + index, + loadAsset: renderList.loadAsset, + totalAssets: renderList.totalAssets, + multiselectEnabled: selectionActive, + isSelected: isSelectionActive && selectedAssets.contains(asset), + onSelect: () => onSelect?.call(asset), + onDeselect: () => onDeselect?.call(asset), + showStorageIndicator: showStorageIndicator, + heroOffset: heroOffset, + showStack: showStack, + ), + ); + }).toList(), + ); + } +} diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 6b0e83e527..73b31617f1 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -15,7 +15,6 @@ class ThumbnailImage extends StatelessWidget { final int totalAssets; final bool showStorageIndicator; final bool showStack; - final bool useGrayBoxPlaceholder; final bool isSelected; final bool multiselectEnabled; final Function? onSelect; @@ -30,7 +29,6 @@ class ThumbnailImage extends StatelessWidget { required this.totalAssets, this.showStorageIndicator = true, this.showStack = false, - this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, this.onDeselect, @@ -138,6 +136,8 @@ class ThumbnailImage extends StatelessWidget { : asset.id + heroOffset, child: ImmichImage.thumbnail( asset, + height: 300, + width: 300, ), ), ); diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_placeholder.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_placeholder.dart new file mode 100644 index 0000000000..d762704835 --- /dev/null +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_placeholder.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class ThumbnailPlaceholder extends StatelessWidget { + final EdgeInsets margin; + final double width; + final double height; + + const ThumbnailPlaceholder({ + super.key, + this.margin = EdgeInsets.zero, + this.width = 250, + this.height = 250, + }); + + static const _brightColors = [ + Color(0xFFF1F3F4), + Color(0xFFB4B6B8), + ]; + + static const _darkColors = [ + Color(0xFF3B3F42), + Color(0xFF2B2F32), + ]; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + margin: margin, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: context.isDarkTheme ? _darkColors : _brightColors, + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ); + } +} diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart index 6b11e668db..eb72c15e8e 100644 --- a/mobile/lib/modules/memories/ui/memory_lane.dart +++ b/mobile/lib/modules/memories/ui/memory_lane.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/modules/memories/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; @@ -60,7 +61,10 @@ class MemoryLane extends HookConsumerWidget { fit: BoxFit.cover, width: 130, height: 200, - useGrayBoxPlaceholder: true, + placeholder: const ThumbnailPlaceholder( + width: 130, + height: 200, + ), ), ), ), diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 21418d5274..b4d881d138 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:octo_image/octo_image.dart'; @@ -17,14 +18,14 @@ class ImmichImage extends StatelessWidget { this.width, this.height, this.fit = BoxFit.cover, - this.useGrayBoxPlaceholder = false, + this.placeholder = const ThumbnailPlaceholder(), this.isThumbnail = false, this.thumbnailSize = 250, super.key, }); final Asset? asset; - final bool useGrayBoxPlaceholder; + final Widget? placeholder; final double? width; final double? height; final BoxFit fit; @@ -47,7 +48,10 @@ class ImmichImage extends StatelessWidget { fit: fit, width: width, height: height, - useGrayBoxPlaceholder: true, + placeholder: ThumbnailPlaceholder( + height: thumbnailSize.toDouble(), + width: thumbnailSize.toDouble(), + ), thumbnailSize: thumbnailSize, ); } @@ -99,7 +103,6 @@ class ImmichImage extends StatelessWidget { asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); @override Widget build(BuildContext context) { - if (asset == null) { return Container( decoration: const BoxDecoration( @@ -119,13 +122,9 @@ class ImmichImage extends StatelessWidget { fadeInDuration: const Duration(milliseconds: 0), fadeOutDuration: const Duration(milliseconds: 400), placeholderBuilder: (context) { - if (useGrayBoxPlaceholder) { + if (placeholder != null) { // Use the gray box placeholder - return const SizedBox.expand( - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ), - ); + return placeholder!; } // No placeholder return const SizedBox();