ファイル/添付ファイルの操作
StorageカテゴリとGraphQL APIカテゴリを組み合わせて、特定のレコードに画像や動画などのファイルを関連付けることができます。例えば、プロフィール画像を持つUserモデルや、関連する画像を持つPostモデルを作成することができます。AmplifyのGraphQL APIおよびStorageカテゴリを使用すると、モデル自体の中でファイルを参照して関連付けを作成することができます。
プロジェクトのセットアップ
クイックスタートガイドの手順に従ってプロジェクトをセットアップしてください。
モデルの定義
amplify/data/resource.tsを開き、以下のモデルを追加してください:
import { type ClientSchema, a, defineData } from "@aws-amplify/backend";
const schema = a.schema({ Song: a .model({ id: a.id().required(), name: a.string().required(), coverArtPath: a.string(), }) .authorization((allow) => [allow.publicApiKey()]),});
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({ schema, authorizationModes: { defaultAuthorizationMode: "apiKey",
apiKeyAuthorizationMode: { expiresInDays: 30, }, },});Storageのセットアップ
次に、Storageを設定し、アプリケーションのすべての認証済み(サインイン済み)ユーザーにアクセスを許可します。amplify/storage/resource.tsファイルを作成し、以下のコードを追加してください。これにより、ファイルアクセスがサインイン済みユーザーのみに制限されます。
import { defineStorage } from "@aws-amplify/backend";
export const storage = defineStorage({ name: "amplify-gen2-files", access: (allow) => ({ "images/*": [allow.authenticated.to(["read", "write", "delete"])], }),});以下のようにamplify/backend.tsファイルでStorageを設定してください:
import { defineBackend } from "@aws-amplify/backend";import { auth } from "./auth/resource";import { data } from "./data/resource";import { storage } from "./storage/resource";
export const backend = defineBackend({ auth, data, storage,});認可の設定
すべてのデータとファイルをパブリックにアクセス可能にする場合を除き、アプリケーションはStorageとDataの両方に対して読み取りおよび書き込みのための認可クレデンシャルが必要です。
StorageカテゴリとDataカテゴリはそれぞれ独自の認可パターンに基づいてデータアクセスを管理します。つまり、各カテゴリに対して適切な認可ロールを設定する必要があります。両カテゴリはAuthカテゴリを通じて設定された同じアクセスクレデンシャルを共有していますが、互いに独立して動作します。例えば、DataにAllow.authenticated()を追加しても、StorageカテゴリのファイルアクセスはGuardされません。同様に、Storageカテゴリに認可ルールを追加しても、APIのデータアクセスはGuardされません。
Storageを設定すると、AmplifyはCognito IDプールロールを使用してバケット上に適切なIAMポリシーを設定します。その後、認証済みユーザーとゲストユーザーがこれらのレベル内で限定的な権限を付与されるように、CRUDベース(作成、更新、読み取り、削除)の権限を追加することもできます。この設定を追加した後も、すべてのStorageアクセスはデフォルトでguestのままです。 誤ってパブリックアクセスされないよう、StorageアクセスレベルはStorageオブジェクト上でグローバルに設定するか、個々の関数呼び出しで設定する必要があります。このガイドでは前者のアプローチを使用し、すべてのStorageアクセスをauthenticatedユーザーに設定します。
各カテゴリの認可ルールを個別に設定できる機能により、データアクセスのより細かいコントロールが可能になり、柔軟性が高まります。認可パターンを混在させる必要があるシナリオでは、個々のStorage関数呼び出しにアクセスレベルを設定してください。例えば、所有者のみがアクセスすべきファイル(個人ファイルなど)にはentity_id CRUDアクセスを、ログイン済みの全ユーザーが共通ファイル(共有フォトアルバムの画像など)を閲覧できるようにするにはauthenticated読み取りアクセスを、全ユーザーがファイル(パブリックプロフィール画像など)を閲覧できるようにするにはguest読み取りアクセスを使用することができます。
Storageの認可レベルの設定方法の詳細については、Storageのドキュメントを参照してください。Dataの認可設定については、APIのドキュメントを参照してください。
関連ファイルを持つレコードの作成
Amplify Dataクライアントを使用してレコードを作成し、Storageにファイルをアップロードし、最後にレコードをアップロードしたファイルと関連付けることができます。以下の例では、Amplify Dataクライアントと、Amplify StorageライブラリのヘルパーであるuploudDataとgetUrlを使用して、レコードを作成し、ファイルをレコードに関連付けます。
let song = Song(name: name)
guard let imageData = artCover.pngData() else { print("Could not get data from image.") return}
// Create the song recordvar createdSong = try await Amplify.API.mutate(request: .create(song)).get()let coverArtPath = "images/\(createdSong.id)"
// Upload the art cover image_ = try await Amplify.Storage.uploadData(path: .fromString(coverArtPath), data: imageData).value
// Update the song record with the image pathcreatedSong.coverArtPath = coverArtPathlet updatedSong = try await Amplify.API.mutate(request: .update(createdSong)).get()関連レコードのファイルの追加または更新
ファイルをレコードに関連付けるには、Storageのアップロードで返されたパスでレコードを更新します。以下の例では、Storageを使用してファイルをアップロードし、ファイルのパスでレコードを更新してから、画像をダウンロードするための署名付きURLを取得します。画像がすでにレコードに関連付けられている場合、レコードは新しい画像で更新されます。
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}
let coverArtPath = "images/\(currentSong.id)"
// Upload the new art image_ = try await Amplify.Storage.uploadData(path: .fromString(coverArtPath), data: imageData).value
// Update the song recordcurrentSong.coverArtPath = coverArtPathlet updatedSong = try await Amplify.API.mutate(request: .update(currentSong)).get()レコードのクエリと関連ファイルの取得
レコードに関連付けられたファイルを取得するには、まずレコードをクエリし、次にStorageを使用して署名付きURLを取得します。署名付きURLを使用してファイルをダウンロードしたり、画像を表示したりすることができます:
// Get the song recordguard let song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return}
// If the record has no associated file, we can return early.guard let coverArtPath = song.coverArtPath else { print("Song does not contain cover art") return}
// Download the art coverprint("coverArtPath: ", coverArtPath)let imageData = try await Amplify.Storage.downloadData(path: .fromString(coverArtPath)).value
let image = UIImage(data: imageData)APIレコードに関連するファイルの削除と除去
StorageファイルとGraphQL APIを操作する際によく使われる削除ワークフローは3つあります:
- ファイルの関連付けを削除し、ファイルとレコードの両方を保持し続ける。
- レコードの関連付けを削除してファイルを削除する。
- ファイルとレコードの両方を削除する。
ファイルの関連付けを削除し、ファイルとレコードの両方を保持し続ける
以下の例では、レコードからファイルの関連付けを削除しますが、S3からファイルは削除せず、データベースのレコードも削除しません。
// Get the song recordguard var song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return}
guard song.coverArtPath != nil else { print("There is no cover art path to remove image association") return}
// Set the association to nil and update itsong.coverArtPath = nil
let updatedSong = try await Amplify.API.mutate(request: .update(song)).get()レコードの関連付けを削除してファイルを削除する
以下の例では、レコードからファイルを削除し、次にS3からファイルを削除します:
// Get the song recordguard var song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return}
guard let coverArtPath = song.coverArtPath else { print("There is no cover art path to remove image association") return}
// Set the association to nil and update itsong.coverArtPath = nillet updatedSong = try await Amplify.API.mutate(request: .update(song)).get()
// Remove the imagetry await Amplify.Storage.remove(path: .fromString(coverArtPath))ファイルとレコードの両方を削除する
// Get the song recordguard let song = try await Amplify.API.query(request: .get(Song.self, byId: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return}
if let coverArt = song.coverArtPath { // Delete the file from S3 try await Amplify.Storage.remove(path: .fromString(coverArt))}
// Delete the song record_ = try await Amplify.API.mutate(request: .delete(song)).get()複数ファイルの操作
ユーザープロフィールに複数の画像を持たせるなど、1つのレコードに複数のファイルを追加したい場合があります。これを行うには、レコードにファイルキーのリストを追加できます。以下の例では、レコードにファイルキーのリストを追加します:
複数のファイルをデータモデルに関連付けるGraphQLスキーマ
amplify/data/resource.tsファイルに以下のモデルを追加してください。
const schema = a.schema({ PhotoAlbum: a .model({ id: a.id().required(), name: a.string().required(), imagePaths: a.string().array(), }) .authorization((allow) => [allow.publicApiKey()]),});複数のファイルを操作する際のCRUD操作は、単一のファイルキーではなくファイルキーのリストを操作する点を除いて、単一ファイルを操作する場合と同じです。
複数の関連ファイルを持つレコードの作成
まずGraphQL APIを使用してレコードを作成し、次にStorageにファイルをアップロードし、最後にレコードとファイルの関連付けを追加します。
// Create the photo album recordlet album = PhotoAlbum(name: name)var createdAlbum = try await Amplify.API.mutate(request: .create(album)).get()
// Upload the photo album imageslet imagePaths = await withTaskGroup(of: String?.self) { group in for imageData in imagesData { group.addTask { let path = "images/\(album.id)-\(UUID().uuidString)" do { _ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value return path } catch { print("Failed with error:", error) return nil } } }
var imagePaths: [String?] = [] for await imagePath in group { imagePaths.append(imagePath) } return imagePaths.compactMap { $0 }}
// Update the album with the image pathscreatedAlbum.imagePaths = imagePathslet updatedAlbum = try await Amplify.API.mutate(request: .update(createdAlbum)).get()関連レコードへの新しいファイルの追加
レコードに追加のファイルを関連付けるには、Storageのアップロードで返されたパスでレコードを更新します。
// Upload the new photo album imagelet path = "images/\(currentAlbum.id)-\(UUID().uuidString)"_ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value
// Get the latest albumguard var album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return}
guard var imagePaths = album.imagePaths else { print("Album does not contain images") await setCurrentAlbum(album) await setCurrentImages([]) return}
// Add new to the existing pathsimagePaths.append(path)
// Update the album with the image pathsalbum.imagePaths = imagePathslet updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()関連レコードのファイルの更新
関連レコードのファイルを更新する操作は、単一ファイルレコードのファイルを更新する場合と同じですが、ファイルキーのリストを更新する必要がある点が異なります。
// Upload new file to Storage:let path = "images/\(currentAlbum.id)-\(UUID().uuidString)"
_ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value
// Update the album with the image keysvar album = currentAlbum
if var imagePaths = album.imagePaths { imagePaths.removeLast() imagePaths.append(path) album.imagePaths = imagePaths} else { album.imagePaths = [path]}
// Update record with updated file associations:let updateResult = try await Amplify.API.mutate(request: .update(album)).get()レコードのクエリと関連ファイルの取得
レコードに関連付けられたファイルを取得するには、まずレコードをクエリし、次にStorageを使用してすべての署名付きURLを取得します。
// Query the record to get the file paths:guard let album = try await Amplify.API.query( request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return}
guard let imagePathsOptional = album.imagePaths else { print("Album does not contain images") await setCurrentAlbum(album) await setCurrentImages([]) return}
let imagePaths = imagePathsOptional.compactMap { $0 }
// Download the photoslet images = await withTaskGroup(of: UIImage?.self) { group in for path in imagePaths { group.addTask { do { let imageData = try await Amplify.Storage.downloadData(path: .fromString(path)).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 }}APIレコードに関連するファイルの削除と除去
APIレコードに関連するファイルの削除と除去のワークフローは、単一ファイルを操作する場合と同じですが、削除を実行する際はファイルパスのリストを反復処理して各ファイルに対してStorage.remove()を呼び出す必要があります。
ファイルの関連付けを削除し、ファイルとレコードの両方を保持し続ける
// Get the album recordguard var album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return}
guard let imagePaths = album.imagePaths, !imagePaths.isEmpty else { print("There are no images to remove association") return}
// Set the association to nil and update italbum.imagePaths = nillet updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()レコードの関連付けを削除してファイルを削除する
// Get the album recordguard var album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return}
guard let imagePathsOptional = album.imagePaths else { print("Album does not contain images") await setCurrentAlbum(album) await setCurrentImages([]) return}let imagePaths = imagePathsOptional.compactMap { $0 }
// Set the associations to nil and update italbum.imagePaths = nillet updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()
// Remove the photosawait withTaskGroup(of: Void.self) { group in for path in imagePaths { group.addTask { do { try await Amplify.Storage.remove(path: .fromString(path)) } catch { print("Failed with error:", error) } } }
for await _ in group { }}レコードとすべての関連ファイルの削除
レコードとファイルを操作する際のデータ整合性
このドキュメントで推奨するアクセスパターンは、削除されたファイルを除去しようとしますが、存在しないファイルを参照するレコードを残すよりも、孤立したファイルを残す方を優先します。これにより、クライアントが存在しないStorageファイルを取得しようとすることが_ほとんどない_ことが保証され、読み取りレイテンシが最適化されます。ただし、ファイルを削除するアプリケーションでは、_デバイス上の_レコードが存在しないファイルを参照する可能性が本質的に生じます。
一例として、APIレコードを作成し、Storageファイルをそのレコードに関連付け、ファイルの署名付きURLを取得する場合があります。「デバイスA」がGraphQL APIを呼び出してAPI_Record_1を作成し、次にそのレコードをFirst_Photoと関連付けます。「デバイスA」が署名付きURLを取得しようとする直前に、「デバイスB」がAPI_Record_1をクエリし、First_Photoを削除して、それに応じてレコードを更新する可能性があります。しかし、「デバイスA」はまだ古いAPI_Record_1を使用しており、そのレコードはすでに存在しないファイルを参照しています。共有グローバル状態がすべての段階で正しく同期されているにもかかわらず、個々のデバイス(「デバイスA」)には存在しないファイルを参照する古いレコードが残っています。同様の問題が更新時にも発生する可能性があります。アプリによっては、リアルタイムデータ / GraphQLサブスクリプションを使用することで、これらの不一致を_さらに_最小化できる場合があります。
これらの不一致がいつ発生するかを理解し、そのようなケースに対して意味のあるエラーハンドリングを追加することが重要です。このガイドには、包括的なエラーハンドリング、リアルタイムサブスクリプション、古くなったレコードの再クエリ、失敗した操作の再試行は含まれていません。ただし、これらはすべて本番レベルのアプリケーションにとって重要な考慮事項です。
完全なサンプル
import SwiftUIimport Amplifyimport AWSAPIPluginimport AWSCognitoAuthPluginimport AWSS3StoragePluginimport Authenticatorimport PhotosUI
@mainstruct WorkingWithFilesApp: App {
init() { do { Amplify.Logging.logLevel = .verbose try Amplify.add(plugin: AWSCognitoAuthPlugin()) try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels())) try Amplify.add(plugin: AWSS3StoragePlugin()) try Amplify.configure(with: .amplifyOutputs) print("Amplify configured with Auth, API, and Storage plugins") } catch { print("Unable to configure Amplify \(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 SwiftUIimport 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 var createdSong = try await Amplify.API.mutate(request: .create(song)).get() let coverArtPath = "images/\(createdSong.id)"
// Upload the art cover image _ = try await Amplify.Storage.uploadData(path: .fromString(coverArtPath), data: imageData).value
// Update the song record with the image path createdSong.coverArtPath = coverArtPath let updatedSong = try await Amplify.API.mutate(request: .update(createdSong)).get()
await setCurrentSong(updatedSong) }
func getSongAndFile(currentSong: Song, imageData: Data) async throws { // Get the song record guard var song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return }
guard let coverArtPath = song.coverArtPath else { print("There is no cover art path to retrieve image") return }
// Download the art cover let imageData = try await Amplify.Storage.downloadData(path: .fromString(coverArtPath)).value
let image = UIImage(data: imageData) }
// 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 }
let coverArtPath = "images/\(currentSong.id)"
// Upload the new art image _ = try await Amplify.Storage.uploadData(path: .fromString(coverArtPath), data: imageData).value
// Update the song record currentSong.coverArtPath = coverArtPath let updatedSong = try await Amplify.API.mutate(request: .update(currentSong)).get()
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 guard let song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return }
guard let coverArtPath = song.coverArtPath 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(path: .fromString(coverArtPath)).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 guard var song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return }
guard song.coverArtPath != nil else { print("There is no cover art path to remove image association") return }
// Set the association to nil and update it song.coverArtPath = nil
let updatedSong = try await Amplify.API.mutate(request: .update(song)).get()
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 guard var song = try await Amplify.API.query(request: .get(Song.self, byIdentifier: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return }
guard let coverArtPath = song.coverArtPath else { print("There is no cover art path to remove image association") return }
// Set the association to nil and update it song.coverArtPath = nil let updatedSong = try await Amplify.API.mutate(request: .update(song)).get()
// Remove the image try await Amplify.Storage.remove(path: .fromString(coverArtPath))
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 guard var song = try await Amplify.API.query(request: .get(Song.self, byId: currentSong.id)).get() else { print("Song may have been deleted, no song by id: ", currentSong.id) return }
if let coverArt = song.coverArtPath { // Remove the image try await Amplify.Storage.remove(path: .fromString(coverArt)) }
// Delete the song record _ = try await Amplify.API.mutate(request: .delete(song)).get()
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 SwiftUIimport Amplifyimport 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) var createdAlbum = try await Amplify.API.mutate(request: .create(album)).get()
// Upload the photo album images let imagePaths = await withTaskGroup(of: String?.self) { group in for imageData in imagesData { group.addTask { let path = "images/\(album.id)-\(UUID().uuidString)" do { _ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value return path } catch { print("Failed with error:", error) return nil } } }
var imagePaths: [String?] = [] for await imagePath in group { imagePaths.append(imagePath) } return imagePaths.compactMap { $0 } }
// Update the album with the image paths createdAlbum.imagePaths = imagePaths let updatedAlbum = try await Amplify.API.mutate(request: .update(createdAlbum)).get()
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) var createdAlbum = try await Amplify.API.mutate(request: .create(album)).get()
// Upload the photo album image let path = "images/\(album.id)-\(UUID().uuidString)" _ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value
// Update the album with the image path createdAlbum.imagePaths = [path] let updatedAlbum = try await Amplify.API.mutate(request: .update(createdAlbum)).get()
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 path = "images/\(currentAlbum.id)-\(UUID().uuidString)" _ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value
// Get the latest album guard var album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return }
guard var imagePaths = album.imagePaths else { print("Album does not contain images") await setCurrentAlbum(album) await setCurrentImages([]) return }
// Add new to the existing paths imagePaths.append(path)
// Update the album with the image paths album.imagePaths = imagePaths let updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()
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 path = "images/\(currentAlbum.id)-\(UUID().uuidString)" _ = try await Amplify.Storage.uploadData(path: .fromString(path), data: imageData).value
// Update the album with the image paths var album = currentAlbum if var imagePaths = album.imagePaths { imagePaths.removeLast() imagePaths.append(path) album.imagePaths = imagePaths } else { album.imagePaths = [path] }
let updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()
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 associate the images with. Create an Album first.") return }
await setCurrentAlbum(nil) await setCurrentImages([])
// Get the song record guard let album = try await Amplify.API.query( request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return }
guard let imagePathsOptional = album.imagePaths else { print("Album does not contain images") await setCurrentAlbum(album) await setCurrentImages([]) return }
let imagePaths = imagePathsOptional.compactMap { $0 }
// Download the photos let images = await withTaskGroup(of: UIImage?.self) { group in for path in imagePaths { group.addTask { do { let imageData = try await Amplify.Storage.downloadData(path: .fromString(path)).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 guard var album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return }
guard let imagePaths = album.imagePaths, !imagePaths.isEmpty else { print("There are no images to remove association") return }
// Set the association to nil and update it album.imagePaths = nil let updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()
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 guard var album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return }
guard let imagePathsOptional = album.imagePaths else { print("Album does not contain images") await setCurrentAlbum(album) await setCurrentImages([]) return } let imagePaths = imagePathsOptional.compactMap { $0 }
// Set the associations to nil and update it album.imagePaths = nil let updatedAlbum = try await Amplify.API.mutate(request: .update(album)).get()
// Remove the photos await withTaskGroup(of: Void.self) { group in for path in imagePaths { group.addTask { do { try await Amplify.Storage.remove(path: .fromString(path)) } 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 guard let album = try await Amplify.API.query(request: .get(PhotoAlbum.self, byId: currentAlbum.id)).get() else { print("Album may have been deleted, no album by id: ", currentAlbum.id) return }
guard let imagePathsOptional = album.imagePaths else { print("Album does not contain images")
// Delete the album record _ = try await Amplify.API.mutate(request: .delete(album))
await setCurrentAlbum(nil) await setCurrentImages([]) return }
let imagePaths = imagePathsOptional.compactMap { $0 }
// Remove the photos await withTaskGroup(of: Void.self) { group in for path in imagePaths { group.addTask { do { try await Amplify.Storage.remove(path: .fromString(path)) } catch { print("Failed with error:", error) } } }
for await _ in group { } }
// Delete the album record _ = try await Amplify.API.mutate(request: .delete(album)).get()
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() }}