import SwiftUI /// Creating a workspace wears the same air as sign-up: the sky with its /// flight ambience and a step badge, a full-bleed white sheet below. Two /// steps: name the workspace, then optionally invite teammates. The org is /// created (and invites sent) at the very end, so the flow works both as the /// zero-orgs gate (the session flips to ready underneath it) and as a cover /// from the workspace switcher. struct WorkspaceCreateFlow: View { /// How the flow is presented: the `.selectOrg` gate offers Sign out and /// cannot be dismissed; a cover (switcher/picker) offers Cancel. enum Context { case gate case cover } private enum Page: Int, CaseIterable { case name, invite var icon: String { switch self { case .name: "briefcase.fill" case .invite: "person.badge.plus" } } var skyLabel: String { switch self { case .name: "Your workspace" case .invite: "Invite your team" } } } @Environment(AppEnvironment.self) private var env var context: Context = .cover /// Cover presenters pass their `isPresented` reset here; the environment /// DismissAction can no-op when the cover is launched from inside a /// detented sheet, so dismissal goes through the binding directly. var onClose: () -> Void = {} @State private var page = Page.name @State private var direction = 1.0 @State private var name = "" @State private var inviteEmails: [InviteEntry] = [InviteEntry()] @State private var busy = false @State private var errorMessage: String? @State private var errorPulse = 0 @State private var badgeAppeared = false @FocusState private var nameFocused: Bool @FocusState private var focusedInvite: UUID? private struct InviteEntry: Identifiable { let id = UUID() var email = "" } 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 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 page != .name { 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") .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() if context == .gate { // Nothing to go back to without a workspace; the only exit // is signing out, like the web's select-org gate. Button("Sign out") { Task { await env.session.logout() } } .font(.system(size: 13.5, weight: .semibold)) .foregroundStyle(.white.opacity(0.9)) .padding(.horizontal, 14) .frame(height: 34) .background(.white.opacity(0.16), in: Capsule()) .buttonStyle(PressableButtonStyle()) .disabled(busy) } else { // Presented as a cover: the app's standard circular close. 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(busy) } } .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 { nameFocused = false focusedInvite = 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 \(page.rawValue + 1) of \(Page.allCases.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 \(page.rawValue + 1) of \(Page.allCases.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(Page.allCases, id: \.rawValue) { segment in Capsule() .fill(.white.opacity(segment.rawValue <= page.rawValue ? 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. 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 .name: namePage case .invite: invitePage } } .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) } Button { Task { await advance() } } label: { Group { if busy { ProgressView().tint(.white) } else { HStack(spacing: 8) { Text(page == .invite ? "Create workspace" : "Continue") if page == .invite { Image(systemName: "paperplane.fill") .font(.system(size: 14, weight: .semibold)) } } } } .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 56) .background( LinearGradient( colors: !canAdvance && !busy ? [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(busy || !canAdvance) .animation(.easeOut(duration: 0.18), value: canAdvance) if page == .invite { Button("Skip for now") { inviteEmails = [InviteEntry()] Task { await advance(skipping: true) } } .font(.system(size: 14, weight: .medium)) .foregroundStyle(.secondary) .disabled(busy) .transition(.opacity) } } .padding(.top, 10) .padding(.bottom, 14) .animation(.easeOut(duration: 0.2), value: page) .animation(.easeOut(duration: 0.2), value: errorMessage != 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 namePage: some View { VStack(alignment: .leading, spacing: 0) { pageTitle( "Name your workspace", "One home for your team's mailboxes, contacts and campaigns." ) TextField("Acme outbound", text: $name) .focused($nameFocused) .submitLabel(.continue) .onSubmit { Task { await advance() } } .font(.system(size: 16.5)) .padding(.horizontal, 16) .frame(height: 56) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) .overlay( RoundedRectangle(cornerRadius: 17) .strokeBorder(nameFocused ? WTheme.accent : .clear, lineWidth: 1.8) ) .animation(.easeOut(duration: 0.18), value: nameFocused) Text("You'll be the owner. Rename it or invite more people anytime.") .font(.system(size: 13)) .foregroundStyle(.tertiary) .padding(.top, 12) } .onAppear { if name.isEmpty { nameFocused = true } } } private var invitePage: some View { VStack(alignment: .leading, spacing: 0) { pageTitle( "Invite your team", "Optional. They'll get an email invitation to join \(name.trimmingCharacters(in: .whitespaces))." ) VStack(spacing: 12) { ForEach($inviteEmails) { $entry in inviteField($entry) } } if inviteEmails.count < 5 { Button { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { inviteEmails.append(InviteEntry()) } } label: { Label("Add another teammate", systemImage: "plus.circle.fill") .font(.system(size: 14.5, weight: .semibold)) .foregroundStyle(WTheme.accent) } .buttonStyle(PressableButtonStyle()) .padding(.top, 14) } Text("Everyone joins as a member. Fine-tune roles later in Team.") .font(.system(size: 13)) .foregroundStyle(.tertiary) .padding(.top, 12) } } private func inviteField(_ entry: Binding) -> some View { HStack(spacing: 8) { Image(systemName: "envelope") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.tertiary) TextField("teammate@company.com", text: entry.email) .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .focused($focusedInvite, equals: entry.wrappedValue.id) .font(.system(size: 16)) if inviteEmails.count > 1 { Button { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { inviteEmails.removeAll { $0.id == entry.wrappedValue.id } } } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 16)) .foregroundStyle(.tertiary) } .buttonStyle(.plain) .accessibilityLabel("Remove teammate") } } .padding(.horizontal, 16) .frame(height: 54) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) .overlay( RoundedRectangle(cornerRadius: 17) .strokeBorder(focusedInvite == entry.wrappedValue.id ? WTheme.accent : .clear, lineWidth: 1.8) ) .animation(.easeOut(duration: 0.18), value: focusedInvite) } // MARK: - Flow private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } /// Loose shape check, mirroring the paste importer: enough to catch typos /// without rejecting unusual but valid addresses. private func looksLikeEmail(_ raw: String) -> Bool { let t = raw.trimmingCharacters(in: .whitespaces) return t.contains("@") && t.contains(".") && !t.hasSuffix("@") } private var validInvites: [String] { inviteEmails .map { $0.email.trimmingCharacters(in: .whitespaces).lowercased() } .filter { looksLikeEmail($0) } } private var canAdvance: Bool { switch page { case .name: return trimmedName.count >= 2 case .invite: // Empty fields are fine (invites are optional), but a half-typed // address should be fixed or cleared, not silently dropped. return inviteEmails.allSatisfy { let t = $0.email.trimmingCharacters(in: .whitespaces) return t.isEmpty || looksLikeEmail(t) } } } private func goBack() { guard let previous = Page(rawValue: page.rawValue - 1) else { return } errorMessage = nil direction = -1 withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { page = previous } } private func advance(skipping: Bool = false) async { guard canAdvance || skipping else { return } errorMessage = nil if page == .invite { await submit() return } nameFocused = false direction = 1 withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { page = Page(rawValue: page.rawValue + 1) ?? .invite } } /// Creates the workspace (the session switches into it), then sends the /// invitations best-effort: the workspace exists either way, and failed /// invites reappear as absent rows in Team where they can be re-sent. private func submit() async { busy = true do { try await env.session.createOrganization(name: trimmedName) for email in validInvites { let _: MoreInviteResponse? = try? await env.api.post( "organization/members/invite", body: MoreInviteBody(email: email, roleIDs: []) ) } UINotificationFeedbackGenerator().notificationOccurred(.success) if context == .cover { onClose() } // In the gate context the session phase flips to .ready and // RootView swaps this flow out on its own. } catch { withAnimation(.easeOut(duration: 0.2)) { errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription } errorPulse += 1 } busy = false } }