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

359 lines
12 KiB
Swift

import Photos
import SwiftUI
// MARK: - PhotoPreviewView
/// Full-screen photo preview with editing toolbar and caption input.
///
/// Presented via `.fullScreenCover` with transparent background.
/// Dismissible by swipe-down drag gesture or back button.
/// On swipe-down, the presenting view (attachment panel) is visible behind.
///
/// Layout (Telegram-style):
/// ```
/// +----------------------------------+
/// | [radio button] |
/// | |
/// | [Full photo] |
/// | |
/// |-----------------------------------|
/// | Add a caption... [emoji] [done]| <- checkmark OUTSIDE bar
/// |-----------------------------------|
/// | [<] [crop] [Aa] [adj] [SD] [>] | <- toolbar row
/// +-----------------------------------+
/// ```
struct PhotoPreviewView: View {
let asset: PHAsset
let isSelected: Bool
let selectionNumber: Int?
@Binding var captionText: String
let onSend: (UIImage) -> Void
let onToggleSelect: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var fullImage: UIImage?
@State private var isLoading = true
@State private var dragOffset: CGFloat = 0
@FocusState private var isKeyboardFocused: Bool
private var isKeyboardVisible: Bool { isKeyboardFocused }
var body: some View {
ZStack {
// Semi-transparent background fades on swipe to reveal content behind
Color.black
.opacity(max(0, 1.0 - dragOffset / 400))
.ignoresSafeArea(.container)
VStack(spacing: 0) {
topBar
Spacer()
photoContent
Spacer()
bottomSection
.padding(.bottom, 12)
}
}
.offset(y: dragOffset)
.gesture(dismissDragGesture)
.preferredColorScheme(.dark)
.task {
await loadFullResolutionImage()
}
}
// MARK: - Drag to Dismiss
private var dismissDragGesture: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation.height
}
}
.onEnded { value in
if value.translation.height > 120 {
dismiss()
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
dragOffset = 0
}
}
}
}
// MARK: - Top Bar
private var topBar: some View {
HStack {
Spacer()
Button {
onToggleSelect()
} label: {
if isSelected {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
.frame(width: 28, height: 28)
if let number = selectionNumber {
Text("\(number)")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
}
} else {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
.frame(width: 28, height: 28)
}
}
.frame(width: 44, height: 44)
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
// MARK: - Photo Content
private var photoContent: some View {
Group {
if let fullImage {
Image(uiImage: fullImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 8)
} else if isLoading {
ProgressView()
.tint(.white)
.scaleEffect(1.2)
}
}
}
// MARK: - Bottom Section
@ViewBuilder
private var bottomSection: some View {
VStack(spacing: 12) {
captionInputBar
.padding(.horizontal, 16)
if !isKeyboardVisible {
toolbarRow
.padding(.horizontal, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
}
// MARK: - Caption Input Bar
/// Inactive: placeholder centered, no icons, no checkmark.
/// Active (keyboard): placeholder slides left, emoji inside bar, checkmark OUTSIDE bar.
private var captionInputBar: some View {
HStack(spacing: 8) {
// Glass capsule input bar
HStack(spacing: 0) {
ZStack {
// Custom animated placeholder (slides center left)
if captionText.isEmpty {
Text("Add a caption...")
.font(.system(size: 16))
.foregroundStyle(.white.opacity(0.4))
.frame(
maxWidth: .infinity,
alignment: isKeyboardVisible ? .leading : .center
)
.padding(.leading, isKeyboardVisible ? 16 : 0)
.allowsHitTesting(false)
}
// TextField (no built-in placeholder, always left-aligned)
TextField("", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.padding(.leading, 16)
.focused($isKeyboardFocused)
}
// Emoji icon only when keyboard active
if isKeyboardVisible {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.padding(.trailing, 12)
}
}
.padding(.horizontal, 3)
.frame(height: 42)
.background { captionBarBackground }
// Checkmark button OUTSIDE the bar (white circle + dark checkmark)
if isKeyboardVisible {
Button {
isKeyboardFocused = false
} label: {
ZStack {
Circle()
.fill(.white)
SingleCheckmarkShape()
.fill(Color.black.opacity(0.85))
.frame(width: 14, height: 10)
}
.frame(width: 42, height: 42)
}
.buttonStyle(.plain)
.transition(.scale.combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
}
@ViewBuilder
private var captionBarBackground: some View {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
} else {
TelegramGlassRoundedRect(cornerRadius: 21)
}
}
// MARK: - Toolbar Row (Telegram 1:1 match)
private var toolbarRow: some View {
HStack(spacing: 0) {
// Back button white outlined circle with chevron (matches screenshot)
Button {
dismiss()
} label: {
ZStack {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
}
.frame(width: 33, height: 33)
}
.buttonStyle(.plain)
Spacer()
// Center editing tools
HStack(spacing: 20) {
toolbarIconButton(systemName: "crop")
toolbarTextButton
toolbarIconButton(systemName: "slider.horizontal.3")
qualityBadge
}
Spacer()
// Send button blue circle with arrow up (matches screenshot)
Button {
guard let image = fullImage else { return }
onSend(image)
} label: {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
Image(systemName: "arrow.up")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 33, height: 33)
}
.buttonStyle(.plain)
}
}
private func toolbarIconButton(systemName: String) -> some View {
Button {
// Phase 1: non-functional
} label: {
Image(systemName: systemName)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
/// "Aa" text editing button.
private var toolbarTextButton: some View {
Button {
// Phase 1: non-functional
} label: {
Text("Aa")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
/// SD quality badge dark filled background with border (matches Telegram).
private var qualityBadge: some View {
Button {
// Phase 1: non-functional
} label: {
Text("SD")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(white: 0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color.white.opacity(0.7), lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
// MARK: - Image Loading
private func loadFullResolutionImage() async {
let manager = PHImageManager.default()
let image = await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
options.isSynchronous = false
manager.requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .default,
options: options
) { image, info in
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
if !isDegraded {
continuation.resume(returning: image)
}
}
}
await MainActor.run {
fullImage = image
isLoading = false
}
}
}