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}