Skip to content

Commit

Permalink
Merge pull request #43 from strvcom/feat/upload
Browse files Browse the repository at this point in the history
Data and file upload support
  • Loading branch information
cejanen authored Sep 6, 2023
2 parents 26b9545 + 70fb5a2 commit 13a5c93
Show file tree
Hide file tree
Showing 25 changed files with 1,812 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0F029850E86000ACBC0 /* ContentView.swift */; };
58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB80C6298521FF0031FC59 /* AuthorizationView.swift */; };
58FB80CE29895ABF0031FC59 /* TestData.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 58FB80CD29895ABF0031FC59 /* TestData.xcassets */; };
B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */; };
B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674BC2A370D1D006D3B9C /* UploadService.swift */; };
B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674BE2A370D33006D3B9C /* UploadItem.swift */; };
B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */; };
B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */; };
B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C42A37102D006D3B9C /* UploadsView.swift */; };
B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C62A371046006D3B9C /* UploadItemView.swift */; };
B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */; };
B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */; };
DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */; };
DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */; };
DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */; };
Expand Down Expand Up @@ -60,6 +69,15 @@
58E4E0F029850E86000ACBC0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
58FB80C6298521FF0031FC59 /* AuthorizationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationView.swift; sourceTree = "<group>"; };
58FB80CD29895ABF0031FC59 /* TestData.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = TestData.xcassets; sourceTree = "<group>"; };
B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUploadRouter.swift; sourceTree = "<group>"; };
B52674BC2A370D1D006D3B9C /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = "<group>"; };
B52674BE2A370D33006D3B9C /* UploadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItem.swift; sourceTree = "<group>"; };
B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsViewModel.swift; sourceTree = "<group>"; };
B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = "<group>"; };
B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = "<group>"; };
B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; sourceTree = "<group>"; };
B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ByteCountFormatter+Convenience.swift"; sourceTree = "<group>"; };
B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormUploadsViewModel.swift; sourceTree = "<group>"; };
DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationViewModel.swift; sourceTree = "<group>"; };
DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadProgressView.swift; sourceTree = "<group>"; };
DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadAPIManager+SharedInstance.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -126,8 +144,9 @@
23A575ED25F8BF0E00617551 /* Scenes */ = {
isa = PBXGroup;
children = (
58C3E75B29B78ED3004FD1CD /* Download */,
58FB80C5298521DA0031FC59 /* Authorization */,
58C3E75B29B78ED3004FD1CD /* Download */,
B52674BB2A370D0D006D3B9C /* Upload */,
);
path = Scenes;
sourceTree = "<group>";
Expand Down Expand Up @@ -168,6 +187,7 @@
children = (
DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */,
58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */,
B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */,
23EA9CE9292FB70A00B8E418 /* SampleUserRouter.swift */,
);
path = Routers;
Expand Down Expand Up @@ -221,10 +241,25 @@
path = Resources;
sourceTree = "<group>";
};
B52674BB2A370D0D006D3B9C /* Upload */ = {
isa = PBXGroup;
children = (
B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */,
B52674BE2A370D33006D3B9C /* UploadItem.swift */,
B52674C62A371046006D3B9C /* UploadItemView.swift */,
B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */,
B52674BC2A370D1D006D3B9C /* UploadService.swift */,
B52674C42A37102D006D3B9C /* UploadsView.swift */,
B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */,
);
path = Upload;
sourceTree = "<group>";
};
DD6E48742A0E2CC70025AD05 /* Extensions */ = {
isa = PBXGroup;
children = (
DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */,
B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -308,10 +343,13 @@
DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */,
DDE8884529476AC300DD3BFF /* SampleRefreshTokenRequest.swift in Sources */,
DDD3AD212951F527006CB777 /* SampleAuthorizationManager.swift in Sources */,
B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */,
23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */,
58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */,
23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */,
B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */,
58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */,
B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */,
58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */,
23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */,
DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */,
Expand All @@ -320,11 +358,17 @@
23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */,
58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */,
23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */,
B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */,
23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */,
58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */,
DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */,
B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */,
B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */,
58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */,
58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */,
B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */,
B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */,
B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */,
DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */,
23EA9CF8292FB70A00B8E418 /* SampleUsersResponse.swift in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SampleUploadRouter.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 12.06.2023.
//

import Foundation
import Networking

enum SampleUploadRouter: Requestable {
case image
case file(URL)
case multipart(boundary: String)

var baseURL: URL {
URL(string: SampleAPIConstants.uploadHost)!
}

var headers: [String: String]? {
switch self {
case .image:
return ["Content-Type": "image/png"]
case let .file(url):
return ["Content-Type": url.mimeType]
case let .multipart(boundary):
return ["Content-Type": "multipart/form-data; boundary=\(boundary)"]
}
}

var path: String {
"/post"
}

var method: HTTPMethod {
.post
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Foundation
enum SampleAPIConstants {
static let userHost = "https://reqres.in/api"
static let authHost = "https://nonexistentmockauth.com/api"
static let uploadHost = "https://httpbin.org"
static let validEmail = "[email protected]"
static let validPassword = "cityslicka"
static let videoUrl = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
Expand Down
3 changes: 3 additions & 0 deletions NetworkingSampleApp/NetworkingSampleApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SwiftUI
enum NetworkingFeature: String, Hashable, CaseIterable {
case authorization
case downloads
case uploads
}

struct ContentView: View {
Expand All @@ -27,6 +28,8 @@ struct ContentView: View {
AuthorizationView()
case .downloads:
DownloadsView()
case .uploads:
UploadsView()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// ByteCountFormatter+Convenience.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 30.06.2023.
//

import Foundation

extension ByteCountFormatter {
static let megaBytesFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useMB]
formatter.countStyle = .file
return formatter
}()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// FormUploadsViewModel.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 19.06.2023.
//

import Foundation
import Networking
import OSLog

@MainActor
final class FormUploadsViewModel: ObservableObject {
@Published var username = ""
@Published var fileUrl: URL?
@Published var isErrorAlertPresented = false
@Published private(set) var error: Error?
@Published private(set) var uploadItemViewModels: [UploadItemViewModel] = []

var selectedFileName: String {
let fileSize = Int64(fileUrl?.fileSize ?? 0)
var fileName = fileUrl?.lastPathComponent ?? ""
let formattedFileSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: fileSize)
if fileSize > 0 { fileName += "\n\(formattedFileSize)" }
return fileName
}

private let uploadService: UploadService

init(uploadService: UploadService = .init()) {
self.uploadService = uploadService
}
}

extension FormUploadsViewModel {
func uploadForm() {
Task {
do {
let multipartFormData = try createMultipartFormData()
let uploadItem = try await uploadService.uploadFormData(multipartFormData)

uploadItemViewModels.append(UploadItemViewModel(
item: uploadItem,
uploadService: uploadService
))

username = ""
fileUrl = nil
} catch {
os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)")
self.error = error
self.isErrorAlertPresented = true
}
}
}
}

// MARK: - Prepare multipartForm data
private extension FormUploadsViewModel {
func createMultipartFormData() throws -> MultipartFormData {
let multipartFormData = MultipartFormData()
multipartFormData.append(Data(username.utf8), name: "username-textfield")
if let fileUrl {
try multipartFormData.append(from: fileUrl, name: "attachment")
}
return multipartFormData
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// UploadItem.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 12.06.2023.
//

import Foundation

struct UploadItem: Hashable {
let id: String
let fileName: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// UploadItemView.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 12.06.2023.
//

import SwiftUI

struct UploadItemView: View {
@ObservedObject var viewModel: UploadItemViewModel

var body: some View {
VStack(alignment: .leading) {
HStack {
HStack {
Text(viewModel.fileName)
.font(.subheadline)
Text(viewModel.stateTitle)
.font(.footnote)
.foregroundColor(.gray)
}

Spacer()

if !viewModel.isCancelled && !viewModel.isRetryable && !viewModel.isCompleted {
HStack {
button(
symbol: viewModel.isPaused ? "play" : "pause",
color: .blue,
action: { viewModel.isPaused ? viewModel.resume() : viewModel.pause() }
)

button(
symbol: "x",
color: .red,
action: { viewModel.cancel() }
)
}
} else if viewModel.isRetryable {
button(
symbol: "repeat",
color: .blue,
action: { viewModel.retry() }
)
}
}

if !viewModel.isCancelled && !viewModel.isRetryable {
ProgressView(value: viewModel.progress, total: viewModel.totalProgress)
.progressViewStyle(.linear)
}
}
.animation(.easeOut(duration: 0.3), value: viewModel.progress)
.padding(.vertical, 8)
.task { await viewModel.observeProgress() }
}
}

private extension UploadItemView {
func button(symbol: String, color: Color, action: @escaping () -> Void) -> some View {
Button(
action: action,
label: {
Image(systemName: symbol)
.symbolVariant(.circle.fill)
.font(.title2)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(color)
}
)
.buttonStyle(.plain)
.contentShape(Circle())
}
}
Loading

0 comments on commit 13a5c93

Please sign in to comment.