Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 225 additions & 12 deletions sample-native-app/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,232 @@
//
// ContentView.swift
// sample-native-app
//
// Created by Muvaffak on 1/16/26.
//

import Combine
import SwiftUI

struct ContentView: View {
@State private var game = GameModel()

var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
GeometryReader { geo in
ZStack {
Color.black.ignoresSafeArea()

switch game.phase {
case .start:
StartScreen(game: game, screenSize: geo.size)
.transition(.opacity)
case .playing:
PlayingScreen(game: game, screenSize: geo.size)
.transition(.opacity)
case .gameOver:
GameOverScreen(game: game)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.3), value: game.phase)
}
}
}

// MARK: - Start Screen

struct StartScreen: View {
var game: GameModel
let screenSize: CGSize
private let intervals: [Double] = [2.0, 3.0, 5.0]

var body: some View {
VStack(spacing: 32) {
Spacer()

Image(systemName: "circle.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.blue)
.symbolEffect(.pulse)

Text("Speedy Circles")
.font(.largeTitle.bold())
.foregroundStyle(.white)

Text("Tap the circles before time runs out!")
.font(.subheadline)
.foregroundStyle(.gray)
.multilineTextAlignment(.center)

VStack(spacing: 12) {
Text("Time Interval")
.font(.headline)
.foregroundStyle(.white.opacity(0.7))

HStack(spacing: 16) {
ForEach(intervals, id: \.self) { interval in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
game.selectedInterval = interval
}
} label: {
Text(String(format: "%.1fs", interval))
.font(.title3.bold())
.frame(width: 80, height: 48)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(
game.selectedInterval == interval
? Color.blue : Color.white.opacity(0.15)
)
)
.foregroundStyle(.white)
}
.accessibilityIdentifier("interval_\(String(format: "%.1f", interval))")
}
}
}

Button {
withAnimation(.easeInOut(duration: 0.3)) {
game.startGame(in: screenSize)
}
} label: {
Text("Start")
.font(.title2.bold())
.frame(width: 200, height: 56)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.green)
)
.foregroundStyle(.white)
}
.accessibilityIdentifier("startButton")

Spacer()
}
.padding()
}
}

// MARK: - Playing Screen

struct PlayingScreen: View {
var game: GameModel
let screenSize: CGSize
@State private var now = Date()

private var remaining: Double {
max(0, game.deadline.timeIntervalSince(now))
}

private var fraction: Double {
game.selectedInterval > 0 ? remaining / game.selectedInterval : 0
}

var body: some View {
ZStack {
VStack(spacing: 4) {
HStack {
Label("\(game.score)", systemImage: "star.fill")
.font(.title2.bold())
.foregroundStyle(.yellow)
.accessibilityIdentifier("scoreLabel")

Spacer()

Text(String(format: "%.1f", remaining))
.font(.title2.monospacedDigit().bold())
.foregroundStyle(fraction < 0.3 ? .red : .white)
.contentTransition(.numericText())
.accessibilityIdentifier("timerLabel")
}
.padding(.horizontal, 20)
.padding(.top, 8)

GeometryReader { barGeo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.white.opacity(0.1))
.frame(height: 6)

RoundedRectangle(cornerRadius: 3)
.fill(fraction < 0.3 ? Color.red : Color.green)
.frame(width: barGeo.size.width * fraction, height: 6)
.animation(.easeInOut(duration: 0.3), value: fraction < 0.3)
}
}
.frame(height: 6)
.padding(.horizontal, 20)

Spacer()
}

Circle()
.fill(game.circleColor.gradient)
.shadow(color: game.circleColor.opacity(0.5), radius: 10)
.frame(width: game.circleSize, height: game.circleSize)
.contentShape(Circle())
.position(game.circlePosition)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
game.circleTapped(in: screenSize)
}
}
.accessibilityElement()
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Target Circle")
.accessibilityIdentifier("targetCircle")
}
.onReceive(
Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
) { date in
now = date
if game.deadline.timeIntervalSince(date) <= 0 {
withAnimation(.easeInOut(duration: 0.3)) {
game.checkTimeout(at: date)
}
}
}
}
}

// MARK: - Game Over Screen

struct GameOverScreen: View {
var game: GameModel

var body: some View {
VStack(spacing: 24) {
Spacer()

Image(systemName: "xmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.red)

Text("Game Over")
.font(.largeTitle.bold())
.foregroundStyle(.white)

Text("\(game.score)")
.font(.system(size: 80, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
.accessibilityIdentifier("finalScore")

Text(game.score == 1 ? "circle tapped" : "circles tapped")
.font(.title3)
.foregroundStyle(.gray)

Button {
withAnimation(.easeInOut(duration: 0.3)) {
game.retry()
}
} label: {
Text("Retry")
.font(.title2.bold())
.frame(width: 200, height: 56)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.blue)
)
.foregroundStyle(.white)
}
.accessibilityIdentifier("retryButton")

Spacer()
}
.padding()
}
Expand Down
73 changes: 73 additions & 0 deletions sample-native-app/GameModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import SwiftUI

enum GamePhase: Equatable {
case start
case playing
case gameOver
}

@Observable
class GameModel {
var phase: GamePhase = .start
var score: Int = 0
var selectedInterval: Double = 3.0
var circlePosition: CGPoint = CGPoint(x: 200, y: 400)
var circleSize: CGFloat = 100
var circleColor: Color = .blue
var deadline: Date = .distantFuture

private let minimumSize: CGFloat = 44
private let shrinkStep: CGFloat = 4
private let startSize: CGFloat = 100

func startGame(in size: CGSize) {
score = 0
circleSize = startSize
moveCircle(in: size)
deadline = Date().addingTimeInterval(selectedInterval)
phase = .playing
}

func circleTapped(in size: CGSize) {
guard phase == .playing else { return }
score += 1
circleSize = max(minimumSize, circleSize - shrinkStep)
moveCircle(in: size)
deadline = Date().addingTimeInterval(selectedInterval)
}

func checkTimeout(at date: Date) {
if phase == .playing && date >= deadline {
phase = .gameOver
}
}

func retry() {
phase = .start
}

private func moveCircle(in size: CGSize) {
let r = circleSize / 2
let topPadding: CGFloat = 80
let edgePadding: CGFloat = 8
let minX = r + edgePadding
let maxX = max(minX + 1, size.width - r - edgePadding)
let minY = r + topPadding
let maxY = max(minY + 1, size.height - r - edgePadding)

circlePosition = CGPoint(
x: .random(in: minX...maxX),
y: .random(in: minY...maxY)
)

let palette: [Color] = [
.red, .blue, .green, .orange, .purple,
.pink, .cyan, .yellow, .mint, .indigo,
]
var next = circleColor
while next == circleColor {
next = palette.randomElement()!
}
circleColor = next
}
}
Loading