729 lines
27 KiB
Swift
729 lines
27 KiB
Swift
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 22–34).
|
||
@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 136–149).
|
||
@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) {}
|
||
}
|
||
|