diff --git a/ios/Warmbly/Features/CRM/CRMDealsView.swift b/ios/Warmbly/Features/CRM/CRMDealsView.swift index d679104dc..fb1c077da 100644 --- a/ios/Warmbly/Features/CRM/CRMDealsView.swift +++ b/ios/Warmbly/Features/CRM/CRMDealsView.swift @@ -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 diff --git a/ios/Warmbly/Features/Campaigns/CampaignDetailView.swift b/ios/Warmbly/Features/Campaigns/CampaignDetailView.swift index 7a859901d..eb3b5a58a 100644 --- a/ios/Warmbly/Features/Campaigns/CampaignDetailView.swift +++ b/ios/Warmbly/Features/Campaigns/CampaignDetailView.swift @@ -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 {