Files
shikaku/ios/Shikaku/ContentView.swift
2026-05-01 09:21:43 -04:00

328 lines
10 KiB
Swift

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
}
}