diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index fd628d469..3854c0c51 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -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" -} \ No newline at end of file +} diff --git a/mobile/lib/interfaces/folder_api.interface.dart b/mobile/lib/interfaces/folder_api.interface.dart new file mode 100644 index 000000000..68c1652e2 --- /dev/null +++ b/mobile/lib/interfaces/folder_api.interface.dart @@ -0,0 +1,6 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IFolderApiRepository { + Future> getAllUniquePaths(); + Future> getAssetsForPath(String? path); +} diff --git a/mobile/lib/models/folder/recursive_folder.model.dart b/mobile/lib/models/folder/recursive_folder.model.dart new file mode 100644 index 000000000..5b54a2e1b --- /dev/null +++ b/mobile/lib/models/folder/recursive_folder.model.dart @@ -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, + }); +} diff --git a/mobile/lib/models/folder/root_folder.model.dart b/mobile/lib/models/folder/root_folder.model.dart new file mode 100644 index 000000000..8f72a539c --- /dev/null +++ b/mobile/lib/models/folder/root_folder.model.dart @@ -0,0 +1,11 @@ +import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; + +class RootFolder { + final List subfolders; + final String path; + + RootFolder({ + required this.subfolders, + required this.path, + }); +} diff --git a/mobile/lib/pages/library/folder/folder.page.dart b/mobile/lib/pages/library/folder/folder.page.dart new file mode 100644 index 000000000..af6f29597 --- /dev/null +++ b/mobile/lib/pages/library/folder/folder.page.dart @@ -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(folder); + final sortOrder = useState(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), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 92fe8cec1..31b465ead 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -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, diff --git a/mobile/lib/providers/folder.provider.dart b/mobile/lib/providers/folder.provider.dart new file mode 100644 index 000000000..810c2cea7 --- /dev/null +++ b/mobile/lib/providers/folder.provider.dart @@ -0,0 +1,62 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/models/folder/root_folder.model.dart'; +import 'package:immich_mobile/services/folder.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:logging/logging.dart'; + +class FolderStructureNotifier extends StateNotifier> { + final FolderService _folderService; + final Logger _log = Logger("FolderStructureNotifier"); + + FolderStructureNotifier(this._folderService) : super(const AsyncLoading()); + + Future fetchFolders(SortOrder order) async { + try { + final folders = await _folderService.getFolderStructure(order); + state = AsyncData(folders); + } catch (e, stack) { + _log.severe("Failed to build folder structure", e, stack); + state = AsyncError(e, stack); + } + } +} + +final folderStructureProvider = + StateNotifierProvider>( + (ref) { + return FolderStructureNotifier( + ref.watch(folderServiceProvider), + ); +}); + +class FolderRenderListNotifier extends StateNotifier> { + final FolderService _folderService; + final RootFolder _folder; + final Logger _log = Logger("FolderAssetsNotifier"); + + FolderRenderListNotifier(this._folderService, this._folder) + : super(const AsyncLoading()); + + Future fetchAssets(SortOrder order) async { + try { + final assets = await _folderService.getFolderAssets(_folder, order); + final renderList = + await RenderList.fromAssets(assets, GroupAssetsBy.none); + state = AsyncData(renderList); + } catch (e, stack) { + _log.severe("Failed to fetch folder assets", e, stack); + state = AsyncError(e, stack); + } + } +} + +final folderRenderListProvider = StateNotifierProvider.family< + FolderRenderListNotifier, + AsyncValue, + RootFolder>((ref, folder) { + return FolderRenderListNotifier( + ref.watch(folderServiceProvider), + folder, + ); +}); diff --git a/mobile/lib/repositories/folder_api.repository.dart b/mobile/lib/repositories/folder_api.repository.dart new file mode 100644 index 000000000..bd7b03515 --- /dev/null +++ b/mobile/lib/repositories/folder_api.repository.dart @@ -0,0 +1,43 @@ +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> getAllUniquePaths() async { + try { + final list = await _api.getUniqueOriginalPaths(); + return list ?? []; + } catch (e, stack) { + _log.severe("Failed to fetch unique original links", e, stack); + return []; + } + } + + @override + Future> getAssetsForPath(String? path) async { + try { + final list = await _api.getAssetsByOriginalPath(path ?? '/'); + return list != null ? list.map(Asset.remote).toList() : []; + } catch (e, stack) { + _log.severe("Failed to fetch Assets by original path", e, stack); + return []; + } + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ae5419b71..cd7a6f6b9 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -5,6 +5,8 @@ import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; +import 'package:immich_mobile/pages/library/folder/folder.page.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'; @@ -207,6 +209,11 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), + CustomRoute( + page: FolderRoute.page, + guards: [_authGuard], + transitionsBuilder: TransitionsBuilders.fadeIn, + ), AutoRoute( page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 299c8a602..e120f512a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1175,6 +1175,40 @@ class PartnerRoute extends PageRouteInfo { ); } +/// manually written (with love) route for +/// [FolderPage] +class FolderRoute extends PageRouteInfo { + FolderRoute({ + RecursiveFolder? folder, + List? children, + }) : super( + FolderRoute.name, + args: FolderRouteArgs(folder: folder), + initialChildren: children, + ); + + static const String name = 'FolderRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return FolderPage(folder: args.folder); + }, + ); +} + +class FolderRouteArgs { + const FolderRouteArgs({this.folder}); + + final RecursiveFolder? folder; + + @override + String toString() { + return 'FolderRouteArgs{folder: $folder}'; + } +} + /// generated route for /// [PeopleCollectionPage] class PeopleCollectionRoute extends PageRouteInfo { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index b87e10f02..0ef68e1c4 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -31,6 +31,7 @@ class ApiService implements Authentication { late DownloadApi downloadApi; late TrashApi trashApi; late StacksApi stacksApi; + late ViewApi viewApi; late MemoriesApi memoriesApi; ApiService() { @@ -64,6 +65,7 @@ class ApiService implements Authentication { downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); stacksApi = StacksApi(_apiClient); + viewApi = ViewApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); } diff --git a/mobile/lib/services/folder.service.dart b/mobile/lib/services/folder.service.dart new file mode 100644 index 000000000..5b97b475b --- /dev/null +++ b/mobile/lib/services/folder.service.dart @@ -0,0 +1,132 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/entities/asset.entity.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/repositories/folder_api.repository.dart'; +import 'package:logging/logging.dart'; + +final folderServiceProvider = Provider( + (ref) => FolderService(ref.watch(folderApiRepositoryProvider)), +); + +class FolderService { + final FolderApiRepository _folderApiRepository; + final Logger _log = Logger("FolderService"); + + FolderService(this._folderApiRepository); + + Future getFolderStructure(SortOrder order) async { + final paths = await _folderApiRepository.getAllUniquePaths(); + + // Create folder structure + Map> folderMap = {}; + + for (String fullPath in paths) { + if (fullPath == '/') continue; + + // Ensure the path starts with a slash + if (!fullPath.startsWith('/')) { + fullPath = '/$fullPath'; + } + + List segments = fullPath.split('/') + ..removeWhere((s) => s.isEmpty); + + String currentPath = ''; + + for (int i = 0; i < segments.length; i++) { + String parentPath = currentPath.isEmpty ? '_root_' : currentPath; + currentPath = + i == 0 ? '/${segments[i]}' : '$currentPath/${segments[i]}'; + + if (!folderMap.containsKey(parentPath)) { + folderMap[parentPath] = []; + } + + if (!folderMap[parentPath]!.any((f) => f.name == segments[i])) { + folderMap[parentPath]!.add( + RecursiveFolder( + path: parentPath == '_root_' ? '' : parentPath, + name: segments[i], + subfolders: [], + ), + ); + // Sort folders based on order parameter + folderMap[parentPath]!.sort( + (a, b) => order == SortOrder.desc + ? b.name.compareTo(a.name) + : a.name.compareTo(b.name), + ); + } + } + } + + void attachSubfolders(RecursiveFolder folder) { + String fullPath = folder.path.isEmpty + ? '/${folder.name}' + : '${folder.path}/${folder.name}'; + + if (folderMap.containsKey(fullPath)) { + folder.subfolders.addAll(folderMap[fullPath]!); + // Sort subfolders based on order parameter + folder.subfolders.sort( + (a, b) => order == SortOrder.desc + ? b.name.compareTo(a.name) + : a.name.compareTo(b.name), + ); + for (var subfolder in folder.subfolders) { + attachSubfolders(subfolder); + } + } + } + + List rootSubfolders = folderMap['_root_'] ?? []; + // Sort root subfolders based on order parameter + rootSubfolders.sort( + (a, b) => order == SortOrder.desc + ? b.name.compareTo(a.name) + : a.name.compareTo(b.name), + ); + + for (var folder in rootSubfolders) { + attachSubfolders(folder); + } + + return RootFolder( + subfolders: rootSubfolders, + path: '/', + ); + } + + Future> getFolderAssets( + RootFolder folder, + SortOrder order, + ) async { + try { + if (folder is RecursiveFolder) { + String fullPath = + folder.path.isEmpty ? folder.name : '${folder.path}/${folder.name}'; + fullPath = fullPath[0] == '/' ? fullPath.substring(1) : fullPath; + var result = await _folderApiRepository.getAssetsForPath(fullPath); + + if (order == SortOrder.desc) { + result.sort((a, b) => b.fileCreatedAt.compareTo(a.fileCreatedAt)); + } else { + result.sort((a, b) => a.fileCreatedAt.compareTo(b.fileCreatedAt)); + } + + return result; + } + final result = await _folderApiRepository.getAssetsForPath('/'); + return result; + } catch (e, stack) { + _log.severe( + "Failed to fetch assets for folder ${folder is RecursiveFolder ? folder.name : "root"}", + e, + stack, + ); + return []; + } + } +} diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 7f97944cd..7a4260679 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/immich_logo_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/sync_stream.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; @@ -186,12 +185,6 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), actions: [ - IconButton( - onPressed: () { - ref.read(syncStreamServiceProvider).syncUsers(); - }, - icon: const Icon(Icons.sync), - ), if (actions != null) ...actions!.map( (action) => Padding( diff --git a/mobile/openapi/lib/api/activities_api.dart b/mobile/openapi/lib/api/activities_api.dart index e5075eee1..5c83ba7db 100644 Binary files a/mobile/openapi/lib/api/activities_api.dart and b/mobile/openapi/lib/api/activities_api.dart differ diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index eb2bb7c0b..a8c518ace 100644 Binary files a/mobile/openapi/lib/api/albums_api.dart and b/mobile/openapi/lib/api/albums_api.dart differ diff --git a/mobile/openapi/lib/api/api_keys_api.dart b/mobile/openapi/lib/api/api_keys_api.dart index 2e7757f20..cf54ac5c0 100644 Binary files a/mobile/openapi/lib/api/api_keys_api.dart and b/mobile/openapi/lib/api/api_keys_api.dart differ diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index e7272d094..f52c70b37 100644 Binary files a/mobile/openapi/lib/api/assets_api.dart and b/mobile/openapi/lib/api/assets_api.dart differ diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index cb8186742..bf987f441 100644 Binary files a/mobile/openapi/lib/api/authentication_api.dart and b/mobile/openapi/lib/api/authentication_api.dart differ diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 30e35b451..7aa9662c2 100644 Binary files a/mobile/openapi/lib/api/deprecated_api.dart and b/mobile/openapi/lib/api/deprecated_api.dart differ diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index b89f340ec..3b11c2f63 100644 Binary files a/mobile/openapi/lib/api/download_api.dart and b/mobile/openapi/lib/api/download_api.dart differ diff --git a/mobile/openapi/lib/api/duplicates_api.dart b/mobile/openapi/lib/api/duplicates_api.dart index b82290e47..715c6d611 100644 Binary files a/mobile/openapi/lib/api/duplicates_api.dart and b/mobile/openapi/lib/api/duplicates_api.dart differ diff --git a/mobile/openapi/lib/api/faces_api.dart b/mobile/openapi/lib/api/faces_api.dart index e92ee93e4..44e3d53f8 100644 Binary files a/mobile/openapi/lib/api/faces_api.dart and b/mobile/openapi/lib/api/faces_api.dart differ diff --git a/mobile/openapi/lib/api/file_reports_api.dart b/mobile/openapi/lib/api/file_reports_api.dart index 5eab91576..73b3feaed 100644 Binary files a/mobile/openapi/lib/api/file_reports_api.dart and b/mobile/openapi/lib/api/file_reports_api.dart differ diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 78afc15c9..182bb14e4 100644 Binary files a/mobile/openapi/lib/api/jobs_api.dart and b/mobile/openapi/lib/api/jobs_api.dart differ diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 36d98d9a8..86acce76b 100644 Binary files a/mobile/openapi/lib/api/libraries_api.dart and b/mobile/openapi/lib/api/libraries_api.dart differ diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 9644fbfc5..ffe72df45 100644 Binary files a/mobile/openapi/lib/api/map_api.dart and b/mobile/openapi/lib/api/map_api.dart differ diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index c5b04a7c7..88897d303 100644 Binary files a/mobile/openapi/lib/api/memories_api.dart and b/mobile/openapi/lib/api/memories_api.dart differ diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index 323fbcc3d..518a1baa4 100644 Binary files a/mobile/openapi/lib/api/notifications_api.dart and b/mobile/openapi/lib/api/notifications_api.dart differ diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart index aafcb2846..9f16e37c7 100644 Binary files a/mobile/openapi/lib/api/o_auth_api.dart and b/mobile/openapi/lib/api/o_auth_api.dart differ diff --git a/mobile/openapi/lib/api/partners_api.dart b/mobile/openapi/lib/api/partners_api.dart index ac0d03054..9f10ea4d1 100644 Binary files a/mobile/openapi/lib/api/partners_api.dart and b/mobile/openapi/lib/api/partners_api.dart differ diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 92bd0fdee..1cdb87885 100644 Binary files a/mobile/openapi/lib/api/people_api.dart and b/mobile/openapi/lib/api/people_api.dart differ diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 70af3ab0a..632107ff7 100644 Binary files a/mobile/openapi/lib/api/search_api.dart and b/mobile/openapi/lib/api/search_api.dart differ diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index 7a832ad61..629949db3 100644 Binary files a/mobile/openapi/lib/api/server_api.dart and b/mobile/openapi/lib/api/server_api.dart differ diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index fcc6cb836..203f801b7 100644 Binary files a/mobile/openapi/lib/api/sessions_api.dart and b/mobile/openapi/lib/api/sessions_api.dart differ diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index a6b2978fe..5bac8988d 100644 Binary files a/mobile/openapi/lib/api/shared_links_api.dart and b/mobile/openapi/lib/api/shared_links_api.dart differ diff --git a/mobile/openapi/lib/api/stacks_api.dart b/mobile/openapi/lib/api/stacks_api.dart index aa1d9b341..84f23ec55 100644 Binary files a/mobile/openapi/lib/api/stacks_api.dart and b/mobile/openapi/lib/api/stacks_api.dart differ diff --git a/mobile/openapi/lib/api/sync_api.dart b/mobile/openapi/lib/api/sync_api.dart index 49a4963bf..fe2876ddd 100644 Binary files a/mobile/openapi/lib/api/sync_api.dart and b/mobile/openapi/lib/api/sync_api.dart differ diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index b63b2b70c..a03b9d3e7 100644 Binary files a/mobile/openapi/lib/api/system_config_api.dart and b/mobile/openapi/lib/api/system_config_api.dart differ diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart index 822a54b14..3bd8bddca 100644 Binary files a/mobile/openapi/lib/api/system_metadata_api.dart and b/mobile/openapi/lib/api/system_metadata_api.dart differ diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index 87c9001a3..f6cfc8720 100644 Binary files a/mobile/openapi/lib/api/tags_api.dart and b/mobile/openapi/lib/api/tags_api.dart differ diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 8c94e09bf..7ea7189b0 100644 Binary files a/mobile/openapi/lib/api/timeline_api.dart and b/mobile/openapi/lib/api/timeline_api.dart differ diff --git a/mobile/openapi/lib/api/trash_api.dart b/mobile/openapi/lib/api/trash_api.dart index 8f8c6ffb3..982dbcbed 100644 Binary files a/mobile/openapi/lib/api/trash_api.dart and b/mobile/openapi/lib/api/trash_api.dart differ diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index a074645e0..b4508d7dc 100644 Binary files a/mobile/openapi/lib/api/users_admin_api.dart and b/mobile/openapi/lib/api/users_admin_api.dart differ diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index b2b9fa882..a48ec54cf 100644 Binary files a/mobile/openapi/lib/api/users_api.dart and b/mobile/openapi/lib/api/users_api.dart differ diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/view_api.dart index f4489f2d1..1fcaec759 100644 Binary files a/mobile/openapi/lib/api/view_api.dart and b/mobile/openapi/lib/api/view_api.dart differ diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f5e4b05d4..7bce65e48 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -83,7 +83,7 @@ dependencies: # Taken from https://github.com/Myzel394/locus/blob/445013d22ec1d759027d4303bd65b30c5c8588c8/pubspec.yaml#L105 dependency_overrides: # TODO: remove once Isar is updated - analyzer: ^6.3.0 + analyzer: ^6.0.0 # TODO: remove once analyzer override is removed meta: ^1.11.0 # TODO: remove once analyzer override is removed diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index bf8b24b55..e2badc6df 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -9,7 +9,11 @@ function dart { wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache header}} +{{>part_of}} +{{#operations}} + +class {{{classname}}} { + {{{classname}}}([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + {{#operation}} + + {{#summary}} + /// {{{.}}} + {{/summary}} + {{#notes}} + {{#summary}} + /// + {{/summary}} + /// {{{notes}}} + /// + /// Note: This method returns the HTTP [Response]. + {{/notes}} + {{^notes}} + {{#summary}} + /// + /// Note: This method returns the HTTP [Response]. + {{/summary}} + {{^summary}} + /// Performs an HTTP '{{{httpMethod}}} {{{path}}}' operation and returns the [Response]. + {{/summary}} + {{/notes}} + {{#hasParams}} + {{#summary}} + /// + {{/summary}} + {{^summary}} + {{#notes}} + /// + {{/notes}} + {{/summary}} + /// Parameters: + /// + {{/hasParams}} + {{#allParams}} + /// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}: + {{#description}} + /// {{{.}}} + {{/description}} + {{^-last}} + /// + {{/-last}} + {{/allParams}} + Future {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + // ignore: prefer_const_declarations + final apiPath = r'{{{path}}}'{{#pathParams}} + .replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}}; + + // ignore: prefer_final_locals + Object? postBody{{#bodyParam}} = {{{paramName}}}{{/bodyParam}}; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + {{#hasQueryParams}} + + {{#queryParams}} + {{^required}} + if ({{{paramName}}} != null) { + {{/required}} + queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); + {{^required}} + } + {{/required}} + {{/queryParams}} + {{/hasQueryParams}} + {{#hasHeaderParams}} + + {{#headerParams}} + {{#required}} + headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + {{/required}} + {{^required}} + if ({{{paramName}}} != null) { + headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/required}} + {{/headerParams}} + {{/hasHeaderParams}} + + const contentTypes = [{{#prioritizedContentTypes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/prioritizedContentTypes}}]; + + {{#isMultipart}} + bool hasFields = false; + final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath)); + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { + hasFields = true; + mp.fields[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/isFile}} + {{#isFile}} + if ({{{paramName}}} != null) { + hasFields = true; + mp.fields[r'{{{baseName}}}'] = {{{paramName}}}.field; + mp.files.add({{{paramName}}}); + } + {{/isFile}} + {{/formParams}} + if (hasFields) { + postBody = mp; + } + {{/isMultipart}} + {{^isMultipart}} + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { + formParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/isFile}} + {{/formParams}} + {{/isMultipart}} + + return apiClient.invokeAPI( + apiPath, + '{{{httpMethod}}}', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + {{#summary}} + /// {{{.}}} + {{/summary}} + {{#notes}} + {{#summary}} + /// + {{/summary}} + /// {{{notes}}} + {{/notes}} + {{#hasParams}} + {{#summary}} + /// + {{/summary}} + {{^summary}} + {{#notes}} + /// + {{/notes}} + {{/summary}} + /// Parameters: + /// + {{/hasParams}} + {{#allParams}} + /// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}: + {{#description}} + /// {{{.}}} + {{/description}} + {{^-last}} + /// + {{/-last}} + {{/allParams}} + Future<{{#returnType}}{{{.}}}?{{/returnType}}{{^returnType}}void{{/returnType}}> {{{nickname}}}({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + final response = await {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}} {{#allParams}}{{^required}}{{{paramName}}}: {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} {{/hasOptionalParams}}); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + {{#returnType}} + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + {{#native_serialization}} + {{#isArray}} + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, '{{{returnType}}}') as List) + .cast<{{{returnBaseType}}}>() + .{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}}; + {{/isArray}} + {{^isArray}} + {{#isMap}} + return {{{returnType}}}.from(await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}'),); + {{/isMap}} + {{^isMap}} + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}',) as {{{returnType}}}; + {{/isMap}}{{/isArray}}{{/native_serialization}} + } + return null; + {{/returnType}} + } + {{/operation}} +} +{{/operations}} diff --git a/open-api/templates/mobile/api.mustache.patch b/open-api/templates/mobile/api.mustache.patch new file mode 100644 index 000000000..e3f888d6d --- /dev/null +++ b/open-api/templates/mobile/api.mustache.patch @@ -0,0 +1,29 @@ +--- api.mustache 2025-01-22 05:50:25 ++++ api.mustache.modified 2025-01-22 05:52:23 +@@ -51,7 +51,7 @@ + {{/allParams}} + Future {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + // ignore: prefer_const_declarations +- final path = r'{{{path}}}'{{#pathParams}} ++ final apiPath = r'{{{path}}}'{{#pathParams}} + .replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}}; + + // ignore: prefer_final_locals +@@ -90,7 +90,7 @@ + + {{#isMultipart}} + bool hasFields = false; +- final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(path)); ++ final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath)); + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { +@@ -121,7 +121,7 @@ + {{/isMultipart}} + + return apiClient.invokeAPI( +- path, ++ apiPath, + '{{{httpMethod}}}', + queryParams, + postBody,