606 lines
23 KiB
Swift
606 lines
23 KiB
Swift
import SwiftUI
|
|
|
|
struct RoundView: View {
|
|
@EnvironmentObject private var localization: LocalizationStore
|
|
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
|
@EnvironmentObject private var repository: HermesRepository
|
|
@EnvironmentObject private var playerCoordinator: PlayerCoordinator
|
|
|
|
let onRetry: () -> Void
|
|
|
|
@State private var phase: Phase = .preview
|
|
@State private var selectedOutcomeID: String?
|
|
@State private var lockAt: Date = .now
|
|
@State private var actionMessage: String?
|
|
@State private var isSubmitting = false
|
|
@State private var swipeFeedback: SwipeFeedback?
|
|
@State private var dragOffset: CGSize = .zero
|
|
|
|
private enum Phase {
|
|
case preview
|
|
case locked
|
|
case reveal
|
|
case result
|
|
}
|
|
|
|
private enum SwipeDirection {
|
|
case left
|
|
case right
|
|
}
|
|
|
|
private struct SwipeFeedback: Equatable {
|
|
let direction: SwipeDirection
|
|
let title: String
|
|
}
|
|
|
|
var body: some View {
|
|
TimelineView(.periodic(from: Date(), by: 1)) { _ in
|
|
let round = repository.currentRound
|
|
let now = repository.serverNow()
|
|
let activeLockAt = round?.event.lockAt ?? lockAt
|
|
let remaining = max(activeLockAt.timeIntervalSince(now), 0)
|
|
let timerLocked = round != nil && remaining <= 0
|
|
let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage(
|
|
localization: localization,
|
|
localeCode: localization.localeCode,
|
|
error: repository.errorCause
|
|
)
|
|
|
|
ZStack {
|
|
sideSelectionOverlay(round: round)
|
|
|
|
swipeSurface(round: round, remaining: remaining, timerLocked: timerLocked, bannerMessage: bannerMessage)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.gesture(swipeGesture(round: round, timerLocked: timerLocked))
|
|
.onAppear {
|
|
if let round {
|
|
startPreview(round)
|
|
}
|
|
}
|
|
.onChange(of: round?.event.id) { _, newValue in
|
|
guard newValue != nil, let round else {
|
|
return
|
|
}
|
|
startPreview(round)
|
|
}
|
|
.onChange(of: timerLocked) { _, isLocked in
|
|
guard isLocked, phase == .preview, selectedOutcomeID == nil else {
|
|
return
|
|
}
|
|
phase = .locked
|
|
playerCoordinator.pause()
|
|
}
|
|
.onChange(of: playerCoordinator.playbackCompletionCount) { _, _ in
|
|
guard phase == .locked, let round, selectedOutcomeID != nil else {
|
|
return
|
|
}
|
|
|
|
startReveal(for: round)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
playerCoordinator.pause()
|
|
}
|
|
}
|
|
|
|
private func swipeSurface(round: HermesRound?, remaining: TimeInterval, timerLocked: Bool, bannerMessage: String?) -> some View {
|
|
ZStack {
|
|
HermesTheme.surface
|
|
.ignoresSafeArea()
|
|
|
|
if round != nil {
|
|
HermesVideoPlayerView(coordinator: playerCoordinator, cornerRadius: 0, fixedHeight: nil)
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
LinearGradient(
|
|
colors: [.black.opacity(0.72), .black.opacity(0.12), .black.opacity(0.78)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.ignoresSafeArea()
|
|
|
|
if let feedback = swipeFeedback {
|
|
swipeFeedbackOverlay(feedback)
|
|
}
|
|
|
|
VStack(spacing: 0) {
|
|
header(round: round, remaining: remaining)
|
|
Spacer()
|
|
if let bannerMessage {
|
|
banner(message: bannerMessage)
|
|
.padding(.horizontal, HermesTheme.screenPadding)
|
|
.padding(.bottom, 16)
|
|
}
|
|
bottomOverlay(round: round, timerLocked: timerLocked)
|
|
}
|
|
}
|
|
.offset(x: dragOffset.width * 0.72)
|
|
.rotationEffect(.degrees(max(min(Double(dragOffset.width / 40.0), 6), -6)))
|
|
.shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 10)
|
|
}
|
|
|
|
private func header(round: HermesRound?, remaining: TimeInterval) -> some View {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(promptTitle(for: round))
|
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
.foregroundStyle(HermesTheme.textPrimary)
|
|
Text(phaseSubtitle(for: round))
|
|
.font(.callout.weight(.medium))
|
|
.foregroundStyle(HermesTheme.textSecondary)
|
|
}
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
SemiCircleCountdownView(progress: progressValue(for: remaining, round: round), label: Self.countdownText(for: remaining))
|
|
}
|
|
.padding(.horizontal, HermesTheme.screenPadding)
|
|
.padding(.top, 112)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func bottomOverlay(round: HermesRound?, timerLocked: Bool) -> some View {
|
|
VStack(spacing: 16) {
|
|
if let round {
|
|
switch phase {
|
|
case .preview:
|
|
EmptyView()
|
|
case .locked:
|
|
lockedOverlay(timerLocked: timerLocked)
|
|
case .reveal:
|
|
revealOverlay(round: round)
|
|
case .result:
|
|
resultOverlay(round: round)
|
|
}
|
|
} else if let banner = actionMessage ?? hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: repository.errorCause) {
|
|
VStack(spacing: 12) {
|
|
Text(banner)
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundStyle(HermesTheme.textPrimary)
|
|
Button(localization.string(for: "common.retry"), action: onRetry)
|
|
.buttonStyle(HermesPrimaryButtonStyle())
|
|
}
|
|
.padding(HermesTheme.contentPadding)
|
|
.hermesCard(elevated: true)
|
|
}
|
|
}
|
|
.padding(.horizontal, HermesTheme.screenPadding)
|
|
.padding(.bottom, 28)
|
|
}
|
|
|
|
private func lockedOverlay(timerLocked: Bool) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(localization.string(for: timerLocked ? "round.freeze_title" : "round.locked_label"))
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(HermesTheme.textPrimary)
|
|
Text(localization.string(for: timerLocked ? "round.freeze_subtitle" : "round.reveal_loading"))
|
|
.font(.callout)
|
|
.foregroundStyle(HermesTheme.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(HermesTheme.contentPadding)
|
|
.background(.black.opacity(0.34))
|
|
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous))
|
|
}
|
|
|
|
private func revealOverlay(round: HermesRound) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(localization.string(for: "reveal.title"))
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(HermesTheme.textPrimary)
|
|
Text(localization.string(for: "reveal.subtitle"))
|
|
.font(.callout)
|
|
.foregroundStyle(HermesTheme.textSecondary)
|
|
Button(localization.string(for: "reveal.cta"), action: showResult)
|
|
.buttonStyle(HermesPrimaryButtonStyle())
|
|
}
|
|
.padding(HermesTheme.contentPadding)
|
|
.background(.black.opacity(0.34))
|
|
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous))
|
|
}
|
|
|
|
private func resultOverlay(round: HermesRound) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(selectedOutcomeID == round.settlement.winningOutcomeId.uuidString ? localization.string(for: "result.win") : localization.string(for: "result.lose"))
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(HermesTheme.textPrimary)
|
|
Text(localization.string(for: "result.selection_label") + ": " + selectedOutcomeTitle(for: round))
|
|
.font(.callout)
|
|
.foregroundStyle(HermesTheme.textSecondary)
|
|
Text(localization.string(for: "result.outcome_label") + ": " + winningOutcomeTitle(for: round))
|
|
.font(.callout)
|
|
.foregroundStyle(HermesTheme.textSecondary)
|
|
Button(localization.string(for: "result.next_round"), action: nextRound)
|
|
.buttonStyle(HermesPrimaryButtonStyle())
|
|
}
|
|
.padding(HermesTheme.contentPadding)
|
|
.background(.black.opacity(0.34))
|
|
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous))
|
|
}
|
|
|
|
private func banner(message: String) -> some View {
|
|
Text(message)
|
|
.font(.callout.weight(.semibold))
|
|
.foregroundStyle(HermesTheme.textPrimary)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(HermesTheme.warning.opacity(0.24))
|
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
}
|
|
|
|
private func swipeFeedbackOverlay(_ feedback: SwipeFeedback) -> some View {
|
|
let color = feedback.direction == .right ? HermesTheme.positive : .red
|
|
|
|
return VStack {
|
|
Spacer()
|
|
Text(feedback.title)
|
|
.font(.system(size: 38, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 18)
|
|
.background(color.opacity(0.88))
|
|
.clipShape(Capsule())
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sideSelectionOverlay(round: HermesRound?) -> some View {
|
|
if let round, phase == .preview {
|
|
let dragThreshold: CGFloat = 10
|
|
let isDraggingRight = dragOffset.width > dragThreshold
|
|
let isDraggingLeft = dragOffset.width < -dragThreshold
|
|
|
|
HStack(spacing: 0) {
|
|
sideTint(
|
|
title: sortedOutcomes(for: round).first.map(outcomeTitle) ?? localization.string(for: "round.yes"),
|
|
subtitle: localization.string(for: "round.swipe_right"),
|
|
color: HermesTheme.positive,
|
|
opacity: isDraggingRight ? min(abs(dragOffset.width) / 240.0, 0.95) : 0
|
|
)
|
|
sideTint(
|
|
title: sortedOutcomes(for: round).dropFirst().first.map(outcomeTitle) ?? localization.string(for: "round.no"),
|
|
subtitle: localization.string(for: "round.swipe_left"),
|
|
color: .red,
|
|
opacity: isDraggingLeft ? min(abs(dragOffset.width) / 240.0, 0.95) : 0
|
|
)
|
|
}
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(false)
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private func sideTint(title: String, subtitle: String, color: Color, opacity: Double) -> some View {
|
|
ZStack(alignment: .center) {
|
|
color.opacity(opacity)
|
|
if opacity > 0.05 {
|
|
VStack(spacing: 8) {
|
|
Text(title)
|
|
.font(.system(size: 30, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
Text(subtitle)
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white.opacity(0.88))
|
|
}
|
|
.padding(24)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func swipeGesture(round: HermesRound?, timerLocked: Bool) -> some Gesture {
|
|
DragGesture(minimumDistance: 12)
|
|
.onChanged { value in
|
|
dragOffset = value.translation
|
|
}
|
|
.onEnded { value in
|
|
defer { dragOffset = .zero }
|
|
guard let round, phase == .preview, !timerLocked, !isSubmitting else {
|
|
swipeFeedback = nil
|
|
return
|
|
}
|
|
|
|
guard abs(value.translation.width) > 145, abs(value.translation.width) > abs(value.translation.height) else {
|
|
swipeFeedback = nil
|
|
return
|
|
}
|
|
|
|
handleSwipe(value.translation.width > 0 ? .right : .left, round: round)
|
|
}
|
|
}
|
|
|
|
private func handleSwipe(_ direction: SwipeDirection, round: HermesRound) {
|
|
let outcomes = sortedOutcomes(for: round)
|
|
let outcome = direction == .right ? outcomes.first : outcomes.dropFirst().first
|
|
guard let outcome else {
|
|
return
|
|
}
|
|
|
|
swipeFeedback = SwipeFeedback(direction: direction, title: outcomeTitle(outcome))
|
|
handleSelection(outcome.id.uuidString)
|
|
confirmSelection()
|
|
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 700_000_000)
|
|
if swipeFeedback?.title == outcomeTitle(outcome) {
|
|
swipeFeedback = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startPreview(_ round: HermesRound) {
|
|
phase = .preview
|
|
selectedOutcomeID = nil
|
|
actionMessage = nil
|
|
isSubmitting = false
|
|
swipeFeedback = nil
|
|
lockAt = round.event.lockAt
|
|
playerCoordinator.prepareForPreview(url: round.media.hlsMasterUrl, startTimeMs: round.media.previewStartMs)
|
|
|
|
analytics.track("round_loaded", attributes: roundAnalyticsAttributes(round))
|
|
analytics.track("preview_started", attributes: roundAnalyticsAttributes(round))
|
|
analytics.track("screen_viewed", attributes: ["screen_name": "round"])
|
|
}
|
|
|
|
private func handleSelection(_ outcomeID: String) {
|
|
guard phase == .preview, !isSubmitting else {
|
|
return
|
|
}
|
|
|
|
selectedOutcomeID = outcomeID
|
|
analytics.track("outcome_focused", attributes: ["screen_name": "round", "outcome_id": outcomeID])
|
|
analytics.track("outcome_selected", attributes: ["screen_name": "round", "outcome_id": outcomeID])
|
|
}
|
|
|
|
private func confirmSelection() {
|
|
guard phase == .preview, !isSubmitting, let round = repository.currentRound else {
|
|
return
|
|
}
|
|
|
|
guard let selectedOutcomeID else {
|
|
actionMessage = localization.string(for: "errors.session_expired")
|
|
return
|
|
}
|
|
|
|
if isDemoMode {
|
|
analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID))
|
|
analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID))
|
|
analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "swipe_selection"]) { _, new in new })
|
|
beginRevealTransition(for: round, selectedOutcomeID: selectedOutcomeID)
|
|
return
|
|
}
|
|
|
|
guard let session = repository.currentSession else {
|
|
actionMessage = localization.string(for: "errors.session_expired")
|
|
return
|
|
}
|
|
|
|
guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? sortedOutcomes(for: round).first?.id else {
|
|
actionMessage = localization.string(for: "errors.generic")
|
|
return
|
|
}
|
|
|
|
if repository.serverNow() >= round.event.lockAt {
|
|
phase = .locked
|
|
playerCoordinator.pause()
|
|
return
|
|
}
|
|
|
|
isSubmitting = true
|
|
actionMessage = nil
|
|
|
|
Task { @MainActor in
|
|
do {
|
|
let request = HermesBetIntentRequest(
|
|
sessionId: session.sessionId,
|
|
eventId: round.event.id,
|
|
marketId: round.market.id,
|
|
outcomeId: outcomeID,
|
|
idempotencyKey: UUID().uuidString,
|
|
clientSentAt: Date()
|
|
)
|
|
|
|
let response = try await repository.submitBetIntent(request)
|
|
|
|
guard response.accepted else {
|
|
actionMessage = localization.string(for: "errors.generic")
|
|
phase = .preview
|
|
isSubmitting = false
|
|
return
|
|
}
|
|
|
|
analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID))
|
|
analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID))
|
|
analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "swipe_selection"]) { _, new in new })
|
|
beginRevealTransition(for: round, selectedOutcomeID: selectedOutcomeID)
|
|
} catch {
|
|
actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic")
|
|
phase = .preview
|
|
}
|
|
|
|
isSubmitting = false
|
|
}
|
|
}
|
|
|
|
private func beginRevealTransition(for round: HermesRound, selectedOutcomeID: String) {
|
|
phase = .locked
|
|
playerCoordinator.play(rate: 2.0)
|
|
analytics.track("round_accelerated", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
|
}
|
|
|
|
private func startReveal(for round: HermesRound) {
|
|
guard let selectedOutcomeID else {
|
|
return
|
|
}
|
|
|
|
phase = .reveal
|
|
playerCoordinator.play(url: revealMediaURL(for: round), startTimeMs: revealStartTimeMs(for: round), rate: 1.0)
|
|
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
|
}
|
|
|
|
private func showResult() {
|
|
guard let round = repository.currentRound, let selectedOutcomeID else {
|
|
return
|
|
}
|
|
|
|
analytics.track("reveal_completed", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
|
phase = .result
|
|
playerCoordinator.pause()
|
|
}
|
|
|
|
private func nextRound() {
|
|
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
|
|
actionMessage = nil
|
|
|
|
Task { @MainActor in
|
|
do {
|
|
if repository.currentSession?.deviceModel == "Demo Device" {
|
|
_ = try await repository.refreshMockRound()
|
|
} else {
|
|
_ = try await repository.refreshRoundFromNetwork()
|
|
}
|
|
} catch {
|
|
actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sortedOutcomes(for round: HermesRound) -> [HermesOutcome] {
|
|
round.market.outcomes.sorted(by: { $0.sortOrder < $1.sortOrder })
|
|
}
|
|
|
|
private func revealMediaURL(for round: HermesRound) -> URL {
|
|
repository.currentSession?.deviceModel == "Demo Device" ? MockHermesData.revealMediaURL() : round.media.hlsMasterUrl
|
|
}
|
|
|
|
private var isDemoMode: Bool {
|
|
repository.currentSession?.deviceModel == "Demo Device"
|
|
}
|
|
|
|
private func revealStartTimeMs(for round: HermesRound) -> Int {
|
|
repository.currentSession?.deviceModel == "Demo Device" ? 0 : round.media.revealStartMs
|
|
}
|
|
|
|
private func promptTitle(for round: HermesRound?) -> String {
|
|
guard let round else {
|
|
return localization.string(for: "common.loading")
|
|
}
|
|
|
|
let localizedQuestion = localization.string(for: round.market.questionKey)
|
|
if localizedQuestion != round.market.questionKey {
|
|
return localizedQuestion
|
|
}
|
|
|
|
return localization.localeCode == "sv" ? round.event.titleSv : round.event.titleEn
|
|
}
|
|
|
|
private func phaseSubtitle(for round: HermesRound?) -> String {
|
|
switch phase {
|
|
case .preview:
|
|
return localization.string(for: "round.selection_prompt")
|
|
case .locked:
|
|
return localization.string(for: "round.reveal_loading")
|
|
case .reveal:
|
|
return localization.string(for: "reveal.subtitle")
|
|
case .result:
|
|
return round.map { winningOutcomeTitle(for: $0) } ?? localization.string(for: "common.loading")
|
|
}
|
|
}
|
|
|
|
private func outcomeTitle(_ outcome: HermesOutcome) -> String {
|
|
switch outcome.labelKey {
|
|
case "round.home":
|
|
return localization.string(for: "round.home")
|
|
case "round.away":
|
|
return localization.string(for: "round.away")
|
|
case "round.yes":
|
|
return localization.string(for: "round.yes")
|
|
case "round.no":
|
|
return localization.string(for: "round.no")
|
|
default:
|
|
return outcome.outcomeCode.capitalized
|
|
}
|
|
}
|
|
|
|
private func selectedOutcomeTitle(for round: HermesRound) -> String {
|
|
guard let selectedOutcomeID,
|
|
let outcome = round.market.outcomes.first(where: { $0.id.uuidString == selectedOutcomeID }) else {
|
|
return localization.string(for: "round.selection_prompt")
|
|
}
|
|
|
|
return outcomeTitle(outcome)
|
|
}
|
|
|
|
private func winningOutcomeTitle(for round: HermesRound) -> String {
|
|
guard let outcome = round.market.outcomes.first(where: { $0.id == round.settlement.winningOutcomeId }) else {
|
|
return localization.string(for: "round.selection_prompt")
|
|
}
|
|
|
|
return outcomeTitle(outcome)
|
|
}
|
|
|
|
private func progressValue(for remaining: TimeInterval, round: HermesRound?) -> Double {
|
|
let totalDuration = round.map { Double(max($0.media.previewEndMs - $0.media.previewStartMs, 1)) / 1_000.0 } ?? 15.0
|
|
return min(max(remaining / totalDuration, 0), 1)
|
|
}
|
|
|
|
private static func countdownText(for remaining: TimeInterval) -> String {
|
|
let totalSeconds = max(Int(remaining.rounded(.down)), 0)
|
|
let minutes = totalSeconds / 60
|
|
let seconds = totalSeconds % 60
|
|
return String(format: "%02d:%02d", minutes, seconds)
|
|
}
|
|
|
|
private func baseSelectionAttributes(_ outcomeId: String) -> [String: String] {
|
|
["screen_name": "round", "outcome_id": outcomeId]
|
|
}
|
|
|
|
private func roundAnalyticsAttributes(_ round: HermesRound) -> [String: String] {
|
|
[
|
|
"screen_name": "round",
|
|
"event_id": round.event.id.uuidString,
|
|
"market_id": round.market.id.uuidString,
|
|
]
|
|
}
|
|
}
|
|
|
|
private struct SemiCircleCountdownView: View {
|
|
let progress: Double
|
|
let label: String
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
SemiCircleShape()
|
|
.stroke(.white.opacity(0.18), style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
|
SemiCircleShape()
|
|
.trim(from: 0, to: progress)
|
|
.stroke(HermesTheme.accent, style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
|
Text(label)
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.top, 22)
|
|
}
|
|
.frame(width: 92, height: 56)
|
|
}
|
|
}
|
|
|
|
private struct SemiCircleShape: Shape {
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
path.addArc(
|
|
center: CGPoint(x: rect.midX, y: rect.maxY),
|
|
radius: rect.width / 2,
|
|
startAngle: .degrees(180),
|
|
endAngle: .degrees(0),
|
|
clockwise: false
|
|
)
|
|
return path
|
|
}
|
|
}
|