feat: split the CRM pipeline stage bar into its own view builder and the campaign detail body into page and dialogs so the iOS app type-checks within the Xcode 26.6 compiler limits CI builds with

This commit is contained in:
Matthew Meszaros
2026-10-12 10:45:15 +02:00
parent 8b667c2d29
commit cf4253dcae
2 changed files with 65 additions and 51 deletions
+20 -11
View File
@@ -194,17 +194,7 @@ struct CRMDealsView: View {
}
if !values.isEmpty {
GeometryReader { proxy in
HStack(spacing: 3) {
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
if item.1 > 0 {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(CRMHex.color(item.0.color) ?? WTheme.paused)
.frame(width: shown ? max(8, (proxy.size.width - CGFloat(values.count) * 3) * CGFloat(item.1 / total)) : 0)
.animation(.spring(response: 0.85, dampingFraction: 0.8).delay(0.2 + Double(index) * 0.08), value: shown)
}
}
Spacer(minLength: 0)
}
stageBar(values, total: total, width: proxy.size.width)
}
.frame(height: 10)
VStack(spacing: 9) {
@@ -233,6 +223,25 @@ struct CRMDealsView: View {
.coverCard()
}
/// One segment per stage, sized by its share of the open value.
private func stageBar(_ values: [(CRMStage, Double)], total: Double, width: CGFloat) -> some View {
let usable: CGFloat = width - CGFloat(values.count) * 3
return HStack(spacing: 3) {
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
if item.1 > 0 {
let share = CGFloat(item.1 / total)
let barWidth: CGFloat = shown ? max(8, usable * share) : 0
let delay: Double = 0.2 + Double(index) * 0.08
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(CRMHex.color(item.0.color) ?? WTheme.paused)
.frame(width: barWidth)
.animation(.spring(response: 0.85, dampingFraction: 0.8).delay(delay), value: shown)
}
}
Spacer(minLength: 0)
}
}
// MARK: List
@ViewBuilder
@@ -103,46 +103,7 @@ struct CampaignDetailView: View {
private static let rowTextLeading: CGFloat = 66
var body: some View {
VStack(spacing: 0) {
hero
.frame(maxWidth: .infinity, alignment: .leading)
Hairline()
hubList
}
.background(alignment: .top) {
AirSkyWash()
.frame(height: 320)
.ignoresSafeArea(edges: .top)
}
.background(WTheme.canvas)
.toolbarBackground(.hidden, for: .navigationBar)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.presenceResource(presenceKey)
.task {
appeared = true
await store.refresh(env.api)
dailyLimit = store.campaign.dailyLimit ?? 50
// Counts for the browse rows load alongside the analytics.
async let overview: Void = overviewStore.load(env.api, campaignID: campaign.id)
async let steps: Void = stepsStore.load(env.api, campaignID: campaign.id)
async let senders: Void = sendersStore.load(env.api, campaignID: campaign.id)
_ = await (overview, steps, senders)
}
.onChange(of: env.realtime.pulse(for: .campaigns)) {
Task {
await store.refresh(env.api)
dailyLimit = store.campaign.dailyLimit ?? 50
}
}
.onChange(of: env.realtime.pulse(for: .analytics)) {
Task { await overviewStore.load(env.api, campaignID: campaign.id) }
}
.onChange(of: dailyLimit) { _, newValue in
guard newValue != campaign.dailyLimit else { return }
saveDailyLimitDebounced(newValue)
}
page
.confirmationDialog(
toggleAction?.isStart == true ? "Start this campaign?" : "Pause this campaign?",
isPresented: $confirmToggle,
@@ -190,6 +151,50 @@ struct CampaignDetailView: View {
}
}
/// The page and what keeps it current; `body` adds the dialogs.
private var page: some View {
VStack(spacing: 0) {
hero
.frame(maxWidth: .infinity, alignment: .leading)
Hairline()
hubList
}
.background(alignment: .top) {
AirSkyWash()
.frame(height: 320)
.ignoresSafeArea(edges: .top)
}
.background(WTheme.canvas)
.toolbarBackground(.hidden, for: .navigationBar)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.presenceResource(presenceKey)
.task {
appeared = true
await store.refresh(env.api)
dailyLimit = store.campaign.dailyLimit ?? 50
// Counts for the browse rows load alongside the analytics.
async let overview: Void = overviewStore.load(env.api, campaignID: campaign.id)
async let steps: Void = stepsStore.load(env.api, campaignID: campaign.id)
async let senders: Void = sendersStore.load(env.api, campaignID: campaign.id)
_ = await (overview, steps, senders)
}
.onChange(of: env.realtime.pulse(for: .campaigns)) {
Task {
await store.refresh(env.api)
dailyLimit = store.campaign.dailyLimit ?? 50
}
}
.onChange(of: env.realtime.pulse(for: .analytics)) {
Task { await overviewStore.load(env.api, campaignID: campaign.id) }
}
.onChange(of: dailyLimit) { _, newValue in
guard newValue != campaign.dailyLimit else { return }
saveDailyLimitDebounced(newValue)
}
}
// MARK: Hero
private var hero: some View {