diff --git a/mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart index 31d17ca8ea..cf56c539cc 100644 --- a/mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/asset_grid.widget.dart @@ -1,13 +1,20 @@ +import 'dart:math'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/models/render_list.model.dart'; import 'package:immich_mobile/domain/models/render_list_element.model.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/presentation/components/common/page_empty.widget.dart'; import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart'; import 'package:immich_mobile/presentation/components/grid/asset_render_grid.widget.dart'; import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart'; +import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart'; +import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart'; import 'package:immich_mobile/utils/constants/size_constants.dart'; +import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart'; import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; import 'package:immich_mobile/utils/extensions/color.extension.dart'; import 'package:intl/intl.dart'; @@ -16,6 +23,249 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; part 'asset_grid_header.widget.dart'; +class ImmichAssetGridView extends StatefulWidget { + const ImmichAssetGridView({super.key}); + + @override + createState() { + return ImmichAssetGridViewState(); + } +} + +class ImmichAssetGridViewState extends State { + final ItemScrollController _itemScrollController = ItemScrollController(); + final ScrollOffsetController _scrollOffsetController = + ScrollOffsetController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + bool _scrolling = false; + + Widget _itemBuilder(BuildContext c, int position) { + int index = position; + + return BlocSelector( + selector: (state) => state.renderList, + builder: (_, renderList) { + final section = renderList.elements.elementAtOrNull(index); + + if (renderList.totalCount == 0 || section == null) { + return const _ImGridEmpty(); + } + + return _Section(sectionIndex: index); + }, // no.of elements are not equal or is modified + ); + } + + Text? _labelBuilder(List elements, int currentPosition) { + final element = elements.elementAtOrNull(currentPosition); + if (element == null) { + return null; + } + + return Text( + DateFormat.yMMMM().format(element.date), + style: TextStyle( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildAssetGrid() { + final useDragScrolling = true; + + // ignore: avoid-local-functions + void dragScrolling(bool active) { + if (active != _scrolling) { + setState(() { + _scrolling = active; + }); + } + } + + // ignore: avoid-local-functions + bool appBarOffset() => true; + + return BlocSelector( + selector: (state) => state.renderList, + builder: (_, renderList) { + final listWidget = ScrollablePositionedList.builder( + itemCount: renderList.elements.length, + itemBuilder: _itemBuilder, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + scrollOffsetController: _scrollOffsetController, + padding: EdgeInsets.only(top: appBarOffset() ? 60 : 0, bottom: 220), + addRepaintBoundaries: true, + ); + + return (useDragScrolling && ModalRoute.of(context) != null) + ? DraggableScrollbar.semicircle( + controller: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + scrollStateListener: dragScrolling, + backgroundColor: context.colorScheme.surfaceContainerHighest, + foregroundColor: context.colorScheme.onSurface, + padding: appBarOffset() + ? const EdgeInsets.only(top: 120) + : const EdgeInsets.only(), + heightOffset: appBarOffset() ? 60 : 0, + scrollbarAnimationDuration: const Duration(milliseconds: 300), + scrollbarTimeToFade: const Duration(milliseconds: 1000), + labelTextBuilder: (pos) => + _labelBuilder(renderList.elements, pos), + labelConstraints: const BoxConstraints(maxHeight: 28), + child: listWidget, + ) + : listWidget; + }, + ); + } + + @override + Widget build(BuildContext context) { + return _buildAssetGrid(); + } +} + +/// A single row of all placeholder widgets +class _PlaceholderRow extends StatelessWidget { + final int number; + final double width; + final double height; + + const _PlaceholderRow({ + super.key, + required this.number, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (int i = 0; i < 4; i++) + ImImagePlaceholder( + key: ValueKey(i), + width: width, + height: height, + margin: 1, + ), + ], + ); + } +} + +/// A section for the render grid +class _Section extends StatelessWidget { + final int sectionIndex; + + const _Section({required this.sectionIndex}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (_, state) => LayoutBuilder( + builder: (_, constraints) { + // ignore: avoid-unsafe-collection-methods + final section = state.renderList.elements[sectionIndex]; + + if (section is RenderListMonthHeaderElement) { + return _MonthHeader(text: section.header); + } + + if (section is RenderListDayHeaderElement) { + return Text(section.header); + } + + if (section is! RenderListAssetElement) { + return const SizedBox(); + } + + final scrolling = state.isDragScrolling; + final assetsPerRow = 4; + final margin = 1.0; + + final width = constraints.maxWidth / 4 - (4 - 1) * margin / 4; + final rows = (section.assetCount + 4 - 1) ~/ 4; + final Future> assetsToRender = scrolling + ? Future.value([]) + : context + .read() + .loadAssets(section.assetCount, section.assetCount); + return FutureBuilder( + future: assetsToRender, + builder: (_, snap) => Column( + key: ValueKey(section.assetCount), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < rows; i++) + scrolling || snap.isWaiting + ? _PlaceholderRow( + key: ValueKey(i), + number: assetsPerRow, + width: width - 1.5, + height: width, + ) + : _AssetRow( + key: ValueKey(i), + assets: snap.data!.nestedSlice( + i * assetsPerRow, + min((i + 1) * assetsPerRow, section.assetCount), + ), + width: width, + margin: margin, + assetsPerRow: assetsPerRow, + ), + ], + ), + ); + }, + ), + // no.of elements are not equal or is modified + buildWhen: (previous, current) => + (previous.renderList.elements.length != + current.renderList.elements.length) || + !previous.renderList.modifiedTime + .isAtSameMomentAs(current.renderList.modifiedTime), + ); + } +} + +/// The row of assets +class _AssetRow extends StatelessWidget { + final List assets; + final double width; + final double margin; + final int assetsPerRow; + + const _AssetRow({ + super.key, + required this.assets, + required this.width, + required this.margin, + required this.assetsPerRow, + }); + + @override + Widget build(BuildContext context) { + return Row( + key: key, + children: assets.mapIndexed((int index, Asset asset) { + final bool last = index + 1 == assetsPerRow; + return Container( + width: width, + height: width, + margin: EdgeInsets.only(right: last ? 0.0 : margin, bottom: margin), + child: ImThumbnail(asset), + ); + }).toList(), + ); + } +} + class ImAssetGrid extends StatefulWidget { /// The padding for the grid final double? topPadding; @@ -142,3 +392,33 @@ class _ImGridEmpty extends StatelessWidget { ); } } + +extension ListExtension on List { + List uniqueConsecutive({ + int Function(E a, E b)? compare, + void Function(E a, E b)? onDuplicate, + }) { + compare ??= (E a, E b) => a == b ? 0 : 1; + int i = 1, j = 1; + for (; i < length; i++) { + if (compare(this[i - 1], this[i]) != 0) { + if (i != j) { + this[j] = this[i]; + } + j++; + } else if (onDuplicate != null) { + onDuplicate(this[i - 1], this[i]); + } + } + length = length == 0 ? 0 : j; + return this; + } + + ListSlice nestedSlice(int start, int end) { + if (this is ListSlice) { + final ListSlice self = this as ListSlice; + return ListSlice(self.source, self.start + start, self.start + end); + } + return ListSlice(this, start, end); + } +} diff --git a/mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart index 274eff79ed..c3467b9dba 100644 --- a/mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/asset_render_grid.widget.dart @@ -36,7 +36,7 @@ class ImStaticGrid extends StatelessWidget { ), itemBuilder: (_, i) { if (isDragging) { - return const ImImagePlaceholder(); + return const ImImagePlaceholder(width: 200, height: 200); } final asset = assetsSnap.isWaiting || assets == null @@ -46,7 +46,7 @@ class ImStaticGrid extends StatelessWidget { dimension: 200, // Show Placeholder when drag scrolled child: asset == null - ? const ImImagePlaceholder() + ? const ImImagePlaceholder(width: 200, height: 200) : ImThumbnail(asset), ); }, diff --git a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart index 0caa1bbd21..c07a7f799e 100644 --- a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart @@ -7,14 +7,23 @@ import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; import 'package:octo_image/octo_image.dart'; class ImImagePlaceholder extends StatelessWidget { - const ImImagePlaceholder({super.key}); + final double width; + final double height; + final double? margin; + const ImImagePlaceholder({ + super.key, + required this.width, + required this.height, + this.margin, + }); @override Widget build(BuildContext context) { return Container( color: context.colorScheme.surfaceContainerHighest, - width: 200, - height: 200, + width: width, + height: height, + margin: EdgeInsets.all(margin ?? 0), ); } } @@ -30,7 +39,7 @@ class ImImage extends StatefulWidget { this.asset, { this.width, this.height, - this.placeholder = const ImImagePlaceholder(), + this.placeholder = const ImImagePlaceholder(width: 200, height: 200), super.key, }); diff --git a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart index 3509017641..39550a0686 100644 --- a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart +++ b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart @@ -26,19 +26,13 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final gridHasPadding = !context.isTablet && _showAppBar.value; - return Scaffold( body: BlocProvider( create: (_) => AssetGridCubit( renderListProvider: RenderListProvider.mainTimeline(), ), child: Stack(children: [ - ImAssetGrid( - topPadding: gridHasPadding - ? kToolbarHeight + context.mediaQueryPadding.top - 8 - : null, - ), + ImmichAssetGridView(), if (!context.isTablet) ValueListenableBuilder( valueListenable: _showAppBar,