feat(share-ext): confirmation card instead of a confusing blank flash
A Share Extension opening its host app is unsupported on modern iOS (restricted ~iOS 14), so the programmatic bizconnect://share open is silently blocked and the user just saw a blank flash back to Photos — looking broken even though the files staged fine. The extension now shows a small native card after staging: "✓ Ready to send — Open Biz Connect to choose a chat", with an "Open Biz Connect" button (user-initiated open has the best chance of working) and a Done button. It still attempts the auto-open first. Either way the app collects the staged files when next opened, so the manual path that already works is unchanged — this just removes the "did it even work?" confusion. Renamed the local `staged` array to `collected` to free `staged` for the state flag. Balance + selectors checked. NEEDS A NEW iOS BUILD (native change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,19 +5,35 @@ import UniformTypeIdentifiers
|
|||||||
// An app CANNOT appear there any other way: the share sheet only lists app extensions, so this has to be a
|
// An app CANNOT appear there any other way: the share sheet only lists app extensions, so this has to be a
|
||||||
// second target inside the app bundle, with its own bundle id and provisioning profile.
|
// second target inside the app bundle, with its own bundle id and provisioning profile.
|
||||||
//
|
//
|
||||||
// It deliberately has NO UI. Picking the conversation happens in the app, where the chat list, search and
|
// The picker itself lives in the APP, not here — the chat list, search and upload progress already exist
|
||||||
// upload progress already exist — re-implementing all of that natively here would be a second, divergent
|
// there, and re-implementing them natively would be a second, divergent copy (and the extension can't
|
||||||
// copy of the app. So this extension only does the part that must happen inside it:
|
// reach the web session where contacts live). So this extension:
|
||||||
// 1. copy the shared items into the App Group container (the only storage both processes can see), and
|
// 1. copies the shared items into the App Group container (the only storage both processes can see),
|
||||||
// 2. bounce the user into the app, which picks them up and shows "Send to…".
|
// 2. tries to open the app (bizconnect://share), and
|
||||||
// If step 2 is blocked, the files still sit staged and are collected the next time the app is opened —
|
// 3. shows a small confirmation card — because a Share Extension opening its host app is unsupported on
|
||||||
// nothing is lost, the hand-off is just deferred.
|
// modern iOS and often silently blocked, so the card gives the user a clear "Open Biz Connect" tap
|
||||||
|
// and states the files are ready. Either way the app collects the staged files when next opened, so
|
||||||
|
// nothing is ever lost.
|
||||||
class ShareViewController: UIViewController {
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
private let appGroup = "group.com.bizgaze.connect"
|
private let appGroup = "group.com.bizgaze.connect"
|
||||||
private let urlScheme = "bizconnect"
|
private let urlScheme = "bizconnect"
|
||||||
private let maxItems = 20
|
private let maxItems = 20
|
||||||
|
|
||||||
|
// Minimal confirmation card (built in code — no storyboard).
|
||||||
|
private let card = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let subtitleLabel = UILabel()
|
||||||
|
private let openButton = UIButton(type: .system)
|
||||||
|
private let doneButton = UIButton(type: .system)
|
||||||
|
private var staged = false
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
buildCard()
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
handleShare()
|
handleShare()
|
||||||
@@ -31,25 +47,104 @@ class ShareViewController: UIViewController {
|
|||||||
.prefix(maxItems)
|
.prefix(maxItems)
|
||||||
guard !providers.isEmpty else { return finish() }
|
guard !providers.isEmpty else { return finish() }
|
||||||
|
|
||||||
var staged: [[String: Any]] = []
|
var collected: [[String: Any]] = []
|
||||||
let lock = NSLock()
|
let lock = NSLock()
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
|
|
||||||
for provider in providers {
|
for provider in providers {
|
||||||
group.enter()
|
group.enter()
|
||||||
load(provider) { record in
|
load(provider) { record in
|
||||||
if let record = record { lock.lock(); staged.append(record); lock.unlock() }
|
if let record = record { lock.lock(); collected.append(record); lock.unlock() }
|
||||||
group.leave()
|
group.leave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
group.notify(queue: .main) { [weak self] in
|
group.notify(queue: .main) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if !staged.isEmpty { self.appendToManifest(staged) }
|
if !collected.isEmpty { self.appendToManifest(collected) }
|
||||||
self.openHostApp()
|
self.staged = !collected.isEmpty
|
||||||
self.finish()
|
self.openHostApp() // best-effort; usually blocked, hence the card below
|
||||||
|
self.showReady(count: collected.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Confirmation UI
|
||||||
|
|
||||||
|
private func buildCard() {
|
||||||
|
card.backgroundColor = .systemBackground
|
||||||
|
card.layer.cornerRadius = 16
|
||||||
|
card.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(card)
|
||||||
|
|
||||||
|
titleLabel.text = "Preparing…"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textColor = .label
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
subtitleLabel.text = ""
|
||||||
|
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
subtitleLabel.textColor = .secondaryLabel
|
||||||
|
subtitleLabel.textAlignment = .center
|
||||||
|
subtitleLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
openButton.setTitle("Open Biz Connect", for: .normal)
|
||||||
|
openButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
openButton.setTitleColor(.white, for: .normal)
|
||||||
|
openButton.backgroundColor = UIColor(red: 0.12, green: 0.23, blue: 0.45, alpha: 1) // brand navy
|
||||||
|
openButton.layer.cornerRadius = 10
|
||||||
|
openButton.addTarget(self, action: #selector(openTapped), for: .touchUpInside)
|
||||||
|
openButton.isHidden = true
|
||||||
|
|
||||||
|
doneButton.setTitle("Done", for: .normal)
|
||||||
|
doneButton.titleLabel?.font = .systemFont(ofSize: 15)
|
||||||
|
doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside)
|
||||||
|
doneButton.isHidden = true
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, openButton, doneButton])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.alignment = .fill
|
||||||
|
stack.setCustomSpacing(6, after: titleLabel)
|
||||||
|
stack.setCustomSpacing(18, after: subtitleLabel)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
card.addSubview(stack)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
card.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
|
||||||
|
card.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
|
||||||
|
card.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
|
||||||
|
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -18),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 22),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -22),
|
||||||
|
openButton.heightAnchor.constraint(equalToConstant: 46)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showReady(count: Int) {
|
||||||
|
if !staged {
|
||||||
|
titleLabel.text = "Couldn’t prepare the file"
|
||||||
|
subtitleLabel.text = "Please try sharing again."
|
||||||
|
} else {
|
||||||
|
titleLabel.text = "✓ Ready to send"
|
||||||
|
let noun = count == 1 ? "item" : "\(count) items"
|
||||||
|
subtitleLabel.text = "Open Biz Connect to choose a chat and send your \(noun)."
|
||||||
|
openButton.isHidden = false
|
||||||
|
}
|
||||||
|
doneButton.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openTapped() {
|
||||||
|
openHostApp()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func doneTapped() {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve one attachment to a staged file (or a text/link record).
|
/// Resolve one attachment to a staged file (or a text/link record).
|
||||||
private func load(_ provider: NSItemProvider, completion: @escaping ([String: Any]?) -> Void) {
|
private func load(_ provider: NSItemProvider, completion: @escaping ([String: Any]?) -> Void) {
|
||||||
// Most specific first; .item is the catch-all for arbitrary documents.
|
// Most specific first; .item is the catch-all for arbitrary documents.
|
||||||
|
|||||||
Reference in New Issue
Block a user