2024-01-15 17:26:13 +02:00
|
|
|
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<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",
|
|
|
|
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,
|
2024-02-08 05:07:43 +02:00
|
|
|
shape: context.isMobile
|
|
|
|
? const RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.only(
|
|
|
|
topRight: Radius.circular(20),
|
|
|
|
topLeft: Radius.circular(20),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: const BeveledRectangleBorder(),
|
2024-01-15 17:26:13 +02:00
|
|
|
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!),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|