-
-
Notifications
You must be signed in to change notification settings - Fork 3k
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
e3e63e5
424e3b6
c1c37fb
538dd32
766949b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||
import 'package:immich_mobile/entities/asset.entity.dart'; | ||
|
||
abstract interface class IFolderApiRepository { | ||
Future<AsyncValue<List<String>>> getAllUniquePaths(); | ||
Future<AsyncValue<List<Asset>>> getAssetsForPath(String? path); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||
import 'package:immich_mobile/entities/asset.entity.dart'; | ||
import 'package:immich_mobile/services/folder.service.dart'; | ||
|
||
class RecursiveFolder { | ||
final String name; | ||
final String path; | ||
List<Asset>? assets; | ||
final List<RecursiveFolder> subfolders; | ||
final FolderService _folderService; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find it strange to include the |
||
|
||
RecursiveFolder({ | ||
required this.path, | ||
required this.name, | ||
this.assets, | ||
required this.subfolders, | ||
required folderService, | ||
}) : _folderService = folderService; | ||
|
||
Future<void> fetchAssets() async { | ||
final result = await _folderService.getFolderAssets(this); | ||
|
||
if (result is AsyncData) { | ||
assets = result.value; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import 'package:immich_mobile/entities/asset.entity.dart'; | ||
import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; | ||
|
||
class RootFolder { | ||
final List<Asset>? assets; | ||
final List<RecursiveFolder> subfolders; | ||
|
||
RootFolder({ | ||
required this.assets, | ||
required this.subfolders, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import 'package:auto_route/auto_route.dart'; | ||
import 'package:easy_localization/easy_localization.dart'; | ||
import 'package:flutter/material.dart'; | ||
import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||
import 'package:immich_mobile/models/folder/root_folder.model.dart'; | ||
import 'package:immich_mobile/routing/router.dart'; | ||
import 'package:immich_mobile/widgets/common/immich_toast.dart'; | ||
import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; | ||
import 'package:immich_mobile/providers/folder.provider.dart'; | ||
|
||
@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); | ||
|
||
return Scaffold( | ||
appBar: AppBar( | ||
title: Text(folder?.name ?? 'Root'), | ||
elevation: 0, | ||
centerTitle: false, | ||
), | ||
body: folderState.when( | ||
data: (rootFolder) { | ||
// if folder is null, the root folder is the current folder | ||
RecursiveFolder? currentFolder = folder == null | ||
? null | ||
: _findFolder(rootFolder, folder!.path, folder!.name); | ||
|
||
if (currentFolder == null && folder != null) { | ||
return Center(child: const Text("Folder not found").tr()); | ||
} else if (currentFolder == null) { | ||
// display root folder | ||
return ListView( | ||
children: [ | ||
if (rootFolder.subfolders.isNotEmpty) | ||
...rootFolder.subfolders.map( | ||
(subfolder) => ListTile( | ||
title: Text(subfolder.name), | ||
onTap: () => | ||
context.pushRoute(FolderRoute(folder: subfolder)), | ||
), | ||
), | ||
if (rootFolder.assets != null && rootFolder.assets!.isNotEmpty) | ||
...rootFolder.assets!.map( | ||
(asset) => ListTile( | ||
title: Text(asset.name), | ||
subtitle: Text(asset.fileName), | ||
), | ||
), | ||
if (rootFolder.subfolders.isEmpty && | ||
(rootFolder.assets == null || rootFolder.assets!.isEmpty)) | ||
Center(child: const Text("No subfolders or assets").tr()), | ||
], | ||
); | ||
} | ||
|
||
return ListView( | ||
children: [ | ||
if (currentFolder.subfolders.isNotEmpty) | ||
...currentFolder.subfolders.map( | ||
(subfolder) => ListTile( | ||
title: Text(subfolder.name), | ||
onTap: () => | ||
context.pushRoute(FolderRoute(folder: subfolder)), | ||
), | ||
), | ||
if (currentFolder.assets != null && | ||
currentFolder.assets!.isNotEmpty) | ||
...currentFolder.assets!.map( | ||
(asset) => ListTile( | ||
title: Text(asset.name), | ||
subtitle: Text(asset.fileName), | ||
), | ||
), | ||
if (currentFolder.subfolders.isEmpty && | ||
(currentFolder.assets == null || | ||
currentFolder.assets!.isEmpty)) | ||
Center(child: const Text("No subfolders or assets").tr()), | ||
], | ||
); | ||
}, | ||
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()); | ||
}, | ||
), | ||
); | ||
} | ||
|
||
RecursiveFolder? _findFolder( | ||
RootFolder rootFolder, | ||
String path, | ||
String name, | ||
) { | ||
if ((path == '/' || path.isEmpty) && | ||
rootFolder.subfolders.any((f) => f.name == name)) { | ||
return rootFolder.subfolders.firstWhere((f) => f.name == name); | ||
} | ||
|
||
for (var subfolder in rootFolder.subfolders) { | ||
final result = _findFolderRecursive(subfolder, path, name); | ||
if (result != null) { | ||
return result; | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
RecursiveFolder? _findFolderRecursive( | ||
RecursiveFolder folder, | ||
String path, | ||
String name, | ||
) { | ||
if (folder.path == path && folder.name == name) { | ||
return folder; | ||
} | ||
|
||
for (var subfolder in folder.subfolders) { | ||
final result = _findFolderRecursive(subfolder, path, name); | ||
if (result != null) { | ||
return result; | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||
import 'package:immich_mobile/models/folder/root_folder.model.dart'; | ||
import 'package:immich_mobile/services/folder.service.dart'; | ||
|
||
class FoldersNotifier extends StateNotifier<AsyncValue<RootFolder>> { | ||
final FolderService _folderService; | ||
|
||
FoldersNotifier(this._folderService) : super(const AsyncLoading()) { | ||
fetchFolders(); | ||
} | ||
|
||
Future<void> fetchFolders() async { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use the |
||
state = await _folderService.getFolderStructure(); | ||
} | ||
} | ||
|
||
final folderStructureProvider = | ||
StateNotifierProvider<FoldersNotifier, AsyncValue<RootFolder>>((ref) { | ||
return FoldersNotifier( | ||
ref.watch(folderServiceProvider), | ||
); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||
import 'package:immich_mobile/entities/asset.entity.dart'; | ||
import 'package:immich_mobile/interfaces/folder_api.interface.dart'; | ||
import 'package:immich_mobile/providers/api.provider.dart'; | ||
import 'package:immich_mobile/repositories/api.repository.dart'; | ||
import 'package:logging/logging.dart'; | ||
import 'package:openapi/api.dart'; | ||
|
||
final folderApiRepositoryProvider = Provider( | ||
(ref) => FolderApiRepository( | ||
ref.watch(apiServiceProvider).viewApi, | ||
), | ||
); | ||
|
||
class FolderApiRepository extends ApiRepository | ||
implements IFolderApiRepository { | ||
final ViewApi _api; | ||
final Logger _log = Logger("FolderApiRepository"); | ||
|
||
FolderApiRepository(this._api); | ||
|
||
@override | ||
Future<AsyncValue<List<String>>> getAllUniquePaths() async { | ||
try { | ||
final list = await _api.getUniqueOriginalPaths(); | ||
return list != null ? AsyncData(list) : const AsyncData([]); | ||
} catch (e, stack) { | ||
_log.severe("Failed to fetch unique original links", e, stack); | ||
return AsyncError(e, stack); | ||
} | ||
} | ||
|
||
@override | ||
Future<AsyncValue<List<Asset>>> getAssetsForPath(String? path) async { | ||
try { | ||
final list = await _api.getAssetsByOriginalPath(path ?? '/'); | ||
return list != null | ||
? AsyncData(list.map(Asset.remote).toList()) | ||
: const AsyncData([]); | ||
} catch (e, stack) { | ||
_log.severe("Failed to fetch Assets by original path", e, stack); | ||
return AsyncError(e, stack); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; | |
import 'package:immich_mobile/entities/asset.entity.dart'; | ||
import 'package:immich_mobile/entities/logger_message.entity.dart'; | ||
import 'package:immich_mobile/entities/user.entity.dart'; | ||
import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; | ||
import 'package:immich_mobile/models/memories/memory.model.dart'; | ||
import 'package:immich_mobile/models/search/search_filter.model.dart'; | ||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; | ||
|
@@ -15,6 +16,7 @@ import 'package:immich_mobile/pages/backup/backup_options.page.dart'; | |
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; | ||
import 'package:immich_mobile/pages/albums/albums.page.dart'; | ||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; | ||
import 'package:immich_mobile/pages/library/folder/folder.page.dart'; | ||
import 'package:immich_mobile/pages/library/local_albums.page.dart'; | ||
import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; | ||
import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; | ||
|
@@ -206,6 +208,11 @@ class AppRouter extends RootStackRouter { | |
guards: [_authGuard, _duplicateGuard], | ||
transitionsBuilder: TransitionsBuilders.slideLeft, | ||
), | ||
CustomRoute( | ||
page: FolderRoute.page, | ||
guards: [_authGuard], | ||
transitionsBuilder: TransitionsBuilders.slideLeft, | ||
), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find using |
||
AutoRoute( | ||
page: PartnerDetailRoute.page, | ||
guards: [_authGuard, _duplicateGuard], | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should just return the
Future<List<String>>
here. So that we don't add additional type dependency fromriverpod
library to the repository