diff --git a/templates/flutter/base/lib/src/services/services.dart.hbs b/templates/flutter/base/lib/src/services/services.dart.hbs index a08b17d..0aa3800 100644 --- a/templates/flutter/base/lib/src/services/services.dart.hbs +++ b/templates/flutter/base/lib/src/services/services.dart.hbs @@ -2,6 +2,8 @@ export 'auth_service.dart'; export 'internet_connection_service.dart'; {{#if flags.usesDio}} export 'dio_service.dart'; +export 'upload_file_payload.dart'; +export 'upload_progress.dart'; {{/if}} {{#if flags.usesHttp}} export 'http_service.dart'; diff --git a/templates/flutter/overlays/networking/dio/lib/src/services/dio_service.dart.hbs b/templates/flutter/overlays/networking/dio/lib/src/services/dio_service.dart.hbs index 5cae9a2..d7c1648 100644 --- a/templates/flutter/overlays/networking/dio/lib/src/services/dio_service.dart.hbs +++ b/templates/flutter/overlays/networking/dio/lib/src/services/dio_service.dart.hbs @@ -1,6 +1,8 @@ import 'package:dio/dio.dart'; import '../config/app_config.dart'; import '../utils/utils.dart'; +import 'upload_file_payload.dart'; +import 'upload_progress.dart'; /// A robust networking service powered by Dio. class DioService { @@ -28,4 +30,83 @@ class DioService { FutureEither delete(String path, {dynamic data, Map? queryParameters}) { return runTask(() => AppConfig.dio.delete(path, data: data, queryParameters: queryParameters), requiresNetwork: true); } + + // --- Upload Methods --- + + FutureEither uploadFile( + String path, + UploadFilePayload payload, { + void Function(UploadProgress)? onProgress, + Map? fields, + Map? queryParameters, + }) { + return uploadFiles( + path, + [payload], + onProgress: onProgress, + fields: fields, + queryParameters: queryParameters, + ); + } + + FutureEither uploadFiles( + String path, + List payloads, { + void Function(UploadProgress)? onProgress, + Map? fields, + Map? queryParameters, + }) { + return runTask( + () async { + final formData = await _buildFormData(payloads, fields: fields); + return AppConfig.dio.post( + path, + data: formData, + queryParameters: queryParameters, + options: _multipartOptions(), + onSendProgress: (sent, total) { + onProgress?.call(UploadProgress(sent: sent, total: total)); + }, + ); + }, + requiresNetwork: true, + ); + } + + // --- Private Helpers --- + + /// Clears the global `application/json` Content-Type header for this request. + /// Dio automatically writes `multipart/form-data; boundary=...` for FormData. + Options _multipartOptions({Duration? sendTimeout}) => Options( + headers: {Headers.contentTypeHeader: null}, + sendTimeout: sendTimeout ?? const Duration(minutes: 5), + ); + + /// Builds a [FormData] from [payloads] and optional string [fields]. + /// Uses [formData.files.add] to correctly handle duplicate field names. + Future _buildFormData( + List payloads, { + Map? fields, + }) async { + if (payloads.isEmpty) { + throw ArgumentError.value(payloads, 'payloads', 'At least one payload is required.'); + } + final formData = FormData(); + + if (fields != null) { + for (final entry in fields.entries) { + formData.fields.add(MapEntry(entry.key, entry.value.toString())); + } + } + + final files = await Future.wait( + payloads.map((p) async => MapEntry(p.fieldName, await p.toMultipartFile())), + ); + + for (final file in files) { + formData.files.add(file); + } + + return formData; + } } diff --git a/templates/flutter/overlays/networking/dio/lib/src/services/upload_file_payload.dart.hbs b/templates/flutter/overlays/networking/dio/lib/src/services/upload_file_payload.dart.hbs new file mode 100644 index 0000000..83d7166 --- /dev/null +++ b/templates/flutter/overlays/networking/dio/lib/src/services/upload_file_payload.dart.hbs @@ -0,0 +1,62 @@ +import 'dart:typed_data'; +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; + +class UploadFilePayload { + final String? filePath; + final Uint8List? bytes; + final String fieldName; + final String? fileName; + final String? mimeType; + + const UploadFilePayload._({ + this.filePath, + this.bytes, + required this.fieldName, + this.fileName, + this.mimeType, + }); + + factory UploadFilePayload({ + String? filePath, + Uint8List? bytes, + String fieldName = 'file', + String? fileName, + String? mimeType, + }) { + if (filePath == null && bytes == null) { + throw ArgumentError('UploadFilePayload requires either filePath or bytes.'); + } + return UploadFilePayload._( + filePath: filePath, + bytes: bytes, + fieldName: fieldName, + fileName: fileName, + mimeType: mimeType, + ); + } + + String get _resolvedFileName => + fileName ?? + (filePath != null + ? filePath!.split(RegExp(r'[/\\]')).last + : 'upload'); + + MediaType? get _mediaType => + mimeType != null ? MediaType.parse(mimeType!) : null; + + Future toMultipartFile() async { + if (bytes != null) { + return MultipartFile.fromBytes( + bytes!, + filename: _resolvedFileName, + contentType: _mediaType, + ); + } + return MultipartFile.fromFile( + filePath!, + filename: _resolvedFileName, + contentType: _mediaType, + ); + } +} diff --git a/templates/flutter/overlays/networking/dio/lib/src/services/upload_progress.dart.hbs b/templates/flutter/overlays/networking/dio/lib/src/services/upload_progress.dart.hbs new file mode 100644 index 0000000..ab4664b --- /dev/null +++ b/templates/flutter/overlays/networking/dio/lib/src/services/upload_progress.dart.hbs @@ -0,0 +1,18 @@ +class UploadProgress { + final int sent; + final int total; + + const UploadProgress({required this.sent, required this.total}); + + /// 0.0–1.0. Returns 0.0 when [total] is unknown (server omits Content-Length). + double get percentage => + total > 0 ? (sent / total).clamp(0.0, 1.0) : 0.0; + + /// True when the server did not return Content-Length. + bool get isIndeterminate => total <= 0; + + @override + String toString() => + 'UploadProgress(sent: $sent, total: $total, ' + '${(percentage * 100).toStringAsFixed(1)}%)'; +}