Amplify has re-imagined the way frontend developers build fullstack applications. Develop and deploy without the hassle.

Page updated Apr 29, 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:

amplify add api

Choose the following when prompted:

? Select from one of the below mentioned services: `GraphQL`
? Choose the default authorization type for the API `Amazon Cognito User Pool`
Do you want to use the default authentication and security configuration? `Default configuration`
How do you want users to be able to sign in? `Username`
Do you want to configure advanced settings? `No, I am done.`
? Here is the GraphQL API that we will create. Select a setting to edit or continue `Continue`
? 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:

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

Add Storage with the command:

amplify add storage

Choose the following when prompted:

? Select from one of the below mentioned services: `Content (Images, audio, video, etc.)`
โœ” Who should have access: `Auth users only`
โœ” What kind of access do you want for Authenticated users? `create/update, read, delete`
โœ” 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.

let song = Song(name: name)
guard let imageData = artCover.pngData() else {
print("Could not get data from UIImage.")
return
}
// Create the song record
let result = try await Amplify.API.mutate(request: .create(song))
guard case .success(var createdSong) = result else {
print("Failed with error: ", result)
return
}
// Upload the art cover image
_ = try await Amplify.Storage.uploadData(key: createdSong.id,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the song record with the image key
createdSong.coverArtKey = createdSong.id
let updateResult = try await Amplify.API.mutate(request: .update(createdSong))
guard case .success(let updatedSong) = updateResult else {
print("Failed with error: ", updateResult)
return
}

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.

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

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:

// Get the song record
let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard let song = queriedSong else {
print("Song may have been deleted, no song with id: ", currentSong.id)
return
}
guard let coverArtKey = song.coverArtKey else {
print("Song does not contain cover art")
return
}
// Download the art cover
let imageData = try await Amplify.Storage.downloadData(key: coverArtKey,
options: .init(accessLevel: .private)).value
let 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.

// Get the song record
let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard var song = queriedSong else {
print("Song may have been deleted, no song by id: ", currentSong.id)
return
}
guard song.coverArtKey != nil else {
print("There is no cover art key to remove image association")
return
}
// Set the association to nil and update it
song.coverArtKey = nil
let updateResult = try await Amplify.API.mutate(request: .update(song))
guard case .success(let updatedSong) = updateResult else {
print("Failed with error: ", result)
return
}

Remove the file association and delete the file

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

// Get the song record
let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard var song = queriedSong else {
print("Song may have been deleted, no song by id: ", currentSong.id)
return
}
guard let coverArtKey = song.coverArtKey else {
print("There is no cover art key to remove image association")
return
}
// Set the association to nil and update it
song.coverArtKey = nil
let updateResult = try await Amplify.API.mutate(request: .update(song))
guard case .success(let updatedSong) = updateResult else {
print("Failed with error: ", result)
return
}
// Remove the image
try await Amplify.Storage.remove(key: coverArtKey,
options: .init(accessLevel: .private))

Delete both file and record

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

// Get the song record
let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard let song = queriedSong else {
print("Song may have been deleted, no song by id: ", currentSong.id)
return
}
if let coverArt = song.coverArtKey {
// Remove the image
try await Amplify.Storage.remove(key: coverArt,
options: .init(accessLevel: .private))
}
// Delete the song record
let deleteResult = try await Amplify.API.mutate(request: .delete(song))
guard case .success = deleteResult else {
print("Failed with error: ", deleteResult)
return
}

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:

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

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.

// Create the photo album record
let album = PhotoAlbum(name: name)
let result = try await Amplify.API.mutate(request: .create(album))
guard case .success(var createdAlbum) = result else {
print("Failed with error: ", result)
return
}
// Upload the photo album images
let imageKeys = await withTaskGroup(of: String?.self) { group in
for imageData in imagesData {
group.addTask {
let key = "\(album.id)-\(UUID().uuidString)"
do {
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
return key
} catch {
print("Failed with error:", error)
return nil
}
}
}
var imageKeys: [String?] = []
for await imageKey in group {
imageKeys.append(imageKey)
}
return imageKeys.compactMap { $0 }
}
// Update the album with the image keys
createdAlbum.imageKeys = imageKeys
let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}

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.

// Create the photo album record
let album = PhotoAlbum(name: name)
let result = try await Amplify.API.mutate(request: .create(album))
guard case .success(var createdAlbum) = result else {
print("Failed with error: ", result)
return
}
// Upload the photo album image
let key = "\(album.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the album with the image key
createdAlbum.imageKeys = [key]
let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}

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.

// Upload the new photo album image
let key = "\(currentAlbum.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
// Get the latest album
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard var album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
return
}
guard var imageKeys = album.imageKeys else {
print("Album does not contain images")
return
}
// Add new to the existing keys
imageKeys.append(key)
// Update the album with the image keys
album.imageKeys = imageKeys
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}

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.

// Upload the new photo album image
let key = "\(currentAlbum.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the album with the image keys
var album = currentAlbum
if var imageKeys = album.imageKeys {
imageKeys.removeLast()
imageKeys.append(key)
album.imageKeys = imageKeys
} else {
album.imageKeys = [key]
}
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}

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.

// Get the song record
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard let album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
return
}
guard let imageKeysOptional = album.imageKeys else {
print("Album does not contain images")
return
}
let imageKeys = imageKeysOptional.compactMap { $0 }
// Download the photos
let images = await withTaskGroup(of: UIImage?.self) { group in
for key in imageKeys {
group.addTask {
do {
let imageData = try await Amplify.Storage.downloadData(key: key,
options: .init(accessLevel: .private)).value
return UIImage(data: imageData)
} catch {
print("Failed with error:", error)
return nil
}
}
}
var images: [UIImage?] = []
for await image in group {
images.append(image)
}
return images.compactMap { $0 }
}

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

// Get the album record
let result = try await Amplify.API.mutate(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard var album = queriedAlbum else {
print("Song may have been deleted, no song by id: ", currentAlbum.id)
return
}
guard let imageKeys = album.imageKeys, !imageKeys.isEmpty else {
print("There are no images to remove association")
return
}
// Set the association to nil and update it
album.imageKeys = nil
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", result)
return
}

Remove the file association and delete the files

// Get the album record
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard let album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
return
}
guard let imageKeysOptional = album.imageKeys else {
print("Album does not contain images")
return
}
let imageKeys = imageKeysOptional.compactMap { $0 }
// Set the associations to nil and update it
album.imageKeys = nil
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", result)
return
}
// Remove the photos
await withTaskGroup(of: Void.self) { group in
for key in imageKeys {
group.addTask {
do {
try await Amplify.Storage.remove(key: key,
options: .init(accessLevel: .private))
} catch {
print("Failed with error:", error)
}
}
}
for await _ in group {
}
}

Delete the record and all associated files

// Get the album record
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard let album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
return
}
guard let imageKeysOptional = album.imageKeys else {
print("Album does not contain images")
// Delete the album record
let deleteResult = try await Amplify.API.mutate(request: .delete(album))
guard case .success = deleteResult else {
print("Failed with error: ", deleteResult)
return
}
return
}
let imageKeys = imageKeysOptional.compactMap { $0 }
// Remove the photos
await withTaskGroup(of: Void.self) { group in
for key in imageKeys {
group.addTask {
do {
try await Amplify.Storage.remove(key: key,
options: .init(accessLevel: .private))
} catch {
print("Failed with error:", error)
}
}
}
for await _ in group {
}
}
// Delete the album record
let deleteResult = try await Amplify.API.mutate(request: .delete(album))
guard case .success = deleteResult else {
print("Failed with error: ", deleteResult)
return
}

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

import SwiftUI
import Amplify
import AWSAPIPlugin
import AWSCognitoAuthPlugin
import AWSS3StoragePlugin
import Authenticator
import PhotosUI
@main
struct WorkingWithFilesApp: App {
init() {
do {
Amplify.Logging.logLevel = .verbose
try Amplify.add(plugin: AWSCognitoAuthPlugin())
try Amplify.add(plugin: AWSS3StoragePlugin())
try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
try Amplify.configure()
print("Amplify configured with API, Storage, and Auth plugins!")
} catch {
print("Failed to initialize Amplify with \(error)")
}
}
var body: some Scene {
WindowGroup {
Authenticator { state in
TabView {
SongView()
.tabItem {
Label("Song", systemImage: "music.note")
}
PhotoAlbumView()
.tabItem {
Label("PhotoAlbum", systemImage: "photo")
}
}
}
}
}
}
struct SignOutButton: View {
var body: some View {
Button("Sign out") {
Task {
await Amplify.Auth.signOut()
}
}.foregroundColor(.black)
}
}
struct TappedButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(10)
.background(configuration.isPressed ? Color.teal.opacity(0.8) : Color.teal)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
extension Color {
static let teal = Color(red: 45/255, green: 111/255, blue: 138/255)
}
struct DimmedBackgroundView: View {
var body: some View {
Color.gray.opacity(0.5)
.ignoresSafeArea()
}
}
struct ImagePicker: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.presentationMode) var presentationMode
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.selectedImage = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
imagePicker.delegate = context.coordinator
return imagePicker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
}
struct MultiImagePicker: UIViewControllerRepresentable {
@Binding var selectedImages: [UIImage]
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 0
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// No need for updates in this case
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: PHPickerViewControllerDelegate {
private let parent: MultiImagePicker
init(parent: MultiImagePicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
DispatchQueue.main.async {
self.parent.selectedImages = []
}
for result in results {
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
if let image = image as? UIImage {
DispatchQueue.main.async {
self.parent.selectedImages.append(image)
}
}
}
}
}
}
}
}
import SwiftUI
import Amplify
class SongViewModel: ObservableObject {
@Published var currentSong: Song? = nil
@Published var currentImage: UIImage? = nil
@Published var isLoading: Bool = false
// Create a song with an associated image
func createSong(name: String, artCover: UIImage) async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
let song = Song(name: name)
guard let imageData = artCover.pngData() else {
print("Could not get data from image.")
return
}
// Create the song record
let result = try await Amplify.API.mutate(request: .create(song))
guard case .success(var createdSong) = result else {
print("Failed with error: ", result)
return
}
// Upload the art cover image
_ = try await Amplify.Storage.uploadData(key: createdSong.id,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the song record with the image key
createdSong.coverArtKey = createdSong.id
let updateResult = try await Amplify.API.mutate(request: .update(createdSong))
guard case .success(let updatedSong) = updateResult else {
print("Failed with error: ", updateResult)
return
}
await setCurrentSong(updatedSong)
}
// Add or update an image for an associated record
func updateArtCover(artCover: UIImage) async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard var currentSong = currentSong else {
print("There is no song to associated the image with. Create a Song first.")
return
}
guard let imageData = artCover.pngData() else {
print("Could not get data from UIImage.")
return
}
// Upload the new art image
_ = try await Amplify.Storage.uploadData(key: currentSong.id,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the song record
currentSong.coverArtKey = currentSong.id
let result = try await Amplify.API.mutate(request: .update(currentSong))
guard case .success(let updatedSong) = result else {
print("Failed with error: ", result)
return
}
await setCurrentSong(updatedSong)
}
func refreshSongAndArtCover() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentSong = currentSong else {
print("There is no song to refresh the record and image. Create a song first.")
return
}
await setCurrentSong(nil)
await setCurrentImage(nil)
// Get the song record
let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard let song = queriedSong else {
print("Song may have been deleted, no song with id: ", currentSong.id)
await setCurrentSong(nil)
return
}
guard let coverArtKey = song.coverArtKey else {
print("Song does not contain cover art")
await setCurrentSong(song)
await setCurrentImage(nil)
return
}
// Download the art cover
let imageData = try await Amplify.Storage.downloadData(key: coverArtKey,
options: .init(accessLevel: .private)).value
let image = UIImage(data: imageData)
await setCurrentSong(song)
await setCurrentImage(image)
}
func removeImageAssociationFromSong() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentSong = currentSong else {
print("There is no song to remove art cover from it. Create a song first.")
return
}
// Get the song record
let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard var song = queriedSong else {
print("Song may have been deleted, no song by id: ", currentSong.id)
await setCurrentSong(nil)
return
}
guard song.coverArtKey != nil else {
print("There is no cover art key to remove image association")
return
}
// Set the association to nil and update it
song.coverArtKey = nil
let updateResult = try await Amplify.API.mutate(request: .update(song))
guard case .success(let updatedSong) = updateResult else {
print("Failed with error: ", result)
return
}
await setCurrentSong(updatedSong)
}
func removeImageAssociationAndDeleteImage() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentSong = currentSong else {
print("There is no song to remove art cover from it. Create a song first.")
return
}
// Get the song record
let result = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard var song = queriedSong else {
print("Song may have been deleted, no song by id: ", currentSong.id)
await setCurrentSong(nil)
return
}
guard let coverArtKey = song.coverArtKey else {
print("There is no cover art key to remove image association")
return
}
// Set the association to nil and update it
song.coverArtKey = nil
let updateResult = try await Amplify.API.mutate(request: .update(song))
guard case .success(let updatedSong) = updateResult else {
print("Failed with error: ", result)
return
}
// Remove the image
try await Amplify.Storage.remove(key: coverArtKey,
options: .init(accessLevel: .private))
await setCurrentSong(updatedSong)
await setCurrentImage(nil)
}
func deleteSongAndArtCover() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentSong = currentSong else {
print("There is no song to delete. Create a song first.")
return
}
// Get the song record
let result = try await Amplify.API.mutate(request: .get(Song.self, byIdentifier: currentSong.id))
guard case .success(let queriedSong) = result else {
print("Failed with error: ", result)
return
}
guard let song = queriedSong else {
print("Song may have been deleted, no song by id: ", currentSong.id)
await setCurrentSong(nil)
return
}
if let coverArt = song.coverArtKey {
// Remove the image
try await Amplify.Storage.remove(key: coverArt,
options: .init(accessLevel: .private))
}
// Delete the song record
let deleteResult = try await Amplify.API.mutate(request: .delete(song))
guard case .success = deleteResult else {
print("Failed with error: ", deleteResult)
return
}
await setCurrentSong(nil)
await setCurrentImage(nil)
}
@MainActor
func setCurrentSong(_ song: Song?) {
self.currentSong = song
}
@MainActor
func setCurrentImage(_ image: UIImage?) {
self.currentImage = image
}
@MainActor
func setIsLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
}
struct SongView: View {
@State private var isImagePickerPresented = false
@State private var songName: String = ""
@StateObject var viewModel = SongViewModel()
var body: some View {
NavigationView {
ZStack {
VStack {
SongInformation()
DisplayImage()
OpenImagePickerButton()
SongNameTextField()
CreateOrUpdateSongButton()
AdditionalOperations()
Spacer()
}
.padding()
.sheet(isPresented: $isImagePickerPresented) {
ImagePicker(selectedImage: $viewModel.currentImage)
}
VStack {
IsLoadingView()
}
}
.navigationBarItems(trailing: SignOutButton())
}
}
@ViewBuilder
func SongInformation() -> some View {
if let song = viewModel.currentSong {
Text("Song Id: \(song.id)").font(.caption)
if song.name != "" {
Text("Song Name: \(song.name)").font(.caption)
}
}
}
@ViewBuilder
func DisplayImage() -> some View {
if let image = viewModel.currentImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Text("No Image Selected")
.foregroundColor(.gray)
}
}
func OpenImagePickerButton() -> some View {
Button("Select \(viewModel.currentImage != nil ? "a new ": "" )song album cover") {
isImagePickerPresented.toggle()
}.buttonStyle(TappedButtonStyle())
}
@ViewBuilder
func SongNameTextField() -> some View {
TextField("\(viewModel.currentSong != nil ? "Update": "Enter") song name", text: $songName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.multilineTextAlignment(.center)
}
@ViewBuilder
func CreateOrUpdateSongButton() -> some View {
if viewModel.currentSong == nil, let image = viewModel.currentImage {
Button("Save") {
Task {
try? await viewModel.createSong(name: songName,
artCover: image)
}
}
.buttonStyle(TappedButtonStyle())
.disabled(viewModel.isLoading)
} else if viewModel.currentSong != nil, let image = viewModel.currentImage {
Button("Update") {
Task {
try? await viewModel.updateArtCover(artCover: image)
}
}
.buttonStyle(TappedButtonStyle())
.disabled(viewModel.isLoading)
}
}
@ViewBuilder
func AdditionalOperations() -> some View {
if viewModel.currentSong != nil {
VStack {
Button("Refresh") {
Task {
try? await viewModel.refreshSongAndArtCover()
}
}.buttonStyle(TappedButtonStyle())
Button("Remove association from song") {
Task {
try? await viewModel.removeImageAssociationFromSong()
}
}.buttonStyle(TappedButtonStyle())
Button("Remove association and delete image") {
Task {
try? await viewModel.removeImageAssociationAndDeleteImage()
}
}.buttonStyle(TappedButtonStyle())
Button("Delete song and art cover") {
Task {
try? await viewModel.deleteSongAndArtCover()
}
songName = ""
}.buttonStyle(TappedButtonStyle())
}.disabled(viewModel.isLoading)
}
}
@ViewBuilder
func IsLoadingView() -> some View {
if viewModel.isLoading {
ZStack {
DimmedBackgroundView()
ProgressView()
}
}
}
}
struct SongView_Previews: PreviewProvider {
static var previews: some View {
SongView()
}
}
import SwiftUI
import Amplify
import Photos
class PhotoAlbumViewModel: ObservableObject {
@Published var currentImages: [UIImage] = []
@Published var currentAlbum: PhotoAlbum? = nil
@Published var isLoading: Bool = false
// Create a record with multiple associated files
func createPhotoAlbum(name: String, photos: [UIImage]) async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
let imagesData = photos.compactMap { $0.pngData() }
guard !imagesData.isEmpty else {
print("Could not get data from [UIImage]")
return
}
// Create the photo album record
let album = PhotoAlbum(name: name)
let result = try await Amplify.API.mutate(request: .create(album))
guard case .success(var createdAlbum) = result else {
print("Failed with error: ", result)
return
}
// Upload the photo album images
let imageKeys = await withTaskGroup(of: String?.self) { group in
for imageData in imagesData {
group.addTask {
let key = "\(album.id)-\(UUID().uuidString)"
do {
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
return key
} catch {
print("Failed with error:", error)
return nil
}
}
}
var imageKeys: [String?] = []
for await imageKey in group {
imageKeys.append(imageKey)
}
return imageKeys.compactMap { $0 }
}
// Update the album with the image keys
createdAlbum.imageKeys = imageKeys
let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}
await setCurrentAlbum(updatedAlbum)
}
// Create a record with a single associated file
func createPhotoAlbum(name: String, photo: UIImage) async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let imageData = photo.pngData() else {
print("Could not get data from UIImage")
return
}
// Create the photo album record
let album = PhotoAlbum(name: name)
let result = try await Amplify.API.mutate(request: .create(album))
guard case .success(var createdAlbum) = result else {
print("Failed with error: ", result)
return
}
// Upload the photo album image
let key = "\(album.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the album with the image key
createdAlbum.imageKeys = [key]
let updateResult = try await Amplify.API.mutate(request: .update(createdAlbum))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}
await setCurrentAlbum(updatedAlbum)
}
// Add new file to an associated record
func addAdditionalPhotos(_ photo: UIImage) async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentAlbum = currentAlbum else {
print("There is no album to associated the images with. Create an Album first.")
return
}
guard let imageData = photo.pngData() else {
print("Could not get data from UIImage.")
return
}
// Upload the new photo album image
let key = "\(currentAlbum.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
// Get the latest album
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard var album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
await setCurrentAlbum(nil)
return
}
guard var imageKeys = album.imageKeys else {
print("Album does not contain images")
await setCurrentAlbum(album)
await setCurrentImages([])
return
}
// Add new to the existing keys
imageKeys.append(key)
// Update the album with the image keys
album.imageKeys = imageKeys
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}
await setCurrentAlbum(updatedAlbum)
}
func replaceLastImage(_ photo: UIImage) async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentAlbum = currentAlbum else {
print("There is no album to associated the images with. Create an Album first.")
return
}
guard let imageData = photo.pngData() else {
print("Could not get data from UIImage")
return
}
// Upload the new photo album image
let key = "\(currentAlbum.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(key: key,
data: imageData,
options: .init(accessLevel: .private)).value
// Update the album with the image keys
var album = currentAlbum
if var imageKeys = album.imageKeys {
imageKeys.removeLast()
imageKeys.append(key)
album.imageKeys = imageKeys
} else {
album.imageKeys = [key]
}
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", updateResult)
return
}
await setCurrentAlbum(updatedAlbum)
}
// Query a record and retrieve the associated files
func refreshAlbumAndPhotos() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentAlbum = currentAlbum else {
print("There is no album to associated the images with. Create an Album first.")
return
}
await setCurrentAlbum(nil)
await setCurrentImages([])
// Get the song record
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard let album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
await setCurrentAlbum(nil)
return
}
guard let imageKeysOptional = album.imageKeys else {
print("Album does not contain images")
await setCurrentAlbum(album)
await setCurrentImages([])
return
}
let imageKeys = imageKeysOptional.compactMap { $0 }
// Download the photos
let images = await withTaskGroup(of: UIImage?.self) { group in
for key in imageKeys {
group.addTask {
do {
let imageData = try await Amplify.Storage.downloadData(key: key,
options: .init(accessLevel: .private)).value
return UIImage(data: imageData)
} catch {
print("Failed with error:", error)
return nil
}
}
}
var images: [UIImage?] = []
for await image in group {
images.append(image)
}
return images.compactMap { $0 }
}
await setCurrentAlbum(album)
await setCurrentImages(images)
}
// Remove the file association
func removeStorageAssociationsFromAlbum() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentAlbum = currentAlbum else {
print("There is no album to associated the images with. Create an Album first.")
return
}
// Get the album record
let result = try await Amplify.API.mutate(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard var album = queriedAlbum else {
print("Song may have been deleted, no song by id: ", currentAlbum.id)
await setCurrentAlbum(nil)
return
}
guard let imageKeys = album.imageKeys, !imageKeys.isEmpty else {
print("There are no images to remove association")
return
}
// Set the association to nil and update it
album.imageKeys = nil
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", result)
return
}
await setCurrentAlbum(updatedAlbum)
}
// Remove the record association and delete the files
func removeStorageAssociationsAndDeletePhotos() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentAlbum = currentAlbum else {
print("There is no album to associated the images with. Create an Album first.")
return
}
// Get the album record
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard var album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
await setCurrentAlbum(nil)
return
}
guard let imageKeysOptional = album.imageKeys else {
print("Album does not contain images")
await setCurrentAlbum(album)
await setCurrentImages([])
return
}
let imageKeys = imageKeysOptional.compactMap { $0 }
// Set the associations to nil and update it
album.imageKeys = nil
let updateResult = try await Amplify.API.mutate(request: .update(album))
guard case .success(let updatedAlbum) = updateResult else {
print("Failed with error: ", result)
return
}
// Remove the photos
await withTaskGroup(of: Void.self) { group in
for key in imageKeys {
group.addTask {
do {
try await Amplify.Storage.remove(key: key,
options: .init(accessLevel: .private))
} catch {
print("Failed with error:", error)
}
}
}
for await _ in group {
}
}
await setCurrentAlbum(updatedAlbum)
await setCurrentImages([])
}
// Delete record and all associated files
func deleteAlbumAndPhotos() async throws {
await setIsLoading(true)
defer {
Task {
await setIsLoading(false)
}
}
guard let currentAlbum = currentAlbum else {
print("There is no album to associated the images with. Create an Album first.")
return
}
// Get the album record
let result = try await Amplify.API.query(request: .get(PhotoAlbum.self, byIdentifier: currentAlbum.id))
guard case .success(let queriedAlbum) = result else {
print("Failed with error: ", result)
return
}
guard let album = queriedAlbum else {
print("Album may have been deleted, no album with id: ", currentAlbum.id)
await setCurrentAlbum(nil)
return
}
guard let imageKeysOptional = album.imageKeys else {
print("Album does not contain images")
// Delete the album record
let deleteResult = try await Amplify.API.mutate(request: .delete(album))
guard case .success = deleteResult else {
print("Failed with error: ", deleteResult)
return
}
await setCurrentAlbum(nil)
await setCurrentImages([])
return
}
let imageKeys = imageKeysOptional.compactMap { $0 }
// Remove the photos
await withTaskGroup(of: Void.self) { group in
for key in imageKeys {
group.addTask {
do {
try await Amplify.Storage.remove(key: key,
options: .init(accessLevel: .private))
} catch {
print("Failed with error:", error)
}
}
}
for await _ in group {
}
}
// Delete the album record
let deleteResult = try await Amplify.API.mutate(request: .delete(album))
guard case .success = deleteResult else {
print("Failed with error: ", deleteResult)
return
}
await setCurrentAlbum(nil)
await setCurrentImages([])
}
@MainActor
func setCurrentAlbum(_ album: PhotoAlbum?) {
self.currentAlbum = album
}
@MainActor
func setCurrentImages(_ images: [UIImage]) {
self.currentImages = images
}
@MainActor
func setIsLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
}
struct PhotoAlbumView: View {
@State private var isImagePickerPresented: Bool = false
@State private var albumName: String = ""
@State private var isLastImagePickerPresented = false
@State private var lastImage: UIImage? = nil
@StateObject var viewModel = PhotoAlbumViewModel()
var body: some View {
NavigationView {
ZStack {
VStack {
AlbumInformation()
DisplayImages()
OpenImagePickerButton()
PhotoAlbumNameTextField()
CreateOrUpdateAlbumButton()
AdditionalOperations()
}
.padding()
.sheet(isPresented: $isImagePickerPresented) {
MultiImagePicker(selectedImages: $viewModel.currentImages)
}
.sheet(isPresented: $isLastImagePickerPresented) {
ImagePicker(selectedImage: $lastImage)
}
VStack {
IsLoadingView()
}
}
.navigationBarItems(trailing: SignOutButton())
}
}
@ViewBuilder
func AlbumInformation() -> some View {
if let album = viewModel.currentAlbum {
Text("Album Id: \(album.id)").font(.caption)
if album.name != "" {
Text("Album Name: \(album.name)").font(.caption)
}
}
}
@ViewBuilder
func DisplayImages() -> some View {
// Display selected images
ScrollView(.horizontal) {
HStack {
ForEach($viewModel.currentImages, id: \.self) { image in
Image(uiImage: image.wrappedValue)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
}
}
}
if $viewModel.currentImages.isEmpty {
Text("No Images Selected")
.foregroundColor(.gray)
}
}
func OpenImagePickerButton() -> some View {
// Button to open the image picker
Button("Select \(!viewModel.currentImages.isEmpty ? "new " : "")photo album images") {
isImagePickerPresented.toggle()
}.buttonStyle(TappedButtonStyle())
}
@ViewBuilder
func PhotoAlbumNameTextField() -> some View {
TextField("\(viewModel.currentAlbum != nil ? "Update": "Enter") album name", text: $albumName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.multilineTextAlignment(.center)
}
@ViewBuilder
func CreateOrUpdateAlbumButton() -> some View {
if viewModel.currentAlbum == nil, !viewModel.currentImages.isEmpty {
Button("Save") {
Task {
try? await viewModel.createPhotoAlbum(name: albumName,
photos: viewModel.currentImages)
}
}
.buttonStyle(TappedButtonStyle())
.disabled(viewModel.isLoading)
} else if viewModel.currentAlbum != nil {
Button("Select \(lastImage != nil ? "another ": "")photo to replace last photo in the album") {
isLastImagePickerPresented.toggle()
}
.buttonStyle(TappedButtonStyle())
.disabled(viewModel.isLoading)
if let lastImage = lastImage {
Image(uiImage: lastImage)
.resizable()
.aspectRatio(contentMode: .fit)
Button("Replace last image in album with above") {
Task {
try? await viewModel.replaceLastImage(lastImage)
self.lastImage = nil
try? await viewModel.refreshAlbumAndPhotos()
}
}
.buttonStyle(TappedButtonStyle())
.disabled(viewModel.isLoading)
Button("Append above image to album") {
Task {
try? await viewModel.addAdditionalPhotos(lastImage)
self.lastImage = nil
try? await viewModel.refreshAlbumAndPhotos()
}
}
.buttonStyle(TappedButtonStyle())
.disabled(viewModel.isLoading)
}
}
}
@ViewBuilder
func AdditionalOperations() -> some View {
if viewModel.currentAlbum != nil {
VStack {
Button("Refresh") {
Task {
try? await viewModel.refreshAlbumAndPhotos()
}
}.buttonStyle(TappedButtonStyle())
Button("Remove associations from album") {
Task {
try? await viewModel.removeStorageAssociationsFromAlbum()
try? await viewModel.refreshAlbumAndPhotos()
}
}.buttonStyle(TappedButtonStyle())
Button("Remove association and delete photos") {
Task {
try? await viewModel.removeStorageAssociationsAndDeletePhotos()
try? await viewModel.refreshAlbumAndPhotos()
}
}.buttonStyle(TappedButtonStyle())
Button("Delete album and images") {
Task {
try? await viewModel.deleteAlbumAndPhotos()
}
albumName = ""
}.buttonStyle(TappedButtonStyle())
}.disabled(viewModel.isLoading)
}
}
@ViewBuilder
func IsLoadingView() -> some View {
if viewModel.isLoading {
ZStack {
DimmedBackgroundView()
ProgressView()
}
}
}
}
struct PhotoAlbumView_Previews: PreviewProvider {
static var previews: some View {
PhotoAlbumView()
}
}