Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions Flipcash/Core/Screens/Conversation/AttachEvent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
32 changes: 16 additions & 16 deletions Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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) }
})
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
15 changes: 4 additions & 11 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions FlipcashTests/Chat/AttachEventTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading