immich/mobile/lib/widgets/map/map_asset_grid.dart

281 lines
10 KiB
Dart
Raw Normal View History

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/providers/asset_viewer/render_list.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/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<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> 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<List<Asset>>([]);
final cachedRenderList = useRef<RenderList?>(null);
final lastRenderElementIndex = useRef<int?>(null);
final assetInSheet = useValueNotifier<String?>(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<MapEvent>(mapEventStream, onData: handleMapEvents);
// Hard-restrict to 4 assets / row in portrait mode
const assetsPerRow = 4;
void handleVisibleItems(Iterable<ItemPosition> 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;
// <RenderElement:offset:0>
// | 1 | 2 | 3 | 4 | 5 | 6 |
// <RenderElement:offset:6>
// | 7 | 8 | 9 |
// <RenderElement:offset: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,
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<String?> 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!),
),
),
),
),
],
),
),
);
}
}