Page updated Jan 16, 2024

Working with files / attachments

The Storage and GraphQL API categories can be used together to associate a file, such as an image or video, with a particular record. For example, you might create a User model with a profile picture, or a Post model with an associated image. With Amplify's GraphQL API and Storage categories, you can reference the file within the model itself to create an association.

For more on the Amplify GraphQL API, see the API documentation. For Storage, see Storage documentation. For Auth, see Auth documentation

To get started, go to your project directory and run the command:

1amplify add api

Choose the following when prompted:

1? Select from one of the below mentioned services: `GraphQL`
2? Choose the default authorization type for the API `Amazon Cognito User Pool`
3 Do you want to use the default authentication and security configuration? `Default configuration`
4 How do you want users to be able to sign in? `Username`
5 Do you want to configure advanced settings? `No, I am done.`
6? Here is the GraphQL API that we will create. Select a setting to edit or continue `Continue`
7? Choose a schema template: `Blank Schema`

When prompted, use the following schema, which can also be found under amplify/backend/api/[name of project]/schema.graphql:

1type Song @model @auth(rules: [{ allow: public }]) {
2 id: ID!
3 name: String!
4 coverArtKey: String # Set as optional to allow adding file after initial create
5}

Add Storage with the command:

1amplify add storage

Choose the following when prompted:

1? Select from one of the below mentioned services: `Content (Images, audio, video, etc.)`
2✔ Who should have access: `Auth users only`
3✔ What kind of access do you want for Authenticated users? `create/update, read, delete`
4✔ Do you want to add a Lambda Trigger for your S3 Bucket? (y/N) · `no`

Run amplify push to deploy the changes.

Once the backend has been provisioned, run amplify codegen models to generate the Swift model types for the app.

Next, add the Amplify(https://github.com/aws-amplify/amplify-swift.git) package to your Xcode project and select the following modules to import when prompted:

  • AWSAPIPlugin
  • AWSCognitoAuthPlugin
  • AWSS3StoragePlugin
  • Amplify

Add the Authenticator from Amplify UI Authenticator for SwiftUI (https://github.com/aws-amplify/amplify-ui-swift-authenticator.git)

For the complete working example, including required imports, obtaining the file from the user, and SwiftUI components, see the Complete Example below.

Configuring authorization

Your application needs authorization credentials for reading and writing to both Storage and the GraphQL API, except in the case where all data and files are intended to be publicly accessible.

The Storage and API categories govern data access based on their own authorization patterns, meaning that it's necessary to configure appropriate auth roles for each individual category. Although both categories share the same access credentials set up through the Auth category, they work independently from one another. For instance, adding an @auth directive to the API schema does not guard against file access in the Storage category. Likewise, adding authorization rules to the Storage category does not guard against data access in the API category.

When you run amplify add storage, the CLI will configure appropriate IAM policies on the bucket using a Cognito Identity Pool role. You will then have the option of adding CRUD (Create, Update, Read and Delete) based permissions as well, so that Authenticated and Guest users will be granted limited permissions within these levels. Even after adding this configuration via the CLI, all Storage access is still public by default. To guard against accidental public access, the Storage access levels must either be configured globally in the configuration, or set within individual method calls. This guide uses the latter approach, setting Storage access to private per method call.

The ability to independently configure authorization rules for each category allows for more granular control over data access, and adds greater flexibility. For scenarios where authorization patterns must be mixed and matched, configure the access level on individual Storage method calls. For example, you may want to use private CRUD access on an individual Storage method call for files that should only be accessible by the owner (such as personal files), protected read access to allow all logged in users to view common files (such as images in a shared photo album), and public read access to allow all users to view a file (such as a public profile picture).

For more details on how to configure Storage authorization levels, see the Storage documentation. For more on configuring GraphQL API authorization, see the API documentation.

Create a record with an associated file

First create a record via the GraphQL API, then upload the file to Storage, and finally add the association between the record and file. Use the following example with the GraphQL API and Storage categories to create a record and associate the file with the record.

Make sure that the file key used for Storage is unique. In this case, the API record's id is used as the file key for Storage to ensure uniqueness so that multiple API records will not be associated with the same file key unintentionally. However, this may not be true if the API record's id is customized to be a combination of two or more fields, or a different primary key field is used.

1let song = Song(name: name)
2
3guard let imageData = artCover.pngData() else {
4 print("Could not get data from UIImage.")
5 return
6}
7
8// Create the song record
9let result = try await Amplify.API.mutate(request: .create(song))
10guard case .success(var createdSong) = result else {
11 print("Failed with error: ", result)
12 return
13}
14
15// Upload the art cover image
16_ = try await Amplify.Storage.uploadData(key: createdSong.id,
17 data: imageData,
18 options: .init(accessLevel: .private)).value
19
20// Update the song record with the image key
21createdSong.coverArtKey = createdSong.id
22let updateResult = try await Amplify.API.mutate(request: .update(createdSong))
23guard case .success(let updatedSong) = updateResult else {
24 print("Failed with error: ", updateResult)
25 return
26}

Add or update a file for an associated record

To associate a new or different file with the record, update the existing record with the file key. The following example uploads the file using Storage and updates the record with the file's key. If an image is already associated with the record, this will update the record with the new image.

1// Upload the new art image
2_ = try await Amplify.Storage.uploadData(key: currentSong.id,
3 data: imageData,
4 options: .init(accessLevel: .private)).value
5
6// Update the song record
7currentSong.coverArtKey = currentSong.id
8let result = try await Amplify.API.mutate(request: .update(currentSong))
9guard case .success(let updatedSong) = result else {
10 print("Failed with error: ", result)
11 return
12}

Query a record and retrieve the associated file

To retrieve the file associated with a record, first query the record, then use Storage to download the data to display an image:

1// Get the song record
2let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
3guard case .success(let queriedSong) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard let song = queriedSong else {
8 print("Song may have been deleted, no song with id: ", currentSong.id)
9 return
10}
11
12guard let coverArtKey = song.coverArtKey else {
13 print("Song does not contain cover art")
14 return
15}
16
17// Download the art cover
18let imageData = try await Amplify.Storage.downloadData(key: coverArtKey,
19 options: .init(accessLevel: .private)).value
20let image = UIImage(data: imageData)

Delete and remove files associated with API records

There are three common deletion workflows when working with Storage files and the GraphQL API:

  1. Remove the file association, continue to persist both file and record.
  2. Remove the file association and delete the file.
  3. Delete both file and record.

Remove the file association, continue to persist both file and record

The following example removes the file association from the record, but does not delete the file from S3 or the record from the DynamoDB instance.

1// Get the song record
2let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
3guard case .success(let queriedSong) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard var song = queriedSong else {
8 print("Song may have been deleted, no song by id: ", currentSong.id)
9 return
10}
11guard song.coverArtKey != nil else {
12 print("There is no cover art key to remove image association")
13 return
14}
15
16// Set the association to nil and update it
17song.coverArtKey = nil
18let updateResult = try await Amplify.API.mutate(request: .update(song))
19guard case .success(let updatedSong) = updateResult else {
20 print("Failed with error: ", result)
21 return
22}

Remove the file association and delete the file

The following example removes the file from the record, then deletes the file from S3:

1// Get the song record
2let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
3guard case .success(let queriedSong) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard var song = queriedSong else {
8 print("Song may have been deleted, no song by id: ", currentSong.id)
9 return
10}
11guard let coverArtKey = song.coverArtKey else {
12 print("There is no cover art key to remove image association")
13 return
14}
15
16// Set the association to nil and update it
17song.coverArtKey = nil
18let updateResult = try await Amplify.API.mutate(request: .update(song))
19guard case .success(let updatedSong) = updateResult else {
20 print("Failed with error: ", result)
21 return
22}
23
24// Remove the image
25try await Amplify.Storage.remove(key: coverArtKey,
26 options: .init(accessLevel: .private))

Delete both file and record

The following example deletes the record from DynamoDB and then deletes the file from S3:

1// Get the song record
2let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
3guard case .success(let queriedSong) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard let song = queriedSong else {
8 print("Song may have been deleted, no song by id: ", currentSong.id)
9 return
10}
11
12if let coverArt = song.coverArtKey {
13 // Remove the image
14 try await Amplify.Storage.remove(key: coverArt,
15 options: .init(accessLevel: .private))
16}
17
18// Delete the song record
19let deleteResult = try await Amplify.API.mutate(request: .delete(song))
20guard case .success = deleteResult else {
21 print("Failed with error: ", deleteResult)
22 return
23}

Working with multiple files

You may want to add multiple files to a single record, such as a user profile with multiple images. To do this, you can add a list of file keys to the record. The following example adds a list of file keys to a record:

GraphQL schema to associate a data model with multiple files

When prompted after running amplify add api use the following schema, which can also be found under amplify/backend/api/[name of project]/schema.graphql:

1type PhotoAlbum @model @auth(rules: [{ allow: public }]) {
2 id: ID!
3 name: String!
4 imageKeys: [String] #Set as optional to allow adding file(s) after initial create
5}

CRUD operations when working with multiple files is the same as when working with a single file, with the exception that we are now working with a list of image keys, as opposed to a single image key.

Create a record with multiple associated files

First create a record via the GraphQL API, then upload the files to Storage, and finally add the associations between the record and files.

1// Create the photo album record
2let album = PhotoAlbum(name: name)
3let result = try await Amplify.API.mutate(request: .create(album))
4guard case .success(var createdAlbum) = result else {
5 print("Failed with error: ", result)
6 return
7}
8
9// Upload the photo album images
10let imageKeys = await withTaskGroup(of: String?.self) { group in
11 for imageData in imagesData {
12 group.addTask {
13 let key = "\(album.id)-\(UUID().uuidString)"
14 do {
15 _ = try await Amplify.Storage.uploadData(key: key,
16 data: imageData,
17 options: .init(accessLevel: .private)).value
18 return key
19 } catch {
20 print("Failed with error:", error)
21 return nil
22 }
23 }
24 }
25
26 var imageKeys: [String?] = []
27 for await imageKey in group {
28 imageKeys.append(imageKey)
29 }
30 return imageKeys.compactMap { $0 }
31}
32
33// Update the album with the image keys
34createdAlbum.imageKeys = imageKeys
35let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
36guard case .success(let updatedAlbum) = updateResult else {
37 print("Failed with error: ", updateResult)
38 return
39}

Create a record with a single associated file

When a schema allows for multiple associated images, you can still create a record with a single associated file.

1// Create the photo album record
2let album = PhotoAlbum(name: name)
3let result = try await Amplify.API.mutate(request: .create(album))
4guard case .success(var createdAlbum) = result else {
5 print("Failed with error: ", result)
6 return
7}
8
9// Upload the photo album image
10let key = "\(album.id)-\(UUID().uuidString)"
11_ = try await Amplify.Storage.uploadData(key: key,
12 data: imageData,
13 options: .init(accessLevel: .private)).value
14
15// Update the album with the image key
16createdAlbum.imageKeys = [key]
17let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
18guard case .success(let updatedAlbum) = updateResult else {
19 print("Failed with error: ", updateResult)
20 return
21}

Add new files to an associated record

To associate additional files with a record, update the record with the keys returned by the Storage uploads.

1// Upload the new photo album image
2let key = "\(currentAlbum.id)-\(UUID().uuidString)"
3_ = try await Amplify.Storage.uploadData(key: key,
4 data: imageData,
5 options: .init(accessLevel: .private)).value
6
7// Get the latest album
8let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
9guard case .success(let queriedAlbum) = result else {
10 print("Failed with error: ", result)
11 return
12}
13guard var album = queriedAlbum else {
14 print("Album may have been deleted, no album with id: ", currentAlbum.id)
15 return
16}
17
18guard var imageKeys = album.imageKeys else {
19 print("Album does not contain images")
20 return
21}
22
23// Add new to the existing keys
24imageKeys.append(key)
25
26// Update the album with the image keys
27album.imageKeys = imageKeys
28let updateResult = try await Amplify.API.mutate(request: .update(album))
29guard case .success(let updatedAlbum) = updateResult else {
30 print("Failed with error: ", updateResult)
31 return
32}

Update the file for an associated record

Updating a file for an associated record is the same as updating a file for a single file record, with the exception that you will need to update the list of file keys. The following replaces the last image in the album with a new image.

1// Upload the new photo album image
2let key = "\(currentAlbum.id)-\(UUID().uuidString)"
3_ = try await Amplify.Storage.uploadData(key: key,
4 data: imageData,
5 options: .init(accessLevel: .private)).value
6
7// Update the album with the image keys
8var album = currentAlbum
9if var imageKeys = album.imageKeys {
10 imageKeys.removeLast()
11 imageKeys.append(key)
12 album.imageKeys = imageKeys
13} else {
14 album.imageKeys = [key]
15}
16let updateResult = try await Amplify.API.mutate(request: .update(album))
17guard case .success(let updatedAlbum) = updateResult else {
18 print("Failed with error: ", updateResult)
19 return
20}

Query a record and retrieve the associated files

To retrieve the files associated with a record, first query the record, then use Storage to retrieve all the images.

1// Get the song record
2let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
3guard case .success(let queriedAlbum) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard let album = queriedAlbum else {
8 print("Album may have been deleted, no album with id: ", currentAlbum.id)
9 return
10}
11
12guard let imageKeysOptional = album.imageKeys else {
13 print("Album does not contain images")
14 return
15}
16let imageKeys = imageKeysOptional.compactMap { $0 }
17
18// Download the photos
19let images = await withTaskGroup(of: UIImage?.self) { group in
20 for key in imageKeys {
21 group.addTask {
22 do {
23 let imageData = try await Amplify.Storage.downloadData(key: key,
24 options: .init(accessLevel: .private)).value
25 return UIImage(data: imageData)
26 } catch {
27 print("Failed with error:", error)
28 return nil
29 }
30 }
31 }
32
33 var images: [UIImage?] = []
34 for await image in group {
35 images.append(image)
36 }
37 return images.compactMap { $0 }
38}

Delete and remove files associated with API records

The workflow for deleting and removing files associated with API records is the same as when working with a single file, except that when performing a delete you will need to iterate over the list of files keys and call Storage.remove() for each file.

Remove the file association, keep the persisted file and record

1// Get the album record
2let result = try await Amplify.API.mutate(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
3guard case .success(let queriedAlbum) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard var album = queriedAlbum else {
8 print("Song may have been deleted, no song by id: ", currentAlbum.id)
9 return
10}
11guard let imageKeys = album.imageKeys, !imageKeys.isEmpty else {
12 print("There are no images to remove association")
13 return
14}
15
16// Set the association to nil and update it
17album.imageKeys = nil
18let updateResult = try await Amplify.API.mutate(request: .update(album))
19guard case .success(let updatedAlbum) = updateResult else {
20 print("Failed with error: ", result)
21 return
22}

Remove the file association and delete the files

1// Get the album record
2let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
3guard case .success(let queriedAlbum) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard let album = queriedAlbum else {
8 print("Album may have been deleted, no album with id: ", currentAlbum.id)
9 return
10}
11
12guard let imageKeysOptional = album.imageKeys else {
13 print("Album does not contain images")
14 return
15}
16let imageKeys = imageKeysOptional.compactMap { $0 }
17
18// Set the associations to nil and update it
19album.imageKeys = nil
20let updateResult = try await Amplify.API.mutate(request: .update(album))
21guard case .success(let updatedAlbum) = updateResult else {
22 print("Failed with error: ", result)
23 return
24}
25
26// Remove the photos
27await withTaskGroup(of: Void.self) { group in
28 for key in imageKeys {
29 group.addTask {
30 do {
31 try await Amplify.Storage.remove(key: key,
32 options: .init(accessLevel: .private))
33 } catch {
34 print("Failed with error:", error)
35 }
36 }
37 }
38
39
40 for await _ in group {
41 }
42}

Delete the record and all associated files

1// Get the album record
2let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
3guard case .success(let queriedAlbum) = result else {
4 print("Failed with error: ", result)
5 return
6}
7guard let album = queriedAlbum else {
8 print("Album may have been deleted, no album with id: ", currentAlbum.id)
9 return
10}
11
12guard let imageKeysOptional = album.imageKeys else {
13 print("Album does not contain images")
14
15 // Delete the album record
16 let deleteResult = try await Amplify.API.mutate(request: .delete(album))
17 guard case .success = deleteResult else {
18 print("Failed with error: ", deleteResult)
19 return
20 }
21 return
22}
23let imageKeys = imageKeysOptional.compactMap { $0 }
24
25// Remove the photos
26await withTaskGroup(of: Void.self) { group in
27 for key in imageKeys {
28 group.addTask {
29 do {
30 try await Amplify.Storage.remove(key: key,
31 options: .init(accessLevel: .private))
32 } catch {
33 print("Failed with error:", error)
34 }
35 }
36 }
37
38
39 for await _ in group {
40 }
41
42}
43
44// Delete the album record
45let deleteResult = try await Amplify.API.mutate(request: .delete(album))
46guard case .success = deleteResult else {
47 print("Failed with error: ", deleteResult)
48 return
49}

Data consistency when working with records and files

The access patterns in this guide attempt to remove deleted files, but favor leaving orphans over leaving records that point to non-existent files. This optimizes for read latency by ensuring clients rarely attempt to fetch a non-existent file from Storage. However, any app that deletes files can inherently cause records on-device to point to non-existent files.

One example is when we create an API record, associate the Storage file with that record. "Device A" calls the GraphQL API to create API_Record_1, and then associates that record with First_Photo. Later, when "Device A" is about to retrieve the file, "Device B" might query API_Record_1, delete First_Photo, and update the record accordingly. However, "Device A" is still using the old API_Record_1, which is now out-of-date. Even though the shared global state is correctly in sync at every stage, the individual device ("Device A") has an out-of-date record that points to a non-existent file. Similar issues can conceivably occur for updates. Depending on your app, some of these mismatches can be minimized even more with real-time data / GraphQL subscriptions.

It is important to understand when these mismatches can occur and to add meaningful error handling around these cases. This guide does not include exhaustive error handling, real-time subscriptions, re-querying of outdated records, or attempts to retry failed operations. However, these are all important considerations for a production-level application.

Complete examples

1import SwiftUI
2import Amplify
3import AWSAPIPlugin
4import AWSCognitoAuthPlugin
5import AWSS3StoragePlugin
6import Authenticator
7import PhotosUI
8
9@main
10struct WorkingWithFilesApp: App {
11
12 init() {
13 do {
14 Amplify.Logging.logLevel = .verbose
15 try Amplify.add(plugin: AWSCognitoAuthPlugin())
16 try Amplify.add(plugin: AWSS3StoragePlugin())
17 try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
18 try Amplify.configure()
19 print("Amplify configured with API, Storage, and Auth plugins!")
20 } catch {
21 print("Failed to initialize Amplify with \(error)")
22 }
23 }
24
25 var body: some Scene {
26 WindowGroup {
27 Authenticator { state in
28 TabView {
29 SongView()
30 .tabItem {
31 Label("Song", systemImage: "music.note")
32 }
33
34 PhotoAlbumView()
35 .tabItem {
36 Label("PhotoAlbum", systemImage: "photo")
37 }
38 }
39
40 }
41 }
42 }
43}
44
45struct SignOutButton: View {
46 var body: some View {
47 Button("Sign out") {
48 Task {
49 await Amplify.Auth.signOut()
50 }
51 }.foregroundColor(.black)
52 }
53}
54
55struct TappedButtonStyle: ButtonStyle {
56 func makeBody(configuration: Configuration) -> some View {
57 configuration.label
58 .padding(10)
59 .background(configuration.isPressed ? Color.teal.opacity(0.8) : Color.teal)
60 .foregroundColor(.white)
61 .clipShape(RoundedRectangle(cornerRadius: 10))
62 }
63}
64
65extension Color {
66 static let teal = Color(red: 45/255, green: 111/255, blue: 138/255)
67}
68
69struct DimmedBackgroundView: View {
70 var body: some View {
71 Color.gray.opacity(0.5)
72 .ignoresSafeArea()
73 }
74}
75
76struct ImagePicker: UIViewControllerRepresentable {
77 @Binding var selectedImage: UIImage?
78 @Environment(\.presentationMode) var presentationMode
79
80 class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
81 let parent: ImagePicker
82
83 init(_ parent: ImagePicker) {
84 self.parent = parent
85 }
86
87 func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
88 if let uiImage = info[.originalImage] as? UIImage {
89 parent.selectedImage = uiImage
90 }
91 parent.presentationMode.wrappedValue.dismiss()
92 }
93
94 func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
95 parent.presentationMode.wrappedValue.dismiss()
96 }
97 }
98
99 func makeCoordinator() -> Coordinator {
100 Coordinator(self)
101 }
102
103 func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
104 let imagePicker = UIImagePickerController()
105 imagePicker.delegate = context.coordinator
106 return imagePicker
107 }
108
109 func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
110 }
111}
112
113struct MultiImagePicker: UIViewControllerRepresentable {
114 @Binding var selectedImages: [UIImage]
115
116 func makeUIViewController(context: Context) -> PHPickerViewController {
117 var configuration = PHPickerConfiguration()
118 configuration.filter = .images
119 configuration.selectionLimit = 0
120
121 let picker = PHPickerViewController(configuration: configuration)
122 picker.delegate = context.coordinator
123 return picker
124 }
125
126 func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
127 // No need for updates in this case
128 }
129
130 func makeCoordinator() -> Coordinator {
131 Coordinator(parent: self)
132 }
133
134 class Coordinator: PHPickerViewControllerDelegate {
135 private let parent: MultiImagePicker
136
137 init(parent: MultiImagePicker) {
138 self.parent = parent
139 }
140
141 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
142 picker.dismiss(animated: true, completion: nil)
143 DispatchQueue.main.async {
144 self.parent.selectedImages = []
145 }
146 for result in results {
147 if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
148 result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
149 if let image = image as? UIImage {
150 DispatchQueue.main.async {
151 self.parent.selectedImages.append(image)
152 }
153 }
154 }
155 }
156 }
157 }
158 }
159}
1import SwiftUI
2import Amplify
3
4class SongViewModel: ObservableObject {
5
6 @Published var currentSong: Song? = nil
7 @Published var currentImage: UIImage? = nil
8 @Published var isLoading: Bool = false
9
10 // Create a song with an associated image
11 func createSong(name: String, artCover: UIImage) async throws {
12 await setIsLoading(true)
13 defer {
14 Task {
15 await setIsLoading(false)
16 }
17 }
18 let song = Song(name: name)
19
20 guard let imageData = artCover.pngData() else {
21 print("Could not get data from image.")
22 return
23 }
24
25 // Create the song record
26 let result = try await Amplify.API.mutate(request: .create(song))
27 guard case .success(var createdSong) = result else {
28 print("Failed with error: ", result)
29 return
30 }
31
32 // Upload the art cover image
33 _ = try await Amplify.Storage.uploadData(key: createdSong.id,
34 data: imageData,
35 options: .init(accessLevel: .private)).value
36
37 // Update the song record with the image key
38 createdSong.coverArtKey = createdSong.id
39 let updateResult = try await Amplify.API.mutate(request: .update(createdSong))
40 guard case .success(let updatedSong) = updateResult else {
41 print("Failed with error: ", updateResult)
42 return
43 }
44
45 await setCurrentSong(updatedSong)
46 }
47
48 // Add or update an image for an associated record
49 func updateArtCover(artCover: UIImage) async throws {
50 await setIsLoading(true)
51 defer {
52 Task {
53 await setIsLoading(false)
54 }
55 }
56
57 guard var currentSong = currentSong else {
58 print("There is no song to associated the image with. Create a Song first.")
59 return
60 }
61 guard let imageData = artCover.pngData() else {
62 print("Could not get data from UIImage.")
63 return
64 }
65
66 // Upload the new art image
67 _ = try await Amplify.Storage.uploadData(key: currentSong.id,
68 data: imageData,
69 options: .init(accessLevel: .private)).value
70
71 // Update the song record
72 currentSong.coverArtKey = currentSong.id
73 let result = try await Amplify.API.mutate(request: .update(currentSong))
74 guard case .success(let updatedSong) = result else {
75 print("Failed with error: ", result)
76 return
77 }
78
79 await setCurrentSong(updatedSong)
80 }
81
82 func refreshSongAndArtCover() async throws {
83 await setIsLoading(true)
84 defer {
85 Task {
86 await setIsLoading(false)
87 }
88 }
89 guard let currentSong = currentSong else {
90 print("There is no song to refresh the record and image. Create a song first.")
91 return
92 }
93 await setCurrentSong(nil)
94 await setCurrentImage(nil)
95
96 // Get the song record
97 let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
98 guard case .success(let queriedSong) = result else {
99 print("Failed with error: ", result)
100 return
101 }
102 guard let song = queriedSong else {
103 print("Song may have been deleted, no song with id: ", currentSong.id)
104 await setCurrentSong(nil)
105 return
106 }
107
108 guard let coverArtKey = song.coverArtKey else {
109 print("Song does not contain cover art")
110 await setCurrentSong(song)
111 await setCurrentImage(nil)
112 return
113 }
114
115 // Download the art cover
116 let imageData = try await Amplify.Storage.downloadData(key: coverArtKey,
117 options: .init(accessLevel: .private)).value
118 let image = UIImage(data: imageData)
119 await setCurrentSong(song)
120 await setCurrentImage(image)
121 }
122
123 func removeImageAssociationFromSong() async throws {
124 await setIsLoading(true)
125 defer {
126 Task {
127 await setIsLoading(false)
128 }
129 }
130 guard let currentSong = currentSong else {
131 print("There is no song to remove art cover from it. Create a song first.")
132 return
133 }
134
135 // Get the song record
136 let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
137 guard case .success(let queriedSong) = result else {
138 print("Failed with error: ", result)
139 return
140 }
141 guard var song = queriedSong else {
142 print("Song may have been deleted, no song by id: ", currentSong.id)
143 await setCurrentSong(nil)
144 return
145 }
146 guard song.coverArtKey != nil else {
147 print("There is no cover art key to remove image association")
148 return
149 }
150
151 // Set the association to nil and update it
152 song.coverArtKey = nil
153 let updateResult = try await Amplify.API.mutate(request: .update(song))
154 guard case .success(let updatedSong) = updateResult else {
155 print("Failed with error: ", result)
156 return
157 }
158
159 await setCurrentSong(updatedSong)
160 }
161
162 func removeImageAssociationAndDeleteImage() async throws {
163 await setIsLoading(true)
164 defer {
165 Task {
166 await setIsLoading(false)
167 }
168 }
169 guard let currentSong = currentSong else {
170 print("There is no song to remove art cover from it. Create a song first.")
171 return
172 }
173
174 // Get the song record
175 let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
176 guard case .success(let queriedSong) = result else {
177 print("Failed with error: ", result)
178 return
179 }
180 guard var song = queriedSong else {
181 print("Song may have been deleted, no song by id: ", currentSong.id)
182 await setCurrentSong(nil)
183 return
184 }
185 guard let coverArtKey = song.coverArtKey else {
186 print("There is no cover art key to remove image association")
187 return
188 }
189
190 // Set the association to nil and update it
191 song.coverArtKey = nil
192 let updateResult = try await Amplify.API.mutate(request: .update(song))
193 guard case .success(let updatedSong) = updateResult else {
194 print("Failed with error: ", result)
195 return
196 }
197
198 // Remove the image
199 try await Amplify.Storage.remove(key: coverArtKey,
200 options: .init(accessLevel: .private))
201
202 await setCurrentSong(updatedSong)
203 await setCurrentImage(nil)
204 }
205
206 func deleteSongAndArtCover() async throws {
207 await setIsLoading(true)
208 defer {
209 Task {
210 await setIsLoading(false)
211 }
212 }
213 guard let currentSong = currentSong else {
214 print("There is no song to delete. Create a song first.")
215 return
216 }
217
218 // Get the song record
219 let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
220 guard case .success(let queriedSong) = result else {
221 print("Failed with error: ", result)
222 return
223 }
224 guard let song = queriedSong else {
225 print("Song may have been deleted, no song by id: ", currentSong.id)
226 await setCurrentSong(nil)
227 return
228 }
229
230 if let coverArt = song.coverArtKey {
231 // Remove the image
232 try await Amplify.Storage.remove(key: coverArt,
233 options: .init(accessLevel: .private))
234 }
235
236 // Delete the song record
237 let deleteResult = try await Amplify.API.mutate(request: .delete(song))
238 guard case .success = deleteResult else {
239 print("Failed with error: ", deleteResult)
240 return
241 }
242 await setCurrentSong(nil)
243 await setCurrentImage(nil)
244 }
245
246 @MainActor
247 func setCurrentSong(_ song: Song?) {
248 self.currentSong = song
249 }
250
251 @MainActor
252 func setCurrentImage(_ image: UIImage?) {
253 self.currentImage = image
254 }
255
256 @MainActor
257 func setIsLoading(_ isLoading: Bool) {
258 self.isLoading = isLoading
259 }
260}
261
262struct SongView: View {
263
264 @State private var isImagePickerPresented = false
265 @State private var songName: String = ""
266
267 @StateObject var viewModel = SongViewModel()
268
269 var body: some View {
270 NavigationView {
271 ZStack {
272 VStack {
273 SongInformation()
274 DisplayImage()
275 OpenImagePickerButton()
276 SongNameTextField()
277 CreateOrUpdateSongButton()
278 AdditionalOperations()
279 Spacer()
280 }
281 .padding()
282 .sheet(isPresented: $isImagePickerPresented) {
283 ImagePicker(selectedImage: $viewModel.currentImage)
284 }
285 VStack {
286 IsLoadingView()
287 }
288 }
289 .navigationBarItems(trailing: SignOutButton())
290 }
291 }
292
293 @ViewBuilder
294 func SongInformation() -> some View {
295 if let song = viewModel.currentSong {
296 Text("Song Id: \(song.id)").font(.caption)
297 if song.name != "" {
298 Text("Song Name: \(song.name)").font(.caption)
299 }
300 }
301 }
302
303 @ViewBuilder
304 func DisplayImage() -> some View {
305 if let image = viewModel.currentImage {
306 Image(uiImage: image)
307 .resizable()
308 .aspectRatio(contentMode: .fit)
309 } else {
310 Text("No Image Selected")
311 .foregroundColor(.gray)
312 }
313
314 }
315
316 func OpenImagePickerButton() -> some View {
317 Button("Select \(viewModel.currentImage != nil ? "a new ": "" )song album cover") {
318 isImagePickerPresented.toggle()
319 }.buttonStyle(TappedButtonStyle())
320 }
321
322 @ViewBuilder
323 func SongNameTextField() -> some View {
324 TextField("\(viewModel.currentSong != nil ? "Update": "Enter") song name", text: $songName)
325 .textFieldStyle(RoundedBorderTextFieldStyle())
326 .multilineTextAlignment(.center)
327 }
328
329 @ViewBuilder
330 func CreateOrUpdateSongButton() -> some View {
331 if viewModel.currentSong == nil, let image = viewModel.currentImage {
332 Button("Save") {
333 Task {
334 try? await viewModel.createSong(name: songName,
335 artCover: image)
336 }
337 }
338 .buttonStyle(TappedButtonStyle())
339 .disabled(viewModel.isLoading)
340 } else if viewModel.currentSong != nil, let image = viewModel.currentImage {
341 Button("Update") {
342 Task {
343 try? await viewModel.updateArtCover(artCover: image)
344 }
345 }
346 .buttonStyle(TappedButtonStyle())
347 .disabled(viewModel.isLoading)
348 }
349 }
350
351 @ViewBuilder
352 func AdditionalOperations() -> some View {
353 if viewModel.currentSong != nil {
354 VStack {
355 Button("Refresh") {
356 Task {
357 try? await viewModel.refreshSongAndArtCover()
358 }
359 }.buttonStyle(TappedButtonStyle())
360 Button("Remove association from song") {
361 Task {
362 try? await viewModel.removeImageAssociationFromSong()
363 }
364 }.buttonStyle(TappedButtonStyle())
365 Button("Remove association and delete image") {
366 Task {
367 try? await viewModel.removeImageAssociationAndDeleteImage()
368 }
369 }.buttonStyle(TappedButtonStyle())
370 Button("Delete song and art cover") {
371 Task {
372 try? await viewModel.deleteSongAndArtCover()
373 }
374 songName = ""
375 }.buttonStyle(TappedButtonStyle())
376 }.disabled(viewModel.isLoading)
377 }
378 }
379
380 @ViewBuilder
381 func IsLoadingView() -> some View {
382 if viewModel.isLoading {
383 ZStack {
384 DimmedBackgroundView()
385 ProgressView()
386 }
387 }
388 }
389}
390
391struct SongView_Previews: PreviewProvider {
392 static var previews: some View {
393 SongView()
394 }
395}
1import SwiftUI
2import Amplify
3import Photos
4
5class PhotoAlbumViewModel: ObservableObject {
6 @Published var currentImages: [UIImage] = []
7 @Published var currentAlbum: PhotoAlbum? = nil
8 @Published var isLoading: Bool = false
9
10 // Create a record with multiple associated files
11 func createPhotoAlbum(name: String, photos: [UIImage]) async throws {
12 await setIsLoading(true)
13 defer {
14 Task {
15 await setIsLoading(false)
16 }
17 }
18
19 let imagesData = photos.compactMap { $0.pngData() }
20 guard !imagesData.isEmpty else {
21 print("Could not get data from [UIImage]")
22 return
23 }
24
25 // Create the photo album record
26 let album = PhotoAlbum(name: name)
27 let result = try await Amplify.API.mutate(request: .create(album))
28 guard case .success(var createdAlbum) = result else {
29 print("Failed with error: ", result)
30 return
31 }
32
33 // Upload the photo album images
34 let imageKeys = await withTaskGroup(of: String?.self) { group in
35 for imageData in imagesData {
36 group.addTask {
37 let key = "\(album.id)-\(UUID().uuidString)"
38 do {
39 _ = try await Amplify.Storage.uploadData(key: key,
40 data: imageData,
41 options: .init(accessLevel: .private)).value
42 return key
43 } catch {
44 print("Failed with error:", error)
45 return nil
46 }
47 }
48 }
49
50 var imageKeys: [String?] = []
51 for await imageKey in group {
52 imageKeys.append(imageKey)
53 }
54 return imageKeys.compactMap { $0 }
55 }
56
57 // Update the album with the image keys
58 createdAlbum.imageKeys = imageKeys
59 let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
60 guard case .success(let updatedAlbum) = updateResult else {
61 print("Failed with error: ", updateResult)
62 return
63 }
64
65 await setCurrentAlbum(updatedAlbum)
66 }
67
68 // Create a record with a single associated file
69 func createPhotoAlbum(name: String, photo: UIImage) async throws {
70 await setIsLoading(true)
71 defer {
72 Task {
73 await setIsLoading(false)
74 }
75 }
76
77 guard let imageData = photo.pngData() else {
78 print("Could not get data from UIImage")
79 return
80 }
81
82 // Create the photo album record
83 let album = PhotoAlbum(name: name)
84 let result = try await Amplify.API.mutate(request: .create(album))
85 guard case .success(var createdAlbum) = result else {
86 print("Failed with error: ", result)
87 return
88 }
89
90 // Upload the photo album image
91 let key = "\(album.id)-\(UUID().uuidString)"
92 _ = try await Amplify.Storage.uploadData(key: key,
93 data: imageData,
94 options: .init(accessLevel: .private)).value
95
96 // Update the album with the image key
97 createdAlbum.imageKeys = [key]
98 let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
99 guard case .success(let updatedAlbum) = updateResult else {
100 print("Failed with error: ", updateResult)
101 return
102 }
103
104 await setCurrentAlbum(updatedAlbum)
105 }
106
107 // Add new file to an associated record
108 func addAdditionalPhotos(_ photo: UIImage) async throws {
109 await setIsLoading(true)
110 defer {
111 Task {
112 await setIsLoading(false)
113 }
114 }
115
116 guard let currentAlbum = currentAlbum else {
117 print("There is no album to associated the images with. Create an Album first.")
118 return
119 }
120
121 guard let imageData = photo.pngData() else {
122 print("Could not get data from UIImage.")
123 return
124 }
125
126 // Upload the new photo album image
127 let key = "\(currentAlbum.id)-\(UUID().uuidString)"
128 _ = try await Amplify.Storage.uploadData(key: key,
129 data: imageData,
130 options: .init(accessLevel: .private)).value
131
132 // Get the latest album
133 let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
134 guard case .success(let queriedAlbum) = result else {
135 print("Failed with error: ", result)
136 return
137 }
138 guard var album = queriedAlbum else {
139 print("Album may have been deleted, no album with id: ", currentAlbum.id)
140 await setCurrentAlbum(nil)
141 return
142 }
143
144 guard var imageKeys = album.imageKeys else {
145 print("Album does not contain images")
146 await setCurrentAlbum(album)
147 await setCurrentImages([])
148 return
149 }
150
151 // Add new to the existing keys
152 imageKeys.append(key)
153
154 // Update the album with the image keys
155 album.imageKeys = imageKeys
156 let updateResult = try await Amplify.API.mutate(request: .update(album))
157 guard case .success(let updatedAlbum) = updateResult else {
158 print("Failed with error: ", updateResult)
159 return
160 }
161
162 await setCurrentAlbum(updatedAlbum)
163 }
164
165 func replaceLastImage(_ photo: UIImage) async throws {
166 await setIsLoading(true)
167 defer {
168 Task {
169 await setIsLoading(false)
170 }
171 }
172
173 guard let currentAlbum = currentAlbum else {
174 print("There is no album to associated the images with. Create an Album first.")
175 return
176 }
177
178 guard let imageData = photo.pngData() else {
179 print("Could not get data from UIImage")
180 return
181 }
182
183
184 // Upload the new photo album image
185 let key = "\(currentAlbum.id)-\(UUID().uuidString)"
186 _ = try await Amplify.Storage.uploadData(key: key,
187 data: imageData,
188 options: .init(accessLevel: .private)).value
189
190 // Update the album with the image keys
191 var album = currentAlbum
192 if var imageKeys = album.imageKeys {
193 imageKeys.removeLast()
194 imageKeys.append(key)
195 album.imageKeys = imageKeys
196 } else {
197 album.imageKeys = [key]
198 }
199 let updateResult = try await Amplify.API.mutate(request: .update(album))
200 guard case .success(let updatedAlbum) = updateResult else {
201 print("Failed with error: ", updateResult)
202 return
203 }
204
205 await setCurrentAlbum(updatedAlbum)
206 }
207
208 // Query a record and retrieve the associated files
209 func refreshAlbumAndPhotos() async throws {
210 await setIsLoading(true)
211 defer {
212 Task {
213 await setIsLoading(false)
214 }
215 }
216 guard let currentAlbum = currentAlbum else {
217 print("There is no album to associated the images with. Create an Album first.")
218 return
219 }
220 await setCurrentAlbum(nil)
221 await setCurrentImages([])
222
223 // Get the song record
224 let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
225 guard case .success(let queriedAlbum) = result else {
226 print("Failed with error: ", result)
227 return
228 }
229 guard let album = queriedAlbum else {
230 print("Album may have been deleted, no album with id: ", currentAlbum.id)
231 await setCurrentAlbum(nil)
232 return
233 }
234
235 guard let imageKeysOptional = album.imageKeys else {
236 print("Album does not contain images")
237 await setCurrentAlbum(album)
238 await setCurrentImages([])
239 return
240 }
241 let imageKeys = imageKeysOptional.compactMap { $0 }
242
243 // Download the photos
244 let images = await withTaskGroup(of: UIImage?.self) { group in
245 for key in imageKeys {
246 group.addTask {
247 do {
248 let imageData = try await Amplify.Storage.downloadData(key: key,
249 options: .init(accessLevel: .private)).value
250 return UIImage(data: imageData)
251 } catch {
252 print("Failed with error:", error)
253 return nil
254 }
255 }
256 }
257
258 var images: [UIImage?] = []
259 for await image in group {
260 images.append(image)
261 }
262 return images.compactMap { $0 }
263 }
264
265 await setCurrentAlbum(album)
266 await setCurrentImages(images)
267 }
268
269 // Remove the file association
270 func removeStorageAssociationsFromAlbum() async throws {
271 await setIsLoading(true)
272 defer {
273 Task {
274 await setIsLoading(false)
275 }
276 }
277 guard let currentAlbum = currentAlbum else {
278 print("There is no album to associated the images with. Create an Album first.")
279 return
280 }
281
282 // Get the album record
283 let result = try await Amplify.API.mutate(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
284 guard case .success(let queriedAlbum) = result else {
285 print("Failed with error: ", result)
286 return
287 }
288 guard var album = queriedAlbum else {
289 print("Song may have been deleted, no song by id: ", currentAlbum.id)
290 await setCurrentAlbum(nil)
291 return
292 }
293 guard let imageKeys = album.imageKeys, !imageKeys.isEmpty else {
294 print("There are no images to remove association")
295 return
296 }
297
298 // Set the association to nil and update it
299 album.imageKeys = nil
300 let updateResult = try await Amplify.API.mutate(request: .update(album))
301 guard case .success(let updatedAlbum) = updateResult else {
302 print("Failed with error: ", result)
303 return
304 }
305
306 await setCurrentAlbum(updatedAlbum)
307 }
308
309 // Remove the record association and delete the files
310 func removeStorageAssociationsAndDeletePhotos() async throws {
311 await setIsLoading(true)
312 defer {
313 Task {
314 await setIsLoading(false)
315 }
316 }
317
318 guard let currentAlbum = currentAlbum else {
319 print("There is no album to associated the images with. Create an Album first.")
320 return
321 }
322
323 // Get the album record
324 let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
325 guard case .success(let queriedAlbum) = result else {
326 print("Failed with error: ", result)
327 return
328 }
329 guard var album = queriedAlbum else {
330 print("Album may have been deleted, no album with id: ", currentAlbum.id)
331 await setCurrentAlbum(nil)
332 return
333 }
334
335 guard let imageKeysOptional = album.imageKeys else {
336 print("Album does not contain images")
337 await setCurrentAlbum(album)
338 await setCurrentImages([])
339 return
340 }
341 let imageKeys = imageKeysOptional.compactMap { $0 }
342
343 // Set the associations to nil and update it
344 album.imageKeys = nil
345 let updateResult = try await Amplify.API.mutate(request: .update(album))
346 guard case .success(let updatedAlbum) = updateResult else {
347 print("Failed with error: ", result)
348 return
349 }
350
351 // Remove the photos
352 await withTaskGroup(of: Void.self) { group in
353 for key in imageKeys {
354 group.addTask {
355 do {
356 try await Amplify.Storage.remove(key: key,
357 options: .init(accessLevel: .private))
358 } catch {
359 print("Failed with error:", error)
360 }
361 }
362 }
363
364 for await _ in group {
365 }
366 }
367
368 await setCurrentAlbum(updatedAlbum)
369 await setCurrentImages([])
370 }
371
372 // Delete record and all associated files
373 func deleteAlbumAndPhotos() async throws {
374 await setIsLoading(true)
375 defer {
376 Task {
377 await setIsLoading(false)
378 }
379 }
380
381 guard let currentAlbum = currentAlbum else {
382 print("There is no album to associated the images with. Create an Album first.")
383 return
384 }
385
386 // Get the album record
387 let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
388 guard case .success(let queriedAlbum) = result else {
389 print("Failed with error: ", result)
390 return
391 }
392 guard let album = queriedAlbum else {
393 print("Album may have been deleted, no album with id: ", currentAlbum.id)
394 await setCurrentAlbum(nil)
395 return
396 }
397
398 guard let imageKeysOptional = album.imageKeys else {
399 print("Album does not contain images")
400
401 // Delete the album record
402 let deleteResult = try await Amplify.API.mutate(request: .delete(album))
403 guard case .success = deleteResult else {
404 print("Failed with error: ", deleteResult)
405 return
406 }
407
408 await setCurrentAlbum(nil)
409 await setCurrentImages([])
410 return
411 }
412 let imageKeys = imageKeysOptional.compactMap { $0 }
413
414 // Remove the photos
415 await withTaskGroup(of: Void.self) { group in
416 for key in imageKeys {
417 group.addTask {
418 do {
419 try await Amplify.Storage.remove(key: key,
420 options: .init(accessLevel: .private))
421 } catch {
422 print("Failed with error:", error)
423 }
424 }
425 }
426
427 for await _ in group {
428 }
429 }
430
431 // Delete the album record
432 let deleteResult = try await Amplify.API.mutate(request: .delete(album))
433 guard case .success = deleteResult else {
434 print("Failed with error: ", deleteResult)
435 return
436 }
437
438 await setCurrentAlbum(nil)
439 await setCurrentImages([])
440 }
441
442 @MainActor
443 func setCurrentAlbum(_ album: PhotoAlbum?) {
444 self.currentAlbum = album
445 }
446
447 @MainActor
448 func setCurrentImages(_ images: [UIImage]) {
449 self.currentImages = images
450 }
451
452 @MainActor
453 func setIsLoading(_ isLoading: Bool) {
454 self.isLoading = isLoading
455 }
456}
457
458struct PhotoAlbumView: View {
459 @State private var isImagePickerPresented: Bool = false
460 @State private var albumName: String = ""
461 @State private var isLastImagePickerPresented = false
462 @State private var lastImage: UIImage? = nil
463 @StateObject var viewModel = PhotoAlbumViewModel()
464
465 var body: some View {
466 NavigationView {
467 ZStack {
468 VStack {
469 AlbumInformation()
470 DisplayImages()
471 OpenImagePickerButton()
472 PhotoAlbumNameTextField()
473 CreateOrUpdateAlbumButton()
474 AdditionalOperations()
475 }
476 .padding()
477 .sheet(isPresented: $isImagePickerPresented) {
478 MultiImagePicker(selectedImages: $viewModel.currentImages)
479 }
480 .sheet(isPresented: $isLastImagePickerPresented) {
481 ImagePicker(selectedImage: $lastImage)
482 }
483 VStack {
484 IsLoadingView()
485 }
486 }
487 .navigationBarItems(trailing: SignOutButton())
488 }
489 }
490
491 @ViewBuilder
492 func AlbumInformation() -> some View {
493 if let album = viewModel.currentAlbum {
494 Text("Album Id: \(album.id)").font(.caption)
495 if album.name != "" {
496 Text("Album Name: \(album.name)").font(.caption)
497 }
498 }
499 }
500
501 @ViewBuilder
502 func DisplayImages() -> some View {
503 // Display selected images
504 ScrollView(.horizontal) {
505 HStack {
506 ForEach($viewModel.currentImages, id: \.self) { image in
507 Image(uiImage: image.wrappedValue)
508 .resizable()
509 .aspectRatio(contentMode: .fit)
510 .frame(width: 100, height: 100)
511 }
512 }
513 }
514 if $viewModel.currentImages.isEmpty {
515 Text("No Images Selected")
516 .foregroundColor(.gray)
517 }
518 }
519
520 func OpenImagePickerButton() -> some View {
521 // Button to open the image picker
522 Button("Select \(!viewModel.currentImages.isEmpty ? "new " : "")photo album images") {
523 isImagePickerPresented.toggle()
524 }.buttonStyle(TappedButtonStyle())
525 }
526
527 @ViewBuilder
528 func PhotoAlbumNameTextField() -> some View {
529 TextField("\(viewModel.currentAlbum != nil ? "Update": "Enter") album name", text: $albumName)
530 .textFieldStyle(RoundedBorderTextFieldStyle())
531 .multilineTextAlignment(.center)
532 }
533
534 @ViewBuilder
535 func CreateOrUpdateAlbumButton() -> some View {
536 if viewModel.currentAlbum == nil, !viewModel.currentImages.isEmpty {
537 Button("Save") {
538 Task {
539 try? await viewModel.createPhotoAlbum(name: albumName,
540 photos: viewModel.currentImages)
541 }
542 }
543 .buttonStyle(TappedButtonStyle())
544 .disabled(viewModel.isLoading)
545 } else if viewModel.currentAlbum != nil {
546 Button("Select \(lastImage != nil ? "another ": "")photo to replace last photo in the album") {
547 isLastImagePickerPresented.toggle()
548 }
549 .buttonStyle(TappedButtonStyle())
550 .disabled(viewModel.isLoading)
551
552 if let lastImage = lastImage {
553 Image(uiImage: lastImage)
554 .resizable()
555 .aspectRatio(contentMode: .fit)
556 Button("Replace last image in album with above") {
557 Task {
558 try? await viewModel.replaceLastImage(lastImage)
559 self.lastImage = nil
560 try? await viewModel.refreshAlbumAndPhotos()
561 }
562 }
563 .buttonStyle(TappedButtonStyle())
564 .disabled(viewModel.isLoading)
565 Button("Append above image to album") {
566 Task {
567 try? await viewModel.addAdditionalPhotos(lastImage)
568 self.lastImage = nil
569 try? await viewModel.refreshAlbumAndPhotos()
570 }
571 }
572 .buttonStyle(TappedButtonStyle())
573 .disabled(viewModel.isLoading)
574 }
575 }
576 }
577
578 @ViewBuilder
579 func AdditionalOperations() -> some View {
580 if viewModel.currentAlbum != nil {
581 VStack {
582 Button("Refresh") {
583 Task {
584 try? await viewModel.refreshAlbumAndPhotos()
585 }
586 }.buttonStyle(TappedButtonStyle())
587 Button("Remove associations from album") {
588 Task {
589 try? await viewModel.removeStorageAssociationsFromAlbum()
590 try? await viewModel.refreshAlbumAndPhotos()
591 }
592 }.buttonStyle(TappedButtonStyle())
593 Button("Remove association and delete photos") {
594 Task {
595 try? await viewModel.removeStorageAssociationsAndDeletePhotos()
596 try? await viewModel.refreshAlbumAndPhotos()
597 }
598 }.buttonStyle(TappedButtonStyle())
599 Button("Delete album and images") {
600 Task {
601 try? await viewModel.deleteAlbumAndPhotos()
602 }
603 albumName = ""
604 }.buttonStyle(TappedButtonStyle())
605 }.disabled(viewModel.isLoading)
606 }
607 }
608
609 @ViewBuilder
610 func IsLoadingView() -> some View {
611 if viewModel.isLoading {
612 ZStack {
613 DimmedBackgroundView()
614 ProgressView()
615 }
616 }
617 }
618}
619
620struct PhotoAlbumView_Previews: PreviewProvider {
621 static var previews: some View {
622 PhotoAlbumView()
623 }
624}