Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/PhotoGridView.swift

357 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 12, 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 12 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
}
}
}
}
}