Files
buzz/mobile/ios/Runner/NativeConfirmationDialog.swift
Diem Nguyen 133c883b8c fix(mobile): confirm before starting a Huddle from the header (#9155)
Tapping the headphones button in a channel header used to start a Huddle
straight away, which turned on the microphone and posted "Huddle
started" to the channel. The button is easy to hit by accident. Now,
when no Huddle is running there, the button asks "Start a Huddle?"
first, with Cancel and Start Huddle.

- `showConfirmation` in `shared/widgets/confirmation_dialog.dart` adds a
non-destructive mode to the shared confirm helper.
`showDestructiveConfirmation` now calls it with `destructive: true`, so
existing callers behave the same. On iOS the native UIKit alert reads a
new `destructive` argument and uses `.default` style instead of
`.destructive` when it's false. On other platforms the confirm button
keeps the default `FilledButton` style instead of the error color.
- `_huddleNavigationAction` asks before calling `start`. That covers
both the Material header button and the iOS native navigation action.
Opening or joining a Huddle that's already running stays one tap, and so
do the explicit "Start new" buttons inside the Huddle sheets.

Companion to block/buzz-app#2687, which adds the same confirmation on
desktop. buzz-app#2660 also moves the desktop button away from the
members button, but there's no equivalent move on mobile: the headphones
is the only action in the header.

### Screenshots

Rendered by a scratch widget test from the real `ChannelDetailPage`
(Android/Material path, Inter loaded) at 390×844. The scratch test has
been removed. On iOS, the same confirmation shows as a native UIKit
alert, which isn't captured here because this machine has no iOS
Simulator.

Before: tapping the headphones goes straight into a call.

<img
src="https://github.com/user-attachments/assets/adced280-2abd-44ad-a079-1c83749892a0"
width="300" alt="Before">

After, light and dark, cropped to the new dialog:

| Light | Dark |
| --- | --- |
| ![After,
light](https://github.com/user-attachments/assets/2e7c5bc3-ebd9-451b-9b20-5d916947722c)
| ![After,
dark](https://github.com/user-attachments/assets/999c1798-9d91-4b83-b8c2-eb8490da6bf0)
|

### Validation

- The existing journey "community transition cancels and awaits an
in-flight Huddle start" now taps the button and checks that the dialog
appears, that Cancel closes it without publishing anything, and that
tapping again and choosing Start Huddle starts the call. With the
confirm removed, this test fails.
- `channel_member_profile_actions_test` checks that destructive
confirmations still send `destructive: true` to the iOS bridge.
- Full `flutter test`: 4,508 passed. `flutter analyze` and `dart format`
are clean.
- Not yet checked: the iOS native alert on a device or simulator, and a
human trying it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Diem Nguyen <diem@squareup.com>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:33:07 -08:00

79 lines
3.3 KiB
Swift

import Flutter
import UIKit
/// Owns a UIKit confirmation alert until dismissal and replies exactly once.
final class NativeConfirmationDialogCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parent: UIViewController?
private weak var alert: UIAlertController?
private var pendingResult: FlutterResult?
private var isFinishing = false
init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
channel = FlutterMethodChannel(name: "buzz/confirmation_dialog", binaryMessenger: messenger)
parent = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { result(false); return }
self.handle(call, result: result)
}
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard call.method == "present" else { result(FlutterMethodNotImplemented); return }
guard let data = call.arguments as? [String: Any],
let title = data["title"] as? String,
let message = data["message"] as? String,
let confirmLabel = data["confirmLabel"] as? String,
let cancelLabel = data["cancelLabel"] as? String else {
result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil))
return
}
// Repeated taps must not create stacked alerts or multiple removals.
guard pendingResult == nil else { result(false); return }
var presenter = parent ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController
while let presented = presenter?.presentedViewController { presenter = presented }
guard let presenter, presenter.view.window != nil,
!presenter.isBeingDismissed, !(presenter is UIAlertController) else {
result(FlutterError(code: "presentation_failed", message: "Cannot present confirmation right now.", details: nil))
return
}
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
alert.addAction(UIAlertAction(title: cancelLabel, style: .cancel) { [weak self] _ in
self?.cancel()
})
let destructive = data["destructive"] as? Bool ?? true
alert.addAction(UIAlertAction(title: confirmLabel, style: destructive ? .destructive : .default) { [weak self] _ in
self?.finish(true)
})
pendingResult = result
self.alert = alert
presenter.present(alert, animated: !UIAccessibility.isReduceMotionEnabled)
}
/// Dismisses an outstanding confirmation without authorizing the action.
func cancel() { finish(false) }
private func finish(_ confirmed: Bool) {
guard pendingResult != nil, !isFinishing else { return }
isFinishing = true
guard let alert else { resolve(confirmed); return }
// Finish native dismissal before Flutter removes the underlying route.
alert.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in
self?.resolve(confirmed)
}
}
private func resolve(_ confirmed: Bool) {
let result = pendingResult
pendingResult = nil
isFinishing = false
alert = nil
result?(confirmed)
}
}