mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-21 08:01:30 +00:00
feat: iOS mailboxes rebuilt end to end - the list is a full-screen drawer browser cover (warmup status scopes with live counts, search pill, multi-select bulk warmup/remove, circular close) since pushing it fought the system back swipe; connecting is an air onboarding flow with real Google/Outlook brand logos, a web-style authorize step with scope bullets, working in-app OAuth via ASWebAuthenticationSession against the new warmbly:// callback, an SMTP/IMAP wizard, and a start-warmup offer on success; the detail screen becomes editable (warmup ramp steppers, sending window and weekday bitmask chips matching the Go scheduler's Sunday-first layout, sender profile, sending limits, disconnect) with debounced optimistic PATCHes
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "google-logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "outlook-logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Modal connect flow. As a sheet it is its own root, so it wraps content in
|
||||
/// a NavigationStack (unlike pushed detail views). Offers the two provider
|
||||
/// OAuth launchers and the fully-native SMTP/IMAP form.
|
||||
struct MailboxConnectSheet: View {
|
||||
@Environment(AppEnvironment.self) private var env
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var mode: Mode = .choose
|
||||
@State private var errorText: String?
|
||||
@State private var isWorking = false
|
||||
|
||||
// SMTP/IMAP form
|
||||
@State private var email = ""
|
||||
@State private var displayName = ""
|
||||
@State private var smtpHost = ""
|
||||
@State private var smtpPort = "465"
|
||||
@State private var smtpUsername = ""
|
||||
@State private var smtpPassword = ""
|
||||
@State private var imapHost = ""
|
||||
@State private var imapPort = "993"
|
||||
@State private var imapUsername = ""
|
||||
@State private var imapPassword = ""
|
||||
|
||||
enum Mode: Equatable {
|
||||
case choose
|
||||
case smtp
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch mode {
|
||||
case .choose: chooser
|
||||
case .smtp: smtpForm
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect a mailbox")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
if mode == .smtp {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Connect") {
|
||||
Task { await connectSMTP() }
|
||||
}
|
||||
.disabled(!smtpFormValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Couldn't connect",
|
||||
isPresented: Binding(
|
||||
get: { errorText != nil },
|
||||
set: { if !$0 { errorText = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errorText ?? "")
|
||||
}
|
||||
}
|
||||
.presentationDragIndicator(.visible)
|
||||
.sensoryFeedback(.selection, trigger: mode)
|
||||
}
|
||||
|
||||
// MARK: Chooser
|
||||
|
||||
private var chooser: some View {
|
||||
List {
|
||||
Section {
|
||||
providerRow("Gmail", subtitle: "Connect with Google", provider: "gmail", symbol: "envelope.fill", tone: .rose)
|
||||
providerRow("Outlook", subtitle: "Connect with Microsoft", provider: "outlook", symbol: "envelope.fill", tone: .sky)
|
||||
} header: {
|
||||
Text("OAuth")
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
mode = .smtp
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
IconTile(symbol: "server.rack", tone: .slate, size: 38)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("SMTP / IMAP")
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Host, port, and app password")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(TapScaleStyle())
|
||||
} header: {
|
||||
Text("Manual")
|
||||
} footer: {
|
||||
Text("Use a host, port, and app password for providers without OAuth.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func providerRow(_ title: String, subtitle: String, provider: String, symbol: String, tone: Tone) -> some View {
|
||||
Button {
|
||||
Task { await startOAuth(provider: provider) }
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
IconTile(symbol: symbol, tone: tone, size: 38)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.forward.app")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(WTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(TapScaleStyle())
|
||||
.disabled(isWorking)
|
||||
}
|
||||
|
||||
// MARK: SMTP form
|
||||
|
||||
private var smtpForm: some View {
|
||||
List {
|
||||
Section("Mailbox") {
|
||||
TextField("Email address", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Sender name", text: $displayName)
|
||||
}
|
||||
Section("Outgoing (SMTP)") {
|
||||
TextField("Host", text: $smtpHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port (465 or 587)", text: $smtpPort)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Username", text: $smtpUsername)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Password", text: $smtpPassword)
|
||||
}
|
||||
Section("Incoming (IMAP)") {
|
||||
TextField("Host", text: $imapHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: $imapPort)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Username", text: $imapUsername)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Password", text: $imapPassword)
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
Task { await connectSMTP() }
|
||||
} label: {
|
||||
Group {
|
||||
if isWorking {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Connect mailbox")
|
||||
.font(.body.weight(.semibold))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(WTheme.accent)
|
||||
.controlSize(.large)
|
||||
.disabled(!smtpFormValid || isWorking)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var smtpFormValid: Bool {
|
||||
guard email.contains("@"), displayName.count >= 2 else { return false }
|
||||
guard !smtpHost.isEmpty, !imapHost.isEmpty else { return false }
|
||||
guard let sPort = Int(smtpPort), sPort == 465 || sPort == 587 else { return false }
|
||||
guard let iPort = Int(imapPort), iPort > 0 else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
private func startOAuth(provider: String) async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
let response: MailboxOAuthStartResponse = try await env.api.post(
|
||||
"emails/onboarding/oauth/start",
|
||||
body: MailboxOAuthStartBody(provider: provider)
|
||||
)
|
||||
if let url = URL(string: response.url) {
|
||||
openURL(url)
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "The provider returned an invalid authorization URL."
|
||||
}
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func connectSMTP() async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
let body = MailboxSMTPConnectBody(
|
||||
email: email.trimmingCharacters(in: .whitespaces),
|
||||
name: displayName.trimmingCharacters(in: .whitespaces),
|
||||
smtp: MailboxServerCredentials(
|
||||
username: smtpUsername,
|
||||
password: smtpPassword,
|
||||
host: smtpHost.trimmingCharacters(in: .whitespaces),
|
||||
port: Int(smtpPort) ?? 465
|
||||
),
|
||||
imap: MailboxServerCredentials(
|
||||
username: imapUsername,
|
||||
password: imapPassword,
|
||||
host: imapHost.trimmingCharacters(in: .whitespaces),
|
||||
port: Int(imapPort) ?? 993
|
||||
)
|
||||
)
|
||||
do {
|
||||
let _: EmailAccount = try await env.api.post("emails/onboarding/smtp-imap", body: body)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,16 @@ final class MailboxDetailStore {
|
||||
private(set) var banStatus: MailboxBanStatus?
|
||||
private(set) var isCheckingAuth = false
|
||||
private(set) var isSaving = false
|
||||
/// True once a local edit or PATCH response refreshed the row, so views
|
||||
/// can stop preferring analytics for fields the list response omits.
|
||||
private(set) var rowEdited = false
|
||||
var actionError: String?
|
||||
|
||||
// Debounce state for stepper bursts: one snapshot per burst, one body.
|
||||
private var pendingBody = MailboxUpdateBody()
|
||||
private var pendingSnapshot: EmailAccount?
|
||||
private var editGeneration = 0
|
||||
|
||||
init(account: EmailAccount) {
|
||||
self.account = account
|
||||
}
|
||||
@@ -75,6 +83,7 @@ final class MailboxDetailStore {
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
|
||||
rowEdited = true
|
||||
apply(updated)
|
||||
return true
|
||||
} catch {
|
||||
@@ -82,4 +91,63 @@ final class MailboxDetailStore {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic partial update: apply the local mutation immediately, PATCH,
|
||||
/// then adopt the server's row (or roll back and surface the error).
|
||||
func update(_ api: APIClient, body: MailboxUpdateBody, mutate: (inout EmailAccount) -> Void) async {
|
||||
let previous = account
|
||||
rowEdited = true
|
||||
withAnimation(.snappy) { mutate(&account) }
|
||||
do {
|
||||
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
|
||||
withAnimation(.snappy) { account = updated }
|
||||
} catch {
|
||||
withAnimation(.snappy) { account = previous }
|
||||
actionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Stepper variant: every tap applies locally right away, but rapid taps
|
||||
/// coalesce into one PATCH after 600ms of quiet. A failed PATCH rolls the
|
||||
/// whole burst back to the pre-burst row.
|
||||
func updateDebounced(
|
||||
_ api: APIClient,
|
||||
mutateBody: (inout MailboxUpdateBody) -> Void,
|
||||
mutate: (inout EmailAccount) -> Void
|
||||
) async {
|
||||
if pendingSnapshot == nil { pendingSnapshot = account }
|
||||
mutateBody(&pendingBody)
|
||||
rowEdited = true
|
||||
withAnimation(.snappy) { mutate(&account) }
|
||||
editGeneration += 1
|
||||
let generation = editGeneration
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
guard generation == editGeneration else { return }
|
||||
let body = pendingBody
|
||||
let snapshot = pendingSnapshot
|
||||
pendingBody = MailboxUpdateBody()
|
||||
pendingSnapshot = nil
|
||||
do {
|
||||
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
|
||||
withAnimation(.snappy) { account = updated }
|
||||
} catch {
|
||||
if let snapshot {
|
||||
withAnimation(.snappy) { account = snapshot }
|
||||
}
|
||||
actionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Disconnect
|
||||
|
||||
/// DELETE /emails/:id (204); true on success so the view can pop.
|
||||
func deleteAccount(_ api: APIClient) async -> Bool {
|
||||
do {
|
||||
let _: EmptyBody = try await api.delete("emails/\(account.id)")
|
||||
return true
|
||||
} catch {
|
||||
actionError = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,18 @@ enum MailboxDetailTab: String, CaseIterable, Identifiable {
|
||||
/// NOT create its own NavigationStack. Claims presence on `mailbox:<id>` and
|
||||
/// reloads on the emailAccounts/analytics pulse.
|
||||
struct MailboxDetailView: View {
|
||||
private enum SetupField: Hashable { case name, replyTo }
|
||||
|
||||
@Environment(AppEnvironment.self) private var env
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var store: MailboxDetailStore
|
||||
@State private var tab: MailboxDetailTab = .overview
|
||||
@State private var displayName = ""
|
||||
@State private var replyTo = ""
|
||||
@State private var fieldsSeeded = false
|
||||
@State private var confirmDisconnect = false
|
||||
@FocusState private var focusedField: SetupField?
|
||||
|
||||
init(account: EmailAccount) {
|
||||
_store = State(initialValue: MailboxDetailStore(account: account))
|
||||
@@ -55,7 +63,10 @@ struct MailboxDetailView: View {
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.presenceResource(store.presenceKey)
|
||||
.task { await store.loadAnalytics(env.api) }
|
||||
.task {
|
||||
seedFieldsIfNeeded()
|
||||
await store.loadAnalytics(env.api)
|
||||
}
|
||||
.task { await store.loadBanStatus(env.api) }
|
||||
.onChange(of: env.realtime.pulse(for: .emailAccounts)) {
|
||||
Task { await store.loadAnalytics(env.api) }
|
||||
@@ -63,6 +74,24 @@ struct MailboxDetailView: View {
|
||||
.onChange(of: env.realtime.pulse(for: .analytics)) {
|
||||
Task { await store.loadAnalytics(env.api) }
|
||||
}
|
||||
.onChange(of: focusedField) { previous, current in
|
||||
// Text rows save on focus loss, like the profile settings rows.
|
||||
if previous == .name, current != .name { saveDisplayName() }
|
||||
if previous == .replyTo, current != .replyTo { saveReplyTo() }
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Disconnect \(account.email)?",
|
||||
isPresented: $confirmDisconnect,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Disconnect", role: .destructive) {
|
||||
Task {
|
||||
if await store.deleteAccount(env.api) { dismiss() }
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Campaigns stop sending from it and history stays.")
|
||||
}
|
||||
.alert(
|
||||
"Something went wrong",
|
||||
isPresented: Binding(
|
||||
@@ -185,8 +214,7 @@ struct MailboxDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
private func healthSection(_ health: MailboxHealth) -> some View {
|
||||
@@ -239,9 +267,10 @@ struct MailboxDetailView: View {
|
||||
private var warmupTab: some View {
|
||||
List {
|
||||
warmupSection
|
||||
rampSection
|
||||
windowSection
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -327,15 +356,351 @@ struct MailboxDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Warmup ramp (editable)
|
||||
|
||||
// List responses omit warmup_reply_rate (decodes as 0), so prefer the
|
||||
// analytics snapshot until a PATCH refreshes the row.
|
||||
private var replyRateValue: Int {
|
||||
if store.rowEdited { return account.warmupReplyRate ?? 0 }
|
||||
return store.analytics?.warmupStatus?.replyRate ?? account.warmupReplyRate ?? 0
|
||||
}
|
||||
|
||||
private var rampSection: some View {
|
||||
Section {
|
||||
settingStepper(
|
||||
"Starting volume", helper: "Emails per day when warmup begins.",
|
||||
value: account.warmupBase ?? 10, range: 1...100, suffix: "/day",
|
||||
write: { $0.warmupBase = $1 }, mutate: { $0.warmupBase = $1 }
|
||||
)
|
||||
settingStepper(
|
||||
"Daily increase", helper: "How many more each day as reputation builds.",
|
||||
value: account.warmupIncrease ?? 1, range: 1...50, suffix: "+/day",
|
||||
write: { $0.warmupIncrease = $1 }, mutate: { $0.warmupIncrease = $1 }
|
||||
)
|
||||
settingStepper(
|
||||
"Maximum volume", helper: "Keep conservative for new mailboxes, about 40 a day.",
|
||||
value: account.warmupMax ?? 40, range: max(1, account.warmupBase ?? 10)...500, suffix: "/day",
|
||||
write: { $0.warmupMax = $1 }, mutate: { $0.warmupMax = $1 }
|
||||
)
|
||||
settingStepper(
|
||||
"Reply rate", helper: "Share of warmup mail that gets a reply.",
|
||||
value: replyRateValue, range: 0...100, suffix: "%",
|
||||
write: { $0.warmupReplyRate = $1 }, mutate: { $0.warmupReplyRate = $1 }
|
||||
)
|
||||
} header: {
|
||||
EyebrowLabel("Ramp configuration")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Sending window (editable)
|
||||
|
||||
/// HH:MM in 30-minute steps, the format the scheduler parses ("15:04").
|
||||
private static let timeSlots: [String] = (0..<48).map {
|
||||
String(format: "%02d:%02d", $0 / 2, $0 % 2 * 30)
|
||||
}
|
||||
|
||||
/// Backend bit layout is Go time.Weekday: bit 0 = Sunday ... bit 6 =
|
||||
/// Saturday (scheduler findNextValidDay); mask 0 means every day.
|
||||
private static let dayBits: [(label: String, bit: Int)] = [
|
||||
("Mon", 1), ("Tue", 2), ("Wed", 3), ("Thu", 4), ("Fri", 5), ("Sat", 6), ("Sun", 0),
|
||||
]
|
||||
|
||||
private var windowSection: some View {
|
||||
Section {
|
||||
timeRow("Start time", current: timeValue(account.warmupStartTime, fallback: "08:00")) { slot in
|
||||
Task {
|
||||
await store.update(env.api, body: MailboxUpdateBody(warmupStartTime: slot)) {
|
||||
$0.warmupStartTime = slot
|
||||
}
|
||||
}
|
||||
}
|
||||
timeRow("End time", current: timeValue(account.warmupEndTime, fallback: "20:00")) { slot in
|
||||
Task {
|
||||
await store.update(env.api, body: MailboxUpdateBody(warmupEndTime: slot)) {
|
||||
$0.warmupEndTime = slot
|
||||
}
|
||||
}
|
||||
}
|
||||
sendingDaysRow
|
||||
} header: {
|
||||
EyebrowLabel("Sending window")
|
||||
}
|
||||
}
|
||||
|
||||
private func timeValue(_ raw: String?, fallback: String) -> String {
|
||||
guard let raw, !raw.isEmpty else { return fallback }
|
||||
return raw
|
||||
}
|
||||
|
||||
private func timeRow(_ title: String, current: String, apply: @escaping (String) -> Void) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
Spacer(minLength: 8)
|
||||
if canManage {
|
||||
Menu {
|
||||
ForEach(Self.timeSlots, id: \.self) { slot in
|
||||
Button {
|
||||
apply(slot)
|
||||
} label: {
|
||||
if slot == current {
|
||||
Label(slot, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(current)
|
||||
.font(.subheadline)
|
||||
.monospacedDigit()
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(WTheme.accent)
|
||||
}
|
||||
} else {
|
||||
Text(current)
|
||||
.font(.subheadline)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var sendingDaysRow: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Sending days")
|
||||
.font(.body.weight(.medium))
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Self.dayBits, id: \.bit) { day in
|
||||
dayChip(day.label, bit: day.bit)
|
||||
}
|
||||
}
|
||||
Text("Leave all off to send every day.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func dayChip(_ label: String, bit: Int) -> some View {
|
||||
let mask = account.warmupDays ?? 0
|
||||
let on = mask & (1 << bit) != 0
|
||||
return Button {
|
||||
let next = mask ^ (1 << bit)
|
||||
Task {
|
||||
await store.update(env.api, body: MailboxUpdateBody(warmupDays: next)) {
|
||||
$0.warmupDays = next
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(on ? Color.white : Color.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 30)
|
||||
.background(on ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Tone.slate.background), in: Capsule())
|
||||
}
|
||||
.buttonStyle(TapScaleStyle())
|
||||
.disabled(!canManage)
|
||||
}
|
||||
|
||||
// MARK: Shared editing controls
|
||||
|
||||
private func settingStepper(
|
||||
_ title: String,
|
||||
helper: String,
|
||||
value: Int,
|
||||
range: ClosedRange<Int>,
|
||||
suffix: String,
|
||||
write: @escaping (inout MailboxUpdateBody, Int) -> Void,
|
||||
mutate: @escaping (inout EmailAccount, Int) -> Void
|
||||
) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
Text(helper)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if canManage {
|
||||
HStack(spacing: 10) {
|
||||
stepButton("minus", enabled: value > range.lowerBound) {
|
||||
step(to: max(range.lowerBound, value - 1), write: write, mutate: mutate)
|
||||
}
|
||||
Text("\(value)\(suffix)")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.frame(minWidth: 46)
|
||||
.contentTransition(.numericText())
|
||||
stepButton("plus", enabled: value < range.upperBound) {
|
||||
step(to: min(range.upperBound, value + 1), write: write, mutate: mutate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("\(value)\(suffix)")
|
||||
.font(.subheadline)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func step(
|
||||
to newValue: Int,
|
||||
write: @escaping (inout MailboxUpdateBody, Int) -> Void,
|
||||
mutate: @escaping (inout EmailAccount, Int) -> Void
|
||||
) {
|
||||
Task {
|
||||
await store.updateDebounced(
|
||||
env.api,
|
||||
mutateBody: { write(&$0, newValue) },
|
||||
mutate: { mutate(&$0, newValue) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func stepButton(_ symbol: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(enabled ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.tertiaryLabel)))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Tone.slate.background, in: Circle())
|
||||
}
|
||||
.buttonStyle(TapScaleStyle())
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
// MARK: Setup tab (domain auth + identity details)
|
||||
|
||||
private var setupTab: some View {
|
||||
List {
|
||||
senderProfileSection
|
||||
limitsSection
|
||||
authSection
|
||||
identitySection
|
||||
if canManage {
|
||||
dangerSection
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: Sender profile (editable)
|
||||
|
||||
private var senderProfileSection: some View {
|
||||
Section {
|
||||
fieldRow("Display name") {
|
||||
TextField("Sender name", text: $displayName)
|
||||
.focused($focusedField, equals: .name)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { saveDisplayName() }
|
||||
.disabled(!canManage)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
fieldRow("Reply-to") {
|
||||
TextField(account.email, text: $replyTo)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($focusedField, equals: .replyTo)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { saveReplyTo() }
|
||||
.disabled(!canManage)
|
||||
}
|
||||
Text("Where replies land. Empty uses the mailbox address.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
EyebrowLabel("Sender profile")
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldRow<Field: View>(_ label: String, @ViewBuilder field: () -> Field) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 96, alignment: .leading)
|
||||
field()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func seedFieldsIfNeeded() {
|
||||
guard !fieldsSeeded else { return }
|
||||
displayName = account.name ?? ""
|
||||
replyTo = account.replyTo ?? ""
|
||||
fieldsSeeded = true
|
||||
}
|
||||
|
||||
private func saveDisplayName() {
|
||||
let trimmed = displayName.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed != (account.name ?? "") else { return }
|
||||
Task {
|
||||
await store.update(env.api, body: MailboxUpdateBody(name: trimmed)) { $0.name = trimmed }
|
||||
displayName = store.account.name ?? trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func saveReplyTo() {
|
||||
let trimmed = replyTo.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed != (account.replyTo ?? "") else { return }
|
||||
Task {
|
||||
// Empty string clears reply-to; replies go to the mailbox itself.
|
||||
await store.update(env.api, body: MailboxUpdateBody(replyTo: trimmed)) { $0.replyTo = trimmed }
|
||||
replyTo = store.account.replyTo ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Sending limits (editable)
|
||||
|
||||
// min_wait_time is stored in seconds; edit it in whole minutes.
|
||||
private var minGapMinutes: Int {
|
||||
max(1, min(120, (account.minWaitTime ?? 600) / 60))
|
||||
}
|
||||
|
||||
private var limitsSection: some View {
|
||||
Section {
|
||||
settingStepper(
|
||||
"Daily campaign cap", helper: "Max cold emails per day. Default 50, raise only with good reputation.",
|
||||
value: account.campaignLimit ?? 50, range: 1...100, suffix: "/day",
|
||||
write: { $0.campaignLimit = $1 }, mutate: { $0.campaignLimit = $1 }
|
||||
)
|
||||
settingStepper(
|
||||
"Minimum gap", helper: "Time between two sends from this mailbox.",
|
||||
value: minGapMinutes, range: 1...120, suffix: " min",
|
||||
write: { $0.minWaitTime = $1 * 60 }, mutate: { $0.minWaitTime = $1 * 60 }
|
||||
)
|
||||
} header: {
|
||||
EyebrowLabel("Sending limits")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Danger zone
|
||||
|
||||
private var dangerSection: some View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
confirmDisconnect = true
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Disconnect mailbox")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
EyebrowLabel("Danger zone")
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -381,23 +746,10 @@ struct MailboxDetailView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
// Name, reply-to, cap and gap moved into the editable sections above.
|
||||
private var identitySection: some View {
|
||||
Section("Details") {
|
||||
if let name = account.name, !name.isEmpty {
|
||||
LabeledContent("Sender name", value: name)
|
||||
}
|
||||
LabeledContent("Provider", value: account.providerLabel)
|
||||
LabeledContent("Daily cap") {
|
||||
Text("\(account.campaignLimit ?? 50)/day")
|
||||
.monospacedDigit()
|
||||
}
|
||||
LabeledContent("Min gap") {
|
||||
Text(MailboxFormat.gap(account.minWaitTime ?? 600))
|
||||
.monospacedDigit()
|
||||
}
|
||||
if let replyTo = account.replyTo, !replyTo.isEmpty {
|
||||
LabeledContent("Reply-to", value: replyTo)
|
||||
}
|
||||
if let synced = account.lastSyncedAt {
|
||||
LabeledContent("Last synced", value: WFormat.relative(synced))
|
||||
}
|
||||
|
||||
@@ -526,11 +526,13 @@ enum MailboxFormat {
|
||||
return "\(seconds) s"
|
||||
}
|
||||
|
||||
/// Weekday bitmask (bit 0 = Monday); 0 or 127 = every day.
|
||||
/// Weekday bitmask (bit 0 = Sunday, matching Go's time.Weekday in the
|
||||
/// scheduler); 0 or 127 = every day. Rendered Mon-first.
|
||||
static func weekdays(_ mask: Int) -> String {
|
||||
guard mask > 0, mask < 127 else { return "every day" }
|
||||
let names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
let picked = (0 ..< 7).filter { mask & (1 << $0) != 0 }.map { names[$0] }
|
||||
let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
let monFirst = [1, 2, 3, 4, 5, 6, 0]
|
||||
let picked = monFirst.filter { mask & (1 << $0) != 0 }.map { names[$0] }
|
||||
return picked.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +1,494 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Mailboxes screen, pushed from Home or More: fleet summary, server-side
|
||||
/// search, cursor pagination, and realtime pulse reloads. No NavigationStack
|
||||
/// of its own; it lives on the pushing tab's stack.
|
||||
/// 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 {
|
||||
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
|
||||
@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 }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("Mailboxes")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showConnect = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Connect a mailbox")
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: EmailAccount.self) { account in
|
||||
MailboxDetailView(account: account)
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search mailboxes")
|
||||
.task(id: searchText) { await runSearch() }
|
||||
.onChange(of: env.realtime.pulse(for: .emailAccounts)) {
|
||||
Task { await store.load(env.api, includeStatuses: canViewAnalytics) }
|
||||
}
|
||||
.onChange(of: env.realtime.pulse(for: .analytics)) {
|
||||
Task { await store.loadStatuses(env.api) }
|
||||
}
|
||||
.sheet(isPresented: $showConnect) { MailboxConnectSheet() }
|
||||
.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.")
|
||||
}
|
||||
.alert(
|
||||
"Something went wrong",
|
||||
isPresented: Binding(
|
||||
get: { store.actionError != nil },
|
||||
set: { if !$0 { store.actionError = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(store.actionError ?? "")
|
||||
}
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// MARK: Content
|
||||
/// 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)
|
||||
.fullScreenCover(isPresented: $showConnect) {
|
||||
MailboxConnectFlow(onClose: { showConnect = false }, onConnected: { account in
|
||||
store.insert(account)
|
||||
})
|
||||
}
|
||||
.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") } },
|
||||
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())
|
||||
}
|
||||
}
|
||||
.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 content: some View {
|
||||
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 store.accounts.isEmpty {
|
||||
if searchText.isEmpty {
|
||||
EmptyStateView(
|
||||
title: "No mailboxes yet",
|
||||
message: "Connect a sender account to start warming and sending.",
|
||||
ctaTitle: "Connect a mailbox"
|
||||
) { showConnect = true }
|
||||
} else {
|
||||
EmptyStateView(
|
||||
title: "No matches",
|
||||
message: "No mailbox matches \"\(searchText)\"."
|
||||
)
|
||||
}
|
||||
} else if visibleAccounts.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
accountList
|
||||
}
|
||||
}
|
||||
|
||||
private var accountList: some View {
|
||||
List {
|
||||
Section {
|
||||
fleetSummary
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 2, leading: 20, bottom: 4, trailing: 20))
|
||||
}
|
||||
Section {
|
||||
ForEach(store.accounts) { account in
|
||||
NavigationLink(value: account) {
|
||||
MailboxRowView(account: account, status: store.statuses[account.id])
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
swipeButtons(for: account)
|
||||
}
|
||||
List {
|
||||
ForEach(visibleAccounts) { account in
|
||||
row(account)
|
||||
}
|
||||
if store.nextCursor != nil {
|
||||
HStack {
|
||||
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(.insetGrouped)
|
||||
.refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) }
|
||||
}
|
||||
|
||||
private var fleetSummary: some View {
|
||||
HStack(spacing: 10) {
|
||||
fleetStat(
|
||||
value: "\(store.totalCount ?? store.accounts.count)",
|
||||
label: "Connected",
|
||||
symbol: "envelope.fill",
|
||||
tone: .sky
|
||||
)
|
||||
fleetStat(
|
||||
value: "\(store.warmingCount)",
|
||||
label: "Warming",
|
||||
symbol: "flame.fill",
|
||||
tone: store.warmingCount > 0 ? .orange : .slate
|
||||
)
|
||||
fleetStat(
|
||||
value: "\(store.issueCount)",
|
||||
label: "Issues",
|
||||
symbol: store.issueCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill",
|
||||
tone: store.issueCount > 0 ? .rose : .emerald
|
||||
)
|
||||
.listStyle(.plain)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) }
|
||||
}
|
||||
}
|
||||
|
||||
private func fleetStat(value: String, label: String, symbol: String, tone: Tone) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(tone.color)
|
||||
Text(value)
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
@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))
|
||||
}
|
||||
Text(label)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.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
|
||||
@@ -207,6 +527,60 @@ struct MailboxesRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: Search + initial load
|
||||
|
||||
private func runSearch() async {
|
||||
@@ -214,6 +588,10 @@ struct MailboxesRootView: View {
|
||||
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)
|
||||
}
|
||||
@@ -224,11 +602,19 @@ struct MailboxesRootView: View {
|
||||
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 {
|
||||
@@ -237,7 +623,7 @@ struct MailboxRowView: View {
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 16, height: 16)
|
||||
.background(Tone.orange.color, in: Circle())
|
||||
.overlay(Circle().strokeBorder(Color(.secondarySystemGroupedBackground), lineWidth: 1.5))
|
||||
.overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: 1.5))
|
||||
.offset(x: 3, y: 3)
|
||||
}
|
||||
}
|
||||
@@ -259,6 +645,7 @@ struct MailboxRowView: View {
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -287,3 +674,77 @@ struct MailboxRowView: View {
|
||||
return "warming"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection bar
|
||||
|
||||
/// Floating bottom-center bar shown while mailboxes are multi-selected:
|
||||
/// count plus bulk warmup start/pause and remove.
|
||||
struct MailboxSelectionBar: View {
|
||||
let count: Int
|
||||
let onStart: () -> Void
|
||||
let onPause: () -> 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")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Navigation drawer for the mailboxes browser, mirroring the contacts /
|
||||
/// campaigns / unibox sidebars: a slim sky hero with live fleet totals, then
|
||||
/// the warmup status scopes as pill rows on a rounded white sheet. Counts come
|
||||
/// from the loaded fleet. The selected pill slides between rows in the scope's
|
||||
/// tone and rows cascade in when the drawer opens.
|
||||
struct MailboxesSidebar: View {
|
||||
let store: MailboxesStore
|
||||
let selection: MailboxScope
|
||||
let topInset: CGFloat
|
||||
let revealed: Bool
|
||||
let onSelect: (MailboxScope) -> Void
|
||||
|
||||
@Namespace private var activeNS
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
hero
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
sectionLabel("Status")
|
||||
scopeRows
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background {
|
||||
UnevenRoundedRectangle(topLeadingRadius: 24, topTrailingRadius: 24, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
.shadow(color: .black.opacity(0.1), radius: 14, y: -4)
|
||||
}
|
||||
}
|
||||
.background(alignment: .top) {
|
||||
AirSkyWash().frame(height: 340)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
|
||||
// MARK: Hero
|
||||
|
||||
private var hero: some View {
|
||||
VStack(alignment: .leading, spacing: 13) {
|
||||
HStack(spacing: 8) {
|
||||
WarmblyLogo()
|
||||
.fill(.white)
|
||||
.frame(width: 21, height: 21 * (764 / 746))
|
||||
Text("Mailboxes")
|
||||
.font(.system(size: 17.5, weight: .heavy))
|
||||
.tracking(-0.4)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Text("Sender accounts, warmup and health")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
heroBadge(symbol: "envelope.fill", text: "\(WFormat.compact(store.allCount)) connected")
|
||||
if store.warmingCount > 0 {
|
||||
heroBadge(symbol: "flame.fill", text: "\(WFormat.compact(store.warmingCount)) warming", live: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, topInset + 12)
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func heroBadge(symbol: String, text: String, live: Bool = false) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 10.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.modifier(PingEffect(active: live, color: .white))
|
||||
Text(text)
|
||||
.font(.footnote.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5.5)
|
||||
.background(.white.opacity(0.16), in: Capsule())
|
||||
}
|
||||
|
||||
private func sectionLabel(_ text: String) -> some View {
|
||||
EyebrowLabel(text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
// MARK: Rows
|
||||
|
||||
@ViewBuilder
|
||||
private var scopeRows: some View {
|
||||
ForEach(Array(MailboxScope.allCases.enumerated()), id: \.element) { index, scope in
|
||||
row(index: index, scope: scope)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(index: Int, scope: MailboxScope) -> some View {
|
||||
let selected = selection == scope
|
||||
let count = store.count(for: scope)
|
||||
// Warming stays orange and issues stay rose even unselected, mirroring
|
||||
// the stat columns; the capsule itself carries the scope's tone.
|
||||
let countColor: Color? = switch scope {
|
||||
case .warming: count > 0 ? Tone.orange.color : nil
|
||||
case .issues: count > 0 ? Tone.rose.color : nil
|
||||
default: nil
|
||||
}
|
||||
return Button {
|
||||
onSelect(scope)
|
||||
} label: {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: scope.icon)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(selected ? scope.tone.color : Color.secondary)
|
||||
.frame(width: 24)
|
||||
Text(scope.title)
|
||||
.font(.subheadline.weight(selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? scope.tone.color : Color.primary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if count > 0 {
|
||||
Text(WFormat.compact(count))
|
||||
.font(.footnote.weight(countColor != nil || selected ? .semibold : .medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(countColor ?? (selected ? scope.tone.color : Color.secondary))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
if selected {
|
||||
Capsule()
|
||||
.fill(scope.tone.background)
|
||||
.matchedGeometryEffect(id: "mailboxdrawer-active", in: activeNS)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(TapScaleStyle())
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(scope.title), \(count)")
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
.opacity(revealed ? 1 : 0)
|
||||
.offset(x: revealed ? 0 : -18)
|
||||
.animation(
|
||||
.spring(response: 0.42, dampingFraction: 0.82)
|
||||
.delay(revealed ? 0.03 + min(Double(index), 14) * 0.024 : 0),
|
||||
value: revealed
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,49 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Scope
|
||||
|
||||
/// Which slice of the fleet the list shows. Every scope is a client-side
|
||||
/// filter over the loaded rows; Issues additionally needs analytics statuses.
|
||||
enum MailboxScope: Hashable, CaseIterable {
|
||||
case all, warming, paused, issues, off
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All mailboxes"
|
||||
case .warming: "Warming"
|
||||
case .paused: "Paused"
|
||||
case .issues: "Issues"
|
||||
case .off: "Warmup off"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .all: "tray.full"
|
||||
case .warming: "flame.fill"
|
||||
case .paused: "pause.circle"
|
||||
case .issues: "exclamationmark.triangle"
|
||||
case .off: "moon.zzz"
|
||||
}
|
||||
}
|
||||
|
||||
var tone: Tone {
|
||||
switch self {
|
||||
case .all: .sky
|
||||
case .warming: .orange
|
||||
case .paused: .amber
|
||||
case .issues: .rose
|
||||
case .off: .slate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Store
|
||||
|
||||
/// List-screen store: the mailbox rows plus the per-account health snapshot
|
||||
/// from `/analytics/accounts`, merged by id.
|
||||
/// from `/analytics/accounts`, merged by id. Owns multi-select and the bulk
|
||||
/// warmup/remove actions behind the floating selection bar.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MailboxesStore {
|
||||
@@ -19,13 +60,68 @@ final class MailboxesStore {
|
||||
|
||||
// MARK: Derived stats
|
||||
|
||||
var allCount: Int { totalCount ?? accounts.count }
|
||||
|
||||
/// Warming = warmup anchor set and not paused; never a status string.
|
||||
var warmingCount: Int { accounts.filter(\.isWarmingActive).count }
|
||||
|
||||
var pausedCount: Int { accounts.filter(\.isWarmupPaused).count }
|
||||
|
||||
var issueCount: Int {
|
||||
accounts.filter { statuses[$0.id]?.health?.hasIssue == true }.count
|
||||
}
|
||||
|
||||
/// Warmup never enabled: no ramp anchor at all.
|
||||
var offCount: Int { accounts.filter { $0.warmup == nil }.count }
|
||||
|
||||
func count(for scope: MailboxScope) -> Int {
|
||||
switch scope {
|
||||
case .all: allCount
|
||||
case .warming: warmingCount
|
||||
case .paused: pausedCount
|
||||
case .issues: issueCount
|
||||
case .off: offCount
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Selection
|
||||
|
||||
private(set) var selectedIDs: Set<String> = []
|
||||
var isSelecting = false
|
||||
|
||||
var selectedCount: Int { selectedIDs.count }
|
||||
|
||||
func isSelected(_ id: String) -> Bool { selectedIDs.contains(id) }
|
||||
|
||||
func toggleSelected(_ id: String) {
|
||||
if selectedIDs.contains(id) { selectedIDs.remove(id) } else { selectedIDs.insert(id) }
|
||||
}
|
||||
|
||||
func enterSelection(with id: String? = nil) {
|
||||
withAnimation(.snappy) {
|
||||
isSelecting = true
|
||||
if let id { selectedIDs.insert(id) }
|
||||
}
|
||||
}
|
||||
|
||||
func exitSelection() {
|
||||
withAnimation(.snappy) {
|
||||
isSelecting = false
|
||||
selectedIDs.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle select-all over the rows currently visible in the list.
|
||||
func selectAll(_ ids: [String]) {
|
||||
withAnimation(.snappy) {
|
||||
if allSelected(of: ids) { selectedIDs.removeAll() } else { selectedIDs = Set(ids) }
|
||||
}
|
||||
}
|
||||
|
||||
func allSelected(of ids: [String]) -> Bool {
|
||||
!ids.isEmpty && ids.allSatisfy { selectedIDs.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: Loading
|
||||
|
||||
func load(_ api: APIClient, includeStatuses: Bool) async {
|
||||
@@ -88,6 +184,18 @@ final class MailboxesStore {
|
||||
return params
|
||||
}
|
||||
|
||||
/// Optimistic insert from the connect flow; realtime refreshes the rest.
|
||||
func insert(_ account: EmailAccount) {
|
||||
withAnimation {
|
||||
if let index = accounts.firstIndex(where: { $0.id == account.id }) {
|
||||
accounts[index] = account
|
||||
} else {
|
||||
accounts.insert(account, at: 0)
|
||||
if let total = totalCount { totalCount = total + 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Row actions
|
||||
|
||||
/// action: "start" | "pause" | "resume" | "stop"; returns the updated row.
|
||||
@@ -114,4 +222,50 @@ final class MailboxesStore {
|
||||
actionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Bulk actions
|
||||
|
||||
/// action: "start" | "pause". Applies per-row results as they land and
|
||||
/// reports partial failures in one line.
|
||||
func bulkWarmup(_ api: APIClient, action: String) async {
|
||||
let ids = Array(selectedIDs)
|
||||
guard !ids.isEmpty else { return }
|
||||
var failures = 0
|
||||
for id in ids {
|
||||
do {
|
||||
let updated: EmailAccount = try await api.post("emails/\(id)/warmup/\(action)")
|
||||
if let index = accounts.firstIndex(where: { $0.id == id }) {
|
||||
withAnimation { accounts[index] = updated }
|
||||
}
|
||||
} catch {
|
||||
failures += 1
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
actionError = "\(failures) of \(ids.count) mailboxes couldn't \(action)"
|
||||
}
|
||||
exitSelection()
|
||||
}
|
||||
|
||||
func bulkRemove(_ api: APIClient) async {
|
||||
let ids = Array(selectedIDs)
|
||||
guard !ids.isEmpty else { return }
|
||||
var failures = 0
|
||||
for id in ids {
|
||||
do {
|
||||
let _: EmptyBody = try await api.delete("emails/\(id)")
|
||||
withAnimation {
|
||||
accounts.removeAll { $0.id == id }
|
||||
statuses.removeValue(forKey: id)
|
||||
if let total = totalCount { totalCount = max(0, total - 1) }
|
||||
}
|
||||
} catch {
|
||||
failures += 1
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
actionError = "\(failures) of \(ids.count) mailboxes couldn't be removed"
|
||||
}
|
||||
exitSelection()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user