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