mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(mobile): show local assets (#905)
* introduce Asset as composition of AssetResponseDTO and AssetEntity * filter out duplicate assets (that are both local and remote, take only remote for now) * only allow remote images to be added to albums * introduce ImmichImage to render Asset using local or remote data * optimized deletion of local assets * local video file playback * allow multiple methods to wait on background service finished * skip local assets when adding to album from home screen * fix and optimize delete * show gray box placeholder for local assets * add comments * fix bug: duplicate assets in state after onNewAssetUploaded
This commit is contained in:
		
							parent
							
								
									99da181cfc
								
							
						
					
					
						commit
						1633af7af6
					
				@ -134,13 +134,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun stopEngine(result: Result?) {
 | 
			
		||||
        clearBackgroundNotification()
 | 
			
		||||
        engine?.destroy()
 | 
			
		||||
        engine = null
 | 
			
		||||
        if (result != null) {
 | 
			
		||||
            Log.d(TAG, "stopEngine result=${result}")
 | 
			
		||||
            resolvableFuture.set(result)
 | 
			
		||||
        }
 | 
			
		||||
        engine?.destroy()
 | 
			
		||||
        engine = null
 | 
			
		||||
        clearBackgroundNotification()
 | 
			
		||||
        waitOnSetForegroundAsync()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -35,10 +35,12 @@ void main() async {
 | 
			
		||||
  await Future.wait([
 | 
			
		||||
    Hive.openBox(userInfoBox),
 | 
			
		||||
    Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
 | 
			
		||||
    Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
 | 
			
		||||
    Hive.openBox(hiveGithubReleaseInfoBox),
 | 
			
		||||
    Hive.openBox(userSettingInfoBox),
 | 
			
		||||
    Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
 | 
			
		||||
    if (!Platform.isAndroid) Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
 | 
			
		||||
    if (!Platform.isAndroid)
 | 
			
		||||
      Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
 | 
			
		||||
    if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox),
 | 
			
		||||
    EasyLocalization.ensureInitialized(),
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
@ -86,8 +88,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 | 
			
		||||
        var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
 | 
			
		||||
 | 
			
		||||
        if (isAuthenticated) {
 | 
			
		||||
          ref.read(backupProvider.notifier).resumeBackup();
 | 
			
		||||
          ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
 | 
			
		||||
          ref.watch(backupProvider.notifier).resumeBackup();
 | 
			
		||||
          ref.watch(assetProvider.notifier).getAllAsset();
 | 
			
		||||
          ref.watch(serverInfoProvider.notifier).getServerVersion();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,9 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
class AssetSelectionPageResult {
 | 
			
		||||
  final Set<AssetResponseDto> selectedNewAsset;
 | 
			
		||||
  final Set<AssetResponseDto> selectedAdditionalAsset;
 | 
			
		||||
  final Set<Asset> selectedNewAsset;
 | 
			
		||||
  final Set<Asset> selectedAdditionalAsset;
 | 
			
		||||
  final bool isAlbumExist;
 | 
			
		||||
 | 
			
		||||
  AssetSelectionPageResult({
 | 
			
		||||
@ -14,8 +13,8 @@ class AssetSelectionPageResult {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  AssetSelectionPageResult copyWith({
 | 
			
		||||
    Set<AssetResponseDto>? selectedNewAsset,
 | 
			
		||||
    Set<AssetResponseDto>? selectedAdditionalAsset,
 | 
			
		||||
    Set<Asset>? selectedNewAsset,
 | 
			
		||||
    Set<Asset>? selectedAdditionalAsset,
 | 
			
		||||
    bool? isAlbumExist,
 | 
			
		||||
  }) {
 | 
			
		||||
    return AssetSelectionPageResult(
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,11 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
class AssetSelectionState {
 | 
			
		||||
  final Set<String> selectedMonths;
 | 
			
		||||
  final Set<AssetResponseDto> selectedNewAssetsForAlbum;
 | 
			
		||||
  final Set<AssetResponseDto> selectedAdditionalAssetsForAlbum;
 | 
			
		||||
  final Set<AssetResponseDto> selectedAssetsInAlbumViewer;
 | 
			
		||||
  final Set<Asset> selectedNewAssetsForAlbum;
 | 
			
		||||
  final Set<Asset> selectedAdditionalAssetsForAlbum;
 | 
			
		||||
  final Set<Asset> selectedAssetsInAlbumViewer;
 | 
			
		||||
  final bool isMultiselectEnable;
 | 
			
		||||
 | 
			
		||||
  /// Indicate the asset selection page is navigated from existing album
 | 
			
		||||
@ -22,9 +21,9 @@ class AssetSelectionState {
 | 
			
		||||
 | 
			
		||||
  AssetSelectionState copyWith({
 | 
			
		||||
    Set<String>? selectedMonths,
 | 
			
		||||
    Set<AssetResponseDto>? selectedNewAssetsForAlbum,
 | 
			
		||||
    Set<AssetResponseDto>? selectedAdditionalAssetsForAlbum,
 | 
			
		||||
    Set<AssetResponseDto>? selectedAssetsInAlbumViewer,
 | 
			
		||||
    Set<Asset>? selectedNewAssetsForAlbum,
 | 
			
		||||
    Set<Asset>? selectedAdditionalAssetsForAlbum,
 | 
			
		||||
    Set<Asset>? selectedAssetsInAlbumViewer,
 | 
			
		||||
    bool? isMultiselectEnable,
 | 
			
		||||
    bool? isAlbumExist,
 | 
			
		||||
  }) {
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 | 
			
		||||
@ -13,7 +14,6 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getAllAlbums() async {
 | 
			
		||||
 | 
			
		||||
    if (await _albumCacheService.isValid() && state.isEmpty) {
 | 
			
		||||
      state = await _albumCacheService.get();
 | 
			
		||||
    }
 | 
			
		||||
@ -34,7 +34,7 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 | 
			
		||||
 | 
			
		||||
  Future<AlbumResponseDto?> createAlbum(
 | 
			
		||||
    String albumTitle,
 | 
			
		||||
    Set<AssetResponseDto> assets,
 | 
			
		||||
    Set<Asset> assets,
 | 
			
		||||
  ) async {
 | 
			
		||||
    AlbumResponseDto? album =
 | 
			
		||||
        await _albumService.createAlbum(albumTitle, assets, []);
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
  AssetSelectionNotifier()
 | 
			
		||||
@ -22,15 +21,15 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
 | 
			
		||||
  void removeAssetsInMonth(
 | 
			
		||||
    String removedMonth,
 | 
			
		||||
    List<AssetResponseDto> assetsInMonth,
 | 
			
		||||
    List<Asset> assetsInMonth,
 | 
			
		||||
  ) {
 | 
			
		||||
    Set<AssetResponseDto> currentAssetList = state.selectedNewAssetsForAlbum;
 | 
			
		||||
    Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum;
 | 
			
		||||
    Set<String> currentMonthList = state.selectedMonths;
 | 
			
		||||
 | 
			
		||||
    currentMonthList
 | 
			
		||||
        .removeWhere((selectedMonth) => selectedMonth == removedMonth);
 | 
			
		||||
 | 
			
		||||
    for (AssetResponseDto asset in assetsInMonth) {
 | 
			
		||||
    for (Asset asset in assetsInMonth) {
 | 
			
		||||
      currentAssetList.removeWhere((e) => e.id == asset.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -40,7 +39,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addAdditionalAssets(List<AssetResponseDto> assets) {
 | 
			
		||||
  void addAdditionalAssets(List<Asset> assets) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      selectedAdditionalAssetsForAlbum: {
 | 
			
		||||
        ...state.selectedAdditionalAssetsForAlbum,
 | 
			
		||||
@ -49,7 +48,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addAllAssetsInMonth(String month, List<AssetResponseDto> assetsInMonth) {
 | 
			
		||||
  void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      selectedMonths: {...state.selectedMonths, month},
 | 
			
		||||
      selectedNewAssetsForAlbum: {
 | 
			
		||||
@ -59,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addNewAssets(List<AssetResponseDto> assets) {
 | 
			
		||||
  void addNewAssets(List<Asset> assets) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      selectedNewAssetsForAlbum: {
 | 
			
		||||
        ...state.selectedNewAssetsForAlbum,
 | 
			
		||||
@ -68,20 +67,20 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void removeSelectedNewAssets(List<AssetResponseDto> assets) {
 | 
			
		||||
    Set<AssetResponseDto> currentList = state.selectedNewAssetsForAlbum;
 | 
			
		||||
  void removeSelectedNewAssets(List<Asset> assets) {
 | 
			
		||||
    Set<Asset> currentList = state.selectedNewAssetsForAlbum;
 | 
			
		||||
 | 
			
		||||
    for (AssetResponseDto asset in assets) {
 | 
			
		||||
    for (Asset asset in assets) {
 | 
			
		||||
      currentList.removeWhere((e) => e.id == asset.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(selectedNewAssetsForAlbum: currentList);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void removeSelectedAdditionalAssets(List<AssetResponseDto> assets) {
 | 
			
		||||
    Set<AssetResponseDto> currentList = state.selectedAdditionalAssetsForAlbum;
 | 
			
		||||
  void removeSelectedAdditionalAssets(List<Asset> assets) {
 | 
			
		||||
    Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum;
 | 
			
		||||
 | 
			
		||||
    for (AssetResponseDto asset in assets) {
 | 
			
		||||
    for (Asset asset in assets) {
 | 
			
		||||
      currentList.removeWhere((e) => e.id == asset.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -109,7 +108,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addAssetsInAlbumViewer(List<AssetResponseDto> assets) {
 | 
			
		||||
  void addAssetsInAlbumViewer(List<Asset> assets) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      selectedAssetsInAlbumViewer: {
 | 
			
		||||
        ...state.selectedAssetsInAlbumViewer,
 | 
			
		||||
@ -118,10 +117,10 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void removeAssetsInAlbumViewer(List<AssetResponseDto> assets) {
 | 
			
		||||
    Set<AssetResponseDto> currentList = state.selectedAssetsInAlbumViewer;
 | 
			
		||||
  void removeAssetsInAlbumViewer(List<Asset> assets) {
 | 
			
		||||
    Set<Asset> currentList = state.selectedAssetsInAlbumViewer;
 | 
			
		||||
 | 
			
		||||
    for (AssetResponseDto asset in assets) {
 | 
			
		||||
    for (Asset asset in assets) {
 | 
			
		||||
      currentList.removeWhere((e) => e.id == asset.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 | 
			
		||||
  SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);
 | 
			
		||||
  SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService)
 | 
			
		||||
      : super([]);
 | 
			
		||||
 | 
			
		||||
  final AlbumService _sharedAlbumService;
 | 
			
		||||
  final SharedAlbumCacheService _sharedAlbumCacheService;
 | 
			
		||||
@ -16,7 +18,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 | 
			
		||||
 | 
			
		||||
  Future<AlbumResponseDto?> createSharedAlbum(
 | 
			
		||||
    String albumName,
 | 
			
		||||
    Set<AssetResponseDto> assets,
 | 
			
		||||
    Set<Asset> assets,
 | 
			
		||||
    List<String> sharedUserIds,
 | 
			
		||||
  ) async {
 | 
			
		||||
    try {
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/api.service.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
@ -29,7 +30,7 @@ class AlbumService {
 | 
			
		||||
 | 
			
		||||
  Future<AlbumResponseDto?> createAlbum(
 | 
			
		||||
    String albumName,
 | 
			
		||||
    Set<AssetResponseDto> assets,
 | 
			
		||||
    Iterable<Asset> assets,
 | 
			
		||||
    List<String> sharedUserIds,
 | 
			
		||||
  ) async {
 | 
			
		||||
    try {
 | 
			
		||||
@ -65,7 +66,7 @@ class AlbumService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<AlbumResponseDto?> createAlbumWithGeneratedName(
 | 
			
		||||
    Set<AssetResponseDto> assets,
 | 
			
		||||
    Iterable<Asset> assets,
 | 
			
		||||
  ) async {
 | 
			
		||||
    return createAlbum(
 | 
			
		||||
        _getNextAlbumName(await getAlbums(isShared: false)), assets, []);
 | 
			
		||||
@ -81,7 +82,7 @@ class AlbumService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
 | 
			
		||||
    Set<AssetResponseDto> assets,
 | 
			
		||||
    Iterable<Asset> assets,
 | 
			
		||||
    String albumId,
 | 
			
		||||
  ) async {
 | 
			
		||||
    try {
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,15 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumViewerThumbnail extends HookConsumerWidget {
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final List<AssetResponseDto> assetList;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final List<Asset> assetList;
 | 
			
		||||
  final bool showStorageIndicator;
 | 
			
		||||
 | 
			
		||||
  const AlbumViewerThumbnail({
 | 
			
		||||
@ -24,8 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var box = Hive.box(userInfoBox);
 | 
			
		||||
    var thumbnailRequestUrl = getThumbnailUrl(asset);
 | 
			
		||||
    var deviceId = ref.watch(authenticationProvider).deviceId;
 | 
			
		||||
    final selectedAssetsInAlbumViewer =
 | 
			
		||||
        ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
 | 
			
		||||
@ -120,27 +115,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
 | 
			
		||||
    _buildThumbnailImage() {
 | 
			
		||||
      return Container(
 | 
			
		||||
        decoration: BoxDecoration(border: drawBorderColor()),
 | 
			
		||||
        child: CachedNetworkImage(
 | 
			
		||||
          cacheKey: asset.id,
 | 
			
		||||
          width: 300,
 | 
			
		||||
          height: 300,
 | 
			
		||||
          memCacheHeight: 200,
 | 
			
		||||
          fit: BoxFit.cover,
 | 
			
		||||
          imageUrl: thumbnailRequestUrl,
 | 
			
		||||
          httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
 | 
			
		||||
          fadeInDuration: const Duration(milliseconds: 250),
 | 
			
		||||
          progressIndicatorBuilder: (context, url, downloadProgress) =>
 | 
			
		||||
              Transform.scale(
 | 
			
		||||
            scale: 0.2,
 | 
			
		||||
            child: CircularProgressIndicator(value: downloadProgress.progress),
 | 
			
		||||
          ),
 | 
			
		||||
          errorWidget: (context, url, error) {
 | 
			
		||||
            return Icon(
 | 
			
		||||
              Icons.image_not_supported_outlined,
 | 
			
		||||
              color: Theme.of(context).primaryColor,
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        child: ImmichImage(asset, width: 300, height: 300),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -167,7 +142,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
 | 
			
		||||
        children: [
 | 
			
		||||
          _buildThumbnailImage(),
 | 
			
		||||
          if (showStorageIndicator) _buildAssetStoreLocationIcon(),
 | 
			
		||||
          if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
 | 
			
		||||
          if (!asset.isImage) _buildVideoLabel(),
 | 
			
		||||
          if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
class AssetGridByMonth extends HookConsumerWidget {
 | 
			
		||||
  final List<AssetResponseDto> assetGroup;
 | 
			
		||||
  final List<Asset> assetGroup;
 | 
			
		||||
  const AssetGridByMonth({Key? key, required this.assetGroup})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
  @override
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
class MonthGroupTitle extends HookConsumerWidget {
 | 
			
		||||
  final String month;
 | 
			
		||||
  final List<AssetResponseDto> assetGroup;
 | 
			
		||||
  final List<Asset> assetGroup;
 | 
			
		||||
 | 
			
		||||
  const MonthGroupTitle({
 | 
			
		||||
    Key? key,
 | 
			
		||||
 | 
			
		||||
@ -1,29 +1,24 @@
 | 
			
		||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
 | 
			
		||||
 | 
			
		||||
class SelectionThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  const SelectionThumbnailImage({Key? key, required this.asset})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var box = Hive.box(userInfoBox);
 | 
			
		||||
    var thumbnailRequestUrl = getThumbnailUrl(asset);
 | 
			
		||||
    var selectedAsset =
 | 
			
		||||
        ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
 | 
			
		||||
    var newAssetsForAlbum =
 | 
			
		||||
        ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
 | 
			
		||||
    var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
 | 
			
		||||
 | 
			
		||||
    Widget _buildSelectionIcon(AssetResponseDto asset) {
 | 
			
		||||
    Widget _buildSelectionIcon(Asset asset) {
 | 
			
		||||
      var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
 | 
			
		||||
      var isNewlySelected =
 | 
			
		||||
          newAssetsForAlbum.map((item) => item.id).contains(asset.id);
 | 
			
		||||
@ -110,30 +105,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
        children: [
 | 
			
		||||
          Container(
 | 
			
		||||
            decoration: BoxDecoration(border: drawBorderColor()),
 | 
			
		||||
            child: CachedNetworkImage(
 | 
			
		||||
              cacheKey: asset.id,
 | 
			
		||||
              width: 150,
 | 
			
		||||
              height: 150,
 | 
			
		||||
              memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
 | 
			
		||||
              fit: BoxFit.cover,
 | 
			
		||||
              imageUrl: thumbnailRequestUrl,
 | 
			
		||||
              httpHeaders: {
 | 
			
		||||
                "Authorization": "Bearer ${box.get(accessTokenKey)}"
 | 
			
		||||
              },
 | 
			
		||||
              fadeInDuration: const Duration(milliseconds: 250),
 | 
			
		||||
              progressIndicatorBuilder: (context, url, downloadProgress) =>
 | 
			
		||||
                  Transform.scale(
 | 
			
		||||
                scale: 0.2,
 | 
			
		||||
                child:
 | 
			
		||||
                    CircularProgressIndicator(value: downloadProgress.progress),
 | 
			
		||||
              ),
 | 
			
		||||
              errorWidget: (context, url, error) {
 | 
			
		||||
                return Icon(
 | 
			
		||||
                  Icons.image_not_supported_outlined,
 | 
			
		||||
                  color: Theme.of(context).primaryColor,
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            child: ImmichImage(asset, width: 150, height: 150),
 | 
			
		||||
          ),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.all(3.0),
 | 
			
		||||
@ -142,7 +114,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
              child: _buildSelectionIcon(asset),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (asset.type != AssetTypeEnum.IMAGE)
 | 
			
		||||
          if (!asset.isImage)
 | 
			
		||||
            Positioned(
 | 
			
		||||
              bottom: 5,
 | 
			
		||||
              right: 5,
 | 
			
		||||
 | 
			
		||||
@ -1,49 +1,23 @@
 | 
			
		||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
 | 
			
		||||
 | 
			
		||||
class SharedAlbumThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  const SharedAlbumThumbnailImage({Key? key, required this.asset})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var box = Hive.box(userInfoBox);
 | 
			
		||||
 | 
			
		||||
    return GestureDetector(
 | 
			
		||||
      onTap: () {
 | 
			
		||||
        // debugPrint("View ${asset.id}");
 | 
			
		||||
      },
 | 
			
		||||
      child: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          CachedNetworkImage(
 | 
			
		||||
            cacheKey: asset.id,
 | 
			
		||||
            width: 500,
 | 
			
		||||
            height: 500,
 | 
			
		||||
            memCacheHeight: 500,
 | 
			
		||||
            fit: BoxFit.cover,
 | 
			
		||||
            imageUrl: getThumbnailUrl(asset),
 | 
			
		||||
            httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
 | 
			
		||||
            fadeInDuration: const Duration(milliseconds: 250),
 | 
			
		||||
            progressIndicatorBuilder: (context, url, downloadProgress) =>
 | 
			
		||||
                Transform.scale(
 | 
			
		||||
              scale: 0.2,
 | 
			
		||||
              child:
 | 
			
		||||
                  CircularProgressIndicator(value: downloadProgress.progress),
 | 
			
		||||
            ),
 | 
			
		||||
            errorWidget: (context, url, error) {
 | 
			
		||||
              return Icon(
 | 
			
		||||
                Icons.image_not_supported_outlined,
 | 
			
		||||
                color: Theme.of(context).primaryColor,
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          ImmichImage(asset, width: 500, height: 500),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 | 
			
		||||
@ -38,9 +39,9 @@ class AlbumViewerPage extends HookConsumerWidget {
 | 
			
		||||
    /// If they exist, add to selected asset state to show they are already selected.
 | 
			
		||||
    void _onAddPhotosPressed(AlbumResponseDto albumInfo) async {
 | 
			
		||||
      if (albumInfo.assets.isNotEmpty == true) {
 | 
			
		||||
        ref
 | 
			
		||||
            .watch(assetSelectionProvider.notifier)
 | 
			
		||||
            .addNewAssets(albumInfo.assets.toList());
 | 
			
		||||
        ref.watch(assetSelectionProvider.notifier).addNewAssets(
 | 
			
		||||
              albumInfo.assets.map((e) => Asset.remote(e)).toList(),
 | 
			
		||||
            );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
 | 
			
		||||
@ -205,8 +206,9 @@ class AlbumViewerPage extends HookConsumerWidget {
 | 
			
		||||
            delegate: SliverChildBuilderDelegate(
 | 
			
		||||
              (BuildContext context, int index) {
 | 
			
		||||
                return AlbumViewerThumbnail(
 | 
			
		||||
                  asset: albumInfo.assets[index],
 | 
			
		||||
                  assetList: albumInfo.assets,
 | 
			
		||||
                  asset: Asset.remote(albumInfo.assets[index]),
 | 
			
		||||
                  assetList:
 | 
			
		||||
                      albumInfo.assets.map((e) => Asset.remote(e)).toList(),
 | 
			
		||||
                  showStorageIndicator: showStorageIndicator,
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
 | 
			
		||||
@ -166,7 +166,7 @@ class CreateAlbumPage extends HookConsumerWidget {
 | 
			
		||||
                return GestureDetector(
 | 
			
		||||
                  onTap: _onBackgroundTapped,
 | 
			
		||||
                  child: SharedAlbumThumbnailImage(
 | 
			
		||||
                    asset: selectedAssets.toList()[index],
 | 
			
		||||
                    asset: selectedAssets.elementAt(index),
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,6 @@ class SharingPage extends HookConsumerWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var box = Hive.box(userInfoBox);
 | 
			
		||||
    var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
 | 
			
		||||
    final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/share.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
 | 
			
		||||
@ -47,7 +47,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
 | 
			
		||||
    state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void shareAsset(AssetResponseDto asset, BuildContext context) async {
 | 
			
		||||
  void shareAsset(Asset asset, BuildContext context) async {
 | 
			
		||||
    showDialog(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (BuildContext buildContext) {
 | 
			
		||||
 | 
			
		||||
@ -2,12 +2,13 @@ import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_map/flutter_map.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:path/path.dart' as p;
 | 
			
		||||
import 'package:latlong2/latlong.dart';
 | 
			
		||||
 | 
			
		||||
class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
  final AssetResponseDto assetDetail;
 | 
			
		||||
  final Asset assetDetail;
 | 
			
		||||
 | 
			
		||||
  const ExifBottomSheet({Key? key, required this.assetDetail})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
@ -26,8 +27,8 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
          child: FlutterMap(
 | 
			
		||||
            options: MapOptions(
 | 
			
		||||
              center: LatLng(
 | 
			
		||||
                assetDetail.exifInfo?.latitude?.toDouble() ?? 0,
 | 
			
		||||
                assetDetail.exifInfo?.longitude?.toDouble() ?? 0,
 | 
			
		||||
                assetDetail.latitude ?? 0,
 | 
			
		||||
                assetDetail.longitude ?? 0,
 | 
			
		||||
              ),
 | 
			
		||||
              zoom: 16.0,
 | 
			
		||||
            ),
 | 
			
		||||
@ -48,8 +49,8 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
                  Marker(
 | 
			
		||||
                    anchorPos: AnchorPos.align(AnchorAlign.top),
 | 
			
		||||
                    point: LatLng(
 | 
			
		||||
                      assetDetail.exifInfo?.latitude?.toDouble() ?? 0,
 | 
			
		||||
                      assetDetail.exifInfo?.longitude?.toDouble() ?? 0,
 | 
			
		||||
                      assetDetail.latitude ?? 0,
 | 
			
		||||
                      assetDetail.longitude ?? 0,
 | 
			
		||||
                    ),
 | 
			
		||||
                    builder: (ctx) => const Image(
 | 
			
		||||
                      image: AssetImage('assets/location-pin.png'),
 | 
			
		||||
@ -63,9 +64,11 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
 | 
			
		||||
 | 
			
		||||
    _buildLocationText() {
 | 
			
		||||
      return Text(
 | 
			
		||||
        "${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
 | 
			
		||||
        "${exifInfo?.city}, ${exifInfo?.state}",
 | 
			
		||||
        style: TextStyle(
 | 
			
		||||
          fontSize: 12,
 | 
			
		||||
          color: Colors.grey[200],
 | 
			
		||||
@ -78,10 +81,10 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
      padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
 | 
			
		||||
      child: ListView(
 | 
			
		||||
        children: [
 | 
			
		||||
          if (assetDetail.exifInfo?.dateTimeOriginal != null)
 | 
			
		||||
          if (exifInfo?.dateTimeOriginal != null)
 | 
			
		||||
            Text(
 | 
			
		||||
              DateFormat('date_format'.tr()).format(
 | 
			
		||||
                assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
 | 
			
		||||
                exifInfo!.dateTimeOriginal!.toLocal(),
 | 
			
		||||
              ),
 | 
			
		||||
              style: TextStyle(
 | 
			
		||||
                color: Colors.grey[400],
 | 
			
		||||
@ -101,7 +104,7 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          // Location
 | 
			
		||||
          if (assetDetail.exifInfo?.latitude != null)
 | 
			
		||||
          if (assetDetail.latitude != null)
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 32.0),
 | 
			
		||||
              child: Column(
 | 
			
		||||
@ -115,21 +118,22 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
                    "exif_bottom_sheet_location",
 | 
			
		||||
                    style: TextStyle(fontSize: 11, color: Colors.grey[400]),
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                  if (assetDetail.exifInfo?.latitude != null &&
 | 
			
		||||
                      assetDetail.exifInfo?.longitude != null)
 | 
			
		||||
                  if (assetDetail.latitude != null &&
 | 
			
		||||
                      assetDetail.longitude != null)
 | 
			
		||||
                    _buildMap(),
 | 
			
		||||
                  if (assetDetail.exifInfo?.city != null &&
 | 
			
		||||
                      assetDetail.exifInfo?.state != null)
 | 
			
		||||
                  if (exifInfo != null &&
 | 
			
		||||
                      exifInfo.city != null &&
 | 
			
		||||
                      exifInfo.state != null)
 | 
			
		||||
                    _buildLocationText(),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    "${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}",
 | 
			
		||||
                    "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
 | 
			
		||||
                    style: TextStyle(fontSize: 12, color: Colors.grey[400]),
 | 
			
		||||
                  )
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          // Detail
 | 
			
		||||
          if (assetDetail.exifInfo != null)
 | 
			
		||||
          if (exifInfo != null)
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 32.0),
 | 
			
		||||
              child: Column(
 | 
			
		||||
@ -153,16 +157,16 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
                    iconColor: Colors.grey[300],
 | 
			
		||||
                    leading: const Icon(Icons.image),
 | 
			
		||||
                    title: Text(
 | 
			
		||||
                      "${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
 | 
			
		||||
                      "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}",
 | 
			
		||||
                      style: const TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                    ),
 | 
			
		||||
                    subtitle: assetDetail.exifInfo?.exifImageHeight != null
 | 
			
		||||
                    subtitle: exifInfo.exifImageHeight != null
 | 
			
		||||
                        ? Text(
 | 
			
		||||
                            "${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth}  ${assetDetail.exifInfo?.fileSizeInByte!}B ",
 | 
			
		||||
                            "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${exifInfo.fileSizeInByte!}B ",
 | 
			
		||||
                          )
 | 
			
		||||
                        : null,
 | 
			
		||||
                  ),
 | 
			
		||||
                  if (assetDetail.exifInfo?.make != null)
 | 
			
		||||
                  if (exifInfo.make != null)
 | 
			
		||||
                    ListTile(
 | 
			
		||||
                      contentPadding: const EdgeInsets.all(0),
 | 
			
		||||
                      dense: true,
 | 
			
		||||
@ -170,11 +174,11 @@ class ExifBottomSheet extends ConsumerWidget {
 | 
			
		||||
                      iconColor: Colors.grey[300],
 | 
			
		||||
                      leading: const Icon(Icons.camera),
 | 
			
		||||
                      title: Text(
 | 
			
		||||
                        "${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
 | 
			
		||||
                        "${exifInfo.make} ${exifInfo.model}",
 | 
			
		||||
                        style: const TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                      ),
 | 
			
		||||
                      subtitle: Text(
 | 
			
		||||
                        "ƒ/${assetDetail.exifInfo?.fNumber}   1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)}   ${assetDetail.exifInfo?.focalLength}mm   ISO${assetDetail.exifInfo?.iso} ",
 | 
			
		||||
                        "ƒ/${exifInfo.fNumber}   1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)}   ${exifInfo.focalLength}mm   ISO${exifInfo.iso} ",
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
 | 
			
		||||
@ -1,17 +1,22 @@
 | 
			
		||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart'
 | 
			
		||||
    show AssetEntityImageProvider, ThumbnailSize;
 | 
			
		||||
import 'package:photo_view/photo_view.dart';
 | 
			
		||||
 | 
			
		||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
 | 
			
		||||
 | 
			
		||||
class _RemotePhotoViewState extends State<RemotePhotoView> {
 | 
			
		||||
  late CachedNetworkImageProvider _imageProvider;
 | 
			
		||||
  late ImageProvider _imageProvider;
 | 
			
		||||
  _RemoteImageStatus _status = _RemoteImageStatus.empty;
 | 
			
		||||
  bool _zoomedIn = false;
 | 
			
		||||
 | 
			
		||||
  late CachedNetworkImageProvider fullProvider;
 | 
			
		||||
  late CachedNetworkImageProvider previewProvider;
 | 
			
		||||
  late CachedNetworkImageProvider thumbnailProvider;
 | 
			
		||||
  late ImageProvider _fullProvider;
 | 
			
		||||
  late ImageProvider _previewProvider;
 | 
			
		||||
  late ImageProvider _thumbnailProvider;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
@ -68,7 +73,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 | 
			
		||||
 | 
			
		||||
  void _performStateTransition(
 | 
			
		||||
    _RemoteImageStatus newStatus,
 | 
			
		||||
    CachedNetworkImageProvider provider,
 | 
			
		||||
    ImageProvider provider,
 | 
			
		||||
  ) {
 | 
			
		||||
    if (_status == newStatus) return;
 | 
			
		||||
 | 
			
		||||
@ -90,40 +95,58 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _loadImages() {
 | 
			
		||||
    thumbnailProvider = _authorizedImageProvider(
 | 
			
		||||
      widget.thumbnailUrl,
 | 
			
		||||
      widget.cacheKey,
 | 
			
		||||
    );
 | 
			
		||||
    _imageProvider = thumbnailProvider;
 | 
			
		||||
    if (widget.asset.isLocal) {
 | 
			
		||||
      _imageProvider = AssetEntityImageProvider(
 | 
			
		||||
        widget.asset.local!,
 | 
			
		||||
        isOriginal: false,
 | 
			
		||||
        thumbnailSize: const ThumbnailSize.square(250),
 | 
			
		||||
      );
 | 
			
		||||
      _fullProvider = AssetEntityImageProvider(widget.asset.local!);
 | 
			
		||||
      _fullProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
        ImageStreamListener((ImageInfo image, _) {
 | 
			
		||||
          _performStateTransition(
 | 
			
		||||
            _RemoteImageStatus.full,
 | 
			
		||||
            _fullProvider,
 | 
			
		||||
          );
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    thumbnailProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
    _thumbnailProvider = _authorizedImageProvider(
 | 
			
		||||
      getThumbnailUrl(widget.asset.remote!),
 | 
			
		||||
      widget.asset.id,
 | 
			
		||||
    );
 | 
			
		||||
    _imageProvider = _thumbnailProvider;
 | 
			
		||||
 | 
			
		||||
    _thumbnailProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
      ImageStreamListener((ImageInfo imageInfo, _) {
 | 
			
		||||
        _performStateTransition(
 | 
			
		||||
          _RemoteImageStatus.thumbnail,
 | 
			
		||||
          thumbnailProvider,
 | 
			
		||||
          _thumbnailProvider,
 | 
			
		||||
        );
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (widget.previewUrl != null) {
 | 
			
		||||
      previewProvider = _authorizedImageProvider(
 | 
			
		||||
        widget.previewUrl!,
 | 
			
		||||
        "${widget.cacheKey}_previewStage",
 | 
			
		||||
    if (widget.threeStageLoading) {
 | 
			
		||||
      _previewProvider = _authorizedImageProvider(
 | 
			
		||||
        getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
 | 
			
		||||
        "${widget.asset.id}_previewStage",
 | 
			
		||||
      );
 | 
			
		||||
      previewProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
      _previewProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
        ImageStreamListener((ImageInfo imageInfo, _) {
 | 
			
		||||
          _performStateTransition(_RemoteImageStatus.preview, previewProvider);
 | 
			
		||||
          _performStateTransition(_RemoteImageStatus.preview, _previewProvider);
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fullProvider = _authorizedImageProvider(
 | 
			
		||||
      widget.imageUrl,
 | 
			
		||||
      "${widget.cacheKey}_fullStage",
 | 
			
		||||
    _fullProvider = _authorizedImageProvider(
 | 
			
		||||
      getImageUrl(widget.asset.remote!),
 | 
			
		||||
      "${widget.asset.id}_fullStage",
 | 
			
		||||
    );
 | 
			
		||||
    fullProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
    _fullProvider.resolve(const ImageConfiguration()).addListener(
 | 
			
		||||
      ImageStreamListener((ImageInfo imageInfo, _) {
 | 
			
		||||
        _performStateTransition(_RemoteImageStatus.full, fullProvider);
 | 
			
		||||
        _performStateTransition(_RemoteImageStatus.full, _fullProvider);
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
@ -139,11 +162,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 | 
			
		||||
    super.dispose();
 | 
			
		||||
 | 
			
		||||
    if (_status == _RemoteImageStatus.full) {
 | 
			
		||||
      await fullProvider.evict();
 | 
			
		||||
      await _fullProvider.evict();
 | 
			
		||||
    } else if (_status == _RemoteImageStatus.preview) {
 | 
			
		||||
      await previewProvider.evict();
 | 
			
		||||
      await _previewProvider.evict();
 | 
			
		||||
    } else if (_status == _RemoteImageStatus.thumbnail) {
 | 
			
		||||
      await thumbnailProvider.evict();
 | 
			
		||||
      await _thumbnailProvider.evict();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await _imageProvider.evict();
 | 
			
		||||
@ -153,23 +176,18 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 | 
			
		||||
class RemotePhotoView extends StatefulWidget {
 | 
			
		||||
  const RemotePhotoView({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required this.thumbnailUrl,
 | 
			
		||||
    required this.imageUrl,
 | 
			
		||||
    required this.asset,
 | 
			
		||||
    required this.authToken,
 | 
			
		||||
    required this.threeStageLoading,
 | 
			
		||||
    required this.isZoomedFunction,
 | 
			
		||||
    required this.isZoomedListener,
 | 
			
		||||
    required this.onSwipeDown,
 | 
			
		||||
    required this.onSwipeUp,
 | 
			
		||||
    this.previewUrl,
 | 
			
		||||
    required this.cacheKey,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  final String thumbnailUrl;
 | 
			
		||||
  final String imageUrl;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final String authToken;
 | 
			
		||||
  final String? previewUrl;
 | 
			
		||||
  final String cacheKey;
 | 
			
		||||
 | 
			
		||||
  final bool threeStageLoading;
 | 
			
		||||
  final void Function() onSwipeDown;
 | 
			
		||||
  final void Function() onSwipeUp;
 | 
			
		||||
  final void Function() isZoomedFunction;
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
 | 
			
		||||
  const TopControlAppBar({
 | 
			
		||||
@ -13,9 +13,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
 | 
			
		||||
    this.loading = false,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final Function onMoreInfoPressed;
 | 
			
		||||
  final Function onDownloadPressed;
 | 
			
		||||
  final VoidCallback? onDownloadPressed;
 | 
			
		||||
  final Function onSharePressed;
 | 
			
		||||
  final bool loading;
 | 
			
		||||
 | 
			
		||||
@ -47,17 +47,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
 | 
			
		||||
              child: const CircularProgressIndicator(strokeWidth: 2.0),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        IconButton(
 | 
			
		||||
          iconSize: iconSize,
 | 
			
		||||
          splashRadius: iconSize,
 | 
			
		||||
          onPressed: () {
 | 
			
		||||
            onDownloadPressed();
 | 
			
		||||
          },
 | 
			
		||||
          icon: Icon(
 | 
			
		||||
            Icons.cloud_download_rounded,
 | 
			
		||||
            color: Colors.grey[200],
 | 
			
		||||
        if (!asset.isLocal)
 | 
			
		||||
          IconButton(
 | 
			
		||||
            iconSize: iconSize,
 | 
			
		||||
            splashRadius: iconSize,
 | 
			
		||||
            onPressed: onDownloadPressed,
 | 
			
		||||
            icon: Icon(
 | 
			
		||||
              Icons.cloud_download_rounded,
 | 
			
		||||
              color: Colors.grey[200],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        IconButton(
 | 
			
		||||
          iconSize: iconSize,
 | 
			
		||||
          splashRadius: iconSize,
 | 
			
		||||
@ -69,17 +68,18 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
 | 
			
		||||
            color: Colors.grey[200],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        IconButton(
 | 
			
		||||
          iconSize: iconSize,
 | 
			
		||||
          splashRadius: iconSize,
 | 
			
		||||
          onPressed: () {
 | 
			
		||||
            onMoreInfoPressed();
 | 
			
		||||
          },
 | 
			
		||||
          icon: Icon(
 | 
			
		||||
            Icons.more_horiz_rounded,
 | 
			
		||||
            color: Colors.grey[200],
 | 
			
		||||
          ),
 | 
			
		||||
        )
 | 
			
		||||
        if (asset.isRemote)
 | 
			
		||||
          IconButton(
 | 
			
		||||
            iconSize: iconSize,
 | 
			
		||||
            splashRadius: iconSize,
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              onMoreInfoPressed();
 | 
			
		||||
            },
 | 
			
		||||
            icon: Icon(
 | 
			
		||||
              Icons.more_horiz_rounded,
 | 
			
		||||
              color: Colors.grey[200],
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -14,12 +14,12 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'
 | 
			
		||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
// ignore: must_be_immutable
 | 
			
		||||
class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
  late List<AssetResponseDto> assetList;
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  late List<Asset> assetList;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  GalleryViewerPage({
 | 
			
		||||
    Key? key,
 | 
			
		||||
@ -27,7 +27,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
    required this.asset,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  AssetResponseDto? assetDetail;
 | 
			
		||||
  Asset? assetDetail;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
@ -37,8 +37,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
    final loading = useState(false);
 | 
			
		||||
    final isZoomed = useState<bool>(false);
 | 
			
		||||
    ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
 | 
			
		||||
 | 
			
		||||
    int indexOfAsset = assetList.indexOf(asset);
 | 
			
		||||
    final indexOfAsset = useState(assetList.indexOf(asset));
 | 
			
		||||
 | 
			
		||||
    PageController controller =
 | 
			
		||||
        PageController(initialPage: assetList.indexOf(asset));
 | 
			
		||||
@ -52,15 +51,15 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
      [],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    @override
 | 
			
		||||
    initState(int index) {
 | 
			
		||||
      indexOfAsset = index;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getAssetExif() async {
 | 
			
		||||
      assetDetail = await ref
 | 
			
		||||
          .watch(assetServiceProvider)
 | 
			
		||||
          .getAssetById(assetList[indexOfAsset].id);
 | 
			
		||||
      if (assetList[indexOfAsset.value].isRemote) {
 | 
			
		||||
        assetDetail = await ref
 | 
			
		||||
            .watch(assetServiceProvider)
 | 
			
		||||
            .getAssetById(assetList[indexOfAsset.value].id);
 | 
			
		||||
      } else {
 | 
			
		||||
        // TODO local exif parsing?
 | 
			
		||||
        assetDetail = assetList[indexOfAsset.value];
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    void showInfo() {
 | 
			
		||||
@ -88,19 +87,20 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
      backgroundColor: Colors.black,
 | 
			
		||||
      appBar: TopControlAppBar(
 | 
			
		||||
        loading: loading.value,
 | 
			
		||||
        asset: assetList[indexOfAsset],
 | 
			
		||||
        asset: assetList[indexOfAsset.value],
 | 
			
		||||
        onMoreInfoPressed: () {
 | 
			
		||||
          showInfo();
 | 
			
		||||
        },
 | 
			
		||||
        onDownloadPressed: () {
 | 
			
		||||
          ref
 | 
			
		||||
              .watch(imageViewerStateProvider.notifier)
 | 
			
		||||
              .downloadAsset(assetList[indexOfAsset], context);
 | 
			
		||||
        },
 | 
			
		||||
        onDownloadPressed: assetList[indexOfAsset.value].isLocal
 | 
			
		||||
            ? null
 | 
			
		||||
            : () {
 | 
			
		||||
                ref.watch(imageViewerStateProvider.notifier).downloadAsset(
 | 
			
		||||
                    assetList[indexOfAsset.value].remote!, context);
 | 
			
		||||
              },
 | 
			
		||||
        onSharePressed: () {
 | 
			
		||||
          ref
 | 
			
		||||
              .watch(imageViewerStateProvider.notifier)
 | 
			
		||||
              .shareAsset(assetList[indexOfAsset], context);
 | 
			
		||||
              .shareAsset(assetList[indexOfAsset.value], context);
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      body: SafeArea(
 | 
			
		||||
@ -113,14 +113,13 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
          itemCount: assetList.length,
 | 
			
		||||
          scrollDirection: Axis.horizontal,
 | 
			
		||||
          onPageChanged: (value) {
 | 
			
		||||
            indexOfAsset.value = value;
 | 
			
		||||
            HapticFeedback.selectionClick();
 | 
			
		||||
          },
 | 
			
		||||
          itemBuilder: (context, index) {
 | 
			
		||||
            initState(index);
 | 
			
		||||
 | 
			
		||||
            getAssetExif();
 | 
			
		||||
 | 
			
		||||
            if (assetList[index].type == AssetTypeEnum.IMAGE) {
 | 
			
		||||
            if (assetList[index].isImage) {
 | 
			
		||||
              return ImageViewerPage(
 | 
			
		||||
                authToken: 'Bearer ${box.get(accessTokenKey)}',
 | 
			
		||||
                isZoomedFunction: isZoomedMethod,
 | 
			
		||||
@ -139,11 +138,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
                },
 | 
			
		||||
                child: Hero(
 | 
			
		||||
                  tag: assetList[index].id,
 | 
			
		||||
                  child: VideoViewerPage(
 | 
			
		||||
                    asset: assetList[index],
 | 
			
		||||
                    videoUrl:
 | 
			
		||||
                        '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}',
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: VideoViewerPage(asset: assetList[index]),
 | 
			
		||||
                ),
 | 
			
		||||
              );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@ -8,13 +8,12 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
// ignore: must_be_immutable
 | 
			
		||||
class ImageViewerPage extends HookConsumerWidget {
 | 
			
		||||
  final String heroTag;
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final String authToken;
 | 
			
		||||
  final ValueNotifier<bool> isZoomedListener;
 | 
			
		||||
  final void Function() isZoomedFunction;
 | 
			
		||||
@ -30,7 +29,7 @@ class ImageViewerPage extends HookConsumerWidget {
 | 
			
		||||
    required this.threeStageLoading,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  AssetResponseDto? assetDetail;
 | 
			
		||||
  Asset? assetDetail;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
@ -38,8 +37,13 @@ class ImageViewerPage extends HookConsumerWidget {
 | 
			
		||||
        ref.watch(imageViewerStateProvider).downloadAssetStatus;
 | 
			
		||||
 | 
			
		||||
    getAssetExif() async {
 | 
			
		||||
      assetDetail =
 | 
			
		||||
          await ref.watch(assetServiceProvider).getAssetById(asset.id);
 | 
			
		||||
      if (asset.isRemote) {
 | 
			
		||||
        assetDetail =
 | 
			
		||||
            await ref.watch(assetServiceProvider).getAssetById(asset.id);
 | 
			
		||||
      } else {
 | 
			
		||||
        // TODO local exif parsing?
 | 
			
		||||
        assetDetail = asset;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
@ -68,17 +72,13 @@ class ImageViewerPage extends HookConsumerWidget {
 | 
			
		||||
          child: Hero(
 | 
			
		||||
            tag: heroTag,
 | 
			
		||||
            child: RemotePhotoView(
 | 
			
		||||
              thumbnailUrl: getThumbnailUrl(asset),
 | 
			
		||||
              cacheKey: asset.id,
 | 
			
		||||
              imageUrl: getImageUrl(asset),
 | 
			
		||||
              previewUrl: threeStageLoading
 | 
			
		||||
                  ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
 | 
			
		||||
                  : null,
 | 
			
		||||
              asset: asset,
 | 
			
		||||
              authToken: authToken,
 | 
			
		||||
              threeStageLoading: threeStageLoading,
 | 
			
		||||
              isZoomedFunction: isZoomedFunction,
 | 
			
		||||
              isZoomedListener: isZoomedListener,
 | 
			
		||||
              onSwipeDown: () => AutoRouter.of(context).pop(),
 | 
			
		||||
              onSwipeUp: () => showInfo(),
 | 
			
		||||
              onSwipeUp: asset.isRemote ? showInfo : () {},
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive/hive.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
@ -6,24 +8,41 @@ import 'package:chewie/chewie.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
import 'package:video_player/video_player.dart';
 | 
			
		||||
 | 
			
		||||
// ignore: must_be_immutable
 | 
			
		||||
class VideoViewerPage extends HookConsumerWidget {
 | 
			
		||||
  final String videoUrl;
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  AssetResponseDto? assetDetail;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  VideoViewerPage({Key? key, required this.videoUrl, required this.asset})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
  const VideoViewerPage({Key? key, required this.asset}) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    if (asset.isLocal) {
 | 
			
		||||
      final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
 | 
			
		||||
      return videoFile.when(
 | 
			
		||||
        data: (data) => VideoThumbnailPlayer(file: data),
 | 
			
		||||
        error: (error, stackTrace) => Icon(
 | 
			
		||||
          Icons.image_not_supported_outlined,
 | 
			
		||||
          color: Theme.of(context).primaryColor,
 | 
			
		||||
        ),
 | 
			
		||||
        loading: () => const Center(
 | 
			
		||||
          child: SizedBox(
 | 
			
		||||
            width: 75,
 | 
			
		||||
            height: 75,
 | 
			
		||||
            child: CircularProgressIndicator.adaptive(),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    final downloadAssetStatus =
 | 
			
		||||
        ref.watch(imageViewerStateProvider).downloadAssetStatus;
 | 
			
		||||
 | 
			
		||||
    String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
 | 
			
		||||
    final box = Hive.box(userInfoBox);
 | 
			
		||||
    final String jwtToken = box.get(accessTokenKey);
 | 
			
		||||
    final String videoUrl =
 | 
			
		||||
        '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}';
 | 
			
		||||
 | 
			
		||||
    return Stack(
 | 
			
		||||
      children: [
 | 
			
		||||
@ -40,11 +59,21 @@ class VideoViewerPage extends HookConsumerWidget {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class VideoThumbnailPlayer extends StatefulWidget {
 | 
			
		||||
  final String url;
 | 
			
		||||
  final String? jwtToken;
 | 
			
		||||
final _fileFamily =
 | 
			
		||||
    FutureProvider.family<File, AssetEntity>((ref, entity) async {
 | 
			
		||||
  final file = await entity.file;
 | 
			
		||||
  if (file == null) {
 | 
			
		||||
    throw Exception();
 | 
			
		||||
  }
 | 
			
		||||
  return file;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
  const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken})
 | 
			
		||||
class VideoThumbnailPlayer extends StatefulWidget {
 | 
			
		||||
  final String? url;
 | 
			
		||||
  final String? jwtToken;
 | 
			
		||||
  final File? file;
 | 
			
		||||
 | 
			
		||||
  const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -63,10 +92,12 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
 | 
			
		||||
 | 
			
		||||
  Future<void> initializePlayer() async {
 | 
			
		||||
    try {
 | 
			
		||||
      videoPlayerController = VideoPlayerController.network(
 | 
			
		||||
        widget.url,
 | 
			
		||||
        httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
 | 
			
		||||
      );
 | 
			
		||||
      videoPlayerController = widget.file == null
 | 
			
		||||
          ? VideoPlayerController.network(
 | 
			
		||||
              widget.url!,
 | 
			
		||||
              httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
 | 
			
		||||
            )
 | 
			
		||||
          : VideoPlayerController.file(widget.file!);
 | 
			
		||||
 | 
			
		||||
      await videoPlayerController.initialize();
 | 
			
		||||
      _createChewieController();
 | 
			
		||||
 | 
			
		||||
@ -50,6 +50,11 @@ class BackgroundService {
 | 
			
		||||
      _Throttle(_updateProgress, notifyInterval);
 | 
			
		||||
  late final _Throttle _throttledDetailNotify =
 | 
			
		||||
      _Throttle(_updateDetailProgress, notifyInterval);
 | 
			
		||||
  Completer<bool> _hasAccessCompleter = Completer();
 | 
			
		||||
  late Future<bool> _hasAccess =
 | 
			
		||||
      Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true);
 | 
			
		||||
 | 
			
		||||
  Future<bool> get hasAccess => _hasAccess;
 | 
			
		||||
 | 
			
		||||
  bool get isBackgroundInitialized {
 | 
			
		||||
    return _isBackgroundInitialized;
 | 
			
		||||
@ -201,6 +206,15 @@ class BackgroundService {
 | 
			
		||||
    if (!Platform.isAndroid) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    if (_hasLock) {
 | 
			
		||||
      debugPrint("WARNING: [acquireLock] called more than once");
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    if (_hasAccessCompleter.isCompleted) {
 | 
			
		||||
      debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed");
 | 
			
		||||
      _hasAccessCompleter = Completer();
 | 
			
		||||
      _hasAccess = _hasAccessCompleter.future;
 | 
			
		||||
    }
 | 
			
		||||
    final int lockTime = Timeline.now;
 | 
			
		||||
    _wantsLockTime = lockTime;
 | 
			
		||||
    final ReceivePort rp = ReceivePort(_portNameLock);
 | 
			
		||||
@ -219,6 +233,7 @@ class BackgroundService {
 | 
			
		||||
    }
 | 
			
		||||
    _hasLock = true;
 | 
			
		||||
    rp.listen(_heartbeatListener);
 | 
			
		||||
    _hasAccessCompleter.complete(true);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -271,6 +286,8 @@ class BackgroundService {
 | 
			
		||||
    }
 | 
			
		||||
    _wantsLockTime = 0;
 | 
			
		||||
    if (_hasLock) {
 | 
			
		||||
      _hasAccessCompleter = Completer();
 | 
			
		||||
      _hasAccess = _hasAccessCompleter.future;
 | 
			
		||||
      IsolateNameServer.removePortNameMapping(_portNameLock);
 | 
			
		||||
      _waitingIsolate?.send(true);
 | 
			
		||||
      _waitingIsolate = null;
 | 
			
		||||
 | 
			
		||||
@ -46,6 +46,17 @@ class HiveBackupAlbums {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Returns a deep copy to allow safe modification without changing the global
 | 
			
		||||
  /// state of [HiveBackupAlbums] before actually saving the changes
 | 
			
		||||
  HiveBackupAlbums deepCopy() {
 | 
			
		||||
    return HiveBackupAlbums(
 | 
			
		||||
      selectedAlbumIds: selectedAlbumIds.toList(),
 | 
			
		||||
      excludedAlbumsIds: excludedAlbumsIds.toList(),
 | 
			
		||||
      lastSelectedBackupTime: lastSelectedBackupTime.toList(),
 | 
			
		||||
      lastExcludedBackupTime: lastExcludedBackupTime.toList(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toMap() {
 | 
			
		||||
    final result = <String, dynamic>{};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -565,11 +565,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
      state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
 | 
			
		||||
      final bool hasLock = await _backgroundService.acquireLock();
 | 
			
		||||
      if (!hasLock) {
 | 
			
		||||
        debugPrint("WARNING [resumeBackup] failed to acquireLock");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      Box<HiveBackupAlbums> box =
 | 
			
		||||
          await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
 | 
			
		||||
      HiveBackupAlbums? albums = box.get(backupInfoKey);
 | 
			
		||||
      await Future.wait([
 | 
			
		||||
        Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
 | 
			
		||||
        Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
 | 
			
		||||
        Hive.openBox(backgroundBackupInfoBox),
 | 
			
		||||
      ]);
 | 
			
		||||
      final HiveBackupAlbums? albums =
 | 
			
		||||
          Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
 | 
			
		||||
      Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
 | 
			
		||||
      Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
 | 
			
		||||
      if (albums != null) {
 | 
			
		||||
@ -584,8 +589,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
          albums.lastExcludedBackupTime,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
 | 
			
		||||
      final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
 | 
			
		||||
      final Box backgroundBox = Hive.box(backgroundBackupInfoBox);
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
        backupProgress: previous,
 | 
			
		||||
        selectedBackupAlbums: selectedAlbums,
 | 
			
		||||
 | 
			
		||||
@ -1,34 +1,90 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive/hive.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/api.service.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:photo_manager/src/types/entity.dart';
 | 
			
		||||
 | 
			
		||||
final assetServiceProvider = Provider(
 | 
			
		||||
  (ref) => AssetService(
 | 
			
		||||
    ref.watch(apiServiceProvider),
 | 
			
		||||
    ref.watch(backupServiceProvider),
 | 
			
		||||
    ref.watch(backgroundServiceProvider),
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
class AssetService {
 | 
			
		||||
  final ApiService _apiService;
 | 
			
		||||
  final BackupService _backupService;
 | 
			
		||||
  final BackgroundService _backgroundService;
 | 
			
		||||
 | 
			
		||||
  AssetService(this._apiService);
 | 
			
		||||
  AssetService(this._apiService, this._backupService, this._backgroundService);
 | 
			
		||||
 | 
			
		||||
  Future<List<AssetResponseDto>?> getAllAsset() async {
 | 
			
		||||
  /// Returns all local, remote assets in that order
 | 
			
		||||
  Future<List<Asset>> getAllAsset({bool urgent = false}) async {
 | 
			
		||||
    final List<Asset> assets = [];
 | 
			
		||||
    try {
 | 
			
		||||
      return await _apiService.assetApi.getAllAssets();
 | 
			
		||||
      // not using `await` here to fetch local & remote assets concurrently
 | 
			
		||||
      final Future<List<AssetResponseDto>?> remoteTask =
 | 
			
		||||
          _apiService.assetApi.getAllAssets();
 | 
			
		||||
      final Iterable<AssetEntity> newLocalAssets;
 | 
			
		||||
      final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
 | 
			
		||||
      final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
 | 
			
		||||
      if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
 | 
			
		||||
        final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
 | 
			
		||||
        final Set<String> existingIds = remoteAssets
 | 
			
		||||
            .where((e) => e.deviceId == deviceId)
 | 
			
		||||
            .map((e) => e.deviceAssetId)
 | 
			
		||||
            .toSet();
 | 
			
		||||
        newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
 | 
			
		||||
      } else {
 | 
			
		||||
        newLocalAssets = localAssets;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
 | 
			
		||||
      // the order (first all local, then remote assets) is important!
 | 
			
		||||
      assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("Error [getAllAsset]  ${e.toString()}");
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    return assets;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// if [urgent] is `true`, do not block by waiting on the background service
 | 
			
		||||
  /// to finish running. Returns an empty list instead after a timeout.
 | 
			
		||||
  Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final Future<bool> hasAccess = urgent
 | 
			
		||||
          ? _backgroundService.hasAccess
 | 
			
		||||
              .timeout(const Duration(milliseconds: 250))
 | 
			
		||||
          : _backgroundService.hasAccess;
 | 
			
		||||
      if (!await hasAccess) {
 | 
			
		||||
        throw Exception("Error [getAllAsset] failed to gain access");
 | 
			
		||||
      }
 | 
			
		||||
      final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
 | 
			
		||||
      final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
 | 
			
		||||
 | 
			
		||||
      return backupAlbumInfo != null
 | 
			
		||||
          ? await _backupService
 | 
			
		||||
              .buildUploadCandidates(backupAlbumInfo.deepCopy())
 | 
			
		||||
          : [];
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("Error [_getLocalAssets] ${e.toString()}");
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<AssetResponseDto?> getAssetById(String assetId) async {
 | 
			
		||||
  Future<Asset?> getAssetById(String assetId) async {
 | 
			
		||||
    try {
 | 
			
		||||
      return await _apiService.assetApi.getAssetById(assetId);
 | 
			
		||||
      return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("Error [getAssetById]  ${e.toString()}");
 | 
			
		||||
      return null;
 | 
			
		||||
@ -36,12 +92,12 @@ class AssetService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<DeleteAssetResponseDto>?> deleteAssets(
 | 
			
		||||
    Set<AssetResponseDto> deleteAssets,
 | 
			
		||||
    Iterable<AssetResponseDto> deleteAssets,
 | 
			
		||||
  ) async {
 | 
			
		||||
    try {
 | 
			
		||||
      List<String> payload = [];
 | 
			
		||||
      final List<String> payload = [];
 | 
			
		||||
 | 
			
		||||
      for (var asset in deleteAssets) {
 | 
			
		||||
      for (final asset in deleteAssets) {
 | 
			
		||||
        payload.add(asset.id);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,27 +1,24 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/json_cache.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
 | 
			
		||||
class AssetCacheService extends JsonCache<List<Asset>> {
 | 
			
		||||
  AssetCacheService() : super("asset_cache");
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void put(List<AssetResponseDto> data) {
 | 
			
		||||
  void put(List<Asset> data) {
 | 
			
		||||
    putRawData(data.map((e) => e.toJson()).toList());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<List<AssetResponseDto>> get() async {
 | 
			
		||||
  Future<List<Asset>> get() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final mapList = await readRawData() as List<dynamic>;
 | 
			
		||||
 | 
			
		||||
      final responseData = mapList
 | 
			
		||||
          .map((e) => AssetResponseDto.fromJson(e))
 | 
			
		||||
          .whereNotNull()
 | 
			
		||||
          .toList();
 | 
			
		||||
      final responseData =
 | 
			
		||||
          mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList();
 | 
			
		||||
 | 
			
		||||
      return responseData;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final assetCacheServiceProvider = Provider(
 | 
			
		||||
      (ref) => AssetCacheService(),
 | 
			
		||||
  (ref) => AssetCacheService(),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import 'dart:math';
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
 | 
			
		||||
enum RenderAssetGridElementType {
 | 
			
		||||
  assetRow,
 | 
			
		||||
@ -9,7 +9,7 @@ enum RenderAssetGridElementType {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class RenderAssetGridRow {
 | 
			
		||||
  final List<AssetResponseDto> assets;
 | 
			
		||||
  final List<Asset> assets;
 | 
			
		||||
 | 
			
		||||
  RenderAssetGridRow(this.assets);
 | 
			
		||||
}
 | 
			
		||||
@ -19,7 +19,7 @@ class RenderAssetGridElement {
 | 
			
		||||
  final RenderAssetGridRow? assetRow;
 | 
			
		||||
  final String? title;
 | 
			
		||||
  final DateTime date;
 | 
			
		||||
  final List<AssetResponseDto>? relatedAssetList;
 | 
			
		||||
  final List<Asset>? relatedAssetList;
 | 
			
		||||
 | 
			
		||||
  RenderAssetGridElement(
 | 
			
		||||
    this.type, {
 | 
			
		||||
@ -31,13 +31,15 @@ class RenderAssetGridElement {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
List<RenderAssetGridElement> assetsToRenderList(
 | 
			
		||||
    List<AssetResponseDto> assets, int assetsPerRow) {
 | 
			
		||||
  List<Asset> assets,
 | 
			
		||||
  int assetsPerRow,
 | 
			
		||||
) {
 | 
			
		||||
  List<RenderAssetGridElement> elements = [];
 | 
			
		||||
 | 
			
		||||
  int cursor = 0;
 | 
			
		||||
  while (cursor < assets.length) {
 | 
			
		||||
    int rowElements = min(assets.length - cursor, assetsPerRow);
 | 
			
		||||
    final date = DateTime.parse(assets[cursor].createdAt);
 | 
			
		||||
    final date = assets[cursor].createdAt;
 | 
			
		||||
 | 
			
		||||
    final rowElement = RenderAssetGridElement(
 | 
			
		||||
      RenderAssetGridElementType.assetRow,
 | 
			
		||||
@ -55,7 +57,9 @@ List<RenderAssetGridElement> assetsToRenderList(
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
List<RenderAssetGridElement> assetGroupsToRenderList(
 | 
			
		||||
    Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) {
 | 
			
		||||
  Map<String, List<Asset>> assetGroups,
 | 
			
		||||
  int assetsPerRow,
 | 
			
		||||
) {
 | 
			
		||||
  List<RenderAssetGridElement> elements = [];
 | 
			
		||||
  DateTime? lastDate;
 | 
			
		||||
 | 
			
		||||
@ -64,8 +68,11 @@ List<RenderAssetGridElement> assetGroupsToRenderList(
 | 
			
		||||
 | 
			
		||||
    if (lastDate == null || lastDate!.month != date.month) {
 | 
			
		||||
      elements.add(
 | 
			
		||||
        RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
 | 
			
		||||
            title: groupName, date: date),
 | 
			
		||||
        RenderAssetGridElement(
 | 
			
		||||
          RenderAssetGridElementType.monthTitle,
 | 
			
		||||
          title: groupName,
 | 
			
		||||
          date: date,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 | 
			
		||||
import 'asset_grid_data_structure.dart';
 | 
			
		||||
import 'daily_title_text.dart';
 | 
			
		||||
@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart';
 | 
			
		||||
 | 
			
		||||
typedef ImmichAssetGridSelectionListener = void Function(
 | 
			
		||||
  bool,
 | 
			
		||||
  Set<AssetResponseDto>,
 | 
			
		||||
  Set<Asset>,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
@ -24,20 +24,20 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
  bool _scrolling = false;
 | 
			
		||||
  final Set<String> _selectedAssets = HashSet();
 | 
			
		||||
 | 
			
		||||
  List<AssetResponseDto> get _assets {
 | 
			
		||||
  List<Asset> get _assets {
 | 
			
		||||
    return widget.renderList
 | 
			
		||||
        .map((e) {
 | 
			
		||||
          if (e.type == RenderAssetGridElementType.assetRow) {
 | 
			
		||||
            return e.assetRow!.assets;
 | 
			
		||||
          } else {
 | 
			
		||||
            return List<AssetResponseDto>.empty();
 | 
			
		||||
            return List<Asset>.empty();
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .flattened
 | 
			
		||||
        .toList();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Set<AssetResponseDto> _getSelectedAssets() {
 | 
			
		||||
  Set<Asset> _getSelectedAssets() {
 | 
			
		||||
    return _selectedAssets
 | 
			
		||||
        .map((e) => _assets.firstWhereOrNull((a) => a.id == e))
 | 
			
		||||
        .whereNotNull()
 | 
			
		||||
@ -48,7 +48,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
    widget.listener?.call(selectionActive, _getSelectedAssets());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _selectAssets(List<AssetResponseDto> assets) {
 | 
			
		||||
  void _selectAssets(List<Asset> assets) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      for (var e in assets) {
 | 
			
		||||
        _selectedAssets.add(e.id);
 | 
			
		||||
@ -57,7 +57,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _deselectAssets(List<AssetResponseDto> assets) {
 | 
			
		||||
  void _deselectAssets(List<Asset> assets) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      for (var e in assets) {
 | 
			
		||||
        _selectedAssets.remove(e.id);
 | 
			
		||||
@ -74,7 +74,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
    _callSelectionListener(false);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool _allAssetsSelected(List<AssetResponseDto> assets) {
 | 
			
		||||
  bool _allAssetsSelected(List<Asset> assets) {
 | 
			
		||||
    return widget.selectionActive &&
 | 
			
		||||
        assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
 | 
			
		||||
  }
 | 
			
		||||
@ -85,7 +85,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildThumbnailOrPlaceholder(
 | 
			
		||||
    AssetResponseDto asset,
 | 
			
		||||
    Asset asset,
 | 
			
		||||
    bool placeholder,
 | 
			
		||||
  ) {
 | 
			
		||||
    if (placeholder) {
 | 
			
		||||
@ -114,7 +114,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
 | 
			
		||||
    return Row(
 | 
			
		||||
      key: Key("asset-row-${row.assets.first.id}"),
 | 
			
		||||
      children: row.assets.map((AssetResponseDto asset) {
 | 
			
		||||
      children: row.assets.map((Asset asset) {
 | 
			
		||||
        bool last = asset == row.assets.last;
 | 
			
		||||
 | 
			
		||||
        return Container(
 | 
			
		||||
@ -134,7 +134,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 | 
			
		||||
  Widget _buildTitle(
 | 
			
		||||
    BuildContext context,
 | 
			
		||||
    String title,
 | 
			
		||||
    List<AssetResponseDto> assets,
 | 
			
		||||
    List<Asset> assets,
 | 
			
		||||
  ) {
 | 
			
		||||
    return DailyTitleText(
 | 
			
		||||
      isoDate: title,
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,15 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
 | 
			
		||||
 | 
			
		||||
class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final List<AssetResponseDto> assetList;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final List<Asset> assetList;
 | 
			
		||||
  final bool showStorageIndicator;
 | 
			
		||||
  final bool useGrayBoxPlaceholder;
 | 
			
		||||
  final bool isSelected;
 | 
			
		||||
@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var box = Hive.box(userInfoBox);
 | 
			
		||||
    var thumbnailRequestUrl = getThumbnailUrl(asset);
 | 
			
		||||
    var deviceId = ref.watch(authenticationProvider).deviceId;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    Widget buildSelectionIcon(AssetResponseDto asset) {
 | 
			
		||||
    Widget buildSelectionIcon(Asset asset) {
 | 
			
		||||
      if (isSelected) {
 | 
			
		||||
        return Icon(
 | 
			
		||||
          Icons.check_circle,
 | 
			
		||||
@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
                      )
 | 
			
		||||
                    : const Border(),
 | 
			
		||||
              ),
 | 
			
		||||
              child: CachedNetworkImage(
 | 
			
		||||
                cacheKey: 'thumbnail-image-${asset.id}',
 | 
			
		||||
              child: ImmichImage(
 | 
			
		||||
                asset,
 | 
			
		||||
                width: 300,
 | 
			
		||||
                height: 300,
 | 
			
		||||
                memCacheHeight: 200,
 | 
			
		||||
                maxWidthDiskCache: 200,
 | 
			
		||||
                maxHeightDiskCache: 200,
 | 
			
		||||
                fit: BoxFit.cover,
 | 
			
		||||
                imageUrl: thumbnailRequestUrl,
 | 
			
		||||
                httpHeaders: {
 | 
			
		||||
                  "Authorization": "Bearer ${box.get(accessTokenKey)}"
 | 
			
		||||
                },
 | 
			
		||||
                fadeInDuration: const Duration(milliseconds: 250),
 | 
			
		||||
                progressIndicatorBuilder: (context, url, downloadProgress) {
 | 
			
		||||
                  if (useGrayBoxPlaceholder) {
 | 
			
		||||
                    return const DecoratedBox(
 | 
			
		||||
                      decoration: BoxDecoration(color: Colors.grey),
 | 
			
		||||
                    );
 | 
			
		||||
                  }
 | 
			
		||||
                  return Transform.scale(
 | 
			
		||||
                    scale: 0.2,
 | 
			
		||||
                    child: CircularProgressIndicator(
 | 
			
		||||
                      value: downloadProgress.progress,
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
                errorWidget: (context, url, error) {
 | 
			
		||||
                  debugPrint("Error getting thumbnail $url = $error");
 | 
			
		||||
                  CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
 | 
			
		||||
 | 
			
		||||
                  return Icon(
 | 
			
		||||
                    Icons.image_not_supported_outlined,
 | 
			
		||||
                    color: Theme.of(context).primaryColor,
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
                useGrayBoxPlaceholder: useGrayBoxPlaceholder,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            if (multiselectEnabled)
 | 
			
		||||
@ -137,14 +101,16 @@ class ThumbnailImage extends HookConsumerWidget {
 | 
			
		||||
                right: 10,
 | 
			
		||||
                bottom: 5,
 | 
			
		||||
                child: Icon(
 | 
			
		||||
                  (deviceId != asset.deviceId)
 | 
			
		||||
                      ? Icons.cloud_done_outlined
 | 
			
		||||
                      : Icons.photo_library_rounded,
 | 
			
		||||
                  asset.isRemote
 | 
			
		||||
                      ? (deviceId == asset.deviceId
 | 
			
		||||
                          ? Icons.cloud_done_outlined
 | 
			
		||||
                          : Icons.cloud_outlined)
 | 
			
		||||
                      : Icons.cloud_off_outlined,
 | 
			
		||||
                  color: Colors.white,
 | 
			
		||||
                  size: 18,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            if (asset.type != AssetTypeEnum.IMAGE)
 | 
			
		||||
            if (!asset.isImage)
 | 
			
		||||
              Positioned(
 | 
			
		||||
                top: 5,
 | 
			
		||||
                right: 5,
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
 | 
			
		||||
@ -14,6 +15,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 | 
			
		||||
@ -31,7 +33,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
    final multiselectEnabled = ref.watch(multiselectProvider.notifier);
 | 
			
		||||
    final selectionEnabledHook = useState(false);
 | 
			
		||||
 | 
			
		||||
    final selection = useState(<AssetResponseDto>{});
 | 
			
		||||
    final selection = useState(<Asset>{});
 | 
			
		||||
    final albums = ref.watch(albumProvider);
 | 
			
		||||
    final albumService = ref.watch(albumServiceProvider);
 | 
			
		||||
 | 
			
		||||
@ -60,7 +62,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
    Widget buildBody() {
 | 
			
		||||
      void selectionListener(
 | 
			
		||||
        bool multiselect,
 | 
			
		||||
        Set<AssetResponseDto> selectedAssets,
 | 
			
		||||
        Set<Asset> selectedAssets,
 | 
			
		||||
      ) {
 | 
			
		||||
        selectionEnabledHook.value = multiselect;
 | 
			
		||||
        selection.value = selectedAssets;
 | 
			
		||||
@ -76,9 +78,27 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
        selectionEnabledHook.value = false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      Iterable<Asset> remoteOnlySelection() {
 | 
			
		||||
        final Set<Asset> assets = selection.value;
 | 
			
		||||
        final bool onlyRemote = assets.every((e) => e.isRemote);
 | 
			
		||||
        if (!onlyRemote) {
 | 
			
		||||
          ImmichToast.show(
 | 
			
		||||
            context: context,
 | 
			
		||||
            msg: "Can not add local assets to albums yet, skipping",
 | 
			
		||||
            gravity: ToastGravity.BOTTOM,
 | 
			
		||||
          );
 | 
			
		||||
          return assets.where((a) => a.isRemote);
 | 
			
		||||
        }
 | 
			
		||||
        return assets;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      void onAddToAlbum(AlbumResponseDto album) async {
 | 
			
		||||
        final Iterable<Asset> assets = remoteOnlySelection();
 | 
			
		||||
        if (assets.isEmpty) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        final result = await albumService.addAdditionalAssetToAlbum(
 | 
			
		||||
          selection.value,
 | 
			
		||||
          assets,
 | 
			
		||||
          album.id,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
@ -103,6 +123,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
                  "added": result.successfullyAdded.toString(),
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
              toastType: ToastType.success,
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@ -111,8 +132,11 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      void onCreateNewAlbum() async {
 | 
			
		||||
        final result =
 | 
			
		||||
            await albumService.createAlbumWithGeneratedName(selection.value);
 | 
			
		||||
        final Iterable<Asset> assets = remoteOnlySelection();
 | 
			
		||||
        if (assets.isEmpty) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        final result = await albumService.createAlbumWithGeneratedName(assets);
 | 
			
		||||
 | 
			
		||||
        if (result != null) {
 | 
			
		||||
          ref.watch(albumProvider.notifier).getAllAlbums();
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,14 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
class SearchResultPageState {
 | 
			
		||||
  final bool isLoading;
 | 
			
		||||
  final bool isSuccess;
 | 
			
		||||
  final bool isError;
 | 
			
		||||
  final List<AssetResponseDto> searchResult;
 | 
			
		||||
  final List<Asset> searchResult;
 | 
			
		||||
 | 
			
		||||
  SearchResultPageState({
 | 
			
		||||
    required this.isLoading,
 | 
			
		||||
@ -20,7 +21,7 @@ class SearchResultPageState {
 | 
			
		||||
    bool? isLoading,
 | 
			
		||||
    bool? isSuccess,
 | 
			
		||||
    bool? isError,
 | 
			
		||||
    List<AssetResponseDto>? searchResult,
 | 
			
		||||
    List<Asset>? searchResult,
 | 
			
		||||
  }) {
 | 
			
		||||
    return SearchResultPageState(
 | 
			
		||||
      isLoading: isLoading ?? this.isLoading,
 | 
			
		||||
@ -44,8 +45,9 @@ class SearchResultPageState {
 | 
			
		||||
      isLoading: map['isLoading'] ?? false,
 | 
			
		||||
      isSuccess: map['isSuccess'] ?? false,
 | 
			
		||||
      isError: map['isError'] ?? false,
 | 
			
		||||
      searchResult: List<AssetResponseDto>.from(
 | 
			
		||||
        map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)),
 | 
			
		||||
      searchResult: List<Asset>.from(
 | 
			
		||||
        map['searchResult']
 | 
			
		||||
            ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -6,8 +6,8 @@ import 'package:immich_mobile/modules/search/models/search_result_page_state.mod
 | 
			
		||||
import 'package:immich_mobile/modules/search/services/search.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:intl/intl.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
 | 
			
		||||
  SearchResultPageNotifier(this._searchService)
 | 
			
		||||
@ -30,8 +30,9 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
 | 
			
		||||
      isSuccess: false,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    List<AssetResponseDto>? assets =
 | 
			
		||||
        await _searchService.searchAsset(searchTerm);
 | 
			
		||||
    List<Asset>? assets = (await _searchService.searchAsset(searchTerm))
 | 
			
		||||
        ?.map((e) => Asset.remote(e))
 | 
			
		||||
        .toList();
 | 
			
		||||
 | 
			
		||||
    if (assets != null) {
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
@ -61,12 +62,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
 | 
			
		||||
  var assets = ref.watch(searchResultPageProvider).searchResult;
 | 
			
		||||
 | 
			
		||||
  assets.sortByCompare<DateTime>(
 | 
			
		||||
    (e) => DateTime.parse(e.createdAt),
 | 
			
		||||
    (e) => e.createdAt,
 | 
			
		||||
    (a, b) => b.compareTo(a),
 | 
			
		||||
  );
 | 
			
		||||
  return assets.groupListsBy(
 | 
			
		||||
    (element) => DateFormat('y-MM-dd')
 | 
			
		||||
        .format(DateTime.parse(element.createdAt).toLocal()),
 | 
			
		||||
    (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,7 @@ import 'package:immich_mobile/modules/settings/views/settings_page.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/auth_guard.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/api.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/splash_screen.dart';
 | 
			
		||||
 | 
			
		||||
@ -65,8 +65,7 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
      final args = routeData.argsAs<VideoViewerRouteArgs>();
 | 
			
		||||
      return MaterialPageX<dynamic>(
 | 
			
		||||
          routeData: routeData,
 | 
			
		||||
          child: VideoViewerPage(
 | 
			
		||||
              key: args.key, videoUrl: args.videoUrl, asset: args.asset));
 | 
			
		||||
          child: VideoViewerPage(key: args.key, asset: args.asset));
 | 
			
		||||
    },
 | 
			
		||||
    BackupControllerRoute.name: (routeData) {
 | 
			
		||||
      return MaterialPageX<dynamic>(
 | 
			
		||||
@ -258,9 +257,7 @@ class TabControllerRoute extends PageRouteInfo<void> {
 | 
			
		||||
/// [GalleryViewerPage]
 | 
			
		||||
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
 | 
			
		||||
  GalleryViewerRoute(
 | 
			
		||||
      {Key? key,
 | 
			
		||||
      required List<AssetResponseDto> assetList,
 | 
			
		||||
      required AssetResponseDto asset})
 | 
			
		||||
      {Key? key, required List<Asset> assetList, required Asset asset})
 | 
			
		||||
      : super(GalleryViewerRoute.name,
 | 
			
		||||
            path: '/gallery-viewer-page',
 | 
			
		||||
            args: GalleryViewerRouteArgs(
 | 
			
		||||
@ -275,9 +272,9 @@ class GalleryViewerRouteArgs {
 | 
			
		||||
 | 
			
		||||
  final Key? key;
 | 
			
		||||
 | 
			
		||||
  final List<AssetResponseDto> assetList;
 | 
			
		||||
  final List<Asset> assetList;
 | 
			
		||||
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
@ -291,7 +288,7 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
 | 
			
		||||
  ImageViewerRoute(
 | 
			
		||||
      {Key? key,
 | 
			
		||||
      required String heroTag,
 | 
			
		||||
      required AssetResponseDto asset,
 | 
			
		||||
      required Asset asset,
 | 
			
		||||
      required String authToken,
 | 
			
		||||
      required void Function() isZoomedFunction,
 | 
			
		||||
      required ValueNotifier<bool> isZoomedListener,
 | 
			
		||||
@ -324,7 +321,7 @@ class ImageViewerRouteArgs {
 | 
			
		||||
 | 
			
		||||
  final String heroTag;
 | 
			
		||||
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  final String authToken;
 | 
			
		||||
 | 
			
		||||
@ -343,29 +340,24 @@ class ImageViewerRouteArgs {
 | 
			
		||||
/// generated route for
 | 
			
		||||
/// [VideoViewerPage]
 | 
			
		||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
 | 
			
		||||
  VideoViewerRoute(
 | 
			
		||||
      {Key? key, required String videoUrl, required AssetResponseDto asset})
 | 
			
		||||
  VideoViewerRoute({Key? key, required Asset asset})
 | 
			
		||||
      : super(VideoViewerRoute.name,
 | 
			
		||||
            path: '/video-viewer-page',
 | 
			
		||||
            args: VideoViewerRouteArgs(
 | 
			
		||||
                key: key, videoUrl: videoUrl, asset: asset));
 | 
			
		||||
            args: VideoViewerRouteArgs(key: key, asset: asset));
 | 
			
		||||
 | 
			
		||||
  static const String name = 'VideoViewerRoute';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class VideoViewerRouteArgs {
 | 
			
		||||
  const VideoViewerRouteArgs(
 | 
			
		||||
      {this.key, required this.videoUrl, required this.asset});
 | 
			
		||||
  const VideoViewerRouteArgs({this.key, required this.asset});
 | 
			
		||||
 | 
			
		||||
  final Key? key;
 | 
			
		||||
 | 
			
		||||
  final String videoUrl;
 | 
			
		||||
 | 
			
		||||
  final AssetResponseDto asset;
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}';
 | 
			
		||||
    return 'VideoViewerRouteArgs{key: $key, asset: $asset}';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										117
									
								
								mobile/lib/shared/models/asset.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								mobile/lib/shared/models/asset.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,117 @@
 | 
			
		||||
import 'package:hive/hive.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
/// Asset (online or local)
 | 
			
		||||
class Asset {
 | 
			
		||||
  Asset.remote(this.remote) {
 | 
			
		||||
    local = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Asset.local(this.local) {
 | 
			
		||||
    remote = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  late final AssetResponseDto? remote;
 | 
			
		||||
  late final AssetEntity? local;
 | 
			
		||||
 | 
			
		||||
  bool get isRemote => remote != null;
 | 
			
		||||
  bool get isLocal => local != null;
 | 
			
		||||
 | 
			
		||||
  String get deviceId =>
 | 
			
		||||
      isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey);
 | 
			
		||||
 | 
			
		||||
  String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id;
 | 
			
		||||
 | 
			
		||||
  String get id => isLocal ? local!.id : remote!.id;
 | 
			
		||||
 | 
			
		||||
  double? get latitude =>
 | 
			
		||||
      isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble();
 | 
			
		||||
 | 
			
		||||
  double? get longitude =>
 | 
			
		||||
      isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble();
 | 
			
		||||
 | 
			
		||||
  DateTime get createdAt =>
 | 
			
		||||
      isLocal ? local!.createDateTime : DateTime.parse(remote!.createdAt);
 | 
			
		||||
 | 
			
		||||
  bool get isImage => isLocal
 | 
			
		||||
      ? local!.type == AssetType.image
 | 
			
		||||
      : remote!.type == AssetTypeEnum.IMAGE;
 | 
			
		||||
 | 
			
		||||
  String get duration => isRemote
 | 
			
		||||
      ? remote!.duration
 | 
			
		||||
      : Duration(seconds: local!.duration).toString();
 | 
			
		||||
 | 
			
		||||
  /// use only for tests
 | 
			
		||||
  set createdAt(DateTime val) {
 | 
			
		||||
    if (isRemote) {
 | 
			
		||||
      remote!.createdAt = val.toIso8601String();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
    if (isLocal) {
 | 
			
		||||
      json["local"] = _assetEntityToJson(local!);
 | 
			
		||||
    } else {
 | 
			
		||||
      json["remote"] = remote!.toJson();
 | 
			
		||||
    }
 | 
			
		||||
    return json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Asset? fromJson(dynamic value) {
 | 
			
		||||
    if (value is Map) {
 | 
			
		||||
      final json = value.cast<String, dynamic>();
 | 
			
		||||
      final l = json["local"];
 | 
			
		||||
      if (l != null) {
 | 
			
		||||
        return Asset.local(_assetEntityFromJson(l));
 | 
			
		||||
      } else {
 | 
			
		||||
        return Asset.remote(AssetResponseDto.fromJson(json["remote"]));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
 | 
			
		||||
  final json = <String, dynamic>{};
 | 
			
		||||
  json["id"] = a.id;
 | 
			
		||||
  json["typeInt"] = a.typeInt;
 | 
			
		||||
  json["width"] = a.width;
 | 
			
		||||
  json["height"] = a.height;
 | 
			
		||||
  json["duration"] = a.duration;
 | 
			
		||||
  json["orientation"] = a.orientation;
 | 
			
		||||
  json["isFavorite"] = a.isFavorite;
 | 
			
		||||
  json["title"] = a.title;
 | 
			
		||||
  json["createDateSecond"] = a.createDateSecond;
 | 
			
		||||
  json["modifiedDateSecond"] = a.modifiedDateSecond;
 | 
			
		||||
  json["latitude"] = a.latitude;
 | 
			
		||||
  json["longitude"] = a.longitude;
 | 
			
		||||
  json["mimeType"] = a.mimeType;
 | 
			
		||||
  json["subtype"] = a.subtype;
 | 
			
		||||
  return json;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
AssetEntity? _assetEntityFromJson(dynamic value) {
 | 
			
		||||
  if (value is Map) {
 | 
			
		||||
    final json = value.cast<String, dynamic>();
 | 
			
		||||
    return AssetEntity(
 | 
			
		||||
      id: json["id"],
 | 
			
		||||
      typeInt: json["typeInt"],
 | 
			
		||||
      width: json["width"],
 | 
			
		||||
      height: json["height"],
 | 
			
		||||
      duration: json["duration"],
 | 
			
		||||
      orientation: json["orientation"],
 | 
			
		||||
      isFavorite: json["isFavorite"],
 | 
			
		||||
      title: json["title"],
 | 
			
		||||
      createDateSecond: json["createDateSecond"],
 | 
			
		||||
      modifiedDateSecond: json["modifiedDateSecond"],
 | 
			
		||||
      latitude: json["latitude"],
 | 
			
		||||
      longitude: json["longitude"],
 | 
			
		||||
      mimeType: json["mimeType"],
 | 
			
		||||
      subtype: json["subtype"],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
@ -1,18 +1,23 @@
 | 
			
		||||
import 'dart:collection';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:intl/intl.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
 | 
			
		||||
class AssetNotifier extends StateNotifier<List<Asset>> {
 | 
			
		||||
  final AssetService _assetService;
 | 
			
		||||
  final AssetCacheService _assetCacheService;
 | 
			
		||||
 | 
			
		||||
  final DeviceInfoService _deviceInfoService = DeviceInfoService();
 | 
			
		||||
  bool _getAllAssetInProgress = false;
 | 
			
		||||
  bool _deleteInProgress = false;
 | 
			
		||||
 | 
			
		||||
  AssetNotifier(this._assetService, this._assetCacheService) : super([]);
 | 
			
		||||
 | 
			
		||||
@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getAllAsset() async {
 | 
			
		||||
    final stopwatch = Stopwatch();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    if (await _assetCacheService.isValid() && state.isEmpty) {
 | 
			
		||||
      stopwatch.start();
 | 
			
		||||
      state = await _assetCacheService.get();
 | 
			
		||||
      debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
 | 
			
		||||
      stopwatch.reset();
 | 
			
		||||
    if (_getAllAssetInProgress || _deleteInProgress) {
 | 
			
		||||
      // guard against multiple calls to this method while it's still working
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    final stopwatch = Stopwatch();
 | 
			
		||||
    try {
 | 
			
		||||
      _getAllAssetInProgress = true;
 | 
			
		||||
 | 
			
		||||
      final bool isCacheValid = await _assetCacheService.isValid();
 | 
			
		||||
      if (isCacheValid && state.isEmpty) {
 | 
			
		||||
        stopwatch.start();
 | 
			
		||||
        state = await _assetCacheService.get();
 | 
			
		||||
        debugPrint(
 | 
			
		||||
            "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
 | 
			
		||||
        stopwatch.reset();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      stopwatch.start();
 | 
			
		||||
      var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
 | 
			
		||||
      debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
 | 
			
		||||
      stopwatch.reset();
 | 
			
		||||
 | 
			
		||||
      state = allAssets;
 | 
			
		||||
    } finally {
 | 
			
		||||
      _getAllAssetInProgress = false;
 | 
			
		||||
    }
 | 
			
		||||
    debugPrint("[getAllAsset] setting new asset state");
 | 
			
		||||
 | 
			
		||||
    stopwatch.start();
 | 
			
		||||
    var allAssets = await _assetService.getAllAsset();
 | 
			
		||||
    debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
 | 
			
		||||
    _cacheState();
 | 
			
		||||
    debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
 | 
			
		||||
    stopwatch.reset();
 | 
			
		||||
 | 
			
		||||
    if (allAssets != null) {
 | 
			
		||||
      state = allAssets;
 | 
			
		||||
 | 
			
		||||
      stopwatch.start();
 | 
			
		||||
      _cacheState();
 | 
			
		||||
      debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
 | 
			
		||||
      stopwatch.reset();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAllAsset() {
 | 
			
		||||
@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onNewAssetUploaded(AssetResponseDto newAsset) {
 | 
			
		||||
    state = [...state, newAsset];
 | 
			
		||||
    final int i = state.indexWhere(
 | 
			
		||||
      (a) =>
 | 
			
		||||
          a.isRemote ||
 | 
			
		||||
          (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) {
 | 
			
		||||
      state = [...state, Asset.remote(newAsset)];
 | 
			
		||||
    } else {
 | 
			
		||||
      // order is important to keep all local-only assets at the beginning!
 | 
			
		||||
      state = [
 | 
			
		||||
        ...state.slice(0, i),
 | 
			
		||||
        ...state.slice(i + 1),
 | 
			
		||||
        Asset.remote(newAsset),
 | 
			
		||||
      ];
 | 
			
		||||
      // TODO here is a place to unify local/remote assets by replacing the
 | 
			
		||||
      // local-only asset in the state with a local&remote asset
 | 
			
		||||
    }
 | 
			
		||||
    _cacheState();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  deleteAssets(Set<AssetResponseDto> deleteAssets) async {
 | 
			
		||||
  deleteAssets(Set<Asset> deleteAssets) async {
 | 
			
		||||
    _deleteInProgress = true;
 | 
			
		||||
    try {
 | 
			
		||||
      final localDeleted = await _deleteLocalAssets(deleteAssets);
 | 
			
		||||
      final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
 | 
			
		||||
      final Set<String> deleted = HashSet();
 | 
			
		||||
      deleted.addAll(localDeleted);
 | 
			
		||||
      deleted.addAll(remoteDeleted);
 | 
			
		||||
      if (deleted.isNotEmpty) {
 | 
			
		||||
        state = state.where((a) => !deleted.contains(a.id)).toList();
 | 
			
		||||
        _cacheState();
 | 
			
		||||
      }
 | 
			
		||||
    } finally {
 | 
			
		||||
      _deleteInProgress = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
 | 
			
		||||
    var deviceInfo = await _deviceInfoService.getDeviceInfo();
 | 
			
		||||
    var deviceId = deviceInfo["deviceId"];
 | 
			
		||||
    var deleteIdList = <String>[];
 | 
			
		||||
    final List<String> local = [];
 | 
			
		||||
    // Delete asset from device
 | 
			
		||||
    for (var asset in deleteAssets) {
 | 
			
		||||
      // Delete asset on device if present
 | 
			
		||||
      if (asset.deviceId == deviceId) {
 | 
			
		||||
    for (final Asset asset in assetsToDelete) {
 | 
			
		||||
      if (asset.isLocal) {
 | 
			
		||||
        local.add(asset.id);
 | 
			
		||||
      } else if (asset.deviceId == deviceId) {
 | 
			
		||||
        // Delete asset on device if it is still present
 | 
			
		||||
        var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
 | 
			
		||||
 | 
			
		||||
        if (localAsset != null) {
 | 
			
		||||
          deleteIdList.add(localAsset.id);
 | 
			
		||||
          local.add(localAsset.id);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await PhotoManager.editor.deleteWithIds(deleteIdList);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("Delete asset from device failed: $e");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Delete asset on server
 | 
			
		||||
    List<DeleteAssetResponseDto>? deleteAssetResult =
 | 
			
		||||
        await _assetService.deleteAssets(deleteAssets);
 | 
			
		||||
 | 
			
		||||
    if (deleteAssetResult == null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (var asset in deleteAssetResult) {
 | 
			
		||||
      if (asset.status == DeleteAssetStatus.SUCCESS) {
 | 
			
		||||
        state =
 | 
			
		||||
            state.where((immichAsset) => immichAsset.id != asset.id).toList();
 | 
			
		||||
    if (local.isNotEmpty) {
 | 
			
		||||
      try {
 | 
			
		||||
        return await PhotoManager.editor.deleteWithIds(local);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        debugPrint("Delete asset from device failed: $e");
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    _cacheState();
 | 
			
		||||
  Future<Iterable<String>> _deleteRemoteAssets(
 | 
			
		||||
    Set<Asset> assetsToDelete,
 | 
			
		||||
  ) async {
 | 
			
		||||
    final Iterable<AssetResponseDto> remote =
 | 
			
		||||
        assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
 | 
			
		||||
    final List<DeleteAssetResponseDto> deleteAssetResult =
 | 
			
		||||
        await _assetService.deleteAssets(remote) ?? [];
 | 
			
		||||
    return deleteAssetResult
 | 
			
		||||
        .where((a) => a.status == DeleteAssetStatus.SUCCESS)
 | 
			
		||||
        .map((a) => a.id);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final assetProvider =
 | 
			
		||||
    StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
 | 
			
		||||
final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
 | 
			
		||||
  return AssetNotifier(
 | 
			
		||||
      ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
final assetGroupByDateTimeProvider = StateProvider((ref) {
 | 
			
		||||
  var assets = ref.watch(assetProvider);
 | 
			
		||||
  final assets = ref.watch(assetProvider).toList();
 | 
			
		||||
  // `toList()` ist needed to make a copy as to NOT sort the original list/state
 | 
			
		||||
 | 
			
		||||
  assets.sortByCompare<DateTime>(
 | 
			
		||||
    (e) => DateTime.parse(e.createdAt),
 | 
			
		||||
    (e) => e.createdAt,
 | 
			
		||||
    (a, b) => b.compareTo(a),
 | 
			
		||||
  );
 | 
			
		||||
  return assets.groupListsBy(
 | 
			
		||||
    (element) => DateFormat('y-MM-dd')
 | 
			
		||||
        .format(DateTime.parse(element.createdAt).toLocal()),
 | 
			
		||||
    (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
final assetGroupByMonthYearProvider = StateProvider((ref) {
 | 
			
		||||
  var assets = ref.watch(assetProvider);
 | 
			
		||||
  // TODO: remove `where` once temporary workaround is no longer needed (to only
 | 
			
		||||
  // allow remote assets to be added to album). Keep `toList()` as to NOT sort
 | 
			
		||||
  // the original list/state
 | 
			
		||||
  final assets = ref.watch(assetProvider).where((e) => e.isRemote).toList();
 | 
			
		||||
 | 
			
		||||
  assets.sortByCompare<DateTime>(
 | 
			
		||||
    (e) => DateTime.parse(e.createdAt),
 | 
			
		||||
    (e) => e.createdAt,
 | 
			
		||||
    (a, b) => b.compareTo(a),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return assets.groupListsBy(
 | 
			
		||||
    (element) => DateFormat('MMMM, y')
 | 
			
		||||
        .format(DateTime.parse(element.createdAt).toLocal()),
 | 
			
		||||
    (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()),
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,11 @@ import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:path/path.dart';
 | 
			
		||||
import 'package:path_provider/path_provider.dart';
 | 
			
		||||
import 'package:share_plus/share_plus.dart';
 | 
			
		||||
import 'package:path/path.dart' as p;
 | 
			
		||||
import 'api.service.dart';
 | 
			
		||||
 | 
			
		||||
final shareServiceProvider =
 | 
			
		||||
@ -17,26 +17,28 @@ class ShareService {
 | 
			
		||||
 | 
			
		||||
  ShareService(this._apiService);
 | 
			
		||||
 | 
			
		||||
  Future<void> shareAsset(AssetResponseDto asset) async {
 | 
			
		||||
  Future<void> shareAsset(Asset asset) async {
 | 
			
		||||
    await shareAssets([asset]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> shareAssets(List<AssetResponseDto> assets) async {
 | 
			
		||||
  Future<void> shareAssets(List<Asset> assets) async {
 | 
			
		||||
    final downloadedFilePaths = assets.map((asset) async {
 | 
			
		||||
      final res = await _apiService.assetApi.downloadFileWithHttpInfo(
 | 
			
		||||
        asset.deviceAssetId,
 | 
			
		||||
        asset.deviceId,
 | 
			
		||||
        isThumb: false,
 | 
			
		||||
        isWeb: false,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      final fileName = p.basename(asset.originalPath);
 | 
			
		||||
 | 
			
		||||
      final tempDir = await getTemporaryDirectory();
 | 
			
		||||
      final tempFile = await File('${tempDir.path}/$fileName').create();
 | 
			
		||||
      tempFile.writeAsBytesSync(res.bodyBytes);
 | 
			
		||||
 | 
			
		||||
      return tempFile.path;
 | 
			
		||||
      if (asset.isRemote) {
 | 
			
		||||
        final tempDir = await getTemporaryDirectory();
 | 
			
		||||
        final fileName = basename(asset.remote!.originalPath);
 | 
			
		||||
        final tempFile = await File('${tempDir.path}/$fileName').create();
 | 
			
		||||
        final res = await _apiService.assetApi.downloadFileWithHttpInfo(
 | 
			
		||||
          asset.remote!.deviceAssetId,
 | 
			
		||||
          asset.remote!.deviceId,
 | 
			
		||||
          isThumb: false,
 | 
			
		||||
          isWeb: false,
 | 
			
		||||
        );
 | 
			
		||||
        tempFile.writeAsBytesSync(res.bodyBytes);
 | 
			
		||||
        return tempFile.path;
 | 
			
		||||
      } else {
 | 
			
		||||
        File? f = await asset.local!.file;
 | 
			
		||||
        return f!.path;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    Share.shareFiles(
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										96
									
								
								mobile/lib/shared/ui/immich_image.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								mobile/lib/shared/ui/immich_image.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,96 @@
 | 
			
		||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/image_url_builder.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
/// Renders an Asset using local data if available, else remote data
 | 
			
		||||
class ImmichImage extends StatelessWidget {
 | 
			
		||||
  const ImmichImage(
 | 
			
		||||
    this.asset, {
 | 
			
		||||
    required this.width,
 | 
			
		||||
    required this.height,
 | 
			
		||||
    this.useGrayBoxPlaceholder = false,
 | 
			
		||||
    super.key,
 | 
			
		||||
  });
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final bool useGrayBoxPlaceholder;
 | 
			
		||||
  final double width;
 | 
			
		||||
  final double height;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    if (asset.isLocal) {
 | 
			
		||||
      return Image(
 | 
			
		||||
        image: AssetEntityImageProvider(
 | 
			
		||||
          asset.local!,
 | 
			
		||||
          isOriginal: false,
 | 
			
		||||
          thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
 | 
			
		||||
        ),
 | 
			
		||||
        width: width,
 | 
			
		||||
        height: height,
 | 
			
		||||
        fit: BoxFit.cover,
 | 
			
		||||
        frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
 | 
			
		||||
          if (wasSynchronouslyLoaded || frame != null) {
 | 
			
		||||
            return child;
 | 
			
		||||
          }
 | 
			
		||||
          return (useGrayBoxPlaceholder
 | 
			
		||||
              ? const SizedBox.square(
 | 
			
		||||
                  dimension: 250,
 | 
			
		||||
                  child: DecoratedBox(
 | 
			
		||||
                    decoration: BoxDecoration(color: Colors.grey),
 | 
			
		||||
                  ),
 | 
			
		||||
                )
 | 
			
		||||
              : Transform.scale(
 | 
			
		||||
                  scale: 0.2,
 | 
			
		||||
                  child: const CircularProgressIndicator(),
 | 
			
		||||
                ));
 | 
			
		||||
        },
 | 
			
		||||
        errorBuilder: (context, error, stackTrace) {
 | 
			
		||||
          debugPrint("Error getting thumb for assetId=${asset.id}: $error");
 | 
			
		||||
          return Icon(
 | 
			
		||||
            Icons.image_not_supported_outlined,
 | 
			
		||||
            color: Theme.of(context).primaryColor,
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    final String token = Hive.box(userInfoBox).get(accessTokenKey);
 | 
			
		||||
    final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!);
 | 
			
		||||
    return CachedNetworkImage(
 | 
			
		||||
      imageUrl: thumbnailRequestUrl,
 | 
			
		||||
      httpHeaders: {"Authorization": "Bearer $token"},
 | 
			
		||||
      cacheKey: 'thumbnail-image-${asset.id}',
 | 
			
		||||
      width: width,
 | 
			
		||||
      height: height,
 | 
			
		||||
      // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
 | 
			
		||||
      // maxHeightDiskCache = null allows to simply store the webp thumbnail
 | 
			
		||||
      // from the server and use it for all rendered thumbnail sizes
 | 
			
		||||
      fit: BoxFit.cover,
 | 
			
		||||
      fadeInDuration: const Duration(milliseconds: 250),
 | 
			
		||||
      progressIndicatorBuilder: (context, url, downloadProgress) {
 | 
			
		||||
        if (useGrayBoxPlaceholder) {
 | 
			
		||||
          return const DecoratedBox(
 | 
			
		||||
            decoration: BoxDecoration(color: Colors.grey),
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
        return Transform.scale(
 | 
			
		||||
          scale: 0.2,
 | 
			
		||||
          child: CircularProgressIndicator(
 | 
			
		||||
            value: downloadProgress.progress,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      errorWidget: (context, url, error) {
 | 
			
		||||
        debugPrint("Error getting thumbnail $url = $error");
 | 
			
		||||
        CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
 | 
			
		||||
        return Icon(
 | 
			
		||||
          Icons.image_not_supported_outlined,
 | 
			
		||||
          color: Theme.of(context).primaryColor,
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,9 +1,10 @@
 | 
			
		||||
import 'package:flutter_test/flutter_test.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
 | 
			
		||||
void main() {
 | 
			
		||||
  final List<AssetResponseDto> testAssets = [];
 | 
			
		||||
  final List<Asset> testAssets = [];
 | 
			
		||||
 | 
			
		||||
  for (int i = 0; i < 150; i++) {
 | 
			
		||||
    int month = i ~/ 31;
 | 
			
		||||
@ -11,39 +12,43 @@ void main() {
 | 
			
		||||
 | 
			
		||||
    DateTime date = DateTime(2022, month, day);
 | 
			
		||||
 | 
			
		||||
    testAssets.add(AssetResponseDto(
 | 
			
		||||
      type: AssetTypeEnum.IMAGE,
 | 
			
		||||
      id: '$i',
 | 
			
		||||
      deviceAssetId: '',
 | 
			
		||||
      ownerId: '',
 | 
			
		||||
      deviceId: '',
 | 
			
		||||
      originalPath: '',
 | 
			
		||||
      resizePath: '',
 | 
			
		||||
      createdAt: date.toIso8601String(),
 | 
			
		||||
      modifiedAt: date.toIso8601String(),
 | 
			
		||||
      isFavorite: false,
 | 
			
		||||
      mimeType: 'image/jpeg',
 | 
			
		||||
      duration: '',
 | 
			
		||||
      webpPath: '',
 | 
			
		||||
      encodedVideoPath: '',
 | 
			
		||||
    ));
 | 
			
		||||
    testAssets.add(
 | 
			
		||||
      Asset.remote(
 | 
			
		||||
        AssetResponseDto(
 | 
			
		||||
          type: AssetTypeEnum.IMAGE,
 | 
			
		||||
          id: '$i',
 | 
			
		||||
          deviceAssetId: '',
 | 
			
		||||
          ownerId: '',
 | 
			
		||||
          deviceId: '',
 | 
			
		||||
          originalPath: '',
 | 
			
		||||
          resizePath: '',
 | 
			
		||||
          createdAt: date.toIso8601String(),
 | 
			
		||||
          modifiedAt: date.toIso8601String(),
 | 
			
		||||
          isFavorite: false,
 | 
			
		||||
          mimeType: 'image/jpeg',
 | 
			
		||||
          duration: '',
 | 
			
		||||
          webpPath: '',
 | 
			
		||||
          encodedVideoPath: '',
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final Map<String, List<AssetResponseDto>> groups = {
 | 
			
		||||
  final Map<String, List<Asset>> groups = {
 | 
			
		||||
    '2022-01-05': testAssets.sublist(0, 5).map((e) {
 | 
			
		||||
      e.createdAt = DateTime(2022, 1, 5).toIso8601String();
 | 
			
		||||
      e.createdAt = DateTime(2022, 1, 5);
 | 
			
		||||
      return e;
 | 
			
		||||
    }).toList(),
 | 
			
		||||
    '2022-01-10': testAssets.sublist(5, 10).map((e) {
 | 
			
		||||
      e.createdAt = DateTime(2022, 1, 10).toIso8601String();
 | 
			
		||||
      e.createdAt = DateTime(2022, 1, 10);
 | 
			
		||||
      return e;
 | 
			
		||||
    }).toList(),
 | 
			
		||||
    '2022-02-17': testAssets.sublist(10, 15).map((e) {
 | 
			
		||||
      e.createdAt = DateTime(2022, 2, 17).toIso8601String();
 | 
			
		||||
      e.createdAt = DateTime(2022, 2, 17);
 | 
			
		||||
      return e;
 | 
			
		||||
    }).toList(),
 | 
			
		||||
    '2022-10-15': testAssets.sublist(15, 30).map((e) {
 | 
			
		||||
      e.createdAt = DateTime(2022, 10, 15).toIso8601String();
 | 
			
		||||
      e.createdAt = DateTime(2022, 10, 15);
 | 
			
		||||
      return e;
 | 
			
		||||
    }).toList()
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user