mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	* Add "details" column to LoggerMessage * Include error details in log details page * Move error details out of log message * Add error message to mixin * Create extension for HTTP Response logging * Fix analyze errors * format * fix analyze errors, format again
		
			
				
	
	
		
			281 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			281 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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,
 | 
						|
                            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!),
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |