mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
814 lines
30 KiB
Swift
814 lines
30 KiB
Swift
import SwiftUI
|
|
|
|
/// Mailboxes browser, presented as a full-screen cover from Home or More
|
|
/// (like the CRM browsers): a slide-in drawer (sky hero + warmup status
|
|
/// scopes with live counts + sliding tone capsule + edge swipe), a search
|
|
/// pill, bare fleet stat columns that drive the same scope, multi-select with
|
|
/// bulk warmup and remove, cursor pagination, and realtime pulse reloads.
|
|
/// Owns its NavigationStack; dismissal goes through `onClose` (the
|
|
/// environment DismissAction is unreliable in this app's cover contexts).
|
|
struct MailboxesRootView: View {
|
|
/// Which bulk tag sheet is open: add applies add_tags, remove remove_tags.
|
|
private enum BulkTagMode: String, Identifiable {
|
|
case add, remove
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
var onClose: () -> Void = {}
|
|
|
|
@Environment(AppEnvironment.self) private var env
|
|
@State private var store = MailboxesStore()
|
|
@State private var searchText = ""
|
|
@State private var scope: MailboxScope = .all
|
|
@State private var sidebarOpen = false
|
|
@State private var sidebarDrag: CGFloat = 0
|
|
@State private var showConnect = false
|
|
@State private var pendingRemove: EmailAccount?
|
|
@State private var confirmBulkRemove = false
|
|
@State private var bulkTagMode: BulkTagMode?
|
|
@State private var tagApplyPulse = 0
|
|
@FocusState private var searchFocused: Bool
|
|
|
|
private static let sidebarWidth: CGFloat = 300
|
|
|
|
private var canManage: Bool { env.session.can(.manageEmails) }
|
|
private var canViewAnalytics: Bool { env.session.can(.viewAnalytics) }
|
|
private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
private var connectedCount: Int { store.allCount }
|
|
|
|
/// The spine bumps emailAccounts and analytics together for account
|
|
/// events; one summed key avoids double reloads.
|
|
private var reloadPulse: Int {
|
|
env.realtime.pulse(for: .emailAccounts) &+ env.realtime.pulse(for: .analytics)
|
|
}
|
|
|
|
/// The loaded rows narrowed to the active scope.
|
|
private var visibleAccounts: [EmailAccount] {
|
|
switch scope {
|
|
case .all:
|
|
return store.accounts
|
|
case .warming:
|
|
return store.accounts.filter(\.isWarmingActive)
|
|
case .paused:
|
|
return store.accounts.filter(\.isWarmupPaused)
|
|
case .issues:
|
|
return store.accounts.filter { store.statuses[$0.id]?.health?.hasIssue == true }
|
|
case .off:
|
|
return store.accounts.filter { $0.warmup == nil }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
browser
|
|
}
|
|
}
|
|
|
|
private var browser: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
mainPane
|
|
.scaleEffect(sidebarOpen ? 0.97 : 1, anchor: .trailing)
|
|
if sidebarOpen {
|
|
Color.black.opacity(0.32)
|
|
.ignoresSafeArea()
|
|
.transition(.opacity)
|
|
.onTapGesture { closeSidebar() }
|
|
}
|
|
drawer(topInset: geo.safeAreaInsets.top)
|
|
}
|
|
}
|
|
// Own chrome, like the other drawer browsers: the drawer hero runs to
|
|
// the top and the search pill row carries the close button.
|
|
.toolbarVisibility(.hidden, for: .navigationBar)
|
|
.navigationDestination(for: EmailAccount.self) { account in
|
|
MailboxDetailView(account: account)
|
|
}
|
|
.task(id: searchText) { await runSearch() }
|
|
.onChange(of: reloadPulse) {
|
|
Task { await store.load(env.api, includeStatuses: canViewAnalytics) }
|
|
}
|
|
.onChange(of: scope) { store.exitSelection() }
|
|
.sensoryFeedback(.selection, trigger: scope)
|
|
.sensoryFeedback(.impact(weight: .light), trigger: sidebarOpen)
|
|
.sensoryFeedback(.impact(weight: .medium), trigger: store.isSelecting)
|
|
.sensoryFeedback(.impact(weight: .light), trigger: tagApplyPulse)
|
|
.fullScreenCover(isPresented: $showConnect) {
|
|
MailboxConnectFlow(onClose: { showConnect = false }, onConnected: { account in
|
|
store.insert(account)
|
|
})
|
|
}
|
|
.sheet(item: $bulkTagMode) { mode in
|
|
MailboxTagPickerSheet(
|
|
title: mode == .add ? "Add tags" : "Remove tags",
|
|
confirmLabel: mode == .add ? "Add" : "Remove",
|
|
requiresSelection: true
|
|
) { tagIDs in
|
|
await applyBulkTags(mode, tagIDs: tagIDs)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Remove \(pendingRemove?.email ?? "this mailbox")?",
|
|
isPresented: Binding(
|
|
get: { pendingRemove != nil },
|
|
set: { if !$0 { pendingRemove = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: pendingRemove
|
|
) { account in
|
|
Button("Remove account", role: .destructive) {
|
|
Task { await store.deleteAccount(env.api, id: account.id) }
|
|
}
|
|
} message: { account in
|
|
Text("Sending stops immediately and \(account.email) leaves all warmup pools.")
|
|
}
|
|
.confirmationDialog(
|
|
"Remove \(store.selectedCount) mailbox\(store.selectedCount == 1 ? "" : "es")?",
|
|
isPresented: $confirmBulkRemove,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Remove \(store.selectedCount)", role: .destructive) {
|
|
Task { await store.bulkRemove(env.api) }
|
|
}
|
|
} message: {
|
|
Text("This disconnects them from Warmbly.")
|
|
}
|
|
.alert(
|
|
"Something went wrong",
|
|
isPresented: Binding(
|
|
get: { store.actionError != nil },
|
|
set: { if !$0 { store.actionError = nil } }
|
|
)
|
|
) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(store.actionError ?? "")
|
|
}
|
|
}
|
|
|
|
// MARK: Main pane
|
|
|
|
private var mainPane: some View {
|
|
VStack(spacing: 0) {
|
|
if store.isSelecting { selectionHeader } else { searchBar }
|
|
if store.hasLoaded, !isSearching, !store.accounts.isEmpty {
|
|
statsHeader
|
|
}
|
|
scopeCaption
|
|
listArea
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.overlay(alignment: .bottom) {
|
|
if store.isSelecting {
|
|
MailboxSelectionBar(
|
|
count: store.selectedCount,
|
|
onStart: { Task { await store.bulkWarmup(env.api, action: "start") } },
|
|
onPause: { Task { await store.bulkWarmup(env.api, action: "pause") } },
|
|
onAddTags: { bulkTagMode = .add },
|
|
onRemoveTags: { bulkTagMode = .remove },
|
|
onRemove: { confirmBulkRemove = true },
|
|
onClear: { store.exitSelection() }
|
|
)
|
|
.padding(.bottom, 10)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 25)
|
|
.onEnded { value in
|
|
// Gmail's edge swipe: open the drawer from the left edge.
|
|
if !sidebarOpen, !store.isSelecting, value.startLocation.x < 44, value.translation.width > 70 {
|
|
openSidebar()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: Search pill
|
|
|
|
private var searchBar: some View {
|
|
HStack(spacing: 10) {
|
|
HStack(spacing: 6) {
|
|
Button {
|
|
openSidebar()
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 38, height: 38)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Open mailboxes menu")
|
|
|
|
TextField("Search by email", text: $searchText)
|
|
.font(.subheadline)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.submitLabel(.search)
|
|
.focused($searchFocused)
|
|
|
|
if !searchText.isEmpty {
|
|
Button {
|
|
searchText = ""
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Clear search")
|
|
}
|
|
|
|
PresenceAvatars()
|
|
.padding(.trailing, 8)
|
|
}
|
|
.frame(height: 44)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
|
|
if canManage {
|
|
Button {
|
|
showConnect = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 44, height: 44)
|
|
.background(Color(.secondarySystemBackground), in: Circle())
|
|
}
|
|
.buttonStyle(TapScaleStyle())
|
|
.accessibilityLabel("Connect a mailbox")
|
|
}
|
|
|
|
Button {
|
|
onClose()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 44, height: 44)
|
|
.background(Color(.secondarySystemBackground), in: Circle())
|
|
}
|
|
.buttonStyle(TapScaleStyle())
|
|
.accessibilityLabel("Close mailboxes")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 4)
|
|
.padding(.bottom, 6)
|
|
}
|
|
|
|
// MARK: Selection header
|
|
|
|
private var selectionHeader: some View {
|
|
let visibleIDs = visibleAccounts.map(\.id)
|
|
return HStack(spacing: 12) {
|
|
Button("Done") { store.exitSelection() }
|
|
.fontWeight(.semibold)
|
|
Spacer()
|
|
Text("\(store.selectedCount) selected")
|
|
.font(.subheadline.weight(.semibold))
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
Spacer()
|
|
Button(store.allSelected(of: visibleIDs) ? "Clear" : "Select all") {
|
|
store.selectAll(visibleIDs)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.frame(height: 44)
|
|
.padding(.top, 4)
|
|
.padding(.bottom, 6)
|
|
}
|
|
|
|
// MARK: Fleet stats
|
|
|
|
/// Bare stat columns over a hairline; each drives the same scope state as
|
|
/// the drawer.
|
|
private var statsHeader: some View {
|
|
VStack(spacing: 10) {
|
|
HStack(spacing: 10) {
|
|
fleetStat("Connected", count: connectedCount, tone: nil, target: .all)
|
|
fleetStat("Warming", count: store.warmingCount, tone: store.warmingCount > 0 ? .orange : nil, target: .warming)
|
|
fleetStat(
|
|
"Issues",
|
|
count: store.issueCount,
|
|
tone: store.issueCount > 0 ? .rose : nil,
|
|
target: .issues,
|
|
enabled: canViewAnalytics
|
|
)
|
|
}
|
|
Divider()
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 6)
|
|
}
|
|
|
|
private func fleetStat(_ label: String, count: Int, tone: Tone?, target: MailboxScope, enabled: Bool = true) -> some View {
|
|
let selected = scope == target && target != .all
|
|
return Button {
|
|
withAnimation(.snappy) { scope = selected ? .all : target }
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
EyebrowLabel(label)
|
|
Text("\(count)")
|
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(tone?.color ?? Color.primary)
|
|
.contentTransition(.numericText())
|
|
Capsule()
|
|
.fill(selected ? (tone?.color ?? WTheme.accent) : Color.clear)
|
|
.frame(width: 26, height: 3)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!enabled)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(label), \(count)")
|
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
|
}
|
|
|
|
// MARK: Scope caption
|
|
|
|
private var scopeCaption: some View {
|
|
HStack(spacing: 6) {
|
|
Text(captionTitle)
|
|
.font(.caption.weight(.semibold))
|
|
.tracking(0.9)
|
|
.foregroundStyle(.secondary)
|
|
if !isSearching, store.count(for: scope) > 0 {
|
|
Text(WFormat.compact(store.count(for: scope)))
|
|
.font(.caption.weight(.semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.tertiary)
|
|
.contentTransition(.numericText())
|
|
}
|
|
Spacer()
|
|
if isSearching, store.hasLoaded {
|
|
Text("\(store.totalCount ?? store.accounts.count) found")
|
|
.font(.caption.weight(.semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.contentTransition(.numericText())
|
|
}
|
|
if canManage, store.hasLoaded, !store.isSelecting, !visibleAccounts.isEmpty {
|
|
Button("Select") { store.enterSelection() }
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(WTheme.accent)
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 2)
|
|
}
|
|
|
|
private var captionTitle: String {
|
|
if isSearching { return "SEARCH RESULTS" }
|
|
return scope.title.uppercased()
|
|
}
|
|
|
|
// MARK: List
|
|
|
|
@ViewBuilder
|
|
private var listArea: some View {
|
|
if !store.hasLoaded {
|
|
ScrollView { SkeletonRows(rows: 10) }
|
|
} else if let error = store.loadError, store.accounts.isEmpty {
|
|
ErrorStateView(title: "Couldn't load mailboxes", message: error) {
|
|
await store.load(env.api, includeStatuses: canViewAnalytics)
|
|
}
|
|
} else if visibleAccounts.isEmpty {
|
|
emptyState
|
|
} else {
|
|
List {
|
|
ForEach(visibleAccounts) { account in
|
|
row(account)
|
|
}
|
|
if store.nextCursor != nil {
|
|
HStack(spacing: 8) {
|
|
Spacer()
|
|
ProgressView().controlSize(.small)
|
|
Text("Loading more…")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 6)
|
|
.listRowSeparator(.hidden)
|
|
.task { await store.loadMore(env.api) }
|
|
} else {
|
|
endMarker
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) }
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(_ account: EmailAccount) -> some View {
|
|
Group {
|
|
if store.isSelecting {
|
|
Button {
|
|
store.toggleSelected(account.id)
|
|
} label: {
|
|
MailboxRowView(
|
|
account: account,
|
|
status: store.statuses[account.id],
|
|
selecting: true,
|
|
selected: store.isSelected(account.id)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
MailboxRowView(account: account, status: store.statuses[account.id])
|
|
.background(NavigationLink(value: account) { EmptyView() }.opacity(0))
|
|
}
|
|
}
|
|
.listRowBackground(store.isSelected(account.id) ? Tone.sky.background : Color(.systemBackground))
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 4, trailing: 16))
|
|
.simultaneousGesture(
|
|
LongPressGesture(minimumDuration: 0.4).onEnded { _ in
|
|
if !store.isSelecting, canManage { store.enterSelection(with: account.id) }
|
|
}
|
|
)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
if !store.isSelecting {
|
|
swipeButtons(for: account)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// End-of-list marker: the exact total for the current scope or search.
|
|
private var endMarker: some View {
|
|
let count = scope == .all ? connectedCount : visibleAccounts.count
|
|
return HStack {
|
|
Spacer()
|
|
Text("\(count) mailbox\(count == 1 ? "" : "es")")
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 10)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyState: some View {
|
|
if isSearching {
|
|
EmptyStateView(
|
|
title: "No matches",
|
|
message: "No mailbox matches \"\(searchText)\"."
|
|
)
|
|
} else {
|
|
switch scope {
|
|
case .warming:
|
|
EmptyStateView(
|
|
title: "Nothing warming",
|
|
message: "Start warmup on a mailbox and it shows up here."
|
|
)
|
|
case .paused:
|
|
EmptyStateView(
|
|
title: "Nothing paused",
|
|
message: "Mailboxes with warmup on hold show up here."
|
|
)
|
|
case .issues:
|
|
EmptyStateView(
|
|
title: "No issues",
|
|
message: "Every mailbox with health data looks fine right now."
|
|
)
|
|
case .off:
|
|
EmptyStateView(
|
|
title: "Everything is warming",
|
|
message: "Mailboxes that never started warmup show up here."
|
|
)
|
|
case .all:
|
|
connectEmptyState
|
|
}
|
|
}
|
|
}
|
|
|
|
private var connectEmptyState: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "envelope.open")
|
|
.font(.system(size: 18))
|
|
.foregroundStyle(WTheme.accent)
|
|
.frame(width: 36, height: 36)
|
|
.background(Tone.sky.background, in: RoundedRectangle(cornerRadius: 8))
|
|
Text("No mailboxes yet")
|
|
.font(.system(size: 14, weight: .medium))
|
|
Text("Connect your first mailbox to start warming up and sending campaigns.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 260)
|
|
if canManage {
|
|
Button("Add mailbox") { showConnect = true }
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
.padding(.top, 6)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(.vertical, 48)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func swipeButtons(for account: EmailAccount) -> some View {
|
|
if canManage {
|
|
Button {
|
|
pendingRemove = account
|
|
} label: {
|
|
Label("Remove", systemImage: "trash")
|
|
}
|
|
.tint(WTheme.negative)
|
|
|
|
switch account.warmupState {
|
|
case .active:
|
|
Button {
|
|
Task { await store.warmupAction(env.api, id: account.id, action: "pause") }
|
|
} label: {
|
|
Label("Pause", systemImage: "pause.fill")
|
|
}
|
|
.tint(WTheme.warning)
|
|
case .paused:
|
|
Button {
|
|
Task { await store.warmupAction(env.api, id: account.id, action: "resume") }
|
|
} label: {
|
|
Label("Resume", systemImage: "flame.fill")
|
|
}
|
|
.tint(Tone.orange.color)
|
|
case .off:
|
|
Button {
|
|
Task { await store.warmupAction(env.api, id: account.id, action: "start") }
|
|
} label: {
|
|
Label("Warm up", systemImage: "flame.fill")
|
|
}
|
|
.tint(Tone.orange.color)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Drawer
|
|
|
|
private func drawer(topInset: CGFloat) -> some View {
|
|
MailboxesSidebar(
|
|
store: store,
|
|
selection: scope,
|
|
topInset: topInset,
|
|
revealed: sidebarOpen
|
|
) { newScope in
|
|
withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) { scope = newScope }
|
|
// Let the highlight capsule slide to the tapped row before closing.
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(280))
|
|
closeSidebar()
|
|
}
|
|
}
|
|
.frame(width: Self.sidebarWidth)
|
|
.frame(maxHeight: .infinity)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 26, topTrailingRadius: 26, style: .continuous))
|
|
.shadow(color: .black.opacity(sidebarOpen ? 0.22 : 0), radius: 30, x: 6, y: 0)
|
|
.ignoresSafeArea()
|
|
.offset(x: drawerOffset)
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
sidebarDrag = min(0, value.translation.width)
|
|
}
|
|
.onEnded { value in
|
|
if value.translation.width < -80 || value.predictedEndTranslation.width < -160 {
|
|
closeSidebar()
|
|
} else {
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { sidebarDrag = 0 }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var drawerOffset: CGFloat {
|
|
(sidebarOpen ? 0 : -Self.sidebarWidth - 40) + sidebarDrag
|
|
}
|
|
|
|
private func openSidebar() {
|
|
searchFocused = false
|
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { sidebarOpen = true }
|
|
}
|
|
|
|
private func closeSidebar() {
|
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) {
|
|
sidebarOpen = false
|
|
sidebarDrag = 0
|
|
}
|
|
}
|
|
|
|
// MARK: Bulk tags
|
|
|
|
/// Bulk add or remove one tag set across the selection, then reload the
|
|
/// rows and leave select mode with a light tap.
|
|
private func applyBulkTags(_ mode: BulkTagMode, tagIDs: [String]) async {
|
|
guard !tagIDs.isEmpty else { return }
|
|
let ok = await store.bulkTags(
|
|
env.api,
|
|
add: mode == .add ? tagIDs : [],
|
|
remove: mode == .remove ? tagIDs : []
|
|
)
|
|
guard ok else { return }
|
|
await store.load(env.api, includeStatuses: canViewAnalytics)
|
|
tagApplyPulse += 1
|
|
store.exitSelection()
|
|
}
|
|
|
|
// MARK: Search + initial load
|
|
|
|
private func runSearch() async {
|
|
if store.hasLoaded {
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
if Task.isCancelled { return }
|
|
}
|
|
// Searching runs server-side across the whole fleet; drop any filter.
|
|
if isSearching, scope != .all {
|
|
withAnimation(.snappy) { scope = .all }
|
|
}
|
|
store.query = searchText
|
|
await store.load(env.api, includeStatuses: canViewAnalytics)
|
|
}
|
|
}
|
|
|
|
// MARK: - Row
|
|
|
|
struct MailboxRowView: View {
|
|
let account: EmailAccount
|
|
let status: AccountAnalytics?
|
|
var selecting: Bool = false
|
|
var selected: Bool = false
|
|
|
|
private var health: MailboxHealth? { status?.health }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
if selecting {
|
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 22))
|
|
.foregroundStyle(selected ? WTheme.accent : Color(.tertiaryLabel))
|
|
.transition(.scale.combined(with: .opacity))
|
|
}
|
|
WAvatar(name: account.email, seed: account.id, size: 42)
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if account.isWarmingActive {
|
|
Image(systemName: "flame.fill")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 16, height: 16)
|
|
.background(Tone.orange.color, in: Circle())
|
|
.overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: 1.5))
|
|
.offset(x: 3, y: 3)
|
|
}
|
|
}
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(account.email)
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(1)
|
|
HStack(spacing: 5) {
|
|
Text(account.providerLabel)
|
|
.foregroundStyle(.secondary)
|
|
warmupCaption
|
|
}
|
|
.font(.footnote)
|
|
}
|
|
Spacer(minLength: 8)
|
|
if let health {
|
|
HealthRing(score: health.score, tone: health.tone)
|
|
.modifier(PingEffect(active: health.hasIssue, color: health.tone.color))
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var warmupCaption: some View {
|
|
switch account.warmupState {
|
|
case .active:
|
|
Text("· \(volumeText)")
|
|
.monospacedDigit()
|
|
.foregroundStyle(Tone.orange.color)
|
|
case .paused:
|
|
Text("· warmup paused")
|
|
.foregroundStyle(WTheme.warning)
|
|
case .off:
|
|
if let sent = status?.dailyUsage?.campaignSent {
|
|
Text("· \(sent) sent today")
|
|
.monospacedDigit()
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var volumeText: String {
|
|
if let ws = status?.warmupStatus {
|
|
return "\(ws.currentVolume ?? 0)/\(ws.targetVolume ?? 0) warm"
|
|
}
|
|
return "warming"
|
|
}
|
|
}
|
|
|
|
// MARK: - Selection bar
|
|
|
|
/// Floating bottom-center bar shown while mailboxes are multi-selected:
|
|
/// count plus bulk warmup start/pause, tag add/remove, and remove.
|
|
struct MailboxSelectionBar: View {
|
|
let count: Int
|
|
let onStart: () -> Void
|
|
let onPause: () -> Void
|
|
let onAddTags: () -> Void
|
|
let onRemoveTags: () -> Void
|
|
let onRemove: () -> Void
|
|
let onClear: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Text("\(count)")
|
|
.font(.subheadline.weight(.bold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.white)
|
|
.frame(minWidth: 26, minHeight: 26)
|
|
.background(WTheme.accent, in: Circle())
|
|
.contentTransition(.numericText())
|
|
|
|
Spacer(minLength: 6)
|
|
|
|
Button(action: onStart) {
|
|
Label("Start", systemImage: "flame.fill")
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(Tone.orange.color)
|
|
.controlSize(.small)
|
|
.disabled(count == 0)
|
|
.accessibilityLabel("Start warmup for \(count) mailboxes")
|
|
|
|
Button(action: onPause) {
|
|
Label("Pause", systemImage: "pause.fill")
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(WTheme.warning)
|
|
.controlSize(.small)
|
|
.disabled(count == 0)
|
|
.accessibilityLabel("Pause warmup for \(count) mailboxes")
|
|
|
|
// One compact entry for both tag actions; the bar is already full.
|
|
Menu {
|
|
Button(action: onAddTags) {
|
|
Label("Add tags", systemImage: "tag")
|
|
}
|
|
Button(action: onRemoveTags) {
|
|
Label("Remove tags", systemImage: "tag.slash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "tag")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(WTheme.accent)
|
|
.frame(width: 30, height: 30)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.disabled(count == 0)
|
|
.accessibilityLabel("Tag \(count) mailboxes")
|
|
|
|
Button(role: .destructive, action: onRemove) {
|
|
Image(systemName: "trash")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.frame(width: 30, height: 30)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(WTheme.negative)
|
|
.controlSize(.small)
|
|
.disabled(count == 0)
|
|
.accessibilityLabel("Remove \(count) mailboxes")
|
|
|
|
Button(action: onClear) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 30, height: 30)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Clear selection")
|
|
}
|
|
.padding(.leading, 12)
|
|
.padding(.trailing, 8)
|
|
.padding(.vertical, 8)
|
|
.background(.regularMaterial, in: Capsule())
|
|
.overlay(Capsule().strokeBorder(Color(.separator).opacity(0.35), lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.14), radius: 14, y: 4)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
}
|