357 lines
12 KiB
Swift
357 lines
12 KiB
Swift
import SwiftUI
|
||
import Photos
|
||
|
||
// MARK: - PhotoGridView
|
||
|
||
/// Custom photo library grid matching Figma attachment panel design (1:1).
|
||
///
|
||
/// Figma layout (node 3994:39103):
|
||
/// ```
|
||
/// ┌──────────┬──────────┬──────────┐
|
||
/// │ │ Photo[0] │ Photo[1] │ ← row 1
|
||
/// │ Camera ├──────────┼──────────┤
|
||
/// │ (live) │ Photo[2] │ Photo[3] │ ← row 2
|
||
/// └──────────┴──────────┴──────────┘
|
||
/// ┌──────────┬──────────┬──────────┐
|
||
/// │ Photo[4] │ Photo[5] │ Photo[6] │ ← row 3 (LazyVGrid)
|
||
/// └──────────┴──────────┴──────────┘
|
||
/// ```
|
||
///
|
||
/// Camera tile: col 1, rows 1–2, shows live rear camera feed (AVCaptureSession).
|
||
/// Photos: square tiles with selection circle (22pt, white border 1.5pt).
|
||
/// 1px spacing between all tiles.
|
||
struct PhotoGridView: View {
|
||
|
||
@Binding var selectedAssets: [PHAsset]
|
||
let maxSelection: Int
|
||
let onCameraTap: () -> Void
|
||
var onPhotoPreview: ((PHAsset) -> Void)? = nil
|
||
|
||
@State private var assets: [PHAsset] = []
|
||
@State private var authorizationStatus: PHAuthorizationStatus = .notDetermined
|
||
|
||
private let imageManager = PHCachingImageManager()
|
||
private let columns = 3
|
||
private let spacing: CGFloat = 1
|
||
|
||
var body: some View {
|
||
Group {
|
||
switch authorizationStatus {
|
||
case .authorized, .limited:
|
||
photoGrid
|
||
case .denied, .restricted:
|
||
permissionDeniedView
|
||
default:
|
||
requestingView
|
||
}
|
||
}
|
||
.task {
|
||
await requestPhotoAccess()
|
||
}
|
||
}
|
||
|
||
// MARK: - Photo Grid
|
||
|
||
private var photoGrid: some View {
|
||
GeometryReader { geometry in
|
||
let tileSize = (geometry.size.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
|
||
let cameraHeight = tileSize * 2 + spacing
|
||
|
||
ScrollView {
|
||
VStack(spacing: spacing) {
|
||
// Header: Camera (2 rows) + 4 photos (2×2 grid)
|
||
headerSection(tileSize: tileSize, cameraHeight: cameraHeight)
|
||
|
||
// Remaining photos: standard 3-column grid
|
||
remainingPhotosGrid(tileSize: tileSize, startIndex: 4)
|
||
}
|
||
.padding(.bottom, 100) // Space for tab bar + send button
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Header section: Camera tile (col 1, rows 1-2) + 4 photos (cols 2-3, rows 1-2).
|
||
@ViewBuilder
|
||
private func headerSection(tileSize: CGFloat, cameraHeight: CGFloat) -> some View {
|
||
HStack(alignment: .top, spacing: spacing) {
|
||
// Camera tile — spans 2 rows (double height)
|
||
CameraPreviewTile(
|
||
width: tileSize,
|
||
height: cameraHeight,
|
||
onTap: onCameraTap
|
||
)
|
||
|
||
// Right side: 2 rows × 2 columns of photos
|
||
VStack(spacing: spacing) {
|
||
// Row 1: photos[0], photos[1]
|
||
HStack(spacing: spacing) {
|
||
headerPhotoTile(index: 0, size: tileSize)
|
||
headerPhotoTile(index: 1, size: tileSize)
|
||
}
|
||
|
||
// Row 2: photos[2], photos[3]
|
||
HStack(spacing: spacing) {
|
||
headerPhotoTile(index: 2, size: tileSize)
|
||
headerPhotoTile(index: 3, size: tileSize)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Single photo tile in the header section.
|
||
@ViewBuilder
|
||
private func headerPhotoTile(index: Int, size: CGFloat) -> some View {
|
||
if index < assets.count {
|
||
let asset = assets[index]
|
||
let isSelected = selectedAssets.contains(where: { $0.localIdentifier == asset.localIdentifier })
|
||
let selectionIndex = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier })
|
||
|
||
PhotoTile(
|
||
asset: asset,
|
||
imageManager: imageManager,
|
||
size: size,
|
||
isSelected: isSelected,
|
||
selectionNumber: selectionIndex.map { $0 + 1 },
|
||
onTap: { handlePhotoTap(asset) },
|
||
onCircleTap: { toggleSelection(asset) }
|
||
)
|
||
.frame(width: size, height: size)
|
||
} else {
|
||
Color(white: 0.15)
|
||
.frame(width: size, height: size)
|
||
}
|
||
}
|
||
|
||
/// Remaining photos after the header, displayed as a standard 3-column LazyVGrid.
|
||
@ViewBuilder
|
||
private func remainingPhotosGrid(tileSize: CGFloat, startIndex: Int) -> some View {
|
||
let remaining = assets.count > startIndex ? Array(assets[startIndex...]) : []
|
||
|
||
LazyVGrid(
|
||
columns: Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns),
|
||
spacing: spacing
|
||
) {
|
||
ForEach(0..<remaining.count, id: \.self) { index in
|
||
let asset = remaining[index]
|
||
let isSelected = selectedAssets.contains(where: { $0.localIdentifier == asset.localIdentifier })
|
||
let selectionIndex = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier })
|
||
|
||
PhotoTile(
|
||
asset: asset,
|
||
imageManager: imageManager,
|
||
size: tileSize,
|
||
isSelected: isSelected,
|
||
selectionNumber: selectionIndex.map { $0 + 1 },
|
||
onTap: { handlePhotoTap(asset) },
|
||
onCircleTap: { toggleSelection(asset) }
|
||
)
|
||
.frame(height: tileSize)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Permission Views
|
||
|
||
private var requestingView: some View {
|
||
VStack(spacing: 16) {
|
||
ProgressView()
|
||
.tint(.white)
|
||
Text("Requesting photo access...")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(.white.opacity(0.6))
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private var permissionDeniedView: some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "photo.on.rectangle.angled")
|
||
.font(.system(size: 40))
|
||
.foregroundStyle(.white.opacity(0.4))
|
||
|
||
Text("Photo Access Required")
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
|
||
Text("Enable photo access in Settings to send images.")
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(.white.opacity(0.6))
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal, 40)
|
||
|
||
Button("Open Settings") {
|
||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
.font(.system(size: 15, weight: .medium))
|
||
.foregroundStyle(RosettaColors.primaryBlue)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
// MARK: - Logic
|
||
|
||
private func requestPhotoAccess() async {
|
||
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||
await MainActor.run {
|
||
authorizationStatus = status
|
||
if status == .authorized || status == .limited {
|
||
loadAssets()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadAssets() {
|
||
let options = PHFetchOptions()
|
||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||
options.fetchLimit = 500
|
||
let result = PHAsset.fetchAssets(with: .image, options: options)
|
||
var fetched: [PHAsset] = []
|
||
result.enumerateObjects { asset, _, _ in
|
||
fetched.append(asset)
|
||
}
|
||
assets = fetched
|
||
}
|
||
|
||
/// Handles tap on the photo body (not the selection circle).
|
||
/// Always opens preview — selection is handled by the circle tap only.
|
||
private func handlePhotoTap(_ asset: PHAsset) {
|
||
onPhotoPreview?(asset)
|
||
}
|
||
|
||
private func toggleSelection(_ asset: PHAsset) {
|
||
if let index = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier }) {
|
||
selectedAssets.remove(at: index)
|
||
} else if selectedAssets.count < maxSelection {
|
||
selectedAssets.append(asset)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CameraPreviewTile
|
||
|
||
/// Camera tile with live AVCaptureSession preview.
|
||
///
|
||
/// Figma: col 1, rows 1–2 in attachment grid. Shows live rear camera feed
|
||
/// with a camera icon overlay in the top-right corner.
|
||
/// Tapping opens full-screen UIImagePickerController for capture.
|
||
private struct CameraPreviewTile: View {
|
||
let width: CGFloat
|
||
let height: CGFloat
|
||
let onTap: () -> Void
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .topTrailing) {
|
||
// Live camera preview (or placeholder on simulator)
|
||
CameraPreviewView()
|
||
.frame(width: width, height: height)
|
||
.clipped()
|
||
|
||
// Camera icon overlay (Figma: top-right)
|
||
Image(systemName: "camera.fill")
|
||
.font(.system(size: 18))
|
||
.foregroundStyle(.white)
|
||
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
|
||
.padding(8)
|
||
}
|
||
.frame(width: width, height: height)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: onTap)
|
||
}
|
||
}
|
||
|
||
// MARK: - PhotoTile
|
||
|
||
/// Single photo tile in the grid with async thumbnail loading and selection circle.
|
||
///
|
||
/// Figma: square aspect ratio, selection circle (22pt, white border 1.5pt) in
|
||
/// top-right corner with 6pt padding. Selected: blue #008BFF filled circle
|
||
/// with white number.
|
||
///
|
||
/// Two tap targets:
|
||
/// - Photo body (`onTap`): selects if unselected, opens preview if selected
|
||
/// - Selection circle (`onCircleTap`): always toggles selection (deselects if selected)
|
||
private struct PhotoTile: View {
|
||
let asset: PHAsset
|
||
let imageManager: PHCachingImageManager
|
||
let size: CGFloat
|
||
let isSelected: Bool
|
||
let selectionNumber: Int?
|
||
let onTap: () -> Void
|
||
let onCircleTap: () -> Void
|
||
|
||
@State private var thumbnail: UIImage?
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .topTrailing) {
|
||
// Photo thumbnail — body tap
|
||
Group {
|
||
if let thumbnail {
|
||
Image(uiImage: thumbnail)
|
||
.resizable()
|
||
.scaledToFill()
|
||
.frame(width: size, height: size)
|
||
.clipped()
|
||
} else {
|
||
Color(white: 0.15)
|
||
.frame(width: size, height: size)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: onTap)
|
||
|
||
// Selection circle (Figma: 22pt, top-right, 6pt inset) — circle tap
|
||
selectionCircle
|
||
.padding(6)
|
||
.contentShape(Circle())
|
||
.onTapGesture(perform: onCircleTap)
|
||
}
|
||
.task(id: asset.localIdentifier) {
|
||
loadThumbnail()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var selectionCircle: some View {
|
||
if isSelected {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color(hex: 0x008BFF))
|
||
.frame(width: 22, height: 22)
|
||
if let number = selectionNumber {
|
||
Text("\(number)")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|
||
} else {
|
||
Circle()
|
||
.strokeBorder(Color.white, lineWidth: 1.5)
|
||
.frame(width: 22, height: 22)
|
||
}
|
||
}
|
||
|
||
private func loadThumbnail() {
|
||
let scale = UIScreen.main.scale
|
||
let targetSize = CGSize(width: size * scale, height: size * scale)
|
||
let options = PHImageRequestOptions()
|
||
options.deliveryMode = .opportunistic
|
||
options.isNetworkAccessAllowed = true
|
||
options.resizeMode = .fast
|
||
|
||
imageManager.requestImage(
|
||
for: asset,
|
||
targetSize: targetSize,
|
||
contentMode: .aspectFill,
|
||
options: options
|
||
) { image, _ in
|
||
if let image {
|
||
DispatchQueue.main.async {
|
||
self.thumbnail = image
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|