-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 546c776
Showing
49 changed files
with
2,876 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"image": "mcr.microsoft.com/devcontainers/universal:2", | ||
"features": { | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
.# This workflow will build a Swift project | ||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift | ||
|
||
name: Swift | ||
|
||
on: | ||
push: | ||
branches: [ "main" ] | ||
pull_request: | ||
branches: [ "main" ] | ||
|
||
jobs: | ||
build: | ||
|
||
runs-on: macos-latest | ||
|
||
steps: | ||
- uses: actions/checkout@v4 | ||
- name: Build | ||
run: swift build -v | ||
- name: Run tests | ||
run: swift test -v |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
/* | ||
See the LICENSE.txt file for this sample’s licensing information. | ||
|
||
Abstract: | ||
The model object for an album of media assets. | ||
*/ | ||
|
||
import Foundation | ||
import Photos | ||
|
||
@Observable | ||
final class Album { | ||
|
||
// MARK: Properties | ||
|
||
let collection: PHAssetCollection | ||
|
||
var title: String | ||
var assets = [Asset]() | ||
|
||
var creationDate: Date? { | ||
collection.startDate | ||
} | ||
|
||
// MARK: Lifecycle | ||
|
||
init(collection: PHAssetCollection) { | ||
self.collection = collection | ||
self.title = collection.localizedTitle ?? "" | ||
} | ||
|
||
// MARK: Methods | ||
|
||
func setTitle(_ title: String) async throws { | ||
try await PHPhotoLibrary.shared().performChanges { | ||
let request = PHAssetCollectionChangeRequest(for: self.collection) | ||
request?.title = title | ||
} | ||
self.title = title | ||
} | ||
|
||
func setAssets(_ assets: Set<Asset>) async throws { | ||
let current = Set(self.assets) | ||
|
||
let toInsert = assets.subtracting(current).map(\.phAsset) | ||
let toRemove = current.subtracting(assets).map(\.phAsset) | ||
|
||
try await PHPhotoLibrary.shared().performChanges { | ||
let request = PHAssetCollectionChangeRequest(for: self.collection) | ||
request?.addAssets(toInsert as NSFastEnumeration) | ||
request?.removeAssets(toRemove as NSFastEnumeration) | ||
} | ||
self.assets = Array(assets) | ||
} | ||
|
||
func fetchAssets() async throws { | ||
// Fetch all assets from this collection. | ||
let fetchResult = PHAsset.fetchAssets(in: collection, options: nil) | ||
|
||
// Enumerate and insert assets. | ||
var phAssets = [PHAsset]() | ||
fetchResult.enumerateObjects { (object, count, stop) in | ||
phAssets.append(object) | ||
} | ||
|
||
// Process the assets. | ||
let assets = phAssets.map { phAsset in | ||
Asset(phAsset: phAsset) | ||
} | ||
|
||
// Update the assets on the main thread. | ||
await MainActor.run { | ||
self.assets = assets | ||
} | ||
} | ||
} | ||
|
||
extension Album: Identifiable, Hashable { | ||
|
||
var id: String { | ||
collection.localIdentifier | ||
} | ||
|
||
func hash(into hasher: inout Hasher) { | ||
hasher.combine(id) | ||
} | ||
|
||
static func == (lhs: Album, rhs: Album) -> Bool { | ||
lhs.id == rhs.id | ||
} | ||
} | ||
|
||
extension Album: @unchecked Sendable { | ||
|
||
var entity: AlbumEntity { | ||
let entity = AlbumEntity(id: id) | ||
entity.name = title | ||
entity.albumType = .custom | ||
entity.creationDate = creationDate | ||
return entity | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
/* | ||
See the LICENSE.txt file for this sample’s licensing information. | ||
|
||
Abstract: | ||
A view that allows a person to choose a media asset. | ||
*/ | ||
|
||
import SwiftUI | ||
|
||
struct AlbumAssetPicker: View { | ||
|
||
// MARK: Properties | ||
|
||
let album: Album | ||
|
||
@State private var selection: Set<Asset> | ||
|
||
@Environment(MediaLibrary.self) private var library | ||
@Environment(\.dismiss) private var dismiss | ||
|
||
private let targetSize = CGSize(width: 100, height: 100) | ||
|
||
private let columns = [ | ||
GridItem(.adaptive(minimum: 110)) | ||
] | ||
|
||
// MARK: Lifecycle | ||
|
||
init(album: Album) { | ||
self.album = album | ||
self._selection = State(wrappedValue: Set(album.assets)) | ||
} | ||
|
||
var body: some View { | ||
NavigationView { | ||
ScrollView { | ||
LazyVGrid(columns: columns, spacing: 10) { | ||
ForEach(library.assets) { asset in | ||
let isSelected = selection.contains(asset) | ||
ZStack(alignment: .bottomLeading) { | ||
Button { | ||
didSelect(asset) | ||
} label: { | ||
AssetView(asset: asset) | ||
.opacity(isSelected ? 0.5 : 1) | ||
} | ||
|
||
if isSelected { | ||
Image(systemName: "checkmark.circle") | ||
.font(.headline) | ||
.foregroundStyle(.white) | ||
.background(Circle().fill(Color.blue)) | ||
.shadow(radius: 4) | ||
.padding(4) | ||
} | ||
} | ||
} | ||
} | ||
#if os(iOS) | ||
// The search bar on iOS already comes with spacing at the top, | ||
// so only add it on the sides and bottom. | ||
.padding([.horizontal, .bottom]) | ||
#else | ||
.padding() | ||
#endif | ||
} | ||
.navigationTitle(album.title) | ||
.toolbarTitleDisplayMode(.inline) | ||
.toolbar { | ||
ToolbarItem(placement: .cancellationAction) { | ||
Button("Cancel", role: .cancel) { | ||
dismiss() | ||
} | ||
} | ||
ToolbarItem(placement: .confirmationAction) { | ||
Button("Done", action: saveAction) | ||
} | ||
} | ||
} | ||
} | ||
|
||
// MARK: Methods | ||
|
||
private func saveAction() { | ||
Task { | ||
// Save changes. | ||
try await album.setAssets(selection) | ||
|
||
// Close. | ||
dismiss() | ||
} | ||
} | ||
|
||
private func didSelect(_ asset: Asset) { | ||
if selection.contains(asset) { | ||
selection.remove(asset) | ||
} else { | ||
selection.insert(asset) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
/* | ||
See the LICENSE.txt file for this sample’s licensing information. | ||
|
||
Abstract: | ||
A view that displays detail information and interactions for a media album. | ||
*/ | ||
|
||
import AppIntents | ||
import SwiftUI | ||
|
||
struct AlbumDetailView: View { | ||
|
||
// MARK: Properties | ||
|
||
let album: Album | ||
|
||
@State private var isNameSheetPresented = false | ||
@State private var isAssetPickerPresented = false | ||
|
||
@Environment(MediaLibrary.self) private var library | ||
@Environment(\.dismiss) private var dismiss | ||
|
||
// MARK: Lifecycle | ||
|
||
var body: some View { | ||
VStack { | ||
AssetGrid(assets: album.assets, album: album) | ||
} | ||
.toolbar { | ||
ToolbarItem { | ||
Menu { | ||
Button("Select Photos", systemImage: "photo.badge.checkmark") { | ||
isAssetPickerPresented = true | ||
} | ||
Button("Rename Album", systemImage: "pencil") { | ||
isNameSheetPresented = true | ||
} | ||
Divider() | ||
Button("Delete", systemImage: "trash", role: .destructive) { | ||
Task { | ||
try await library.deleteAlbums([album]) | ||
dismiss() | ||
} | ||
} | ||
} label: { | ||
Image(systemName: "ellipsis") | ||
} | ||
} | ||
} | ||
.sheet(isPresented: $isNameSheetPresented) { | ||
AlbumNameView(album: album) | ||
} | ||
.sheet(isPresented: $isAssetPickerPresented) { | ||
AlbumAssetPicker(album: album) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
/* | ||
See the LICENSE.txt file for this sample’s licensing information. | ||
|
||
Abstract: | ||
An entity that describes a photo album. | ||
*/ | ||
|
||
import AppIntents | ||
import CoreLocation | ||
|
||
@AssistantEntity(schema: .photos.album) | ||
struct AlbumEntity: IndexedEntity { | ||
|
||
// MARK: Static | ||
|
||
static let defaultQuery = AlbumQuery() | ||
|
||
// MARK: Properties | ||
|
||
let id: String | ||
|
||
var name: String | ||
var creationDate: Date? | ||
var albumType: AlbumType | ||
|
||
var displayRepresentation: DisplayRepresentation { | ||
DisplayRepresentation( | ||
title: "\(name)", | ||
subtitle: albumType.localizedStringResource | ||
) | ||
} | ||
} | ||
|
||
extension AlbumEntity { | ||
|
||
struct AlbumQuery: EntityQuery { | ||
|
||
@Dependency | ||
var library: MediaLibrary | ||
|
||
@MainActor | ||
func entities(for identifiers: [AlbumEntity.ID]) async throws -> [AlbumEntity] { | ||
library.albums(for: identifiers).map(\.entity) | ||
} | ||
|
||
@MainActor | ||
func suggestedEntities() async throws -> [AlbumEntity] { | ||
library.albums.prefix(3).map(\.entity) | ||
} | ||
} | ||
} |
Oops, something went wrong.