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

729 lines
27 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
import PhotosUI
// MARK: - AttachmentPanelView
/// Figma-style bottom sheet for selecting photos and files to attach to a message.
///
/// Figma nodes: 3994:39103 (panel), 4758:50706 (tab bar).
///
/// Structure:
/// - Toolbar: X button (left) + "Recents" title (centered)
/// - Content: Photo grid (Gallery) or File picker (File)
/// - Tab bar: [Gallery] [File] [Avatar] glass capsule background
/// - Send button: appears when items selected, shows count
///
/// Desktop parity: `DialogInput.tsx` attachment menu (paperclip) + file dialog.
struct AttachmentPanelView: View {
let onSend: ([PendingAttachment], String) -> Void
let onSendAvatar: () -> Void
/// When false, tapping avatar tab offers to set an avatar instead of sending.
var hasAvatar: Bool = true
/// Called when user has no avatar and taps the avatar tab navigate to profile.
var onSetAvatar: (() -> Void)?
@Environment(\.dismiss) private var dismiss
@State private var selectedTab: AttachmentTab = .gallery
@State private var selectedAssets: [PHAsset] = []
@State private var showCamera = false
@State private var showFilePicker = false
@State private var capturedImage: UIImage?
@State private var captionText: String = ""
@State private var previewAsset: IdentifiableAsset?
private var hasSelection: Bool { !selectedAssets.isEmpty }
var body: some View {
ZStack(alignment: .bottom) {
// Dark surface background (#1C1C1E NOT pure black, so sheet rounded
// corners are visible against the app's black background behind)
Color(hex: 0x1C1C1E).ignoresSafeArea()
VStack(spacing: 0) {
// Grabber + Toolbar
toolbar
// Content
switch selectedTab {
case .gallery:
PhotoGridView(
selectedAssets: $selectedAssets,
maxSelection: PendingAttachment.maxAttachmentsPerMessage,
onCameraTap: { showCamera = true },
onPhotoPreview: { asset in
previewAsset = IdentifiableAsset(asset: asset)
}
)
case .file:
fileTabContent
case .avatar:
// Avatar is an action tab handled in tabButton tap
Spacer()
}
Spacer(minLength: 0)
}
// Bottom: Tab bar + Send button
bottomBar
}
.sheet(isPresented: $showCamera) {
CameraPickerView { image in
capturedImage = image
handleCapturedImage(image)
}
.ignoresSafeArea()
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { urls in
handlePickedFiles(urls)
}
}
.fullScreenCover(item: $previewAsset) { item in
PhotoPreviewView(
asset: item.asset,
isSelected: selectedAssets.contains(where: { $0.localIdentifier == item.id }),
selectionNumber: selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }).map { $0 + 1 },
captionText: $captionText,
onSend: { image in
let caption = captionText
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], caption)
dismiss()
},
onToggleSelect: {
if let idx = selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }) {
selectedAssets.remove(at: idx)
} else if selectedAssets.count < PendingAttachment.maxAttachmentsPerMessage {
selectedAssets.append(item.asset)
}
}
)
.background(TransparentFullScreenBackground())
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.attachmentCornerRadius(20)
.preferredColorScheme(.dark)
}
// MARK: - Toolbar (Telegram-style: dark surface header)
/// Dark surface header matching Telegram attachment panel.
/// Custom grabber + 44pt close button (Figma: node 4764-50752) + centered title.
private var toolbar: some View {
VStack(spacing: 0) {
// Custom drag indicator (replaces system .presentationDragIndicator)
Capsule()
.fill(Color.white.opacity(0.3))
.frame(width: 36, height: 5)
.padding(.top, 5)
.padding(.bottom, 14)
// Close button + centered title
HStack {
// Close button (same glass style as GlassBackButton, but with X icon)
Button {
dismiss()
} label: {
CloseIconShape()
.fill(.white)
.frame(width: 14, height: 14)
.frame(width: 44, height: 44)
}
.background { closeButtonGlass }
.clipShape(Circle())
Spacer()
// Title with dropdown chevron (Telegram-style, ~20pt semibold)
HStack(spacing: 5) {
Text(tabTitle)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.45))
}
Spacer()
// Right side: selection badge (deselects all) or invisible spacer
if hasSelection {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
selectedAssets.removeAll()
}
} label: {
selectionBadge
}
.transition(.scale.combined(with: .opacity))
} else {
// Invisible spacer to balance close button width (keeps title centered)
Color.clear
.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 10)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: hasSelection)
}
}
/// Selection count badge blue capsule with + count. Tapping deselects all.
private var selectionBadge: some View {
HStack(spacing: 2) {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("\(selectedAssets.count)")
.font(.system(size: 12, weight: .bold))
}
.foregroundStyle(.white)
.frame(width: 44, height: 28)
.background(Color(hex: 0x008BFF), in: Capsule())
}
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@ViewBuilder
private var closeButtonGlass: some View {
if #available(iOS 26, *) {
Circle()
.fill(Color.white.opacity(0.08))
.glassEffect(.regular, in: .circle)
} else {
TelegramGlassCircle()
}
}
/// Title text changes based on selected tab.
private var tabTitle: String {
switch selectedTab {
case .gallery: return "Recents"
case .file: return "File"
case .avatar: return "Avatar"
}
}
// MARK: - File Tab Content
private var fileTabContent: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "doc.fill")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
Text("Select a file to send")
.font(.system(size: 16))
.foregroundStyle(.white.opacity(0.5))
Button {
showFilePicker = true
} label: {
Text("Browse Files")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule())
}
Spacer()
}
.frame(maxWidth: .infinity)
.task {
// Auto-open file picker when File tab is selected
try? await Task.sleep(for: .milliseconds(300))
showFilePicker = true
}
}
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 0) {
if hasSelection {
// Caption input bar (replaces tab bar when photos selected)
captionInputBar
.padding(.horizontal, 16)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
} else {
// Tab bar (Figma: node 4758:50706 glass capsule)
tabBar
.padding(.horizontal, 25)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.25), value: hasSelection)
.background(
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3),
.init(color: .black, location: 0.8),
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea(edges: .bottom)
)
}
// MARK: - Caption Input Bar (matches ChatDetail composer style)
/// Caption input bar shown when photos are selected.
/// Matches ChatDetailView's composer capsule: `.thinMaterial` glass rounded rect.
/// Layout: [text field] [emoji] [send button]
private var captionInputBar: some View {
HStack(spacing: 0) {
// Caption text field
TextField("Add a caption...", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.padding(.leading, 6)
// Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon)
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
.padding(.trailing, 8)
// Send button (exact ChatDetail match: 38×36 capsule with sendPlane icon)
Button {
sendSelectedPhotos()
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.frame(width: 22, height: 19)
.frame(width: 38, height: 36)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
}
.padding(3)
.frame(minHeight: 42, alignment: .bottom)
.background { captionBarBackground }
}
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
@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: - Tab Bar (Figma: glass capsule, 3 tabs)
/// Glass capsule tab bar matching RosettaTabBar pattern.
/// Tabs: Gallery | File | Avatar.
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
private var tabBar: some View {
HStack(spacing: 0) {
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
tabButton(.file, icon: "doc.fill", label: "File")
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
}
.padding(4)
.background { tabBarBackground }
.clipShape(Capsule())
.contentShape(Capsule())
.tabBarShadow()
}
/// Glass background matching RosettaTabBar (lines 136149).
@ViewBuilder
private var tabBarBackground: some View {
if #available(iOS 26, *) {
// iOS 26+ native liquid glass
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
// iOS < 26 matches RosettaTabBar: .regularMaterial + border
Capsule()
.fill(.regularMaterial)
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
)
}
}
/// Individual tab button matching RosettaTabBar dimensions exactly.
/// Icon: 22pt regular (frame height 28), Label: 10pt, VStack spacing: 2, padding: 6pt.
private func tabButton(_ tab: AttachmentTab, icon: String, label: String) -> some View {
let isSelected = selectedTab == tab
return Button {
if tab == .avatar {
if hasAvatar {
onSendAvatar()
dismiss()
} else {
// No avatar set offer to set one
dismiss()
onSetAvatar?()
}
return
} else {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
}
} label: {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 22, weight: .regular))
.frame(height: 28)
Text(label)
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
}
// RosettaTabBar colors: selected=#008BFF, unselected=white
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
.frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6)
.background {
if isSelected {
if #available(iOS 26, *) {
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
// Matches RosettaTabBar selection indicator: .thinMaterial
Capsule()
.fill(.thinMaterial)
.padding(.vertical, 2)
}
}
}
}
.buttonStyle(.plain)
}
// MARK: - Actions
private func sendSelectedPhotos() {
let assets = selectedAssets
let caption = captionText
let manager = PHImageManager.default()
Task {
var attachments: [PendingAttachment] = []
for asset in assets {
if let image = await loadFullImage(asset: asset, manager: manager) {
attachments.append(PendingAttachment.fromImage(image))
}
}
await MainActor.run {
onSend(attachments, caption)
dismiss()
}
}
}
private func handleCapturedImage(_ image: UIImage) {
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], "")
dismiss()
}
private func handlePickedFiles(_ urls: [URL]) {
var attachments: [PendingAttachment] = []
for url in urls.prefix(PendingAttachment.maxAttachmentsPerMessage) {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: url) {
attachments.append(PendingAttachment.fromFile(
data: data,
fileName: url.lastPathComponent
))
}
}
if !attachments.isEmpty {
onSend(attachments, "")
dismiss()
}
}
private func loadFullImage(asset: PHAsset, manager: PHImageManager) async -> UIImage? {
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)
}
}
}
}
}
// MARK: - AttachmentTab
private enum AttachmentTab: Hashable {
case gallery
case file
case avatar
}
// MARK: - IdentifiableAsset
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
struct IdentifiableAsset: Identifiable {
let id: String
let asset: PHAsset
init(asset: PHAsset) {
self.id = asset.localIdentifier
self.asset = asset
}
}
// MARK: - Close Icon (Figma SVG: node 4764-50752)
/// Custom X icon matching Figma close button design.
/// SVG source: 20×20 viewport, fill path. Rendered at ~14pt inside 44pt circle.
private struct CloseIconShape: Shape {
func path(in rect: CGRect) -> Path {
let sx = rect.width / 19.6289
let sy = rect.height / 19.6289
func pt(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: x * sx, y: y * sy)
}
var p = Path()
p.move(to: pt(17.7734, 0.3174))
p.addCurve(to: pt(18.1396, 0.0732), control1: pt(17.8711, 0.2035), control2: pt(17.9932, 0.1221))
p.addCurve(to: pt(18.5547, 0), control1: pt(18.2699, 0.0244), control2: pt(18.4082, 0))
p.addCurve(to: pt(18.9453, 0.0732), control1: pt(18.6849, 0), control2: pt(18.8151, 0.0244))
p.addCurve(to: pt(19.3115, 0.3174), control1: pt(19.0918, 0.1221), control2: pt(19.2139, 0.2035))
p.addCurve(to: pt(19.5557, 0.6836), control1: pt(19.4255, 0.415), control2: pt(19.5068, 0.5371))
p.addCurve(to: pt(19.6289, 1.0742), control1: pt(19.6045, 0.8138), control2: pt(19.6289, 0.944))
p.addCurve(to: pt(19.5557, 1.4893), control1: pt(19.6289, 1.2207), control2: pt(19.6045, 1.3591))
p.addCurve(to: pt(19.3115, 1.8555), control1: pt(19.5068, 1.6357), control2: pt(19.4255, 1.7578))
p.addLine(to: pt(11.3525, 9.8145))
p.addLine(to: pt(19.3115, 17.7734))
p.addCurve(to: pt(19.5557, 18.1396), control1: pt(19.4255, 17.8711), control2: pt(19.5068, 17.9932))
p.addCurve(to: pt(19.6289, 18.5547), control1: pt(19.6045, 18.2699), control2: pt(19.6289, 18.4082))
p.addCurve(to: pt(19.5557, 18.9453), control1: pt(19.6289, 18.6849), control2: pt(19.6045, 18.8151))
p.addCurve(to: pt(19.3115, 19.3115), control1: pt(19.5068, 19.0918), control2: pt(19.4255, 19.2139))
p.addCurve(to: pt(18.9453, 19.5557), control1: pt(19.2139, 19.4255), control2: pt(19.0918, 19.5068))
p.addCurve(to: pt(18.5547, 19.6289), control1: pt(18.8151, 19.6045), control2: pt(18.6849, 19.6289))
p.addCurve(to: pt(18.1396, 19.5557), control1: pt(18.4082, 19.6289), control2: pt(18.2699, 19.6045))
p.addCurve(to: pt(17.7734, 19.3115), control1: pt(17.9932, 19.5068), control2: pt(17.8711, 19.4255))
p.addLine(to: pt(9.8145, 11.3525))
p.addLine(to: pt(1.8555, 19.3115))
p.addCurve(to: pt(1.4893, 19.5557), control1: pt(1.7578, 19.4255), control2: pt(1.6357, 19.5068))
p.addCurve(to: pt(1.0742, 19.6289), control1: pt(1.3591, 19.6045), control2: pt(1.2207, 19.6289))
p.addCurve(to: pt(0.6836, 19.5557), control1: pt(0.944, 19.6289), control2: pt(0.8138, 19.6045))
p.addCurve(to: pt(0.3174, 19.3115), control1: pt(0.5371, 19.5068), control2: pt(0.415, 19.4255))
p.addCurve(to: pt(0.0732, 18.9453), control1: pt(0.2035, 19.2139), control2: pt(0.1221, 19.0918))
p.addCurve(to: pt(0, 18.5547), control1: pt(0.0244, 18.8151), control2: pt(0, 18.6849))
p.addCurve(to: pt(0.0732, 18.1396), control1: pt(0, 18.4082), control2: pt(0.0244, 18.2699))
p.addCurve(to: pt(0.3174, 17.7734), control1: pt(0.1221, 17.9932), control2: pt(0.2035, 17.8711))
p.addLine(to: pt(8.2764, 9.8145))
p.addLine(to: pt(0.3174, 1.8555))
p.addCurve(to: pt(0.0732, 1.4893), control1: pt(0.2035, 1.7578), control2: pt(0.1221, 1.6357))
p.addCurve(to: pt(0, 1.0742), control1: pt(0.0244, 1.3591), control2: pt(0, 1.2207))
p.addCurve(to: pt(0.0732, 0.6836), control1: pt(0, 0.944), control2: pt(0.0244, 0.8138))
p.addCurve(to: pt(0.3174, 0.3174), control1: pt(0.1221, 0.5371), control2: pt(0.2035, 0.415))
p.addCurve(to: pt(0.6836, 0.0732), control1: pt(0.415, 0.2035), control2: pt(0.5371, 0.1221))
p.addCurve(to: pt(1.0742, 0), control1: pt(0.8138, 0.0244), control2: pt(0.944, 0))
p.addCurve(to: pt(1.4893, 0.0732), control1: pt(1.2207, 0), control2: pt(1.3591, 0.0244))
p.addCurve(to: pt(1.8555, 0.3174), control1: pt(1.6357, 0.1221), control2: pt(1.7578, 0.2035))
p.addLine(to: pt(9.8145, 8.2764))
p.addLine(to: pt(17.7734, 0.3174))
p.closeSubpath()
return p
}
}
// MARK: - Presentation Corner Radius (iOS 16.4+)
/// Sets a custom corner radius on the sheet presentation.
/// `.presentationCornerRadius` is iOS 16.4+, so this modifier guards availability.
private struct AttachmentCornerRadiusModifier: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
if #available(iOS 16.4, *) {
content.presentationCornerRadius(radius)
} else {
content
}
}
}
private extension View {
func attachmentCornerRadius(_ radius: CGFloat) -> some View {
modifier(AttachmentCornerRadiusModifier(radius: radius))
}
}
// MARK: - Tab Bar Shadow Modifier
/// Shadow for iOS < 26 tab bar (matches RosettaTabBar's TabBarShadowModifier).
private struct AttachmentTabBarShadowModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
}
private extension View {
func tabBarShadow() -> some View {
modifier(AttachmentTabBarShadowModifier())
}
}
// MARK: - Preview Background Modifier (iOS 16.4+)
/// Sets a custom background on the sheet presentation.
/// `.presentationBackground` is iOS 16.4+, so this modifier guards availability.
private struct PreviewBackgroundModifier<S: ShapeStyle>: ViewModifier {
let style: S
func body(content: Content) -> some View {
if #available(iOS 16.4, *) {
content.presentationBackground(style)
} else {
content
}
}
}
private extension View {
func previewBackground<S: ShapeStyle>(_ style: S) -> some View {
modifier(PreviewBackgroundModifier(style: style))
}
}
// MARK: - CameraPickerView
/// UIKit camera wrapper for taking photos.
struct CameraPickerView: UIViewControllerRepresentable {
let onCapture: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onCapture: onCapture)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onCapture: (UIImage) -> Void
init(onCapture: @escaping (UIImage) -> Void) {
self.onCapture = onCapture
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage {
onCapture(image)
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}
// MARK: - DocumentPickerView
/// UIKit document picker wrapper for selecting files.
struct DocumentPickerView: UIViewControllerRepresentable {
let onPick: ([URL]) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
picker.allowsMultipleSelection = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let onPick: ([URL]) -> Void
init(onPick: @escaping ([URL]) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
onPick(urls)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
// No-op user cancelled
}
}
}
// MARK: - Transparent FullScreen Background
/// UIKit hack to make `.fullScreenCover` background transparent.
/// Walks the view hierarchy to find the presentation container and sets
/// its background to clear, allowing the presenting view to show through.
struct TransparentFullScreenBackground: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}