From 5790f9b2f6d1374bb1f00f9e6ac707184eb967d4 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 29 Aug 2026 02:29:29 +0700 Subject: [PATCH] #5 fix camera capture preview after shooting - Dismiss the camera fullScreenCover through the SwiftUI binding synchronously in the picker delegate before background file preparation, so UIImagePickerController no longer dismisses itself and desynchronizes the presentation state that the preview sheet waits on. - Keep the archive-sync suspension reserved across the dismissal and surface an explicit error when the captured file fails to prepare. - Add verify.sh invariants for the camera dismissal flow. --- Scripts/verify.sh | 8 ++++++ Sources/Shared/UI/ChatView.swift | 25 +++++++++++++++---- Sources/Shared/UI/SystemPhotoCameraView.swift | 17 ++++++++++++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 910f577..3ec377a 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -336,6 +336,14 @@ grep -q 'let items = pickedMediaItems' Sources/Shared/UI/ChatView.swift || { echo "Gallery staging must run on picker dismissal only" exit 1 } +grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || { + echo "The camera picker must request dismissal before background preparation" + exit 1 +} +grep -q 'onDismissRequest' Sources/Shared/UI/ChatView.swift || { + echo "The camera cover must dismiss through the SwiftUI binding" + exit 1 +} grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || { echo "MAM reconnects must prefer the durable archive UID over timestamps" exit 1 diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index d974d3f..c68d080 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -254,13 +254,23 @@ struct ChatView: View { } ) { SystemPhotoCameraView( - onMedia: { result in + onDismissRequest: { + // Runs synchronously in the picker delegate while the + // camera is still presented: dismiss through the + // binding so SwiftUI owns the transition, and reserve + // the archive-sync suspension across the dismissal + // while the file is prepared in the background. showingPhotoCamera = false + photoCameraIsPreparingResult = true + }, + onMedia: { result in switch result { case .success(let media): photoCameraIsPreparingResult = true Task { await stageCapturedMedia(media) } case .failure(let error): + photoCameraIsPreparingResult = false + releaseArchiveSyncAfterCapture() model.errorMessage = error.localizedDescription } }, @@ -1227,11 +1237,12 @@ struct ChatView: View { } } + @discardableResult private func stageImportedFiles( _ urls: [URL], preferredKind: ChatMessage.Kind? = nil - ) async { - guard !urls.isEmpty else { return } + ) async -> Bool { + guard !urls.isEmpty else { return false } isPreparingAttachments = true defer { isPreparingAttachments = false } let prepared = await model.prepareAttachmentDrafts( @@ -1239,10 +1250,11 @@ struct ChatView: View { preferredKind: preferredKind, for: conversation.jid ) - guard !prepared.isEmpty else { return } + guard !prepared.isEmpty else { return false } model.discardAttachmentDrafts(attachmentDrafts) attachmentDrafts = prepared presentAttachmentPreviewAfterPickerDismissal() + return true } private func stageCapturedMedia(_ media: CapturedCameraMedia) async { @@ -1251,7 +1263,10 @@ struct ChatView: View { photoCameraIsPreparingResult = false releaseArchiveSyncAfterCapture() } - await stageImportedFiles([media.url], preferredKind: media.kind) + let staged = await stageImportedFiles([media.url], preferredKind: media.kind) + if !staged, model.errorMessage == nil { + model.errorMessage = "Камера вернула файл, который не удалось подготовить к отправке." + } } private func presentCamera() { diff --git a/Sources/Shared/UI/SystemPhotoCameraView.swift b/Sources/Shared/UI/SystemPhotoCameraView.swift index d67fd94..f20f3eb 100644 --- a/Sources/Shared/UI/SystemPhotoCameraView.swift +++ b/Sources/Shared/UI/SystemPhotoCameraView.swift @@ -12,6 +12,13 @@ import UIKit @MainActor struct SystemPhotoCameraView: UIViewControllerRepresentable { + /// Called synchronously on the main thread inside + /// `didFinishPickingMediaWithInfo`, before any background preparation. + /// The host must flip its `isPresented` binding here so SwiftUI drives + /// the dismissal; letting UIImagePickerController dismiss itself leaves + /// the fullScreenCover state desynchronized and breaks the follow-up + /// preview presentation. + let onDismissRequest: () -> Void let onMedia: (Result) -> Void let onCancel: () -> Void @@ -20,7 +27,11 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(onMedia: onMedia, onCancel: onCancel) + Coordinator( + onDismissRequest: onDismissRequest, + onMedia: onMedia, + onCancel: onCancel + ) } func makeUIViewController(context: Context) -> UIImagePickerController { @@ -42,14 +53,17 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { ) {} final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let onDismissRequest: () -> Void private let onMedia: (Result) -> Void private let onCancel: () -> Void private var completed = false init( + onDismissRequest: @escaping () -> Void, onMedia: @escaping (Result) -> Void, onCancel: @escaping () -> Void ) { + self.onDismissRequest = onDismissRequest self.onMedia = onMedia self.onCancel = onCancel } @@ -60,6 +74,7 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { ) { guard !completed else { return } completed = true + onDismissRequest() let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) } if mediaType?.conforms(to: .movie) == true,