mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 00:02:43 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user