ClueBar.swift (6603B)
1 import SwiftUI 2 3 private struct ClueKey: Hashable { 4 let direction: Puzzle.Direction 5 let number: Int 6 } 7 8 private struct ReplayClueTarget { 9 let position: GridPosition 10 let direction: Puzzle.Direction? 11 } 12 13 struct ClueBarSlot: View { 14 @Bindable var session: PlayerSession 15 let replayFrame: ReplayFrame? 16 17 private var replayClueTarget: ReplayClueTarget? { 18 guard let cursor = replayFrame?.cursor else { return nil } 19 return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection) 20 } 21 22 var body: some View { 23 ZStack(alignment: .bottom) { 24 ClueBarReservation() 25 26 ClueBar(session: session, replayClueTarget: replayClueTarget) 27 } 28 } 29 } 30 31 private struct ClueBarReservation: View { 32 var body: some View { 33 ClueBarContent( 34 label: "99 Across", 35 clueText: AttributedString("Clue reservation"), 36 reservesClueSpace: true 37 ) 38 .opacity(0) 39 .accessibilityHidden(true) 40 .allowsHitTesting(false) 41 } 42 } 43 44 private struct ClueBarContent: View { 45 let label: String 46 let clueText: AttributedString 47 var reservesClueSpace = false 48 var currentKey: ClueKey? 49 var slideEdge: Edge = .trailing 50 var onPrevious: (() -> Void)? 51 var onNext: (() -> Void)? 52 var onClueTap: (() -> Void)? 53 var onLabelTap: (() -> Void)? 54 55 var body: some View { 56 HStack(alignment: .clueCenter, spacing: 8) { 57 ClueBarIcon(systemName: "chevron.left", action: onPrevious) 58 59 VStack(alignment: .leading, spacing: 4) { 60 Text(label) 61 .font(.caption) 62 .textCase(.uppercase) 63 .foregroundStyle(.secondary) 64 .contentShape(Rectangle()) 65 .highPriorityGesture( 66 TapGesture() 67 .onEnded { 68 onLabelTap?() 69 } 70 ) 71 ZStack(alignment: .leading) { 72 clueTextView 73 } 74 .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] } 75 .frame(maxWidth: .infinity, alignment: .leading) 76 .clipped() 77 } 78 .contentShape(Rectangle()) 79 .onTapGesture { 80 onClueTap?() 81 } 82 83 ClueBarIcon(systemName: "chevron.right", action: onNext) 84 } 85 .padding(.horizontal, 8) 86 .padding(.top, 12) 87 .padding(.bottom, 6) 88 } 89 90 @ViewBuilder 91 private var clueTextView: some View { 92 baseClueText 93 .id(currentKey) 94 .transition(.push(from: slideEdge)) 95 } 96 97 private var baseClueText: some View { 98 Text(clueText) 99 .font(.headline) 100 .lineLimit(2, reservesSpace: reservesClueSpace) 101 .multilineTextAlignment(.leading) 102 .frame(maxWidth: .infinity, alignment: .leading) 103 } 104 } 105 106 private struct ClueBarIcon: View { 107 let systemName: String 108 var action: (() -> Void)? 109 110 var body: some View { 111 if let action { 112 Button(action: action) { 113 icon 114 } 115 .buttonStyle(.plain) 116 } else { 117 icon 118 } 119 } 120 121 private var icon: some View { 122 Image(systemName: systemName) 123 .font(.title3.weight(.semibold)) 124 .frame(width: 44, height: 44) 125 .contentShape(Rectangle()) 126 } 127 } 128 129 private struct ClueBar: View { 130 @Bindable var session: PlayerSession 131 let replayClueTarget: ReplayClueTarget? 132 @Environment(PlayerPreferences.self) private var preferences 133 @Environment(\.colorScheme) private var colorScheme 134 @State private var slideEdge: Edge = .trailing 135 @State private var isShowingClueList = false 136 137 private var backgroundColor: Color { 138 preferences.color.clueBarFill(dark: colorScheme == .dark) 139 } 140 141 var body: some View { 142 let display = replayClueDisplay ?? liveClueDisplay 143 let isShowingReplayClue = replayClueDisplay != nil 144 145 ClueBarContent( 146 label: label(for: display.clue, direction: display.direction), 147 clueText: display.clue?.attributedText ?? AttributedString("—"), 148 currentKey: display.currentKey, 149 slideEdge: slideEdge, 150 onPrevious: isShowingReplayClue ? nil : { 151 slideEdge = .leading 152 session.goToPreviousClue() 153 }, 154 onNext: isShowingReplayClue ? nil : { 155 slideEdge = .trailing 156 session.goToNextClue() 157 }, 158 onClueTap: isShowingReplayClue ? nil : { 159 isShowingClueList = true 160 }, 161 onLabelTap: isShowingReplayClue ? nil : { 162 session.toggleDirection() 163 } 164 ) 165 .background(backgroundColor) 166 .animation( 167 isShowingReplayClue ? nil : .smooth(duration: 0.22), 168 value: display.currentKey 169 ) 170 .sheet(isPresented: $isShowingClueList) { 171 ClueList(session: session) 172 .presentationDetents([.medium, .large]) 173 .presentationDragIndicator(.visible) 174 } 175 } 176 177 private var liveClueDisplay: ClueDisplay { 178 let clue = session.currentClue() 179 return ClueDisplay(clue: clue, direction: session.direction) 180 } 181 182 private var replayClueDisplay: ClueDisplay? { 183 guard let replayClueTarget else { return nil } 184 let position = replayClueTarget.position 185 guard let direction = replayClueTarget.direction else { return nil } 186 return ClueDisplay( 187 clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), 188 direction: direction 189 ) 190 } 191 192 private struct ClueDisplay { 193 let clue: Puzzle.Clue? 194 let direction: Puzzle.Direction 195 196 var currentKey: ClueKey? { 197 clue.map { ClueKey(direction: direction, number: $0.number) } 198 } 199 } 200 201 private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String { 202 let direction = direction == .across ? "Across" : "Down" 203 if let clue { 204 return "\(clue.number) \(direction)" 205 } 206 return direction 207 } 208 } 209 210 private extension VerticalAlignment { 211 enum ClueCenterID: AlignmentID { 212 static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } 213 } 214 static let clueCenter = VerticalAlignment(ClueCenterID.self) 215 }