mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	
		
			
				
	
	
		
			314 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			314 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:immich_mobile/constants/enums.dart';
 | 
						|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
						|
import 'package:immich_mobile/extensions/theme_extensions.dart';
 | 
						|
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
 | 
						|
import 'package:immich_mobile/models/folder/root_folder.model.dart';
 | 
						|
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
 | 
						|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
 | 
						|
import 'package:immich_mobile/providers/folder.provider.dart';
 | 
						|
import 'package:immich_mobile/routing/router.dart';
 | 
						|
import 'package:immich_mobile/utils/bytes_units.dart';
 | 
						|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
 | 
						|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
 | 
						|
 | 
						|
RecursiveFolder? _findFolderInStructure(
 | 
						|
  RootFolder rootFolder,
 | 
						|
  RecursiveFolder targetFolder,
 | 
						|
) {
 | 
						|
  for (final folder in rootFolder.subfolders) {
 | 
						|
    if (targetFolder.path == '/' && folder.path.isEmpty && folder.name == targetFolder.name) {
 | 
						|
      return folder;
 | 
						|
    }
 | 
						|
 | 
						|
    if (folder.path == targetFolder.path && folder.name == targetFolder.name) {
 | 
						|
      return folder;
 | 
						|
    }
 | 
						|
 | 
						|
    if (folder.subfolders.isNotEmpty) {
 | 
						|
      final found = _findFolderInStructure(folder, targetFolder);
 | 
						|
      if (found != null) return found;
 | 
						|
    }
 | 
						|
  }
 | 
						|
  return null;
 | 
						|
}
 | 
						|
 | 
						|
@RoutePage()
 | 
						|
class FolderPage extends HookConsumerWidget {
 | 
						|
  final RecursiveFolder? folder;
 | 
						|
 | 
						|
  const FolderPage({super.key, this.folder});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final folderState = ref.watch(folderStructureProvider);
 | 
						|
    final currentFolder = useState<RecursiveFolder?>(folder);
 | 
						|
    final sortOrder = useState<SortOrder>(SortOrder.asc);
 | 
						|
 | 
						|
    useEffect(
 | 
						|
      () {
 | 
						|
        if (folder == null) {
 | 
						|
          ref.read(folderStructureProvider.notifier).fetchFolders(sortOrder.value);
 | 
						|
        }
 | 
						|
        return null;
 | 
						|
      },
 | 
						|
      [],
 | 
						|
    );
 | 
						|
 | 
						|
    // Update current folder when root structure changes
 | 
						|
    useEffect(
 | 
						|
      () {
 | 
						|
        if (folder != null && folderState.hasValue) {
 | 
						|
          final updatedFolder = _findFolderInStructure(folderState.value!, folder!);
 | 
						|
          if (updatedFolder != null) {
 | 
						|
            currentFolder.value = updatedFolder;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        return null;
 | 
						|
      },
 | 
						|
      [folderState],
 | 
						|
    );
 | 
						|
 | 
						|
    void onToggleSortOrder() {
 | 
						|
      final newOrder = sortOrder.value == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
 | 
						|
 | 
						|
      ref.read(folderStructureProvider.notifier).fetchFolders(newOrder);
 | 
						|
 | 
						|
      sortOrder.value = newOrder;
 | 
						|
    }
 | 
						|
 | 
						|
    return Scaffold(
 | 
						|
      appBar: AppBar(
 | 
						|
        title: Text(currentFolder.value?.name ?? tr("folders")),
 | 
						|
        elevation: 0,
 | 
						|
        centerTitle: false,
 | 
						|
        actions: [
 | 
						|
          IconButton(
 | 
						|
            icon: const Icon(Icons.swap_vert),
 | 
						|
            onPressed: onToggleSortOrder,
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
      body: folderState.when(
 | 
						|
        data: (rootFolder) {
 | 
						|
          if (folder == null) {
 | 
						|
            return FolderContent(
 | 
						|
              folder: rootFolder,
 | 
						|
              root: rootFolder,
 | 
						|
              sortOrder: sortOrder.value,
 | 
						|
            );
 | 
						|
          } else {
 | 
						|
            return FolderContent(
 | 
						|
              folder: currentFolder.value!,
 | 
						|
              root: rootFolder,
 | 
						|
              sortOrder: sortOrder.value,
 | 
						|
            );
 | 
						|
          }
 | 
						|
        },
 | 
						|
        loading: () => const Center(
 | 
						|
          child: CircularProgressIndicator(),
 | 
						|
        ),
 | 
						|
        error: (error, stack) {
 | 
						|
          ImmichToast.show(
 | 
						|
            context: context,
 | 
						|
            msg: "failed_to_load_folder".tr(),
 | 
						|
            toastType: ToastType.error,
 | 
						|
          );
 | 
						|
          return Center(child: const Text("failed_to_load_folder").tr());
 | 
						|
        },
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class FolderContent extends HookConsumerWidget {
 | 
						|
  final RootFolder? folder;
 | 
						|
  final RootFolder root;
 | 
						|
  final SortOrder sortOrder;
 | 
						|
 | 
						|
  const FolderContent({
 | 
						|
    super.key,
 | 
						|
    this.folder,
 | 
						|
    required this.root,
 | 
						|
    this.sortOrder = SortOrder.asc,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final folderRenderlist = ref.watch(folderRenderListProvider(folder!));
 | 
						|
 | 
						|
    // Initial asset fetch
 | 
						|
    useEffect(
 | 
						|
      () {
 | 
						|
        if (folder == null) return;
 | 
						|
        ref.read(folderRenderListProvider(folder!).notifier).fetchAssets(sortOrder);
 | 
						|
        return null;
 | 
						|
      },
 | 
						|
      [folder],
 | 
						|
    );
 | 
						|
 | 
						|
    if (folder == null) {
 | 
						|
      return Center(child: const Text("folder_not_found").tr());
 | 
						|
    }
 | 
						|
 | 
						|
    getSubtitle(int subFolderCount) {
 | 
						|
      if (subFolderCount > 0) {
 | 
						|
        return "$subFolderCount ${tr("folders")}".toLowerCase();
 | 
						|
      }
 | 
						|
 | 
						|
      if (subFolderCount == 1) {
 | 
						|
        return "1 ${tr("folder")}".toLowerCase();
 | 
						|
      }
 | 
						|
 | 
						|
      return "";
 | 
						|
    }
 | 
						|
 | 
						|
    return Column(
 | 
						|
      children: [
 | 
						|
        FolderPath(currentFolder: folder!, root: root),
 | 
						|
        Expanded(
 | 
						|
          child: folderRenderlist.when(
 | 
						|
            data: (list) {
 | 
						|
              if (folder!.subfolders.isEmpty && list.isEmpty) {
 | 
						|
                return Center(child: const Text("empty_folder").tr());
 | 
						|
              }
 | 
						|
 | 
						|
              return ListView(
 | 
						|
                children: [
 | 
						|
                  if (folder!.subfolders.isNotEmpty)
 | 
						|
                    ...folder!.subfolders.map(
 | 
						|
                      (subfolder) => LargeLeadingTile(
 | 
						|
                        leading: Icon(
 | 
						|
                          Icons.folder,
 | 
						|
                          color: context.primaryColor,
 | 
						|
                          size: 48,
 | 
						|
                        ),
 | 
						|
                        title: Text(
 | 
						|
                          subfolder.name,
 | 
						|
                          softWrap: false,
 | 
						|
                          overflow: TextOverflow.ellipsis,
 | 
						|
                          style: context.textTheme.titleSmall?.copyWith(
 | 
						|
                            fontWeight: FontWeight.w600,
 | 
						|
                          ),
 | 
						|
                        ),
 | 
						|
                        subtitle: subfolder.subfolders.isNotEmpty
 | 
						|
                            ? Text(
 | 
						|
                                getSubtitle(subfolder.subfolders.length),
 | 
						|
                                style: context.textTheme.bodyMedium?.copyWith(
 | 
						|
                                  color: context.colorScheme.onSurfaceSecondary,
 | 
						|
                                ),
 | 
						|
                              )
 | 
						|
                            : null,
 | 
						|
                        onTap: () => context.pushRoute(FolderRoute(folder: subfolder)),
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  if (!list.isEmpty && list.allAssets != null && list.allAssets!.isNotEmpty)
 | 
						|
                    ...list.allAssets!.map(
 | 
						|
                      (asset) => LargeLeadingTile(
 | 
						|
                        onTap: () {
 | 
						|
                          ref.read(currentAssetProvider.notifier).set(asset);
 | 
						|
                          context.pushRoute(
 | 
						|
                            GalleryViewerRoute(
 | 
						|
                              renderList: list,
 | 
						|
                              initialIndex: list.allAssets!.indexOf(asset),
 | 
						|
                            ),
 | 
						|
                          );
 | 
						|
                        },
 | 
						|
                        leading: ClipRRect(
 | 
						|
                          borderRadius: const BorderRadius.all(
 | 
						|
                            Radius.circular(15),
 | 
						|
                          ),
 | 
						|
                          child: SizedBox(
 | 
						|
                            width: 80,
 | 
						|
                            height: 80,
 | 
						|
                            child: ThumbnailImage(
 | 
						|
                              asset: asset,
 | 
						|
                              showStorageIndicator: false,
 | 
						|
                            ),
 | 
						|
                          ),
 | 
						|
                        ),
 | 
						|
                        title: Text(
 | 
						|
                          asset.fileName,
 | 
						|
                          maxLines: 2,
 | 
						|
                          softWrap: false,
 | 
						|
                          overflow: TextOverflow.ellipsis,
 | 
						|
                          style: context.textTheme.titleSmall?.copyWith(
 | 
						|
                            fontWeight: FontWeight.w600,
 | 
						|
                          ),
 | 
						|
                        ),
 | 
						|
                        subtitle: Text(
 | 
						|
                          "${asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo?.fileSize ?? 0) : ""} • ${DateFormat.yMMMd().format(asset.fileCreatedAt)}",
 | 
						|
                          style: context.textTheme.bodyMedium?.copyWith(
 | 
						|
                            color: context.colorScheme.onSurfaceSecondary,
 | 
						|
                          ),
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                ],
 | 
						|
              );
 | 
						|
            },
 | 
						|
            loading: () => const Center(
 | 
						|
              child: CircularProgressIndicator(),
 | 
						|
            ),
 | 
						|
            error: (error, stack) {
 | 
						|
              ImmichToast.show(
 | 
						|
                context: context,
 | 
						|
                msg: "failed_to_load_assets".tr(),
 | 
						|
                toastType: ToastType.error,
 | 
						|
              );
 | 
						|
              return Center(child: const Text("failed_to_load_assets").tr());
 | 
						|
            },
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class FolderPath extends StatelessWidget {
 | 
						|
  final RootFolder currentFolder;
 | 
						|
  final RootFolder root;
 | 
						|
 | 
						|
  const FolderPath({
 | 
						|
    super.key,
 | 
						|
    required this.currentFolder,
 | 
						|
    required this.root,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    if (currentFolder.path.isEmpty || currentFolder.path == '/') {
 | 
						|
      return const SizedBox.shrink();
 | 
						|
    }
 | 
						|
 | 
						|
    return Container(
 | 
						|
      width: double.infinity,
 | 
						|
      alignment: Alignment.centerLeft,
 | 
						|
      child: SingleChildScrollView(
 | 
						|
        scrollDirection: Axis.horizontal,
 | 
						|
        child: Padding(
 | 
						|
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | 
						|
          child: Row(
 | 
						|
            mainAxisAlignment: MainAxisAlignment.start,
 | 
						|
            children: [
 | 
						|
              Text(
 | 
						|
                currentFolder.path,
 | 
						|
                style: TextStyle(
 | 
						|
                  fontFamily: 'Inconsolata',
 | 
						|
                  fontWeight: FontWeight.bold,
 | 
						|
                  fontSize: 14,
 | 
						|
                  color: context.colorScheme.onSurface.withAlpha(175),
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |