krz/daybyday

clone: git clone https://gitbay.org/krz/daybyday.git

main: NumberCard.swift · raw

  1//
  2//  NumberCard.swift
  3//  DayByDay
  4//
  5
  6import SwiftUI
  7
  8/// A single large, colorful card representing one number (110).
  9/// Tapping the card plays a bounce animation and speaks the number name aloud.
 10struct NumberCard: View {
 11    let number: LearnNumber
 12    var isHighlighted = false
 13    @State private var isTapped = false
 14
 15    private var cardScale: CGFloat {
 16        if isTapped { return 1.12 }
 17        if isHighlighted { return 1.04 }
 18        return 1.0
 19    }
 20
 21    var body: some View {
 22        ZStack {
 23            RoundedRectangle(cornerRadius: 32, style: .continuous)
 24                .fill(number.color.gradient)
 25                .shadow(color: number.color.opacity(0.4), radius: 8, y: 4)
 26
 27            VStack(spacing: 10) {
 28                Text("\(number.numeral)")
 29                    .font(.system(size: 60, weight: .heavy, design: .rounded))
 30                    .foregroundStyle(.white)
 31
 32                Text(number.name)
 33                    .font(.system(size: 28, weight: .bold, design: .rounded))
 34                    .foregroundStyle(.white)
 35
 36                // Dot pattern showing the quantity
 37                DotPattern(count: number.numeral)
 38                    .padding(.top, 4)
 39            }
 40        }
 41        .overlay {
 42            if isHighlighted {
 43                RoundedRectangle(cornerRadius: 32, style: .continuous)
 44                    .strokeBorder(.white, lineWidth: 5)
 45            }
 46        }
 47        .shadow(color: isHighlighted ? .white.opacity(0.8) : .clear, radius: 12)
 48        .scaleEffect(cardScale)
 49        .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
 50        .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0))
 51        .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped)
 52        .onTapGesture {
 53            isTapped = true
 54            SpeechSynthesizer.shared.speak(number.name)
 55            DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
 56                isTapped = false
 57            }
 58        }
 59        .accessibilityLabel(number.name)
 60        .accessibilityAddTraits(.isButton)
 61    }
 62}
 63
 64// MARK: - Number model
 65
 66enum LearnNumber: Int, CaseIterable, Identifiable {
 67    case one = 1, two, three, four, five, six, seven, eight, nine, ten
 68
 69    var id: LearnNumber { self }
 70
 71    /// Highlight the number matching today's day-of-month if it falls in 110.
 72    static var current: LearnNumber? {
 73        let day = Calendar.current.component(.day, from: Date())
 74        return LearnNumber(rawValue: day)
 75    }
 76
 77    var numeral: Int { rawValue }
 78
 79    var name: String {
 80        switch self {
 81        case .one:   "One"
 82        case .two:   "Two"
 83        case .three: "Three"
 84        case .four:  "Four"
 85        case .five:  "Five"
 86        case .six:   "Six"
 87        case .seven: "Seven"
 88        case .eight: "Eight"
 89        case .nine:  "Nine"
 90        case .ten:   "Ten"
 91        }
 92    }
 93
 94    var color: Color {
 95        switch self {
 96        case .one:   Color(red: 0.9, green: 0.3, blue: 0.3)   // red
 97        case .two:   Color(red: 1.0, green: 0.55, blue: 0.2)  // orange
 98        case .three: Color(red: 1.0, green: 0.8, blue: 0.2)   // yellow
 99        case .four:  Color(red: 0.35, green: 0.75, blue: 0.4)  // green
100        case .five:  Color(red: 0.2, green: 0.7, blue: 0.85)   // sky blue
101        case .six:   Color(red: 0.4, green: 0.45, blue: 0.9)   // blue
102        case .seven: Color(red: 0.6, green: 0.4, blue: 0.85)   // purple
103        case .eight: Color(red: 0.85, green: 0.35, blue: 0.6)  // pink
104        case .nine:  Color(red: 0.5, green: 0.75, blue: 0.45)  // lime
105        case .ten:   Color(red: 0.25, green: 0.8, blue: 0.75)  // teal
106        }
107    }
108}
109
110// MARK: - Dot pattern
111
112/// Displays a grid of filled circles representing a quantity.
113/// 15 dots use centered rows; 610 use a 5-column grid that wraps naturally.
114struct DotPattern: View {
115    let count: Int
116
117    private var columns: [GridItem] {
118        let maxCols = count <= 5 ? min(count, 5) : 5
119        return Array(repeating: GridItem(.fixed(14), spacing: 6), count: maxCols)
120    }
121
122    var body: some View {
123        LazyVGrid(columns: columns, spacing: 6) {
124            ForEach(0..<count, id: \.self) { _ in
125                Circle()
126                    .fill(.white.opacity(0.8))
127                    .frame(width: 14, height: 14)
128            }
129        }
130        .frame(maxWidth: .infinity)
131    }
132}
133
134#Preview {
135    HStack(spacing: 24) {
136        NumberCard(number: .three, isHighlighted: true)
137            .frame(width: 280, height: 300)
138        NumberCard(number: .seven)
139            .frame(width: 280, height: 300)
140    }
141    .padding()
142}