diff --git a/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json new file mode 100644 index 00000000..52778d36 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "google-logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/google-logo.png b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/google-logo.png new file mode 100644 index 00000000..971ef954 Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/google-logo.png differ diff --git a/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json new file mode 100644 index 00000000..3d9d1c4e --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "outlook-logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/outlook-logo.png b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/outlook-logo.png new file mode 100644 index 00000000..8f5ff31a Binary files /dev/null and b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/outlook-logo.png differ diff --git a/ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift b/ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift new file mode 100644 index 00000000..8e3e558f --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift @@ -0,0 +1,1123 @@ +import AuthenticationServices +import SwiftUI + +/// Full-screen "connect a mailbox" onboarding wearing the same air as +/// sign-up: the sky with its flight ambience and a step badge, a full-bleed +/// white sheet below. Gmail/Outlook connect through an in-app +/// ASWebAuthenticationSession ceremony; everything else goes through the +/// native SMTP/IMAP form. Dismissal always goes through `onClose()`; the +/// environment DismissAction can no-op in this app's presentation contexts. +struct MailboxConnectFlow: View { + var onClose: () -> Void = {} + var onConnected: ((EmailAccount) -> Void)? = nil + + private enum Page: Int { + case provider, connect, account, imap, smtp, done + + var icon: String { + switch self { + case .provider: "envelope.badge.person.crop" + case .connect: "lock.shield.fill" + case .account: "person.crop.circle" + case .imap: "tray.and.arrow.down.fill" + case .smtp: "paperplane.fill" + case .done: "checkmark.seal.fill" + } + } + + var skyLabel: String { + switch self { + case .provider: "Add a mailbox" + case .connect: "Authorize access" + case .account: "Your address" + case .imap: "Incoming mail" + case .smtp: "Outgoing mail" + case .done: "Connected" + } + } + } + + /// Which branch the step badge counts: OAuth is two steps, SMTP is five. + private enum Route { + case undecided, oauth, smtp + } + + private enum WarmupOffer: Equatable { + case offer, starting, active, failed(String) + } + + private enum Field: Hashable { + case name, email + case imapHost, imapPort, imapUser, imapPass + case smtpHost, smtpPort, smtpUser, smtpPass + } + + @Environment(AppEnvironment.self) private var env + + @State private var page = Page.provider + @State private var route = Route.undecided + @State private var direction = 1.0 + @State private var badgeAppeared = false + + // OAuth path + @State private var pendingProvider = "gmail" + @State private var oauthProvider: String? + @State private var oauthFlow = MailboxOAuthFlow() + + // SMTP path form + @State private var name = "" + @State private var email = "" + @State private var imapHost = "" + @State private var imapPort = "993" + @State private var imapUsername = "" + @State private var imapPassword = "" + @State private var smtpHost = "" + @State private var smtpPort = "587" + @State private var sameLogin = true + @State private var smtpUsername = "" + @State private var smtpPassword = "" + + @State private var busy = false + @State private var errorMessage: String? + @State private var errorPulse = 0 + + // Done page + @State private var connectedAccount: EmailAccount? + @State private var warmupOffer = WarmupOffer.offer + + @FocusState private var focused: Field? + + var body: some View { + ZStack(alignment: .bottom) { + SkyBackdrop() + VStack(spacing: 0) { + topBar + skyArea + sheet + } + } + .sensoryFeedback(.impact(weight: .light), trigger: page) + .sensoryFeedback(.error, trigger: errorPulse) + } + + private var pagePath: [Page] { + route == .smtp + ? [.provider, .account, .imap, .smtp, .done] + : [.provider, .connect, .done] + } + + private var stepIndex: Int { pagePath.firstIndex(of: page) ?? 0 } + + private var isBusy: Bool { busy || warmupOffer == .starting || oauthProvider != nil } + + private var pageTransition: AnyTransition { + .asymmetric( + insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity), + removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity) + ) + } + + // MARK: - Sky chrome + + private var topBar: some View { + HStack(spacing: 12) { + if stepIndex > 0, page != .done { + Button { + goBack() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(.white.opacity(0.16), in: Circle()) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel("Back") + .disabled(isBusy) + .transition(.opacity.combined(with: .scale(scale: 0.7))) + } + + HStack(spacing: 8) { + WarmblyLogo() + .fill(.white) + .frame(width: 27, height: 28) + Text("Warmbly") + .font(.system(size: 20, weight: .heavy)) + .tracking(-0.4) + .foregroundStyle(.white) + .fixedSize() + } + .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3) + + Spacer() + + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(.white.opacity(0.16), in: Circle()) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel("Cancel") + .disabled(isBusy) + } + .padding(.horizontal, 16) + .padding(.top, 6) + .animation(.spring(response: 0.45, dampingFraction: 0.86), value: page) + } + + private var skyArea: some View { + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { focused = nil } + + HeroFlightScene() + + pageBadge + .scaleEffect(badgeAppeared ? 1 : 0.9) + .opacity(badgeAppeared ? 1 : 0) + } + .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: page) + .onAppear { + withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) { + badgeAppeared = true + } + } + } + + private var pageBadge: some View { + ViewThatFits(in: .vertical) { + VStack(spacing: 12) { + ZStack { + Circle().fill(.white.opacity(0.16)) + Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) + Image(systemName: page.icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .contentTransition(.symbolEffect(.replace)) + } + .frame(width: 54, height: 54) + + VStack(spacing: 4) { + Text(page.skyLabel) + .font(.system(size: 15.5, weight: .bold)) + .foregroundStyle(.white) + .contentTransition(.opacity) + Text("Step \(stepIndex + 1) of \(pagePath.count)") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.7)) + .contentTransition(.numericText()) + } + + pageSegments + } + .padding(.vertical, 16) + + HStack(spacing: 10) { + ZStack { + Circle().fill(.white.opacity(0.16)) + Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) + Image(systemName: page.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .contentTransition(.symbolEffect(.replace)) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 4) { + Text(page.skyLabel) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(.white) + .contentTransition(.opacity) + HStack(spacing: 7) { + pageSegments + Text("Step \(stepIndex + 1) of \(pagePath.count)") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.7)) + .contentTransition(.numericText()) + } + } + } + .padding(.vertical, 8) + } + .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2) + } + + private var pageSegments: some View { + HStack(spacing: 6) { + ForEach(Array(pagePath.enumerated()), id: \.element.rawValue) { index, segment in + Capsule() + .fill(.white.opacity(index <= stepIndex ? 0.95 : 0.3)) + .frame(width: segment == page ? 26 : 14, height: 4) + } + } + } + + // MARK: - The sheet + + private var sheet: some View { + VStack(alignment: .leading, spacing: 0) { + // Always a ScrollView: ViewThatFits swaps identities when the + // keyboard shrinks the space, which destroys the focused field + // mid-tap on the taller form pages. + ScrollView { + pageContent + } + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) + + footer + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36)) + .fill(Color(.systemBackground) + .shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6))) + .padding(.bottom, -600) + .ignoresSafeArea() + } + .geometryGroup() + .layoutPriority(1) + } + + private var pageContent: some View { + Group { + switch page { + case .provider: providerPage + case .connect: connectPage + case .account: accountPage + case .imap: imapPage + case .smtp: smtpPage + case .done: donePage + } + } + .padding(.top, 30) + .padding(.bottom, 8) + .transition(pageTransition) + } + + private var footer: some View { + VStack(spacing: 12) { + if let errorMessage { + Text(errorMessage) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.negative) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity) + } + + if page == .smtp, busy { + Text("Verified against your server before saving.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity) + } + + if page == .connect, oauthProvider != nil { + Text("Waiting for authorization in the \(providerDisplayName) window.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity) + } + + if page != .provider { + Button { + Task { await primaryAction() } + } label: { + Group { + if primaryBusy { + ProgressView().tint(.white) + } else { + HStack(spacing: 8) { + Text(primaryLabel) + if let icon = primaryIcon { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + } + } + } + } + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background( + LinearGradient( + colors: !canAdvance && !primaryBusy + ? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)] + : [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], + startPoint: .top, + endPoint: .bottom + ), + in: RoundedRectangle(cornerRadius: 17) + ) + .shadow(color: !canAdvance ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6) + } + .buttonStyle(PressableButtonStyle()) + .disabled(primaryBusy || !canAdvance) + .animation(.easeOut(duration: 0.18), value: canAdvance) + } + + if page == .done, warmupOffer == .offer { + Button("Skip for now") { + onClose() + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + .disabled(primaryBusy) + .transition(.opacity) + } + } + .padding(.top, 10) + .padding(.bottom, 14) + .animation(.easeOut(duration: 0.2), value: page) + .animation(.easeOut(duration: 0.2), value: errorMessage != nil) + .animation(.easeOut(duration: 0.2), value: warmupOffer) + } + + private var primaryBusy: Bool { busy || warmupOffer == .starting || oauthProvider != nil } + + private var primaryLabel: String { + switch page { + case .provider, .account, .imap: "Continue" + case .connect: "Continue with \(providerDisplayName)" + case .smtp: "Connect mailbox" + case .done: + switch warmupOffer { + case .offer, .starting: "Start warmup" + case .active, .failed: "Done" + } + } + } + + private var primaryIcon: String? { + switch page { + case .connect: "arrow.up.right.square" + case .smtp: "paperplane.fill" + case .done: warmupOffer == .offer ? "flame.fill" : nil + default: nil + } + } + + // MARK: - Pages + + private func pageTitle(_ title: String, _ subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(title) + .font(.system(size: 30, weight: .bold)) + .tracking(-0.6) + .foregroundStyle(.primary) + Text(subtitle) + .font(.system(size: 15.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 22) + } + + private var providerPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Connect a sending account", + "Warm it up and send campaigns from your own address." + ) + + VStack(spacing: 12) { + providerRow( + "Gmail / Google Workspace", + sub: "OAuth via Google. Best deliverability for Gmail.", + provider: "gmail" + ) { + brandLogoTile("GoogleLogo", size: 42) + } + providerRow( + "Outlook / Microsoft 365", + sub: "OAuth via Microsoft. Native sync for Outlook accounts.", + provider: "outlook" + ) { + brandLogoTile("OutlookLogo", size: 42) + } + providerRow( + "Other (SMTP / IMAP)", + sub: "Any provider with a host, port and app password.", + provider: nil + ) { + IconTile(symbol: "server.rack", tone: .slate, size: 42) + } + } + + Text("Credentials are stored encrypted. You can disconnect anytime.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .padding(.top, 12) + } + } + + /// Brand mark on a white tile with a hairline border, like the web's + /// provider rows. + private func brandLogoTile(_ asset: String, size: CGFloat) -> some View { + Image(asset) + .resizable() + .scaledToFit() + .frame(width: size * 0.55, height: size * 0.55) + .frame(width: size, height: size) + .background(.white, in: RoundedRectangle(cornerRadius: size * 0.32)) + .overlay( + RoundedRectangle(cornerRadius: size * 0.32) + .strokeBorder(Color(.separator).opacity(0.5), lineWidth: 1) + ) + } + + private func providerRow( + _ title: String, + sub: String, + provider: String?, + @ViewBuilder tile: () -> Tile + ) -> some View { + Button { + errorMessage = nil + if let provider { + pendingProvider = provider + route = .oauth + goTo(.connect) + } else { + route = .smtp + goTo(.account) + } + } label: { + HStack(spacing: 12) { + tile() + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(sub) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + } + .buttonStyle(PressableButtonStyle()) + } + + // MARK: - Connect page (OAuth ceremony) + + private var providerDisplayName: String { + pendingProvider == "outlook" ? "Microsoft" : "Google" + } + + private var providerLogoAsset: String { + pendingProvider == "outlook" ? "OutlookLogo" : "GoogleLogo" + } + + private var connectPage: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 14) { + brandLogoTile(providerLogoAsset, size: 56) + VStack(alignment: .leading, spacing: 3) { + Text("Connect with \(providerDisplayName)") + .font(.system(size: 22, weight: .bold)) + .tracking(-0.4) + .foregroundStyle(.primary) + Text(pendingProvider == "outlook" ? "Outlook or Microsoft 365" : "Gmail or Google Workspace") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + } + .padding(.bottom, 16) + + Text("We'll open a \(providerDisplayName) sign-in window. Approve the access and you're done.") + .font(.system(size: 15.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 12) { + connectScopeRow("paperplane.fill", "Send and read mail on your behalf") + connectScopeRow("arrowshape.turn.up.left.fill", "Track replies and deliveries") + connectScopeRow("lock.fill", "Tokens are stored encrypted. Revoke anytime.") + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + } + } + + private func connectScopeRow(_ symbol: String, _ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(WTheme.accent) + .frame(width: 18) + Text(text) + .font(.system(size: 14)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var accountPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Your sending address", + "The name and email recipients will see." + ) + + VStack(alignment: .leading, spacing: 14) { + fieldBlock("Name", helper: "The sender name recipients see.") { + styledField("Alex Rivera", text: $name, field: .name) { + focused = .email + } + .textContentType(.name) + } + + fieldBlock("Email") { + styledField("alex@company.com", text: $email, field: .email, submit: .continue) { + Task { await advanceIfValid() } + } + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + } + .onAppear { + if name.isEmpty { focused = .name } + } + } + + private var imapPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Incoming mail", + "IMAP keeps replies and inbox activity in sync." + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + fieldBlock("Host") { + styledField("imap.example.com", text: $imapHost, field: .imapHost) { + focused = .imapUser + } + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + fieldBlock("Port") { + styledField("993", text: $imapPort, field: .imapPort) + .keyboardType(.numberPad) + .frame(width: 96) + } + } + + fieldBlock("Username") { + styledField("alex@company.com", text: $imapUsername, field: .imapUser) { + focused = .imapPass + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + fieldBlock("Password", helper: "Usually an app-specific password, not your main login.") { + styledField("App password", text: $imapPassword, field: .imapPass, secure: true, submit: .continue) { + Task { await advanceIfValid() } + } + } + } + } + .onAppear { + if imapUsername.isEmpty { imapUsername = trimmedEmail } + if imapHost.isEmpty { focused = .imapHost } + } + } + + private var smtpPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Outgoing mail", + "The SMTP server your campaigns send through." + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + fieldBlock("Host") { + styledField("smtp.example.com", text: $smtpHost, field: .smtpHost) { + focused = .smtpPort + } + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + fieldBlock("Port", helper: "465 or 587.") { + styledField("587", text: $smtpPort, field: .smtpPort) + .keyboardType(.numberPad) + .frame(width: 96) + } + } + + Toggle(isOn: $sameLogin.animation(.spring(response: 0.4, dampingFraction: 0.85))) { + Text("Use the same login as IMAP") + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(.primary) + } + .tint(WTheme.accent) + .padding(.horizontal, 16) + .frame(height: 56) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + + if !sameLogin { + fieldBlock("Username") { + styledField("alex@company.com", text: $smtpUsername, field: .smtpUser) { + focused = .smtpPass + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .transition(.opacity.combined(with: .move(edge: .top))) + + fieldBlock("Password") { + styledField("App password", text: $smtpPassword, field: .smtpPass, secure: true, submit: .continue) { + Task { await advanceIfValid() } + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .onAppear { + if smtpHost.isEmpty { focused = .smtpHost } + } + } + + private var donePage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Mailbox connected", + "Syncing has started. It will appear in your accounts list right away." + ) + + if let account = connectedAccount { + HStack(spacing: 12) { + IconTile(symbol: "envelope.fill", tone: .sky, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text(account.email) + .font(.system(size: 16.5, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + Text(account.providerLabel) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.white, WTheme.positive) + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + .padding(.bottom, 14) + } + + warmupBlock + } + } + + private var warmupBlock: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + IconTile(symbol: "flame.fill", tone: .orange, size: 42) + VStack(alignment: .leading, spacing: 4) { + Text(warmupOffer == .active ? "Warming up" : "Start warming up") + .font(.system(size: 15.5, weight: .semibold)) + .foregroundStyle(.primary) + .contentTransition(.opacity) + if warmupOffer == .active { + StatusPill(text: "warming", tone: .orange, pulsing: true) + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + Spacer(minLength: 8) + if warmupOffer == .active { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.white, Tone.orange.color) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + } + + Text(warmupOffer == .active + ? "This mailbox is building sender reputation. Track its progress from the accounts list." + : "Gradually builds sender reputation before campaigns. Starts at 10 a day and ramps automatically.") + .font(.system(size: 13.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if case let .failed(message) = warmupOffer { + Text(message) + .font(.system(size: 13)) + .foregroundStyle(WTheme.negative) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Tone.orange.background.opacity(0.55), in: RoundedRectangle(cornerRadius: 17)) + .overlay( + RoundedRectangle(cornerRadius: 17) + .strokeBorder(Tone.orange.color.opacity(0.25), lineWidth: 1) + ) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: warmupOffer) + } + + // MARK: - Field primitives + + private func fieldBlock(_ label: String, helper: String? = nil, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + content() + if let helper { + Text(helper) + .font(.system(size: 12.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func styledField( + _ placeholder: String, + text: Binding, + field: Field, + secure: Bool = false, + submit: SubmitLabel = .next, + onSubmit: @escaping () -> Void = {} + ) -> some View { + Group { + if secure { + SecureField(placeholder, text: text) + } else { + TextField(placeholder, text: text) + } + } + .focused($focused, equals: field) + .submitLabel(submit) + .onSubmit(onSubmit) + .font(.system(size: 16.5)) + .padding(.horizontal, 16) + .frame(height: 56) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + .overlay( + RoundedRectangle(cornerRadius: 17) + .strokeBorder(focused == field ? WTheme.accent : .clear, lineWidth: 1.8) + ) + .animation(.easeOut(duration: 0.18), value: focused) + } + + // MARK: - Flow + + private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + private var trimmedEmail: String { email.trimmingCharacters(in: .whitespaces) } + + private var canAdvance: Bool { + switch page { + case .provider: + return false + case .connect: + return true + case .account: + return trimmedName.count >= 2 && trimmedEmail.contains("@") && trimmedEmail.contains(".") + case .imap: + return !imapHost.trimmingCharacters(in: .whitespaces).isEmpty + && (Int(imapPort) ?? 0) > 0 + case .smtp: + let port = Int(smtpPort) ?? 0 + return !smtpHost.trimmingCharacters(in: .whitespaces).isEmpty + && (port == 465 || port == 587) + case .done: + return true + } + } + + private func goTo(_ target: Page) { + focused = nil + let path = pagePath + let from = path.firstIndex(of: page) ?? 0 + let to = path.firstIndex(of: target) ?? 0 + direction = to >= from ? 1 : -1 + withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { + page = target + } + } + + private func goBack() { + errorMessage = nil + let path = pagePath + guard let index = path.firstIndex(of: page), index > 0 else { return } + let previous = path[index - 1] + focused = nil + direction = -1 + withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { + page = previous + } + if previous == .provider { route = .undecided } + } + + private func primaryAction() async { + switch page { + case .provider: + break + case .connect: + await startOAuth(pendingProvider) + case .account, .imap, .smtp: + await advanceIfValid() + case .done: + switch warmupOffer { + case .offer: + await startWarmup() + case .active, .failed: + onClose() + case .starting: + break + } + } + } + + private func advanceIfValid() async { + guard canAdvance else { return } + errorMessage = nil + switch page { + case .account: + goTo(.imap) + case .imap: + goTo(.smtp) + case .smtp: + await submitSMTP() + case .provider, .connect, .done: + break + } + } + + private func showError(_ message: String) { + withAnimation(.easeOut(duration: 0.2)) { + errorMessage = message + } + errorPulse += 1 + } + + private func finishConnect(_ account: EmailAccount) { + connectedAccount = account + onConnected?(account) + UINotificationFeedbackGenerator().notificationOccurred(.success) + if route != .smtp { route = .oauth } + goTo(.done) + } + + // MARK: - OAuth + + private func startOAuth(_ provider: String) async { + guard oauthProvider == nil, !busy else { return } + withAnimation(.easeOut(duration: 0.2)) { errorMessage = nil } + oauthProvider = provider + defer { oauthProvider = nil } + do { + let start: MailboxOAuthStartResponse = try await env.api.post( + "emails/onboarding/oauth/start", + body: MailboxOAuthStartBody(provider: provider) + ) + guard let url = URL(string: start.url) else { + showError("The provider returned an invalid authorization URL.") + return + } + + let callback = try await oauthFlow.authorize(url: url) + let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] + func query(_ name: String) -> String? { + items.first { $0.name == name }?.value + } + + if let providerError = query("error"), !providerError.isEmpty { + // Declining consent is a cancel, not a failure worth a banner. + if providerError != "access_denied" { + showError("The provider couldn't authorize the connection (\(providerError)).") + } + return + } + guard query("state") == start.state else { + showError("Sign-in session mismatch. Please try again.") + return + } + guard let code = query("code"), !code.isEmpty else { + showError("The provider didn't return an authorization code. Please try again.") + return + } + + let account: EmailAccount = try await env.api.post( + "emails/onboarding/oauth/finish", + body: MailboxOAuthFinishBody(code: code, state: start.state) + ) + finishConnect(account) + } catch MailboxOAuthFlow.Failure.cancelled { + // User closed the sheet; stay quiet. + } catch { + showError((error as? APIError)?.errorDescription ?? error.localizedDescription) + } + } + + // MARK: - SMTP submit + + private func submitSMTP() async { + focused = nil + errorMessage = nil + busy = true + let login = ( + username: imapUsername, + password: imapPassword + ) + let body = MailboxSMTPConnectBody( + email: trimmedEmail, + name: trimmedName, + smtp: MailboxServerCredentials( + username: sameLogin ? login.username : smtpUsername, + password: sameLogin ? login.password : smtpPassword, + host: smtpHost.trimmingCharacters(in: .whitespaces), + port: Int(smtpPort) ?? 587 + ), + imap: MailboxServerCredentials( + username: login.username, + password: login.password, + host: imapHost.trimmingCharacters(in: .whitespaces), + port: Int(imapPort) ?? 993 + ) + ) + do { + let account: EmailAccount = try await env.api.post("emails/onboarding/smtp-imap", body: body) + busy = false + finishConnect(account) + } catch { + busy = false + showError((error as? APIError)?.errorDescription ?? error.localizedDescription) + } + } + + // MARK: - Warmup offer + + private func startWarmup() async { + guard let account = connectedAccount, warmupOffer == .offer else { return } + withAnimation(.easeOut(duration: 0.2)) { warmupOffer = .starting } + do { + let updated: EmailAccount = try await env.api.post("emails/\(account.id)/warmup/start") + connectedAccount = updated + UINotificationFeedbackGenerator().notificationOccurred(.success) + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + warmupOffer = .active + } + } catch { + let message = (error as? APIError)?.errorDescription ?? error.localizedDescription + withAnimation(.easeOut(duration: 0.2)) { + warmupOffer = .failed(message) + } + errorPulse += 1 + } + } +} + +/// `POST /emails/onboarding/oauth/finish` body (not in MailboxModels). +private struct MailboxOAuthFinishBody: Encodable { + var code: String + var state: String +} + +/// The mailbox OAuth ceremony: opens the backend-minted authorization URL in +/// an ASWebAuthenticationSession and hands back the `warmbly://email-oauth` +/// callback. Mirrors GoogleSignInFlow's presentation-context pattern. +@MainActor +final class MailboxOAuthFlow: NSObject, ASWebAuthenticationPresentationContextProviding { + enum Failure: Error { + case cancelled + case malformedResponse + } + + private var activeSession: ASWebAuthenticationSession? + private var presentationWindow: UIWindow? + + func authorize(url: URL) async throws -> URL { + // Captured up front (we're on the main actor) so the presentation + // callback never has to conjure a window of its own. + guard let window = Self.keyWindow else { throw Failure.malformedResponse } + presentationWindow = window + defer { presentationWindow = nil } + + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "warmbly") { [weak self] callbackURL, error in + Task { @MainActor in self?.activeSession = nil } + if let error { + if let webError = error as? ASWebAuthenticationSessionError, webError.code == .canceledLogin { + continuation.resume(throwing: Failure.cancelled) + } else { + continuation.resume(throwing: error) + } + return + } + guard let callbackURL else { + continuation.resume(throwing: Failure.malformedResponse) + return + } + continuation.resume(returning: callbackURL) + } + session.presentationContextProvider = self + // Keep the user's provider session so re-connects skip the login. + session.prefersEphemeralWebBrowserSession = false + activeSession = session + if !session.start() { + activeSession = nil + continuation.resume(throwing: Failure.malformedResponse) + } + } + } + + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + MainActor.assumeIsolated { + // authorize guards that a window exists before the session starts. + presentationWindow ?? Self.keyWindow! + } + } + + private static var keyWindow: UIWindow? { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + return windows.first(where: \.isKeyWindow) ?? windows.first + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift b/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift deleted file mode 100644 index f26a043f..00000000 --- a/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift +++ /dev/null @@ -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 - } - } -} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift b/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift index 9541bc8e..5da03f8b 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift @@ -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 + } + } } diff --git a/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift b/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift index 3ed94dec..e53fec2c 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift @@ -27,10 +27,18 @@ enum MailboxDetailTab: String, CaseIterable, Identifiable { /// NOT create its own NavigationStack. Claims presence on `mailbox:` 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, + 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(_ 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)) } diff --git a/ios/Warmbly/Features/Mailboxes/MailboxModels.swift b/ios/Warmbly/Features/Mailboxes/MailboxModels.swift index 6dc714d3..6ff33b27 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxModels.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxModels.swift @@ -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: " ") } } diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift b/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift index 9f68a413..e15d49e4 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift @@ -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) + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift b/ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift new file mode 100644 index 00000000..e34daae9 --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift @@ -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 + ) + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift b/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift index cb96bc97..e018c635 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift @@ -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 = [] + 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() + } }