feat(mobile): Folder View for mobile (#15047)

* very rough prototype for folder navigation without assets

* fix: refactored data model and tried to implement asset loading

* fix: openapi generator shadowing query param in /view/folder

* add simple alphanumeric sorting for folders

* basic asset viewing in folders

* rudimentary switch sorting order

* fixed reactivity when toggling sort order

* Fixed trailing comma

* Fixed bad merge conflict resolution

* Regenerated open-api

* Added rudimentary breadcrumbs

* Fixed linting problems

* feat: cleanup

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Arno 2025-03-06 18:27:43 +01:00 committed by GitHub
parent deb399ea15
commit 4ebc25c754
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 877 additions and 10 deletions

View file

@ -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"
}
}

View file

@ -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);
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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),
),
),
],
),
),
),
);
}
}

View file

@ -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,

View file

@ -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<AsyncValue<RootFolder>> {
final FolderService _folderService;
final Logger _log = Logger("FolderStructureNotifier");
FolderStructureNotifier(this._folderService) : super(const AsyncLoading());
Future<void> 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<FolderStructureNotifier, AsyncValue<RootFolder>>(
(ref) {
return FolderStructureNotifier(
ref.watch(folderServiceProvider),
);
});
class FolderRenderListNotifier extends StateNotifier<AsyncValue<RenderList>> {
final FolderService _folderService;
final RootFolder _folder;
final Logger _log = Logger("FolderAssetsNotifier");
FolderRenderListNotifier(this._folderService, this._folder)
: super(const AsyncLoading());
Future<void> 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<RenderList>,
RootFolder>((ref, folder) {
return FolderRenderListNotifier(
ref.watch(folderServiceProvider),
folder,
);
});

View file

@ -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<List<String>> 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<List<Asset>> 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 [];
}
}
}

View file

@ -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],

View file

@ -1175,6 +1175,40 @@ class PartnerRoute extends PageRouteInfo<void> {
);
}
/// manually written (with love) route for
/// [FolderPage]
class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
FolderRoute({
RecursiveFolder? folder,
List<PageRouteInfo>? 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<FolderRouteArgs>();
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<void> {

View file

@ -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);
}

View file

@ -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<RootFolder> getFolderStructure(SortOrder order) async {
final paths = await _folderApiRepository.getAllUniquePaths();
// Create folder structure
Map<String, List<RecursiveFolder>> folderMap = {};
for (String fullPath in paths) {
if (fullPath == '/') continue;
// Ensure the path starts with a slash
if (!fullPath.startsWith('/')) {
fullPath = '/$fullPath';
}
List<String> 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<RecursiveFolder> 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<List<Asset>> 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 [];
}
}
}

View file

@ -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(

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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

View file

@ -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 <native_class.mustache.patch
cd ../../../../
cd ../../
wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
cd ../../
npx --yes @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
# Post generate patches

View file

@ -0,0 +1,194 @@
{{>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<Response> {{{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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
{{#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 = <String>[{{#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}}

View file

@ -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<Response> {{{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,