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 (1–10).
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 1–10.
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/// 1–5 dots use centered rows; 6–10 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}