summaryrefslogtreecommitdiff
path: root/DayByDay
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-09 20:30:56 -0500
committerChristian Cleberg <[email protected]>2026-03-09 20:31:43 -0500
commit83479f9840f230c0c0dbee083b75735090ae9322 (patch)
treef4c79dc64d3308df30965b72f66882d27faa022c /DayByDay
parent84f1d5479a9fa884fd3d9119c5edcc4ca42810b5 (diff)
downloaddaybyday-83479f9840f230c0c0dbee083b75735090ae9322.tar.gz
daybyday-83479f9840f230c0c0dbee083b75735090ae9322.tar.bz2
daybyday-83479f9840f230c0c0dbee083b75735090ae9322.zip
initial 1.0 release
Diffstat (limited to 'DayByDay')
-rw-r--r--DayByDay/Assets.xcassets/AccentColor.colorset/Contents.json15
-rw-r--r--DayByDay/Assets.xcassets/AppIcon.appiconset/Contents.json37
-rw-r--r--DayByDay/Assets.xcassets/AppIcon.appiconset/logo_1024.pngbin0 -> 156349 bytes
-rw-r--r--DayByDay/Assets.xcassets/AppIcon.appiconset/logo_dark_1024.pngbin0 -> 164404 bytes
-rw-r--r--DayByDay/Assets.xcassets/Contents.json6
-rw-r--r--DayByDay/ContentView.swift110
-rw-r--r--DayByDay/DayByDayApp.swift17
-rw-r--r--DayByDay/DayCard.swift117
-rw-r--r--DayByDay/DaysOfWeekView.swift30
-rw-r--r--DayByDay/MonthCard.swift130
-rw-r--r--DayByDay/MonthsOfYearView.swift30
-rw-r--r--DayByDay/SeasonCard.swift111
-rw-r--r--DayByDay/SeasonsView.swift30
-rw-r--r--DayByDay/SpeechSynthesizer.swift52
14 files changed, 685 insertions, 0 deletions
diff --git a/DayByDay/Assets.xcassets/AccentColor.colorset/Contents.json b/DayByDay/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..d90b065
--- /dev/null
+++ b/DayByDay/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "platform" : "universal",
+ "reference" : "systemOrangeColor"
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/DayByDay/Assets.xcassets/AppIcon.appiconset/Contents.json b/DayByDay/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..da79705
--- /dev/null
+++ b/DayByDay/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,37 @@
+{
+ "images" : [
+ {
+ "filename" : "logo_1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "logo_dark_1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/DayByDay/Assets.xcassets/AppIcon.appiconset/logo_1024.png b/DayByDay/Assets.xcassets/AppIcon.appiconset/logo_1024.png
new file mode 100644
index 0000000..efbe0ea
--- /dev/null
+++ b/DayByDay/Assets.xcassets/AppIcon.appiconset/logo_1024.png
Binary files differ
diff --git a/DayByDay/Assets.xcassets/AppIcon.appiconset/logo_dark_1024.png b/DayByDay/Assets.xcassets/AppIcon.appiconset/logo_dark_1024.png
new file mode 100644
index 0000000..8ec4266
--- /dev/null
+++ b/DayByDay/Assets.xcassets/AppIcon.appiconset/logo_dark_1024.png
Binary files differ
diff --git a/DayByDay/Assets.xcassets/Contents.json b/DayByDay/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/DayByDay/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/DayByDay/ContentView.swift b/DayByDay/ContentView.swift
new file mode 100644
index 0000000..c3ede7a
--- /dev/null
+++ b/DayByDay/ContentView.swift
@@ -0,0 +1,110 @@
+//
+// ContentView.swift
+// DayByDay
+//
+// Created by cmc on 2026-03-09.
+//
+
+import SwiftUI
+
+struct ContentView: View {
+ @State private var selectedTab = 0
+ @AppStorage("hasSeenVoiceTip") private var hasSeenVoiceTip = false
+
+ private let tabs: [(label: String, symbol: String)] = [
+ ("Days", "calendar"),
+ ("Months", "calendar.badge.clock"),
+ ("Seasons", "leaf.fill"),
+ ]
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Group {
+ switch selectedTab {
+ case 0: DaysOfWeekView()
+ case 1: MonthsOfYearView()
+ default: SeasonsView()
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ Divider()
+
+ // Bottom tab bar with large tap targets
+ HStack(spacing: 0) {
+ ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in
+ Button {
+ selectedTab = index
+ } label: {
+ VStack(spacing: 6) {
+ Image(systemName: tab.symbol)
+ .font(.system(size: 32))
+ Text(tab.label)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ }
+ .foregroundStyle(selectedTab == index ? Color.accentColor : Color.secondary)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(tab.label)
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.bottom, 8)
+ .background(.bar)
+ }
+ .overlay {
+ if !hasSeenVoiceTip {
+ VoiceTipOverlay {
+ withAnimation { hasSeenVoiceTip = true }
+ }
+ }
+ }
+ }
+}
+
+/// A one-time informational overlay for parents explaining how to download
+/// a higher-quality voice for the best experience. Dismissed with a single tap.
+struct VoiceTipOverlay: View {
+ let onDismiss: () -> Void
+
+ var body: some View {
+ ZStack {
+ Color.black.opacity(0.4)
+ .ignoresSafeArea()
+
+ VStack(spacing: 20) {
+ Image(systemName: "speaker.wave.3.fill")
+ .font(.system(size: 48))
+ .foregroundStyle(.blue)
+
+ Text("Better Voices Available")
+ .font(.system(size: 28, weight: .bold, design: .rounded))
+
+ Text("For the best experience, download an enhanced voice on your iPad.\n\nSettings → Accessibility → Spoken Content → Voices → English → tap a voice marked Enhanced or Premium to download it.")
+ .font(.system(size: 18, design: .rounded))
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.secondary)
+
+ Text("Tap anywhere to dismiss")
+ .font(.system(size: 16, weight: .medium, design: .rounded))
+ .foregroundStyle(.tertiary)
+ .padding(.top, 8)
+ }
+ .padding(40)
+ .frame(maxWidth: 520)
+ .background(
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .fill(.regularMaterial)
+ )
+ }
+ .contentShape(Rectangle())
+ .onTapGesture(perform: onDismiss)
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/DayByDay/DayByDayApp.swift b/DayByDay/DayByDayApp.swift
new file mode 100644
index 0000000..2cce4db
--- /dev/null
+++ b/DayByDay/DayByDayApp.swift
@@ -0,0 +1,17 @@
+//
+// DayByDayApp.swift
+// DayByDay
+//
+// Created by cmc on 2026-03-09.
+//
+
+import SwiftUI
+
+@main
+struct DayByDayApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/DayByDay/DayCard.swift b/DayByDay/DayCard.swift
new file mode 100644
index 0000000..4ee3614
--- /dev/null
+++ b/DayByDay/DayCard.swift
@@ -0,0 +1,117 @@
+//
+// DayCard.swift
+// DayByDay
+//
+
+import SwiftUI
+
+/// A single large, colorful card representing one day of the week.
+/// Tapping the card plays a bounce animation and speaks the day name aloud.
+struct DayCard: View {
+ let day: DayOfWeek
+ var isHighlighted = false
+ @State private var isTapped = false
+
+ var body: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .fill(day.color.gradient)
+ .shadow(color: day.color.opacity(0.4), radius: 8, y: 4)
+
+ VStack(spacing: 16) {
+ Image(systemName: day.symbol)
+ .font(.system(size: 64))
+ .foregroundStyle(.white)
+ .symbolRenderingMode(.hierarchical)
+
+ Text(day.name)
+ .font(.system(size: 32, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ }
+ }
+ .overlay {
+ if isHighlighted {
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .strokeBorder(.white, lineWidth: 5)
+ }
+ }
+ .shadow(color: isHighlighted ? .white.opacity(0.8) : .clear, radius: 12)
+ .scaleEffect(isTapped ? 1.12 : (isHighlighted ? 1.04 : 1.0))
+ .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
+ .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0))
+ .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped)
+ .onTapGesture {
+ isTapped = true
+ SpeechSynthesizer.shared.speak(day.name)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+ isTapped = false
+ }
+ }
+ .accessibilityLabel(day.name)
+ .accessibilityAddTraits(.isButton)
+ }
+}
+
+// MARK: - Day model
+
+enum DayOfWeek: Int, CaseIterable, Identifiable {
+ case sunday, monday, tuesday, wednesday, thursday, friday, saturday
+
+ var id: Int { rawValue }
+
+ /// The day of the week for today's date.
+ static var current: DayOfWeek {
+ // Calendar weekday: 1 = Sunday, 7 = Saturday
+ let weekday = Calendar.current.component(.weekday, from: Date())
+ return DayOfWeek(rawValue: weekday - 1)!
+ }
+
+ var name: String {
+ switch self {
+ case .sunday: "Sunday"
+ case .monday: "Monday"
+ case .tuesday: "Tuesday"
+ case .wednesday: "Wednesday"
+ case .thursday: "Thursday"
+ case .friday: "Friday"
+ case .saturday: "Saturday"
+ }
+ }
+
+ /// A recognisable SF Symbol icon for each day so children who cannot read
+ /// still get a unique visual cue.
+ var symbol: String {
+ switch self {
+ case .sunday: "sun.max.fill"
+ case .monday: "moon.fill"
+ case .tuesday: "star.fill"
+ case .wednesday: "cloud.fill"
+ case .thursday: "bolt.fill"
+ case .friday: "heart.fill"
+ case .saturday: "sparkles"
+ }
+ }
+
+ /// Bright, child-friendly colour for each card.
+ var color: Color {
+ switch self {
+ case .sunday: Color(red: 1.0, green: 0.6, blue: 0.2) // orange
+ case .monday: Color(red: 0.35, green: 0.5, blue: 0.9) // blue
+ case .tuesday: Color(red: 0.9, green: 0.3, blue: 0.4) // red-pink
+ case .wednesday: Color(red: 0.3, green: 0.75, blue: 0.45) // green
+ case .thursday: Color(red: 0.55, green: 0.4, blue: 0.9) // purple
+ case .friday: Color(red: 1.0, green: 0.8, blue: 0.2) // yellow
+ case .saturday: Color(red: 0.2, green: 0.8, blue: 0.8) // teal
+ }
+ }
+}
+
+#Preview {
+ HStack(spacing: 24) {
+ DayCard(day: .wednesday, isHighlighted: true)
+ .frame(width: 200, height: 260)
+ DayCard(day: .thursday)
+ .frame(width: 200, height: 260)
+ }
+ .padding()
+}
diff --git a/DayByDay/DaysOfWeekView.swift b/DayByDay/DaysOfWeekView.swift
new file mode 100644
index 0000000..aed32cb
--- /dev/null
+++ b/DayByDay/DaysOfWeekView.swift
@@ -0,0 +1,30 @@
+//
+// DaysOfWeekView.swift
+// DayByDay
+//
+
+import SwiftUI
+
+/// Displays all seven days of the week as large, tappable cards in a scrollable grid.
+/// Adapts between 1 and 2 columns depending on available width.
+struct DaysOfWeekView: View {
+ private let columns = [
+ GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24)
+ ]
+
+ var body: some View {
+ ScrollView {
+ LazyVGrid(columns: columns, spacing: 24) {
+ ForEach(DayOfWeek.allCases) { day in
+ DayCard(day: day, isHighlighted: day == .current)
+ .frame(height: 200)
+ }
+ }
+ .padding(32)
+ }
+ }
+}
+
+#Preview {
+ DaysOfWeekView()
+}
diff --git a/DayByDay/MonthCard.swift b/DayByDay/MonthCard.swift
new file mode 100644
index 0000000..f462fd4
--- /dev/null
+++ b/DayByDay/MonthCard.swift
@@ -0,0 +1,130 @@
+//
+// MonthCard.swift
+// DayByDay
+//
+
+import SwiftUI
+
+/// A single large, colorful card representing one month of the year.
+/// Tapping the card plays a bounce animation and speaks the month name aloud.
+struct MonthCard: View {
+ let month: MonthOfYear
+ var isHighlighted = false
+ @State private var isTapped = false
+
+ var body: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .fill(month.color.gradient)
+ .shadow(color: month.color.opacity(0.4), radius: 8, y: 4)
+
+ VStack(spacing: 16) {
+ Image(systemName: month.symbol)
+ .font(.system(size: 64))
+ .foregroundStyle(.white)
+ .symbolRenderingMode(.hierarchical)
+
+ Text(month.name)
+ .font(.system(size: 32, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ }
+ }
+ .overlay {
+ if isHighlighted {
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .strokeBorder(.white, lineWidth: 5)
+ }
+ }
+ .shadow(color: isHighlighted ? .white.opacity(0.8) : .clear, radius: 12)
+ .scaleEffect(isTapped ? 1.12 : (isHighlighted ? 1.04 : 1.0))
+ .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
+ .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0))
+ .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped)
+ .onTapGesture {
+ isTapped = true
+ SpeechSynthesizer.shared.speak(month.name)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+ isTapped = false
+ }
+ }
+ .accessibilityLabel(month.name)
+ .accessibilityAddTraits(.isButton)
+ }
+}
+
+// MARK: - Month model
+
+enum MonthOfYear: Int, CaseIterable, Identifiable {
+ case january, february, march, april, may, june,
+ july, august, september, october, november, december
+
+ var id: Int { rawValue }
+
+ /// The month for today's date.
+ static var current: MonthOfYear {
+ // Calendar month: 1 = January, 12 = December
+ let month = Calendar.current.component(.month, from: Date())
+ return MonthOfYear(rawValue: month - 1)!
+ }
+
+ var name: String {
+ switch self {
+ case .january: "January"
+ case .february: "February"
+ case .march: "March"
+ case .april: "April"
+ case .may: "May"
+ case .june: "June"
+ case .july: "July"
+ case .august: "August"
+ case .september: "September"
+ case .october: "October"
+ case .november: "November"
+ case .december: "December"
+ }
+ }
+
+ var symbol: String {
+ switch self {
+ case .january: "snowflake"
+ case .february: "heart.fill"
+ case .march: "wind"
+ case .april: "cloud.rain.fill"
+ case .may: "leaf.fill"
+ case .june: "sun.max.fill"
+ case .july: "fireworks"
+ case .august: "beach.umbrella.fill"
+ case .september: "book.fill"
+ case .october: "moon.stars.fill"
+ case .november: "fork.knife"
+ case .december: "gift.fill"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .january: Color(red: 0.55, green: 0.75, blue: 0.95) // ice blue
+ case .february: Color(red: 0.9, green: 0.35, blue: 0.5) // pink
+ case .march: Color(red: 0.45, green: 0.8, blue: 0.55) // spring green
+ case .april: Color(red: 0.5, green: 0.65, blue: 0.9) // rain blue
+ case .may: Color(red: 0.95, green: 0.7, blue: 0.2) // golden
+ case .june: Color(red: 1.0, green: 0.55, blue: 0.25) // warm orange
+ case .july: Color(red: 0.85, green: 0.25, blue: 0.3) // red
+ case .august: Color(red: 0.2, green: 0.75, blue: 0.8) // ocean teal
+ case .september: Color(red: 0.6, green: 0.45, blue: 0.85) // purple
+ case .october: Color(red: 0.9, green: 0.5, blue: 0.15) // pumpkin
+ case .november: Color(red: 0.7, green: 0.5, blue: 0.3) // brown
+ case .december: Color(red: 0.3, green: 0.5, blue: 0.85) // winter blue
+ }
+ }
+}
+
+#Preview {
+ HStack(spacing: 24) {
+ MonthCard(month: .march, isHighlighted: true)
+ .frame(width: 200, height: 260)
+ MonthCard(month: .april)
+ .frame(width: 200, height: 260)
+ }
+ .padding()
+}
diff --git a/DayByDay/MonthsOfYearView.swift b/DayByDay/MonthsOfYearView.swift
new file mode 100644
index 0000000..fb8e002
--- /dev/null
+++ b/DayByDay/MonthsOfYearView.swift
@@ -0,0 +1,30 @@
+//
+// MonthsOfYearView.swift
+// DayByDay
+//
+
+import SwiftUI
+
+/// Displays all twelve months as large, tappable cards in a scrollable grid.
+/// Adapts between 1 and 2 columns depending on available width.
+struct MonthsOfYearView: View {
+ private let columns = [
+ GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24)
+ ]
+
+ var body: some View {
+ ScrollView {
+ LazyVGrid(columns: columns, spacing: 24) {
+ ForEach(MonthOfYear.allCases) { month in
+ MonthCard(month: month, isHighlighted: month == .current)
+ .frame(height: 200)
+ }
+ }
+ .padding(32)
+ }
+ }
+}
+
+#Preview {
+ MonthsOfYearView()
+}
diff --git a/DayByDay/SeasonCard.swift b/DayByDay/SeasonCard.swift
new file mode 100644
index 0000000..c84dfb6
--- /dev/null
+++ b/DayByDay/SeasonCard.swift
@@ -0,0 +1,111 @@
+//
+// SeasonCard.swift
+// DayByDay
+//
+
+import SwiftUI
+
+/// A single large, colorful card representing one season.
+/// Tapping the card plays a bounce animation and speaks the season name aloud.
+struct SeasonCard: View {
+ let season: Season
+ var isHighlighted = false
+ @State private var isTapped = false
+
+ var body: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .fill(season.color.gradient)
+ .shadow(color: season.color.opacity(0.4), radius: 8, y: 4)
+
+ VStack(spacing: 16) {
+ Image(systemName: season.symbol)
+ .font(.system(size: 64))
+ .foregroundStyle(.white)
+ .symbolRenderingMode(.hierarchical)
+
+ Text(season.name)
+ .font(.system(size: 32, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ }
+ }
+ .overlay {
+ if isHighlighted {
+ RoundedRectangle(cornerRadius: 32, style: .continuous)
+ .strokeBorder(.white, lineWidth: 5)
+ }
+ }
+ .shadow(color: isHighlighted ? .white.opacity(0.8) : .clear, radius: 12)
+ .scaleEffect(isTapped ? 1.12 : (isHighlighted ? 1.04 : 1.0))
+ .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
+ .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0))
+ .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped)
+ .onTapGesture {
+ isTapped = true
+ SpeechSynthesizer.shared.speak(season.name)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+ isTapped = false
+ }
+ }
+ .accessibilityLabel(season.name)
+ .accessibilityAddTraits(.isButton)
+ }
+}
+
+// MARK: - Season model
+
+enum Season: Int, CaseIterable, Identifiable {
+ case spring, summer, fall, winter
+
+ var id: Int { rawValue }
+
+ /// The season for today's date.
+ /// Spring: March–May, Summer: June–August,
+ /// Fall: September–November, Winter: December–February.
+ static var current: Season {
+ let month = Calendar.current.component(.month, from: Date())
+ switch month {
+ case 3...5: return .spring
+ case 6...8: return .summer
+ case 9...11: return .fall
+ default: return .winter
+ }
+ }
+
+ var name: String {
+ switch self {
+ case .spring: "Spring"
+ case .summer: "Summer"
+ case .fall: "Fall"
+ case .winter: "Winter"
+ }
+ }
+
+ var symbol: String {
+ switch self {
+ case .spring: "leaf.fill"
+ case .summer: "sun.max.fill"
+ case .fall: "wind"
+ case .winter: "snowflake"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .spring: Color(red: 0.45, green: 0.8, blue: 0.5) // fresh green
+ case .summer: Color(red: 1.0, green: 0.6, blue: 0.15) // sunny orange
+ case .fall: Color(red: 0.85, green: 0.45, blue: 0.2) // autumn orange
+ case .winter: Color(red: 0.4, green: 0.65, blue: 0.95) // cool blue
+ }
+ }
+}
+
+#Preview {
+ HStack(spacing: 24) {
+ SeasonCard(season: .spring, isHighlighted: true)
+ .frame(width: 200, height: 260)
+ SeasonCard(season: .summer)
+ .frame(width: 200, height: 260)
+ }
+ .padding()
+}
diff --git a/DayByDay/SeasonsView.swift b/DayByDay/SeasonsView.swift
new file mode 100644
index 0000000..848bb13
--- /dev/null
+++ b/DayByDay/SeasonsView.swift
@@ -0,0 +1,30 @@
+//
+// SeasonsView.swift
+// DayByDay
+//
+
+import SwiftUI
+
+/// Displays all four seasons as large, tappable cards in a scrollable grid.
+/// Adapts between 1 and 2 columns depending on available width.
+struct SeasonsView: View {
+ private let columns = [
+ GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24)
+ ]
+
+ var body: some View {
+ ScrollView {
+ LazyVGrid(columns: columns, spacing: 24) {
+ ForEach(Season.allCases) { season in
+ SeasonCard(season: season, isHighlighted: season == .current)
+ .frame(height: 200)
+ }
+ }
+ .padding(32)
+ }
+ }
+}
+
+#Preview {
+ SeasonsView()
+}
diff --git a/DayByDay/SpeechSynthesizer.swift b/DayByDay/SpeechSynthesizer.swift
new file mode 100644
index 0000000..cac5caf
--- /dev/null
+++ b/DayByDay/SpeechSynthesizer.swift
@@ -0,0 +1,52 @@
+//
+// SpeechSynthesizer.swift
+// DayByDay
+//
+
+import AVFoundation
+
+/// Wrapper around AVSpeechSynthesizer that selects the highest-quality
+/// en-US neural voice available on the device. Falls back gracefully if
+/// premium/enhanced voices haven't been downloaded yet.
+final class SpeechSynthesizer {
+ static let shared = SpeechSynthesizer()
+
+ private let synthesizer = AVSpeechSynthesizer()
+ private let voice: AVSpeechSynthesisVoice?
+
+ private init() {
+ self.voice = Self.bestAvailableVoice()
+ }
+
+ func speak(_ text: String) {
+ if synthesizer.isSpeaking {
+ synthesizer.stopSpeaking(at: .immediate)
+ }
+
+ let utterance = AVSpeechUtterance(string: text)
+ utterance.voice = voice
+ utterance.rate = 0.4
+ utterance.pitchMultiplier = 1.0
+ utterance.preUtteranceDelay = 0
+ synthesizer.speak(utterance)
+ }
+
+ /// Picks the best en-US voice on the device.
+ /// Preference order: premium > enhanced > default quality.
+ /// Skips novelty voices so the child always hears a natural-sounding voice.
+ private static func bestAvailableVoice() -> AVSpeechSynthesisVoice? {
+ let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in
+ voice.language.hasPrefix("en-US")
+ && !voice.voiceTraits.contains(.isNoveltyVoice)
+ }
+
+ if let premium = candidates.first(where: { $0.quality == .premium }) {
+ return premium
+ }
+ if let enhanced = candidates.first(where: { $0.quality == .enhanced }) {
+ return enhanced
+ }
+ // Fall back to the best default-quality voice.
+ return candidates.first ?? AVSpeechSynthesisVoice(language: "en-US")
+ }
+}