Files
Kisani/Services/PhotoLibraryService.swift
Robin Kutesa ae38f6e417 feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
  LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
  drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
  step indicator (2/3, 3/3), spring entrance animations, and UGreen
  connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
  now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
  onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
  PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:20:53 +03:00

117 lines
4.7 KiB
Swift

import Foundation
import Photos
final class PhotoLibraryService: PhotoLibraryProtocol {
var authorizationStatus: PHAuthorizationStatus {
PHPhotoLibrary.authorizationStatus(for: .readWrite)
}
func requestAuthorization() async -> PHAuthorizationStatus {
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
var subpredicates: [NSPredicate] = []
var mediaTypes: [PHAssetMediaType] = []
if filter.includePhotos { mediaTypes.append(.image) }
if filter.includeVideos { mediaTypes.append(.video) }
if !mediaTypes.isEmpty {
let typePredicate = NSPredicate(
format: "mediaType IN %@",
mediaTypes.map(\.rawValue)
)
subpredicates.append(typePredicate)
}
if !filter.includeScreenshots {
subpredicates.append(NSPredicate(
format: "NOT ((mediaSubtype & %d) != 0)",
PHAssetMediaSubtype.photoScreenshot.rawValue
))
}
if subpredicates.count > 1 {
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
} else {
options.predicate = subpredicates.first
}
let result = PHAsset.fetchAssets(with: options)
var assets: [PhotoAsset] = []
result.enumerateObjects { asset, _, _ in
autoreleasepool {
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
let resources = PHAssetResource.assetResources(for: asset)
let isRAW = resources.contains { $0.type == .alternatePhoto }
if isRAW && !filter.includeRAW { return }
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
assets.append(PhotoAsset(
localIdentifier: asset.localIdentifier,
filename: filename,
creationDate: asset.creationDate,
mediaType: asset.mediaType,
pixelWidth: asset.pixelWidth,
pixelHeight: asset.pixelHeight,
duration: asset.duration,
isScreenshot: isScreenshot,
isRAW: isRAW
))
}
}
return assets
}
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
let fetchResult = PHAsset.fetchAssets(
withLocalIdentifiers: [asset.localIdentifier],
options: nil
)
guard let phAsset = fetchResult.firstObject else {
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
}
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension((asset.filename as NSString).pathExtension)
return try await withCheckedThrowingContinuation { continuation in
if phAsset.mediaType == .video {
let options = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
guard let urlAsset = avAsset as? AVURLAsset else {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
return
}
continuation.resume(returning: urlAsset.url)
}
} else {
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
options.version = .current
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
guard let data else {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
return
}
do {
try data.write(to: tmpURL)
continuation.resume(returning: tmpURL)
} catch {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
}
}
}
}
}
}