Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 187 additions & 5 deletions src/views/shared/asc/BundleIDSetupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ struct BundleIDSetupView: View {
case creating // Registering bundle ID + enabling capabilities
case manual // Step 2: manual action — create app in ASC
case confirming // Checking if app exists in ASC
case browsing // Browse existing apps to connect
}

@State private var phase: Phase = .form
@State private var tld = "com"
@State private var organization = ""
@State private var appName = ""
@State private var selectedCapabilities: Set<String> = []
Expand All @@ -28,6 +30,12 @@ struct BundleIDSetupView: View {

@State private var showCreateInstructions = false

// Browse existing apps state
@State private var allApps: [ASCApp] = []
@State private var isLoadingApps = false
@State private var appSearchText = ""
@State private var loadAppsError: String?

// Capabilities supported by the ASC API (can be enabled automatically)
private static let capabilities: [(type: String, name: String)] = [
("PUSH_NOTIFICATIONS", "Push Notifications"),
Expand Down Expand Up @@ -98,14 +106,15 @@ struct BundleIDSetupView: View {
}

private var bundleIdPreview: String {
let prefix = sanitize(tld)
let org = sanitize(organization)
let app = sanitize(appName)
guard !org.isEmpty, !app.isEmpty else { return "com...." }
return "com.\(org).\(app)"
guard !prefix.isEmpty, !org.isEmpty, !app.isEmpty else { return "\(prefix.isEmpty ? "com" : prefix)...." }
return "\(prefix).\(org).\(app)"
}

private var isFormValid: Bool {
!sanitize(organization).isEmpty && !sanitize(appName).isEmpty
!sanitize(tld).isEmpty && !sanitize(organization).isEmpty && !sanitize(appName).isEmpty
}

var body: some View {
Expand All @@ -120,6 +129,8 @@ struct BundleIDSetupView: View {
manualContent
case .confirming:
confirmingContent
case .browsing:
browsingContent
}
}
.padding(32)
Expand All @@ -145,6 +156,22 @@ struct BundleIDSetupView: View {
.font(.callout)
.foregroundStyle(.secondary)

Button {
loadAllApps()
phase = .browsing
} label: {
HStack(spacing: 4) {
Text("Already have an app?")
.foregroundStyle(.secondary)
Text("Connect Existing App")
Image(systemName: "arrow.right")
.font(.caption)
}
.font(.callout)
}
.buttonStyle(.plain)
.foregroundStyle(.blue)

HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Expand All @@ -158,6 +185,12 @@ struct BundleIDSetupView: View {

// Fields
VStack(alignment: .leading, spacing: 16) {
labeledField("Top-Level Domain", hint: "e.g. com, io, org, net") {
TextField("com", text: $tld)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}

labeledField("Organization", hint: "Your company or personal identifier") {
TextField("mycompany", text: $organization)
.textFieldStyle(.roundedBorder)
Expand Down Expand Up @@ -415,18 +448,167 @@ struct BundleIDSetupView: View {
.frame(maxWidth: .infinity, minHeight: 200)
}

// MARK: - Phase 5: Browse Existing Apps

private var filteredApps: [ASCApp] {
guard !appSearchText.isEmpty else { return allApps }
let query = appSearchText.lowercased()
return allApps.filter {
$0.name.lowercased().contains(query) || $0.bundleId.lowercased().contains(query)
}
}

@ViewBuilder
private var browsingContent: some View {
HStack(spacing: 10) {
Image(systemName: "link.circle")
.font(.title2)
.foregroundStyle(.blue)
Text("Connect to Existing App")
.font(.title2.weight(.semibold))
}
Text("Select an app from your App Store Connect account.")
.font(.callout)
.foregroundStyle(.secondary)

Button {
phase = .form
} label: {
HStack(spacing: 4) {
Text("Need a new app?")
.foregroundStyle(.secondary)
Text("Register Bundle ID")
Image(systemName: "arrow.right")
.font(.caption)
}
.font(.callout)
}
.buttonStyle(.plain)
.foregroundStyle(.blue)

Divider()

if isLoadingApps {
VStack(spacing: 12) {
ProgressView()
Text("Loading apps\u{2026}")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 200)
} else if let loadAppsError {
VStack(spacing: 8) {
Text(loadAppsError)
.font(.callout)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
Button("Retry") { loadAllApps() }
.buttonStyle(.borderedProminent)
}
} else if allApps.isEmpty {
VStack(spacing: 8) {
Text("No apps found in your App Store Connect account.")
.font(.callout)
.foregroundStyle(.secondary)
Text("Register a new bundle ID to create one.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, minHeight: 120)
} else {
TextField("Search apps\u{2026}", text: $appSearchText)
.textFieldStyle(.roundedBorder)

let apps = filteredApps
if apps.isEmpty {
Text("No apps matching \"\(appSearchText)\"")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 80)
} else {
VStack(spacing: 2) {
ForEach(apps) { app in
Button {
connectToApp(app)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(app.name)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
Text(app.bundleId)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(.background.secondary)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
}
}
}

private func loadAllApps() {
guard let service = asc.service else {
loadAppsError = "ASC service not configured"
return
}
isLoadingApps = true
loadAppsError = nil
Task {
do {
let apps = try await service.fetchAllApps()
allApps = apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
isLoadingApps = false
} catch {
loadAppsError = error.localizedDescription
isLoadingApps = false
}
}
}

private func connectToApp(_ selectedApp: ASCApp) {
if let projectId = asc.loadedProjectId {
let storage = ProjectStorage()
if var metadata = storage.readMetadata(projectId: projectId) {
metadata.bundleIdentifier = selectedApp.bundleId
try? storage.writeMetadata(projectId: projectId, metadata: metadata)
}
}

error = nil
phase = .confirming

Task {
let found = await asc.fetchApp(bundleId: selectedApp.bundleId)
if found {
asc.credentialsError = nil
asc.resetTabState()
await asc.fetchTabData(tab)
} else {
error = "Failed to load app \"\(selectedApp.name)\" from App Store Connect."
phase = .browsing
}
}
}

// MARK: - Actions

private func prefill() {
guard let projectId = asc.loadedProjectId else { return }
let storage = ProjectStorage()
guard let metadata = storage.readMetadata(projectId: projectId) else { return }

// Pre-fill from existing bundle ID if it looks like com.xxx.yyy
// Pre-fill from existing bundle ID if it looks like tld.org.app
if let existingBundleId = metadata.bundleIdentifier,
!existingBundleId.isEmpty {
let parts = existingBundleId.split(separator: ".")
if parts.count >= 3 && parts[0] == "com" {
if parts.count >= 3 {
tld = String(parts[0])
organization = String(parts[1])
appName = parts.dropFirst(2).joined(separator: ".")
return
Expand Down