ClueBar.swift (6742B)
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(.asymmetric( 95 insertion: .move(edge: slideEdge), 96 removal: .move(edge: slideEdge == .trailing ? .leading : .trailing) 97 )) 98 } 99 100 private var baseClueText: some View { 101 Text(clueText) 102 .font(.headline) 103 .lineLimit(2, reservesSpace: reservesClueSpace) 104 .multilineTextAlignment(.leading) 105 .frame(maxWidth: .infinity, alignment: .leading) 106 } 107 } 108 109 private struct ClueBarIcon: View { 110 let systemName: String 111 var action: (() -> Void)? 112 113 var body: some View { 114 if let action { 115 Button(action: action) { 116 icon 117 } 118 .buttonStyle(.plain) 119 } else { 120 icon 121 } 122 } 123 124 private var icon: some View { 125 Image(systemName: systemName) 126 .font(.title3.weight(.semibold)) 127 .frame(width: 44, height: 44) 128 .contentShape(Rectangle()) 129 } 130 } 131 132 private struct ClueBar: View { 133 @Bindable var session: PlayerSession 134 let replayClueTarget: ReplayClueTarget? 135 @Environment(PlayerPreferences.self) private var preferences 136 @Environment(\.colorScheme) private var colorScheme 137 @State private var slideEdge: Edge = .trailing 138 @State private var isShowingClueList = false 139 140 private var backgroundColor: Color { 141 preferences.color.clueBarFill(dark: colorScheme == .dark) 142 } 143 144 var body: some View { 145 let display = replayClueDisplay ?? liveClueDisplay 146 let isShowingReplayClue = replayClueDisplay != nil 147 148 ClueBarContent( 149 label: label(for: display.clue, direction: display.direction), 150 clueText: display.clue?.attributedText ?? AttributedString("—"), 151 currentKey: display.currentKey, 152 slideEdge: slideEdge, 153 onPrevious: isShowingReplayClue ? nil : { 154 slideEdge = .leading 155 session.goToPreviousClue() 156 }, 157 onNext: isShowingReplayClue ? nil : { 158 slideEdge = .trailing 159 session.goToNextClue() 160 }, 161 onClueTap: isShowingReplayClue ? nil : { 162 isShowingClueList = true 163 }, 164 onLabelTap: isShowingReplayClue ? nil : { 165 session.toggleDirection() 166 } 167 ) 168 .background(backgroundColor) 169 .animation( 170 isShowingReplayClue ? nil : .smooth(duration: 0.22), 171 value: display.currentKey 172 ) 173 .sheet(isPresented: $isShowingClueList) { 174 ClueList(session: session) 175 .presentationDetents([.medium, .large]) 176 .presentationDragIndicator(.visible) 177 } 178 } 179 180 private var liveClueDisplay: ClueDisplay { 181 let clue = session.currentClue() 182 return ClueDisplay(clue: clue, direction: session.direction) 183 } 184 185 private var replayClueDisplay: ClueDisplay? { 186 guard let replayClueTarget else { return nil } 187 let position = replayClueTarget.position 188 guard let direction = replayClueTarget.direction else { return nil } 189 return ClueDisplay( 190 clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), 191 direction: direction 192 ) 193 } 194 195 private struct ClueDisplay { 196 let clue: Puzzle.Clue? 197 let direction: Puzzle.Direction 198 199 var currentKey: ClueKey? { 200 clue.map { ClueKey(direction: direction, number: $0.number) } 201 } 202 } 203 204 private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String { 205 let direction = direction == .across ? "Across" : "Down" 206 if let clue { 207 return "\(clue.number) \(direction)" 208 } 209 return direction 210 } 211 } 212 213 private extension VerticalAlignment { 214 enum ClueCenterID: AlignmentID { 215 static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } 216 } 217 static let clueCenter = VerticalAlignment(ClueCenterID.self) 218 }