mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
367 lines
14 KiB
Swift
367 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
/// Chip-based recipient editor with contact autocomplete, shared by the
|
|
/// compose window and the reply composer (web ContactRecipientField parity).
|
|
/// Committed addresses render as removable chips; typing searches contacts by
|
|
/// name, email, or company, and a query that matches a contact category
|
|
/// offers it as a filter that narrows the suggestions to that group.
|
|
///
|
|
/// The parent owns both the chips and the in-progress text so a send can
|
|
/// include an address the user typed but never committed.
|
|
struct RecipientField<Trailing: View>: View {
|
|
@Environment(AppEnvironment.self) private var env
|
|
|
|
let label: String
|
|
@Binding var addresses: [String]
|
|
@Binding var text: String
|
|
@ViewBuilder var trailing: () -> Trailing
|
|
|
|
@State private var suggestions: [Contact] = []
|
|
@State private var categories: [ContactCategory] = []
|
|
@State private var activeCategory: ContactCategory?
|
|
@State private var showPicker = false
|
|
@FocusState private var focused: Bool
|
|
|
|
private var canSearch: Bool { env.session.can(.viewContacts) }
|
|
|
|
private var query: String { text.trimmingCharacters(in: .whitespaces) }
|
|
|
|
/// Categories whose title matches the query (offered as filter rows).
|
|
private var categoryMatches: [ContactCategory] {
|
|
guard query.count >= 2, activeCategory == nil else { return [] }
|
|
let lowered = query.lowercased()
|
|
return categories
|
|
.filter { ($0.title ?? "").lowercased().contains(lowered) }
|
|
.prefix(2)
|
|
.map { $0 }
|
|
}
|
|
|
|
private var showsSuggestions: Bool {
|
|
focused && (!suggestions.isEmpty || !categoryMatches.isEmpty)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
row
|
|
if showsSuggestions {
|
|
suggestionList
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.snappy, value: showsSuggestions)
|
|
.task {
|
|
guard canSearch, categories.isEmpty else { return }
|
|
if let me: ContactsMePayload = try? await env.api.get("auth/me") {
|
|
categories = me.categories ?? []
|
|
}
|
|
}
|
|
.task(id: "\(query)|\(activeCategory?.id ?? "")") {
|
|
await search()
|
|
}
|
|
.onChange(of: focused) {
|
|
// Leaving the field commits what was typed, like the web chips.
|
|
if !focused { commitText() }
|
|
}
|
|
}
|
|
|
|
// MARK: Row
|
|
|
|
private var row: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
Text(label)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 44, alignment: .leading)
|
|
RecipientFlow(spacing: 6) {
|
|
if let activeCategory {
|
|
categoryFilterChip(activeCategory)
|
|
}
|
|
ForEach(addresses, id: \.self) { address in
|
|
chip(address)
|
|
}
|
|
TextField(addresses.isEmpty && activeCategory == nil ? "" : " ", text: $text)
|
|
.font(.subheadline)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.keyboardType(.emailAddress)
|
|
.submitLabel(.return)
|
|
.focused($focused)
|
|
.onSubmit {
|
|
commitText()
|
|
focused = true
|
|
}
|
|
.onChange(of: text) { commitSeparators() }
|
|
.frame(minWidth: 80)
|
|
}
|
|
if canSearch {
|
|
Button {
|
|
showPicker = true
|
|
} label: {
|
|
Image(systemName: "plus.circle")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 28, height: 28)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Choose contacts")
|
|
.sheet(isPresented: $showPicker) {
|
|
ContactPickerSheet(existing: addresses) { emails in
|
|
for email in emails { append(email) }
|
|
}
|
|
}
|
|
}
|
|
trailing()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.frame(minHeight: 46, alignment: .center)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { focused = true }
|
|
}
|
|
|
|
private func chip(_ address: String) -> some View {
|
|
Button {
|
|
withAnimation(.snappy) { addresses.removeAll { $0 == address } }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(address)
|
|
.font(.footnote.weight(.medium))
|
|
.lineLimit(1)
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 9)
|
|
.padding(.vertical, 4.5)
|
|
.background(Color(.secondarySystemBackground), in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Remove \(address)")
|
|
}
|
|
|
|
private func categoryFilterChip(_ category: ContactCategory) -> some View {
|
|
let tint = Color(uniboxHex: category.color) ?? WTheme.accent
|
|
return Button {
|
|
withAnimation(.snappy) { activeCategory = nil }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Circle().fill(tint).frame(width: 7, height: 7)
|
|
Text(category.title ?? "Category")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(tint)
|
|
.lineLimit(1)
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(tint.opacity(0.6))
|
|
}
|
|
.padding(.horizontal, 9)
|
|
.padding(.vertical, 4.5)
|
|
.background(tint.opacity(0.12), in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Clear \(category.title ?? "category") filter")
|
|
}
|
|
|
|
// MARK: Suggestions
|
|
|
|
private var suggestionList: some View {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
ForEach(categoryMatches) { category in
|
|
categoryRow(category)
|
|
Divider().padding(.leading, 56)
|
|
}
|
|
ForEach(suggestions.prefix(6)) { contact in
|
|
contactRow(contact)
|
|
Divider().padding(.leading, 56)
|
|
}
|
|
}
|
|
.background(Color(.systemBackground))
|
|
}
|
|
|
|
private func categoryRow(_ category: ContactCategory) -> some View {
|
|
let tint = Color(uniboxHex: category.color) ?? WTheme.accent
|
|
return Button {
|
|
withAnimation(.snappy) {
|
|
activeCategory = category
|
|
text = ""
|
|
}
|
|
focused = true
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(tint)
|
|
.frame(width: 28)
|
|
Text("Filter by ")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
+ Text(category.title ?? "category")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(tint)
|
|
Spacer(minLength: 8)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.frame(minHeight: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(TapScaleStyle())
|
|
}
|
|
|
|
private func contactRow(_ contact: Contact) -> some View {
|
|
Button {
|
|
select(contact)
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
WAvatar(name: contact.displayName, seed: contact.id, size: 28)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
HStack(spacing: 6) {
|
|
Text(contact.hasName ? contact.displayName : (contact.email ?? ""))
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
ForEach((contact.categories ?? []).prefix(2)) { category in
|
|
categoryDot(category)
|
|
}
|
|
}
|
|
if let detail = contactDetailLine(contact) {
|
|
Text(detail)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
Spacer(minLength: 8)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.frame(minHeight: 46)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(TapScaleStyle())
|
|
}
|
|
|
|
private func categoryDot(_ category: ContactMiniCategory) -> some View {
|
|
let tint = Color(uniboxHex: category.color) ?? WTheme.accent
|
|
return HStack(spacing: 3) {
|
|
Circle().fill(tint).frame(width: 6, height: 6)
|
|
Text(category.title ?? "")
|
|
.font(.system(size: 10.5, weight: .medium))
|
|
.foregroundStyle(tint)
|
|
.lineLimit(1)
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(tint.opacity(0.1), in: Capsule())
|
|
}
|
|
|
|
private func contactDetailLine(_ contact: Contact) -> String? {
|
|
var parts: [String] = []
|
|
if contact.hasName, let email = contact.email, !email.isEmpty { parts.append(email) }
|
|
if let company = contact.company, !company.isEmpty { parts.append(company) }
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}
|
|
|
|
// MARK: Behavior
|
|
|
|
private func search() async {
|
|
guard canSearch else { return }
|
|
let wantsResults = query.count >= 2 || activeCategory != nil
|
|
guard wantsResults else {
|
|
if !suggestions.isEmpty { suggestions = [] }
|
|
return
|
|
}
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
guard !Task.isCancelled else { return }
|
|
var body = ContactSearchBody()
|
|
if !query.isEmpty { body.query = query }
|
|
if let activeCategory { body.categoryIDs = [activeCategory.id] }
|
|
guard let page: ContactSearchPage = try? await env.api.post(
|
|
"contacts/search", body: body, query: ["limit": "8"]
|
|
), !Task.isCancelled else { return }
|
|
let taken = Set(addresses.map { $0.lowercased() })
|
|
suggestions = page.data.filter { contact in
|
|
guard let email = contact.email, !email.isEmpty else { return false }
|
|
return !taken.contains(email.lowercased())
|
|
}
|
|
}
|
|
|
|
private func select(_ contact: Contact) {
|
|
guard let email = contact.email, !email.isEmpty else { return }
|
|
append(email)
|
|
text = ""
|
|
focused = true
|
|
}
|
|
|
|
/// Splitting on `, ; \n` commits complete parts as chips as they're typed.
|
|
private func commitSeparators() {
|
|
guard text.contains(where: { $0 == "," || $0 == ";" || $0 == "\n" }) else { return }
|
|
var parts = text.split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "\n" }).map(String.init)
|
|
let endsWithSeparator = text.last.map { $0 == "," || $0 == ";" || $0 == "\n" } ?? false
|
|
let remainder = endsWithSeparator ? "" : (parts.popLast() ?? "")
|
|
for part in parts { append(part) }
|
|
text = remainder
|
|
}
|
|
|
|
private func commitText() {
|
|
let pending = text
|
|
text = ""
|
|
for part in pending.split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "\n" }) {
|
|
append(String(part))
|
|
}
|
|
}
|
|
|
|
private func append(_ raw: String) {
|
|
let address = UniboxAddress.bare(raw.trimmingCharacters(in: .whitespaces))
|
|
guard !address.isEmpty else { return }
|
|
guard !addresses.contains(where: { $0.lowercased() == address.lowercased() }) else { return }
|
|
withAnimation(.snappy) { addresses.append(address) }
|
|
}
|
|
}
|
|
|
|
extension RecipientField where Trailing == EmptyView {
|
|
init(label: String, addresses: Binding<[String]>, text: Binding<String>) {
|
|
self.init(label: label, addresses: addresses, text: text, trailing: { EmptyView() })
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow layout
|
|
|
|
/// Minimal wrapping layout for recipient chips + the inline text field.
|
|
struct RecipientFlow: Layout {
|
|
var spacing: CGFloat = 6
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let width = proposal.width ?? .infinity
|
|
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if x > 0, x + size.width > width {
|
|
x = 0
|
|
y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
x += size.width + spacing
|
|
rowHeight = max(rowHeight, size.height)
|
|
}
|
|
return CGSize(width: width == .infinity ? x : width, height: y + rowHeight)
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if x > bounds.minX, x + size.width > bounds.maxX {
|
|
x = bounds.minX
|
|
y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
subview.place(
|
|
at: CGPoint(x: x, y: y),
|
|
proposal: ProposedViewSize(width: min(size.width, bounds.width), height: size.height)
|
|
)
|
|
x += size.width + spacing
|
|
rowHeight = max(rowHeight, size.height)
|
|
}
|
|
}
|
|
}
|