From 71216ccb095b44ab23534cedd38c9da0027c8e8f Mon Sep 17 00:00:00 2001 From: Mohammed Ezz Date: Sat, 11 Apr 2026 06:43:17 +0200 Subject: [PATCH 1/4] feat: add multipart upload support to DioService with progress tracking --- .../base/lib/src/services/services.dart.hbs | 2 + .../dio/lib/src/services/dio_service.dart.hbs | 87 +++++++++++++++++++ .../src/services/upload_file_payload.dart.hbs | 46 ++++++++++ .../lib/src/services/upload_progress.dart.hbs | 18 ++++ 4 files changed, 153 insertions(+) create mode 100644 templates/flutter/overlays/networking/dio/lib/src/services/upload_file_payload.dart.hbs create mode 100644 templates/flutter/overlays/networking/dio/lib/src/services/upload_progress.dart.hbs 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..2e25251 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,89 @@ 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 runTask( + () async { + final formData = await _buildFormData([payload], 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, + ); + } + + 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 { + assert(payloads.isNotEmpty, '_buildFormData requires at least one payload'); + 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..ee1d029 --- /dev/null +++ b/templates/flutter/overlays/networking/dio/lib/src/services/upload_file_payload.dart.hbs @@ -0,0 +1,46 @@ +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, + this.fieldName = 'file', + this.fileName, + this.mimeType, + }) : assert( + filePath != null || bytes != null, + 'UploadFilePayload requires either filePath or bytes.', + ); + + 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)}%)'; +} From 914d47154e7d60a9ee9b828236f86eb8fb179455 Mon Sep 17 00:00:00 2001 From: Mohammed Ezz Date: Sat, 11 Apr 2026 07:40:52 +0200 Subject: [PATCH 2/4] fix: replace debug assert with runtime validation for empty payloads --- .../networking/dio/lib/src/services/dio_service.dart.hbs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 2e25251..d891c14 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 @@ -96,7 +96,9 @@ class DioService { List payloads, { Map? fields, }) async { - assert(payloads.isNotEmpty, '_buildFormData requires at least one payload'); + if (payloads.isEmpty) { + throw ArgumentError.value(payloads, 'payloads', 'At least one payload is required.'); + } final formData = FormData(); if (fields != null) { From f9b157ab0917b7cd02c610b2cf6e45df4d0a52e5 Mon Sep 17 00:00:00 2001 From: Mohammed Ezz Date: Sat, 11 Apr 2026 07:44:29 +0200 Subject: [PATCH 3/4] fix: replace debug asserts with runtime validation in upload flow --- .../src/services/upload_file_payload.dart.hbs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) 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 index ee1d029..83d7166 100644 --- 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 @@ -9,16 +9,32 @@ class UploadFilePayload { final String? fileName; final String? mimeType; - const UploadFilePayload({ + const UploadFilePayload._({ this.filePath, this.bytes, - this.fieldName = 'file', + required this.fieldName, this.fileName, this.mimeType, - }) : assert( - filePath != null || bytes != null, - 'UploadFilePayload requires either filePath or bytes.', - ); + }); + + 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 ?? From 894c2984ae849082394050dbf64d8f5a59c275f7 Mon Sep 17 00:00:00 2001 From: Mohammed Ezz Date: Sat, 11 Apr 2026 07:51:07 +0200 Subject: [PATCH 4/4] refactor: simplify uploadFile method by delegating to uploadFiles --- .../dio/lib/src/services/dio_service.dart.hbs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) 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 d891c14..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 @@ -40,20 +40,12 @@ class DioService { Map? fields, Map? queryParameters, }) { - return runTask( - () async { - final formData = await _buildFormData([payload], 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, + return uploadFiles( + path, + [payload], + onProgress: onProgress, + fields: fields, + queryParameters: queryParameters, ); }