krz/daybyday
iOS app for children to learn days of the week, months, and seasons with audio assist.
clone: git clone https://gitbay.org/krz/daybyday.git
main: DayByDay/ContentView.swift · raw
1//
2// ContentView.swift
3// DayByDay
4//
5// Created by cmc on 2026-03-09.
6//
7
8import SwiftUI
9
10// MARK: - Category model
11
12enum Category: Int, CaseIterable, Identifiable {
13 case today, days, months, seasons, numbers, colors, shapes, animals, alphabet,
14 weather, bodyParts, food
15
16 var id: Int { rawValue }
17
18 var label: String {
19 switch self {
20 case .today: "Today"
21 case .days: "Days"
22 case .months: "Months"
23 case .seasons: "Seasons"
24 case .numbers: "Numbers"
25 case .colors: "Colors"
26 case .shapes: "Shapes"
27 case .animals: "Animals"
28 case .alphabet: "Alphabet"
29 case .weather: "Weather"
30 case .bodyParts: "Body"
31 case .food: "Food"
32 }
33 }
34
35 var symbol: String {
36 switch self {
37 case .today: "sun.horizon.fill"
38 case .days: "calendar"
39 case .months: "calendar.badge.clock"
40 case .seasons: "leaf.fill"
41 case .numbers: "123.rectangle.fill"
42 case .colors: "paintpalette.fill"
43 case .shapes: "pentagon.fill"
44 case .animals: "pawprint.fill"
45 case .alphabet: "abc"
46 case .weather: "cloud.sun.rain.fill"
47 case .bodyParts: "figure.stand"
48 case .food: "carrot.fill"
49 }
50 }
51
52 var color: Color {
53 switch self {
54 case .today: .orange
55 case .days: .blue
56 case .months: .purple
57 case .seasons: .green
58 case .numbers: .red
59 case .colors: .pink
60 case .shapes: .teal
61 case .animals: Color(red: 0.7, green: 0.45, blue: 0.2)
62 case .alphabet: .indigo
63 case .weather: Color(red: 0.4, green: 0.75, blue: 0.95)
64 case .bodyParts: Color(red: 0.9, green: 0.5, blue: 0.6)
65 case .food: Color(red: 0.95, green: 0.6, blue: 0.2)
66 }
67 }
68
69 /// Whether this tile uses a special gradient background instead of the solid color.
70 var usesGradientBackground: Bool {
71 self == .colors
72 }
73}
74
75// MARK: - Home grid tile
76
77struct CategoryTile: View {
78 let category: Category
79
80 var body: some View {
81 ZStack {
82 if category.usesGradientBackground {
83 RoundedRectangle(cornerRadius: 32, style: .continuous)
84 .fill(
85 LinearGradient(
86 colors: [.red, .orange, .yellow, .green, .blue, .purple],
87 startPoint: .leading,
88 endPoint: .trailing
89 )
90 )
91 .shadow(color: category.color.opacity(0.4), radius: 8, y: 4)
92 } else {
93 RoundedRectangle(cornerRadius: 32, style: .continuous)
94 .fill(category.color.gradient)
95 .shadow(color: category.color.opacity(0.4), radius: 8, y: 4)
96 }
97
98 VStack(spacing: 12) {
99 Image(systemName: category.symbol)
100 .font(.system(size: 48))
101 .foregroundStyle(.white)
102 .symbolRenderingMode(.hierarchical)
103
104 Text(category.label)
105 .font(.system(size: 24, weight: .bold, design: .rounded))
106 .foregroundStyle(.white)
107 }
108 }
109 .accessibilityLabel(category.label)
110 }
111}
112
113// MARK: - Content view
114
115struct ContentView: View {
116 @AppStorage("hasSeenVoiceTip") private var hasSeenVoiceTip = false
117
118 private let columns = [
119 GridItem(.flexible(), spacing: 24),
120 GridItem(.flexible(), spacing: 24)
121 ]
122
123 var body: some View {
124 NavigationStack {
125 ScrollView {
126 LazyVGrid(columns: columns, spacing: 24) {
127 ForEach(Category.allCases) { category in
128 NavigationLink(value: category) {
129 CategoryTile(category: category)
130 .aspectRatio(1, contentMode: .fit)
131 }
132 .buttonStyle(.plain)
133 }
134 }
135 .padding(24)
136
137 Button {
138 hasSeenVoiceTip = false
139 } label: {
140 Label("Voice Quality Tips", systemImage: "speaker.wave.2.fill")
141 .font(.system(size: 16, weight: .medium, design: .rounded))
142 .foregroundStyle(.secondary)
143 .frame(maxWidth: .infinity)
144 .padding(.vertical, 14)
145 .background(Color(.systemGray6))
146 .cornerRadius(16)
147 }
148 .buttonStyle(.plain)
149 .padding(.horizontal, 24)
150 .padding(.bottom, 24)
151 }
152 .navigationTitle("DayByDay")
153 .navigationDestination(for: Category.self) { category in
154 destinationView(for: category)
155 .navigationTitle(category.label)
156 }
157 .overlay {
158 if !hasSeenVoiceTip {
159 VoiceTipOverlay {
160 withAnimation { hasSeenVoiceTip = true }
161 }
162 }
163 }
164 }
165 }
166
167 @ViewBuilder
168 private func destinationView(for category: Category) -> some View {
169 switch category {
170 case .today: TodayView()
171 case .days: DaysOfWeekView()
172 case .months: MonthsOfYearView()
173 case .seasons: SeasonsView()
174 case .numbers: NumbersView()
175 case .colors: ColorsView()
176 case .shapes: ShapesView()
177 case .animals: AnimalsView()
178 case .alphabet: AlphabetView()
179 case .weather: WeatherView()
180 case .bodyParts: BodyPartsView()
181 case .food: FoodView()
182 }
183 }
184}
185
186/// A one-time informational overlay for parents explaining how to download
187/// a higher-quality voice for the best experience. Dismissed with a single tap.
188struct VoiceTipOverlay: View {
189 let onDismiss: () -> Void
190
191 var body: some View {
192 ZStack {
193 Color.black.opacity(0.4)
194 .ignoresSafeArea()
195
196 VStack(spacing: 20) {
197 Image(systemName: "speaker.wave.3.fill")
198 .font(.system(size: 48))
199 .foregroundStyle(.blue)
200
201 Text("Better Voices Available")
202 .font(.system(size: 28, weight: .bold, design: .rounded))
203
204 Text("For the best experience, download an enhanced voice on your device.\n\nSettings → Accessibility → Read & Speak → Voices → English → tap a voice marked Enhanced or Premium to download it.")
205 .font(.system(size: 18, design: .rounded))
206 .multilineTextAlignment(.center)
207 .foregroundStyle(.secondary)
208
209 Text("Tap anywhere to dismiss")
210 .font(.system(size: 16, weight: .medium, design: .rounded))
211 .foregroundStyle(.tertiary)
212 .padding(.top, 8)
213 }
214 .padding(40)
215 .frame(maxWidth: 520)
216 .background(
217 RoundedRectangle(cornerRadius: 32, style: .continuous)
218 .fill(.regularMaterial)
219 )
220 }
221 .contentShape(Rectangle())
222 .onTapGesture(perform: onDismiss)
223 }
224}
225
226#Preview {
227 ContentView()
228}