359 lines
12 KiB
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
|
|
}
|
|
}
|
|
}
|