refactor(mobile): Use widgets in Immich asset grid (#7140)

* Refactors to use widgets in immich asset grid

Adds comments

* Fixes asset thumbnail absoluteOffset

* feat(mobile): Uses gradient image placeholders in memory lane and in the asset grid (#7142)

* Uses gradient image placeholders in memory lane and in the asset grid

* Changes to create placeholders in immmich image.thumbnail

* removed unused import

* uses context.isDarkTheme and fixed function signature for allAssetsSelected
This commit is contained in:
martyfuhry 2024-02-17 22:31:34 -05:00 committed by GitHub
parent 0730b54ca9
commit 70e5febb72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 364 additions and 193 deletions

View File

@ -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<ImmichAssetGridView> {
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<Asset> 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<Asset> 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<Asset> 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<ImmichAssetGridView> {
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<ImmichAssetGridView> {
);
}
}
/// 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<Asset> selectedAssets;
final bool scrolling;
final double margin;
final int assetsPerRow;
final RenderList renderList;
final bool selectionActive;
final bool dynamicLayout;
final Function(List<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) 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<Asset> 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<Asset> assets;
final bool selectionActive;
final Function(List<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final Function(List<Asset>) 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<Asset> assets;
final Set<Asset> 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(),
);
}
}

View File

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

View File

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

View File

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

View File

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