import SwiftUI struct ContentView: View { @StateObject private var game = ShikakuGame(size: 5) @State private var mode: DrawingMode = .draw @State private var zoom: CGFloat = 1.0 @State private var pinchStartZoom: CGFloat? @State private var isShowingSeedSheet = false @State private var seedInput = "" @State private var seedError: String? private let zoomMinimum: CGFloat = 0.5 private let zoomMaximum: CGFloat = 3.0 private let zoomStep: CGFloat = 0.15 var body: some View { VStack(spacing: 12) { header sizePicker modePicker boardViewport zoomControls controls statusText } .padding(.top, 18) .padding(.bottom, 20) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(ShikakuTheme.background.ignoresSafeArea()) .sheet(isPresented: $isShowingSeedSheet) { seedSheet } } private var header: some View { HStack { Text("SHIKAKU") .font(.system(size: 24, weight: .black, design: .rounded)) .foregroundStyle(ShikakuTheme.primaryText) Spacer() TimelineView(.periodic(from: .now, by: 1)) { timeline in Text(game.formattedElapsed(at: timeline.date)) .font(.system(size: 18, weight: .semibold, design: .monospaced)) .foregroundStyle(ShikakuTheme.mutedText) .monospacedDigit() } } .padding(.horizontal, 20) } private var sizePicker: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(ShikakuGame.supportedSizes, id: \.self) { size in Button { guard size != game.size else { return } game.newPuzzle(size: size) mode = .draw resetZoom() } label: { Text("\(size)x\(size)") .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() .frame(minWidth: 56) } .buttonStyle(.bordered) .tint(size == game.size ? ShikakuTheme.accent : ShikakuTheme.mutedText) } } .padding(.horizontal, 20) } } private var modePicker: some View { Picker("Mode", selection: $mode) { ForEach(DrawingMode.allCases) { mode in Label(mode.title, systemImage: mode.symbolName).tag(mode) } } .pickerStyle(.segmented) .padding(.horizontal, 20) } private var boardViewport: some View { GeometryReader { proxy in let currentBoardSide = boardSide let contentWidth = max(currentBoardSide + 32, proxy.size.width) let contentHeight = max(currentBoardSide + 32, proxy.size.height) ScrollView([.horizontal, .vertical]) { ZStack { ShikakuGridView(game: game, mode: mode, side: currentBoardSide) } .frame(width: contentWidth, height: contentHeight, alignment: .center) } .scrollIndicators(.visible) .background(ShikakuTheme.panel) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .simultaneousGesture(pinchZoomGesture) } .padding(.horizontal, 12) .frame(maxHeight: .infinity) } private var zoomControls: some View { HStack(spacing: 12) { Button { updateZoom(by: -zoomStep) } label: { Image(systemName: "minus.magnifyingglass") .frame(width: 34, height: 34) } .buttonStyle(.bordered) .disabled(zoom <= zoomMinimum) Text("\(Int((zoom * 100).rounded()))%") .font(.system(size: 14, weight: .semibold, design: .monospaced)) .foregroundStyle(ShikakuTheme.mutedText) .monospacedDigit() .frame(width: 58) Button { updateZoom(by: zoomStep) } label: { Image(systemName: "plus.magnifyingglass") .frame(width: 34, height: 34) } .buttonStyle(.bordered) .disabled(zoom >= zoomMaximum) Button { resetZoom() } label: { Label("100%", systemImage: "arrow.counterclockwise") } .buttonStyle(.bordered) .tint(ShikakuTheme.mutedText) } .font(.system(size: 14, weight: .semibold, design: .rounded)) .padding(.horizontal, 20) } private var controls: some View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { Button { game.newPuzzle() mode = .draw } label: { Label("New", systemImage: "arrow.clockwise") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(ShikakuTheme.accent) Button { game.checkSolution() } label: { Label("Check", systemImage: "checkmark.circle") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .tint(ShikakuTheme.primaryText) Button(role: .destructive) { game.clear() mode = .draw } label: { Label("Clear", systemImage: "trash") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) Button { seedInput = "" seedError = nil isShowingSeedSheet = true } label: { Label("Seed", systemImage: "number") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .tint(ShikakuTheme.primaryText) } .font(.system(size: 15, weight: .semibold, design: .rounded)) .padding(.horizontal, 20) } private var statusText: some View { Text(game.message) .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundStyle(statusColor) .frame(height: 20) .lineLimit(1) .minimumScaleFactor(0.75) .padding(.horizontal, 20) } private var statusColor: Color { switch game.status { case .playing: return ShikakuTheme.mutedText case .solved: return ShikakuTheme.success case .error: return ShikakuTheme.error } } private var seedSheet: some View { VStack(alignment: .leading, spacing: 16) { Text("CURRENT SEED") .font(.system(size: 12, weight: .bold, design: .rounded)) .foregroundStyle(ShikakuTheme.mutedText) Text("\(game.currentSeed)") .font(.system(size: 22, weight: .bold, design: .monospaced)) .foregroundStyle(ShikakuTheme.primaryText) .textSelection(.enabled) Divider() TextField("e.g. 123456789", text: $seedInput) .font(.system(size: 18, weight: .semibold, design: .monospaced)) .keyboardType(.numbersAndPunctuation) .submitLabel(.go) .onSubmit(startPuzzleFromSeed) .textFieldStyle(.roundedBorder) if let seedError { Text(seedError) .font(.system(size: 13, weight: .medium, design: .rounded)) .foregroundStyle(ShikakuTheme.error) } Spacer(minLength: 0) HStack { Button("Cancel") { isShowingSeedSheet = false } .buttonStyle(.bordered) Spacer() Button { startPuzzleFromSeed() } label: { Label("Start Puzzle", systemImage: "play.fill") } .buttonStyle(.borderedProminent) .tint(ShikakuTheme.accent) } } .padding(24) .presentationDetents([.height(320)]) .presentationDragIndicator(.visible) .background(ShikakuTheme.background) } private var boardSide: CGFloat { CGFloat(game.size) * baseCellSide(for: game.size) * zoom } private var pinchZoomGesture: some Gesture { MagnificationGesture() .onChanged { value in if pinchStartZoom == nil { pinchStartZoom = zoom } zoom = clampedZoom((pinchStartZoom ?? zoom) * value) } .onEnded { _ in pinchStartZoom = nil } } private func baseCellSide(for size: Int) -> CGFloat { switch size { case 5: return 56 case 7: return 52 case 10: return 46 case 15: return 36 case 20: return 28 case 25: return 24 default: return 40 } } private func updateZoom(by delta: CGFloat) { zoom = clampedZoom(zoom + delta) } private func resetZoom() { zoom = 1.0 pinchStartZoom = nil } private func clampedZoom(_ value: CGFloat) -> CGFloat { min(zoomMaximum, max(zoomMinimum, value)) } private func startPuzzleFromSeed() { let trimmed = seedInput.trimmingCharacters(in: .whitespacesAndNewlines) guard let seed = UInt64(trimmed) else { seedError = "Enter a whole-number seed." return } game.newPuzzle(seed: seed) mode = .draw resetZoom() isShowingSeedSheet = false } }