Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): Folder View for mobile #15047

Merged
merged 27 commits into from
Mar 6, 2025
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e3e63e5
very rough prototype for folder navigation without assets
arnolicious Jan 2, 2025
424e3b6
Merge branch 'main' of github.com:immich-app/immich into feat/mobile-…
alextran1502 Jan 3, 2025
c1c37fb
fix: refactored data model and tried to implement asset loading
arnolicious Jan 4, 2025
538dd32
Merge branch 'main' into feat/mobile-folder-view
arnolicious Jan 4, 2025
766949b
Merge branch 'main' into feat/mobile-folder-view
arnolicious Jan 6, 2025
62fa229
Merge branch 'main' into feat/mobile-folder-view
arnolicious Jan 10, 2025
4e05030
fix: openapi generator shadowing query param in /view/folder
shenlong-tanwen Jan 22, 2025
22b32e5
Merge branch 'main' into feat/mobile-folder-view
arnolicious Jan 25, 2025
f78532e
add simple alphanumeric sorting for folders
arnolicious Jan 25, 2025
9b495e5
basic asset viewing in folders
arnolicious Jan 25, 2025
fd94741
rudimentary switch sorting order
arnolicious Jan 25, 2025
8b9346d
fixed reactivity when toggling sort order
arnolicious Feb 1, 2025
0e56363
Merge branch 'main' into feat/mobile-folder-view
arnolicious Feb 1, 2025
aeadf6f
Merge branch 'main' into feat/mobile-folder-view
arnolicious Feb 7, 2025
617acf9
Merge branch 'main' into feat/mobile-folder-view
arnolicious Mar 3, 2025
01bf2ee
Merge branch 'main' into feat/mobile-folder-view
arnolicious Mar 3, 2025
1286ec7
Fixed trailing comma
arnolicious Mar 3, 2025
618aea0
Merge branch 'feat/mobile-folder-view' of https://github.com/arnolici…
arnolicious Mar 3, 2025
96400a1
Fixed bad merge conflict resolution
arnolicious Mar 3, 2025
4bcda49
Merge branch 'main' into feat/mobile-folder-view
arnolicious Mar 3, 2025
2327f06
Regenerated open-api
arnolicious Mar 3, 2025
7d95888
Added rudimentary breadcrumbs
arnolicious Mar 3, 2025
3ac9740
Merge branch 'main' into feat/mobile-folder-view
arnolicious Mar 3, 2025
7e3f6b9
Fixed linting problems
arnolicious Mar 3, 2025
031e9fe
Merge branch 'feat/mobile-folder-view' of https://github.com/arnolici…
arnolicious Mar 3, 2025
b5108c4
Merge branch 'main' into feat/mobile-folder-view
alextran1502 Mar 6, 2025
6a4b424
feat: cleanup
alextran1502 Mar 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion mobile/assets/i18n/en-US.json
Original file line number Diff line number Diff line change
@@ -252,6 +252,7 @@
"edit_date_time_dialog_timezone": "Timezone",
"edit_image_title": "Edit",
"edit_location_dialog_title": "Location",
"empty_folder": "This folder is empty",
"end_date": "End date",
"enqueued": "Enqueued",
"enter_wifi_name": "Enter WiFi name",
@@ -275,6 +276,11 @@
"favorites_page_title": "Favorites",
"filename_search": "File name or extension",
"filter": "Filter",
"folders": "Folders",
"folder": "Folder",
"failed_to_load_folder": "Failed to load folder",
"failed_to_load_assets": "Failed to load assets",
"folder_not_found": "Folder not found",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"grant_permission": "Grant permission",
"haptic_feedback_switch": "Enable haptic feedback",
@@ -678,4 +684,4 @@
"viewer_unstack": "Un-Stack",
"wifi_name": "WiFi Name",
"your_wifi_name": "Your WiFi name"
}
}
6 changes: 6 additions & 0 deletions mobile/lib/interfaces/folder_api.interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:immich_mobile/entities/asset.entity.dart';

abstract interface class IFolderApiRepository {
Future<List<String>> getAllUniquePaths();
Future<List<Asset>> getAssetsForPath(String? path);
}
11 changes: 11 additions & 0 deletions mobile/lib/models/folder/recursive_folder.model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:immich_mobile/models/folder/root_folder.model.dart';

class RecursiveFolder extends RootFolder {
final String name;

RecursiveFolder({
required this.name,
required super.path,
required super.subfolders,
});
}
11 changes: 11 additions & 0 deletions mobile/lib/models/folder/root_folder.model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';

class RootFolder {
final List<RecursiveFolder> subfolders;
final String path;

RootFolder({
required this.subfolders,
required this.path,
});
}
320 changes: 320 additions & 0 deletions mobile/lib/pages/library/folder/folder.page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
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/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: () => 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),
),
),
],
),
),
),
);
}
}
13 changes: 13 additions & 0 deletions mobile/lib/pages/library/library.page.dart
Original file line number Diff line number Diff line change
@@ -128,6 +128,19 @@ class QuickAccessButtons extends ConsumerWidget {
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
leading: const Icon(
Icons.folder_outlined,
size: 26,
),
title: Text(
'folders'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
onTap: () => context.pushRoute(FolderRoute()),
),
ListTile(
leading: const Icon(
Icons.group_outlined,
size: 26,
Loading