import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/render_list.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/map/models/map_event.model.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/utils/color_filter_generator.dart'; import 'package:immich_mobile/utils/throttle.dart'; import 'package:logging/logging.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class MapAssetGrid extends HookConsumerWidget { final Stream mapEventStream; final Function(String)? onGridAssetChanged; final Function(String)? onZoomToAsset; final Function(bool, Set)? onAssetsSelected; final ValueNotifier> selectedAssets; final ScrollController controller; const MapAssetGrid({ required this.mapEventStream, this.onGridAssetChanged, this.onZoomToAsset, this.onAssetsSelected, required this.selectedAssets, required this.controller, super.key, }); @override Widget build(BuildContext context, WidgetRef ref) { final log = Logger("MapAssetGrid"); final assetsInBounds = useState>([]); final cachedRenderList = useRef(null); final lastRenderElementIndex = useRef(null); final assetInSheet = useValueNotifier(null); final gridScrollThrottler = useThrottler(interval: const Duration(milliseconds: 300)); void handleMapEvents(MapEvent event) async { if (event is MapAssetsInBoundsUpdated) { assetsInBounds.value = await ref .read(dbProvider) .assets .getAllByRemoteId(event.assetRemoteIds); return; } } useOnStreamChange(mapEventStream, onData: handleMapEvents); // Hard-restrict to 4 assets / row in portrait mode const assetsPerRow = 4; void handleVisibleItems(Iterable positions) { final orderedPos = positions.sortedByField((p) => p.index); // Index of row where the items are mostly visible const partialOffset = 0.20; final item = orderedPos .firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset); // Guard no elements, reset state // Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0) if (item == null || item.itemLeadingEdge == 0) { lastRenderElementIndex.value = null; return; } final renderElement = cachedRenderList.value?.elements.elementAtOrNull(item.index); // Guard no render list or render element if (renderElement == null) { return; } // Reset index lastRenderElementIndex.value == item.index; // // | 1 | 2 | 3 | 4 | 5 | 6 | // // | 7 | 8 | 9 | // // | 10 | // Skip through the assets from the previous row final rowOffset = renderElement.offset; // Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge; final edgeOffset = (totalOffset - partialOffset) / // Round the total count to the next multiple of [assetsPerRow] ((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor(); // trailing should never be above the totalOffset final columnOffset = (totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/ edgeOffset; final assetOffset = rowOffset + columnOffset; final selectedAsset = cachedRenderList.value?.allAssets ?.elementAtOrNull(assetOffset) ?.remoteId; if (selectedAsset != null) { onGridAssetChanged?.call(selectedAsset); assetInSheet.value = selectedAsset; } } return Card( margin: EdgeInsets.zero, child: Stack( children: [ /// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the /// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves Align( alignment: Alignment.bottomCenter, child: FractionallySizedBox( // Place it just below the drag handle heightFactor: 0.80, child: assetsInBounds.value.isNotEmpty ? ref.watch(renderListProvider(assetsInBounds.value)).when( data: (renderList) { // Cache render list here to use it back during visibleItemsListener cachedRenderList.value = renderList; return ValueListenableBuilder( valueListenable: selectedAssets, builder: (_, value, __) => ImmichAssetGrid( shrinkWrap: true, renderList: renderList, showDragScroll: false, assetsPerRow: assetsPerRow, showMultiSelectIndicator: false, selectionActive: value.isNotEmpty, listener: onAssetsSelected, visibleItemsListener: (pos) => gridScrollThrottler .run(() => handleVisibleItems(pos)), ), ); }, error: (error, stackTrace) { log.warning( "Cannot get assets in the current map bounds $error", error, stackTrace, ); return const SizedBox.shrink(); }, loading: () => const SizedBox.shrink(), ) : _MapNoAssetsInSheet(), ), ), _MapSheetDragRegion( controller: controller, assetsInBoundCount: assetsInBounds.value.length, assetInSheet: assetInSheet, onZoomToAsset: onZoomToAsset, ), ], ), ); } } class _MapNoAssetsInSheet extends StatelessWidget { @override Widget build(BuildContext context) { const image = Image( height: 150, width: 150, image: AssetImage('assets/lighthouse.png'), ); return Center( child: ListView( shrinkWrap: true, children: [ context.isDarkTheme ? const InvertionFilter( child: SaturationFilter( saturation: -1, child: BrightnessFilter( brightness: -5, child: image, ), ), ) : image, const SizedBox(height: 20), Center( child: Text( "map_zoom_to_see_photos".tr(), style: context.textTheme.displayLarge?.copyWith(fontSize: 18), ), ), ], ), ); } } class _MapSheetDragRegion extends StatelessWidget { final ScrollController controller; final int assetsInBoundCount; final ValueNotifier assetInSheet; final Function(String)? onZoomToAsset; const _MapSheetDragRegion({ required this.controller, required this.assetsInBoundCount, required this.assetInSheet, this.onZoomToAsset, }); @override Widget build(BuildContext context) { final assetsInBoundsText = assetsInBoundCount > 0 ? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()]) : "map_no_assets_in_bounds".tr(); return SingleChildScrollView( controller: controller, physics: const ClampingScrollPhysics(), child: Card( margin: EdgeInsets.zero, shape: context.isMobile ? const RoundedRectangleBorder( borderRadius: BorderRadius.only( topRight: Radius.circular(20), topLeft: Radius.circular(20), ), ) : const BeveledRectangleBorder(), elevation: 0.0, child: Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 15), const CustomDraggingHandle(), const SizedBox(height: 15), Text(assetsInBoundsText, style: context.textTheme.bodyLarge), const Divider(height: 35), ], ), ValueListenableBuilder( valueListenable: assetInSheet, builder: (_, value, __) => Visibility( visible: value != null, child: Positioned( right: 15, top: 15, child: IconButton( icon: Icon( Icons.map_outlined, color: context.textTheme.displayLarge?.color, ), iconSize: 20, tooltip: 'Zoom to bounds', onPressed: () => onZoomToAsset?.call(value!), ), ), ), ), ], ), ), ); } }