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) + } +}