From 1692d01925c25388c5ea7404160b60cb879ccc62 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 14:48:29 -0400 Subject: [PATCH] refactor(chat): decide attach close events in one place The bar and the overlay each had their own handlers for what takes the attach panel or card down. The bar handled typing, edits and focus; the overlay handled focus loss, backgrounding, the keyboard hiding and its window going missing. AttachEvent.response(in:) now holds that table as one exhaustive switch over the event and the host, and ConversationBarModel.handle applies it. The overlay still tears itself down synchronously when the response is a reset. Behavior is unchanged: the bar's panel still stays up when the app backgrounds. --- .../Screens/Conversation/AttachEvent.swift | 106 ++++++++++++++++++ .../Conversation/AttachKeyboardOverlay.swift | 32 +++--- .../Conversation/ConversationBottomBar.swift | 15 +-- FlipcashTests/Chat/AttachEventTests.swift | 76 +++++++++++++ 4 files changed, 202 insertions(+), 27 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/AttachEvent.swift create mode 100644 FlipcashTests/Chat/AttachEventTests.swift diff --git a/Flipcash/Core/Screens/Conversation/AttachEvent.swift b/Flipcash/Core/Screens/Conversation/AttachEvent.swift new file mode 100644 index 000000000..5bd0fc322 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachEvent.swift @@ -0,0 +1,106 @@ +// +// AttachEvent.swift +// Flipcash +// + +import SwiftUI + +/// Where the attach panel and its cards are drawn while an event arrives. +enum AttachHost: Equatable { + /// In the bottom bar, with the keyboard down. + case bar + + /// In the keyboard's own window, over the keys. + case keyboardWindow + + /// In a backdrop that replaced the composer's input view. + case inputView +} + +/// Something outside the attach surface that may take the panel or card down. +enum AttachEvent: Equatable { + /// The composer's draft text changed. + case draftChanged + + /// The composer started editing a sent message. + case editBegan + + /// The composer's field gained focus. + case fieldFocused + + /// The composer's field lost focus. + case fieldBlurred + + /// The app left the foreground. + case appBackgrounded + + /// The keyboard announced it is going down. + case keyboardWillHide + + /// The keyboard's window could not be found again after the keyboard rebuilt its view tree. + case keyboardWindowLost + + /// What this event does to the attach surface drawn in `host`. + func response(in host: AttachHost) -> AttachEventResponse { + switch self { + case .draftChanged: + return .dismissPanel + case .editBegan: + return .clearForEdit + case .fieldFocused: + // Focusing the field uncovers the send end the panel covers. Over the keyboard the field + // is already focused. + return host == .bar ? .dismissPanel : .none + case .fieldBlurred, .appBackgrounded: + // The bar's panel doesn't depend on the keyboard or the foreground. + return host == .bar ? .none : .reset + case .keyboardWillHide: + // The input view swap announces the keyboard going and coming; focus loss covers it. + return host == .keyboardWindow ? .reset : .none + case .keyboardWindowLost: + return host == .bar ? .none : .reset + } + } +} + +/// What an `AttachEvent` does to the attach surface. +enum AttachEventResponse: Equatable { + /// Nothing. + case none + + /// The panel animates down if it's open; an open card stays. + case dismissPanel + + /// The card closes at once and the panel animates down: an edit takes `+`'s slot. + case clearForEdit + + /// The panel and the card go at once, with nothing to animate out. + case reset +} + +extension ConversationBarModel { + /// Applies what `event` does to the attach surface drawn in `host`, and returns it so the + /// overlay can tear itself down on a `.reset`. + @discardableResult + func handle(_ event: AttachEvent, in host: AttachHost) -> AttachEventResponse { + let response = event.response(in: host) + switch response { + case .none: + break + case .dismissPanel: + guard attachPanel.isOpen else { break } + attachPanel.animate { $0.dismiss() } + case .clearForEdit: + if attachCard.isOpen { + attachCard.close() + attachCard.exitDidFinish() + } + if attachPanel.isOpen { + attachPanel.animate { $0.dismiss() } + } + case .reset: + resetAttach() + } + return response + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift index 0926ba88c..e6f0a14b1 100644 --- a/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift +++ b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift @@ -126,11 +126,16 @@ final class AttachKeyboardOverlay { model.overKeyboard.deactivate() } - /// Takes the panel and card down at once: the keyboard went, or the app left the foreground. - func abort() { - guard mode != nil else { return } - logger.info("Attach overlay aborted", metadata: [:]) - model.resetAttach() + /// Passes `event` to the model, and comes down at once if it takes the panel and card with it. + private func receive(_ event: AttachEvent) { + let host: AttachHost + switch mode { + case .keyboardWindow: host = .keyboardWindow + case .inputView: host = .inputView + case .dismissKeyboard, nil: return + } + guard model.handle(event, in: host) == .reset else { return } + logger.info("Attach overlay aborted", metadata: ["event": "\(event)"]) end() } @@ -174,7 +179,7 @@ final class AttachKeyboardOverlay { }, report: { [weak container] region, rect in container?.hitRects[region] = rect }, onFinished: { [weak self] in self?.end() }, - onAbort: { [weak self] in self?.abort() } + onEvent: { [weak self] in self?.receive($0) } ) let host = UIHostingController(rootView: AnyView(root)) host.view.backgroundColor = .clear @@ -213,7 +218,7 @@ final class AttachKeyboardOverlay { case .dismissKeyboard: nil } guard let window else { - self.abort() + self.receive(.keyboardWindowLost) return } self.install(container, in: window) @@ -229,15 +234,10 @@ final class AttachKeyboardOverlay { }) } observers.append(center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.abort() } + MainActor.assumeIsolated { self?.receive(.appBackgrounded) } }) - // The input view swap announces the keyboard going and coming, so only the keyboard's own - // window takes a hide as the keyboard leaving. Focus loss covers the swap. observers.append(center.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { - guard self?.mode == .keyboardWindow else { return } - self?.abort() - } + MainActor.assumeIsolated { self?.receive(.keyboardWillHide) } }) } } @@ -320,7 +320,7 @@ private struct AttachOverlayRoot: View { let actions: () -> AttachActions let report: (AttachOverlayRegion, CGRect?) -> Void let onFinished: () -> Void - let onAbort: () -> Void + let onEvent: (AttachEvent) -> Void // Read live, as the bar reads its own, so a photo staged while the menu is up updates both. private var rows: ConversationBarBottomRow { @@ -356,7 +356,7 @@ private struct AttachOverlayRoot: View { // The field losing focus takes the keyboard, and with it what was drawn over it. .onChange(of: model.isComposing) { _, isComposing in if !isComposing { - onAbort() + onEvent(.fieldBlurred) } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 4fc4c2e46..ad1dc17fa 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -512,24 +512,17 @@ struct ConversationBottomBar: View { } // Typing takes the attach panel down, as a tap outside it does. .onChange(of: composer.draft) { - guard model.attachPanel.isOpen else { return } - model.attachPanel.animate { $0.dismiss() } + model.handle(.draftChanged, in: .bar) } // An edit takes `+`'s slot, and the panel or card with it, so Back has no panel to return to. .onChange(of: composer.isEditing) { _, isEditing in guard isEditing else { return } - if model.attachCard.isOpen { - model.attachCard.close() - model.attachCard.exitDidFinish() - } - if model.attachPanel.isOpen { - model.attachPanel.animate { $0.dismiss() } - } + model.handle(.editBegan, in: .bar) } // So does focusing the field, whose send-button end the panel leaves uncovered. .onChange(of: model.isComposing) { _, isComposing in - guard isComposing, model.attachPanel.isOpen else { return } - model.attachPanel.animate { $0.dismiss() } + guard isComposing else { return } + model.handle(.fieldFocused, in: .bar) } // No shared GlassEffectContainer: the composer's glass is a background diff --git a/FlipcashTests/Chat/AttachEventTests.swift b/FlipcashTests/Chat/AttachEventTests.swift new file mode 100644 index 000000000..765e7a746 --- /dev/null +++ b/FlipcashTests/Chat/AttachEventTests.swift @@ -0,0 +1,76 @@ +// +// AttachEventTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import Flipcash + +@MainActor +@Suite("Attach events") +struct AttachEventTests { + + private static let screen = CGRect(x: 0, y: 0, width: 402, height: 800) + + @Test( + "Each event does the same thing in each host as the bar and overlay did before", + arguments: [ + (AttachEvent.draftChanged, AttachHost.bar, AttachEventResponse.dismissPanel), + (.draftChanged, .keyboardWindow, .dismissPanel), + (.editBegan, .bar, .clearForEdit), + (.editBegan, .inputView, .clearForEdit), + (.fieldFocused, .bar, .dismissPanel), + (.fieldFocused, .keyboardWindow, .none), + (.fieldBlurred, .bar, .none), + (.fieldBlurred, .keyboardWindow, .reset), + (.fieldBlurred, .inputView, .reset), + (.appBackgrounded, .bar, .none), + (.appBackgrounded, .keyboardWindow, .reset), + (.appBackgrounded, .inputView, .reset), + (.keyboardWillHide, .bar, .none), + (.keyboardWillHide, .keyboardWindow, .reset), + (.keyboardWillHide, .inputView, .none), + (.keyboardWindowLost, .keyboardWindow, .reset), + (.keyboardWindowLost, .inputView, .reset), + ] + ) + func responseTable(event: AttachEvent, host: AttachHost, expected: AttachEventResponse) { + #expect(event.response(in: host) == expected) + } + + @Test("Typing dismisses an open panel") + func draftDismissesPanel() { + let model = ConversationBarModel() + model.attachPanel.open() + model.handle(.draftChanged, in: .bar) + #expect(!model.attachPanel.isOpen) + } + + @Test("Starting an edit closes an open card at once") + func editClosesCard() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screen: Self.screen) + model.handle(.editBegan, in: .bar) + #expect(!model.attachCard.isOpen) + } + + @Test("Backgrounding leaves the bar's card up") + func backgroundKeepsBarCard() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screen: Self.screen) + model.handle(.appBackgrounded, in: .bar) + #expect(model.attachCard.isOpen) + } + + @Test("Backgrounding takes the card over the keyboard down and reports a reset") + func backgroundResetsOverlayCard() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screen: Self.screen) + let response = model.handle(.appBackgrounded, in: .keyboardWindow) + #expect(response == .reset) + #expect(!model.attachCard.isOpen) + } +}