From c53a320698c17d98477c0384f1f644da9735f645 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 14 Mar 2026 09:56:39 -0500 Subject: implement more learning categories --- AlphabetView.swift | 30 +++ AnimalCard.swift | 103 +++++++++++ AnimalsView.swift | 30 +++ BodyPartCard.swift | 114 ++++++++++++ BodyPartsView.swift | 29 +++ ColorCard.swift | 86 +++++++++ ColorsView.swift | 30 +++ DayByDay.xcodeproj/project.pbxproj | 93 +++++++++- .../LaunchBackground.colorset/Contents.json | 38 ++++ DayByDay/ContentView.swift | 202 ++++++++++++++++----- DayByDay/DayByDayApp.swift | 17 ++ DayByDay/Info.plist | 15 ++ DayByDay/SpeechSynthesizer.swift | 37 ++-- FoodCard.swift | 115 ++++++++++++ FoodView.swift | 29 +++ LetterCard.swift | 84 +++++++++ NumberCard.swift | 136 ++++++++++++++ NumbersView.swift | 33 ++++ Screenshots/ipad.png | Bin 1350388 -> 1273841 bytes Screenshots/iphone.png | Bin 1176013 -> 1601412 bytes ShapeCard.swift | 130 +++++++++++++ ShapesView.swift | 30 +++ TodayView.swift | 84 +++++++++ WeatherCard.swift | 102 +++++++++++ WeatherView.swift | 29 +++ claude.md | 31 +++- 26 files changed, 1556 insertions(+), 71 deletions(-) create mode 100644 AlphabetView.swift create mode 100644 AnimalCard.swift create mode 100644 AnimalsView.swift create mode 100644 BodyPartCard.swift create mode 100644 BodyPartsView.swift create mode 100644 ColorCard.swift create mode 100644 ColorsView.swift create mode 100644 DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 DayByDay/Info.plist create mode 100644 FoodCard.swift create mode 100644 FoodView.swift create mode 100644 LetterCard.swift create mode 100644 NumberCard.swift create mode 100644 NumbersView.swift create mode 100644 ShapeCard.swift create mode 100644 ShapesView.swift create mode 100644 TodayView.swift create mode 100644 WeatherCard.swift create mode 100644 WeatherView.swift diff --git a/AlphabetView.swift b/AlphabetView.swift new file mode 100644 index 0000000..64af026 --- /dev/null +++ b/AlphabetView.swift @@ -0,0 +1,30 @@ +// +// AlphabetView.swift +// DayByDay +// + +import SwiftUI + +/// Displays all 26 letters A–Z as large, tappable cards in a scrollable grid. +/// Uses a smaller minimum size to fit more letters across the screen. +struct AlphabetView: View { + private let columns = [ + GridItem(.adaptive(minimum: 140, maximum: 300), spacing: 20) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(LearnLetter.allCases) { letter in + LetterCard(letter: letter) + .frame(height: 160) + } + } + .padding(32) + } + } +} + +#Preview { + AlphabetView() +} diff --git a/AnimalCard.swift b/AnimalCard.swift new file mode 100644 index 0000000..2207d45 --- /dev/null +++ b/AnimalCard.swift @@ -0,0 +1,103 @@ +// +// AnimalCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one animal. +/// Tapping the card plays a bounce animation and speaks the animal name aloud. +struct AnimalCard: View { + let animal: LearnAnimal + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(animal.color.gradient) + .shadow(color: animal.color.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 16) { + Image(systemName: animal.symbol) + .font(.system(size: 64)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + + Text(animal.name) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + } + .scaleEffect(isTapped ? 1.12 : 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(animal.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(animal.name) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Animal model + +enum LearnAnimal: Int, CaseIterable, Identifiable { + case cat, dog, bird, fish, rabbit, turtle, ladybug, ant, lizard + + var id: Int { rawValue } + + var name: String { + switch self { + case .cat: "Cat" + case .dog: "Dog" + case .bird: "Bird" + case .fish: "Fish" + case .rabbit: "Rabbit" + case .turtle: "Turtle" + case .ladybug: "Ladybug" + case .ant: "Ant" + case .lizard: "Lizard" + } + } + + var symbol: String { + switch self { + case .cat: "cat.fill" + case .dog: "dog.fill" + case .bird: "bird.fill" + case .fish: "fish.fill" + case .rabbit: "hare.fill" + case .turtle: "tortoise.fill" + case .ladybug: "ladybug.fill" + case .ant: "ant.fill" + case .lizard: "lizard.fill" + } + } + + var color: Color { + switch self { + case .cat: Color(red: 0.9, green: 0.55, blue: 0.2) // orange + case .dog: Color(red: 0.6, green: 0.45, blue: 0.3) // brown + case .bird: Color(red: 0.3, green: 0.65, blue: 0.9) // sky blue + case .fish: Color(red: 0.2, green: 0.75, blue: 0.8) // teal + case .rabbit: Color(red: 0.75, green: 0.6, blue: 0.8) // lavender + case .turtle: Color(red: 0.35, green: 0.7, blue: 0.4) // green + case .ladybug: Color(red: 0.9, green: 0.25, blue: 0.25) // red + case .ant: Color(red: 0.3, green: 0.3, blue: 0.3) // dark gray + case .lizard: Color(red: 0.45, green: 0.75, blue: 0.35) // lime + } + } +} + +#Preview { + HStack(spacing: 24) { + AnimalCard(animal: .cat) + .frame(width: 200, height: 260) + } + .padding() +} diff --git a/AnimalsView.swift b/AnimalsView.swift new file mode 100644 index 0000000..d3c5a2d --- /dev/null +++ b/AnimalsView.swift @@ -0,0 +1,30 @@ +// +// AnimalsView.swift +// DayByDay +// + +import SwiftUI + +/// Displays 12 animals as large, tappable cards in a scrollable grid. +/// Adapts between 1 and 2 columns depending on available width. +struct AnimalsView: View { + private let columns = [ + GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnAnimal.allCases) { animal in + AnimalCard(animal: animal) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + AnimalsView() +} diff --git a/BodyPartCard.swift b/BodyPartCard.swift new file mode 100644 index 0000000..a3693de --- /dev/null +++ b/BodyPartCard.swift @@ -0,0 +1,114 @@ +// +// BodyPartCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one body part. +/// Tapping the card plays a bounce animation and speaks the body part name aloud. +struct BodyPartCard: View { + let bodyPart: LearnBodyPart + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(bodyPart.color.gradient) + .shadow(color: bodyPart.color.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 16) { + Image(systemName: bodyPart.symbol) + .font(.system(size: 64)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + + Text(bodyPart.name) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + } + .scaleEffect(isTapped ? 1.12 : 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(bodyPart.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(bodyPart.name) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Body part model + +enum LearnBodyPart: Int, CaseIterable, Identifiable { + case head, eyes, ears, nose, mouth, hands, fingers, arms, legs, feet, heart, belly + + var id: Int { rawValue } + + var name: String { + switch self { + case .head: "Head" + case .eyes: "Eyes" + case .ears: "Ears" + case .nose: "Nose" + case .mouth: "Mouth" + case .hands: "Hands" + case .fingers: "Fingers" + case .arms: "Arms" + case .legs: "Legs" + case .feet: "Feet" + case .heart: "Heart" + case .belly: "Belly" + } + } + + var symbol: String { + switch self { + case .head: "brain.head.profile" + case .eyes: "eye.fill" + case .ears: "ear.fill" + case .nose: "nose.fill" + case .mouth: "mouth.fill" + case .hands: "hand.raised.fill" + case .fingers: "hand.point.up.fill" + case .arms: "figure.arms.open" + case .legs: "figure.walk" + case .feet: "shoeprints.fill" + case .heart: "heart.fill" + case .belly: "circle.fill" + } + } + + var color: Color { + switch self { + case .head: Color(red: 0.9, green: 0.5, blue: 0.3) // warm orange + case .eyes: Color(red: 0.3, green: 0.65, blue: 0.9) // blue + case .ears: Color(red: 0.6, green: 0.45, blue: 0.85) // purple + case .nose: Color(red: 0.9, green: 0.4, blue: 0.5) // rose + case .mouth: Color(red: 0.9, green: 0.3, blue: 0.35) // red + case .hands: Color(red: 0.95, green: 0.7, blue: 0.3) // golden + case .fingers: Color(red: 0.35, green: 0.75, blue: 0.5) // green + case .arms: Color(red: 0.25, green: 0.7, blue: 0.7) // teal + case .legs: Color(red: 0.5, green: 0.4, blue: 0.8) // indigo + case .feet: Color(red: 0.7, green: 0.5, blue: 0.3) // brown + case .heart: Color(red: 0.85, green: 0.3, blue: 0.45) // deep pink + case .belly: Color(red: 1.0, green: 0.6, blue: 0.4) // peach + } + } +} + +#Preview { + HStack(spacing: 24) { + BodyPartCard(bodyPart: .head) + .frame(width: 200, height: 260) + BodyPartCard(bodyPart: .hands) + .frame(width: 200, height: 260) + } + .padding() +} diff --git a/BodyPartsView.swift b/BodyPartsView.swift new file mode 100644 index 0000000..73019f5 --- /dev/null +++ b/BodyPartsView.swift @@ -0,0 +1,29 @@ +// +// BodyPartsView.swift +// DayByDay +// + +import SwiftUI + +/// Displays all 12 body parts as large, tappable cards in a scrollable grid. +struct BodyPartsView: View { + private let columns = [ + GridItem(.adaptive(minimum: 280), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnBodyPart.allCases) { part in + BodyPartCard(bodyPart: part) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + BodyPartsView() +} diff --git a/ColorCard.swift b/ColorCard.swift new file mode 100644 index 0000000..0bbe2b9 --- /dev/null +++ b/ColorCard.swift @@ -0,0 +1,86 @@ +// +// ColorCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large card filled with a color. Tapping speaks the color name aloud. +struct ColorCard: View { + let learnColor: LearnColor + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(learnColor.color.gradient) + .shadow(color: learnColor.color.opacity(0.4), radius: 8, y: 4) + + Text(learnColor.name) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 4, y: 2) + } + .scaleEffect(isTapped ? 1.12 : 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(learnColor.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(learnColor.name) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Color model + +enum LearnColor: Int, CaseIterable, Identifiable { + case red, orange, yellow, green, blue, purple, pink, brown, black, white + + var id: Int { rawValue } + + var name: String { + switch self { + case .red: "Red" + case .orange: "Orange" + case .yellow: "Yellow" + case .green: "Green" + case .blue: "Blue" + case .purple: "Purple" + case .pink: "Pink" + case .brown: "Brown" + case .black: "Black" + case .white: "White" + } + } + + var color: Color { + switch self { + case .red: Color(red: 0.9, green: 0.2, blue: 0.2) + case .orange: Color(red: 1.0, green: 0.55, blue: 0.15) + case .yellow: Color(red: 1.0, green: 0.82, blue: 0.15) + case .green: Color(red: 0.2, green: 0.75, blue: 0.35) + case .blue: Color(red: 0.2, green: 0.45, blue: 0.9) + case .purple: Color(red: 0.55, green: 0.3, blue: 0.85) + case .pink: Color(red: 0.95, green: 0.4, blue: 0.6) + case .brown: Color(red: 0.55, green: 0.35, blue: 0.2) + case .black: Color(red: 0.15, green: 0.15, blue: 0.15) + case .white: Color(red: 0.92, green: 0.92, blue: 0.92) + } + } +} + +#Preview { + HStack(spacing: 24) { + ColorCard(learnColor: .red) + .frame(width: 200, height: 200) + ColorCard(learnColor: .blue) + .frame(width: 200, height: 200) + } + .padding() +} diff --git a/ColorsView.swift b/ColorsView.swift new file mode 100644 index 0000000..9b935e5 --- /dev/null +++ b/ColorsView.swift @@ -0,0 +1,30 @@ +// +// ColorsView.swift +// DayByDay +// + +import SwiftUI + +/// Displays 10 colors as large, tappable cards in a scrollable grid. +/// Adapts between 1 and 2 columns depending on available width. +struct ColorsView: View { + private let columns = [ + GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnColor.allCases) { color in + ColorCard(learnColor: color) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + ColorsView() +} diff --git a/DayByDay.xcodeproj/project.pbxproj b/DayByDay.xcodeproj/project.pbxproj index 3ff18e9..9e2876c 100644 --- a/DayByDay.xcodeproj/project.pbxproj +++ b/DayByDay.xcodeproj/project.pbxproj @@ -7,17 +7,64 @@ objects = { /* Begin PBXBuildFile section */ + 8B1B4FF92F659AD1005C246F /* WeatherCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FF82F659AD1005C246F /* WeatherCard.swift */; }; + 8B1B4FFB2F659AD1005C246F /* WeatherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */; }; + 8B1B4FFD2F659AD2005C246F /* BodyPartCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */; }; + 8B1B4FFF2F659AD2005C246F /* BodyPartsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */; }; + 8B1B50012F659AD3005C246F /* FoodCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B50002F659AD3005C246F /* FoodCard.swift */; }; + 8B1B50032F659AD4005C246F /* FoodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B50022F659AD4005C246F /* FoodView.swift */; }; + 8B57F7282F63BBB4001F6F5E /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7272F63BBB4001F6F5E /* TodayView.swift */; }; + 8B57F72A2F63BBB5001F6F5E /* NumberCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */; }; + 8B57F72C2F63BBB6001F6F5E /* NumbersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */; }; + 8B57F72E2F63BBB7001F6F5E /* ColorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */; }; + 8B57F7302F63BBB8001F6F5E /* ColorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */; }; + 8B57F7322F63BBB9001F6F5E /* ShapeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */; }; + 8B57F7342F63BBBA001F6F5E /* ShapesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */; }; + 8B57F7362F63BBBA001F6F5E /* AnimalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */; }; + 8B57F7382F63BBBB001F6F5E /* AnimalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */; }; + 8B57F73A2F63BBBD001F6F5E /* LetterCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */; }; + 8B57F73C2F63BBBD001F6F5E /* AlphabetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */; }; 8B7051C12F5F90CD00491D58 /* claude.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B7051C02F5F90C800491D58 /* claude.md */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 8B1B4FF82F659AD1005C246F /* WeatherCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherCard.swift; sourceTree = ""; }; + 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherView.swift; sourceTree = ""; }; + 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyPartCard.swift; sourceTree = ""; }; + 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyPartsView.swift; sourceTree = ""; }; + 8B1B50002F659AD3005C246F /* FoodCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodCard.swift; sourceTree = ""; }; + 8B1B50022F659AD4005C246F /* FoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodView.swift; sourceTree = ""; }; + 8B57F7272F63BBB4001F6F5E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; + 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberCard.swift; sourceTree = ""; }; + 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersView.swift; sourceTree = ""; }; + 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorCard.swift; sourceTree = ""; }; + 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorsView.swift; sourceTree = ""; }; + 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeCard.swift; sourceTree = ""; }; + 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapesView.swift; sourceTree = ""; }; + 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimalCard.swift; sourceTree = ""; }; + 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimalsView.swift; sourceTree = ""; }; + 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterCard.swift; sourceTree = ""; }; + 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlphabetView.swift; sourceTree = ""; }; 8B7051B22F5F908800491D58 /* DayByDay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DayByDay.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8B7051C02F5F90C800491D58 /* claude.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = claude.md; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 8B5214E62F621E0200E1D35A /* Exceptions for "DayByDay" folder in "DayByDay" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8B7051B12F5F908800491D58 /* DayByDay */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 8B7051B42F5F908800491D58 /* DayByDay */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8B5214E62F621E0200E1D35A /* Exceptions for "DayByDay" folder in "DayByDay" target */, + ); path = DayByDay; sourceTree = ""; }; @@ -40,6 +87,23 @@ 8B7051C02F5F90C800491D58 /* claude.md */, 8B7051B42F5F908800491D58 /* DayByDay */, 8B7051B32F5F908800491D58 /* Products */, + 8B57F7272F63BBB4001F6F5E /* TodayView.swift */, + 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */, + 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */, + 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */, + 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */, + 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */, + 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */, + 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */, + 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */, + 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */, + 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */, + 8B1B4FF82F659AD1005C246F /* WeatherCard.swift */, + 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */, + 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */, + 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */, + 8B1B50002F659AD3005C246F /* FoodCard.swift */, + 8B1B50022F659AD4005C246F /* FoodView.swift */, ); sourceTree = ""; }; @@ -126,6 +190,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8B1B50032F659AD4005C246F /* FoodView.swift in Sources */, + 8B57F72E2F63BBB7001F6F5E /* ColorCard.swift in Sources */, + 8B57F73C2F63BBBD001F6F5E /* AlphabetView.swift in Sources */, + 8B57F73A2F63BBBD001F6F5E /* LetterCard.swift in Sources */, + 8B57F7382F63BBBB001F6F5E /* AnimalsView.swift in Sources */, + 8B57F72C2F63BBB6001F6F5E /* NumbersView.swift in Sources */, + 8B57F72A2F63BBB5001F6F5E /* NumberCard.swift in Sources */, + 8B1B50012F659AD3005C246F /* FoodCard.swift in Sources */, + 8B1B4FF92F659AD1005C246F /* WeatherCard.swift in Sources */, + 8B1B4FFB2F659AD1005C246F /* WeatherView.swift in Sources */, + 8B1B4FFD2F659AD2005C246F /* BodyPartCard.swift in Sources */, + 8B1B4FFF2F659AD2005C246F /* BodyPartsView.swift in Sources */, + 8B57F7342F63BBBA001F6F5E /* ShapesView.swift in Sources */, + 8B57F7362F63BBBA001F6F5E /* AnimalCard.swift in Sources */, + 8B57F7322F63BBB9001F6F5E /* ShapeCard.swift in Sources */, + 8B57F7282F63BBB4001F6F5E /* TodayView.swift in Sources */, + 8B57F7302F63BBB8001F6F5E /* ColorsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -259,21 +340,21 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DayByDay/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Day By Day"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DayByDay; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -292,21 +373,21 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DayByDay/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Day By Day"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DayByDay; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json b/DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..0425637 --- /dev/null +++ b/DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DayByDay/ContentView.swift b/DayByDay/ContentView.swift index c3ede7a..9924c5d 100644 --- a/DayByDay/ContentView.swift +++ b/DayByDay/ContentView.swift @@ -7,62 +7,180 @@ import SwiftUI +// MARK: - Category model + +enum Category: Int, CaseIterable, Identifiable { + case today, days, months, seasons, numbers, colors, shapes, animals, alphabet, + weather, bodyParts, food + + var id: Int { rawValue } + + var label: String { + switch self { + case .today: "Today" + case .days: "Days" + case .months: "Months" + case .seasons: "Seasons" + case .numbers: "Numbers" + case .colors: "Colors" + case .shapes: "Shapes" + case .animals: "Animals" + case .alphabet: "Alphabet" + case .weather: "Weather" + case .bodyParts: "Body" + case .food: "Food" + } + } + + var symbol: String { + switch self { + case .today: "sun.horizon.fill" + case .days: "calendar" + case .months: "calendar.badge.clock" + case .seasons: "leaf.fill" + case .numbers: "123.rectangle.fill" + case .colors: "paintpalette.fill" + case .shapes: "pentagon.fill" + case .animals: "pawprint.fill" + case .alphabet: "abc" + case .weather: "cloud.sun.rain.fill" + case .bodyParts: "figure.stand" + case .food: "carrot.fill" + } + } + + var color: Color { + switch self { + case .today: .orange + case .days: .blue + case .months: .purple + case .seasons: .green + case .numbers: .red + case .colors: .pink + case .shapes: .teal + case .animals: Color(red: 0.7, green: 0.45, blue: 0.2) + case .alphabet: .indigo + case .weather: Color(red: 0.4, green: 0.75, blue: 0.95) + case .bodyParts: Color(red: 0.9, green: 0.5, blue: 0.6) + case .food: Color(red: 0.95, green: 0.6, blue: 0.2) + } + } + + /// Whether this tile uses a special gradient background instead of the solid color. + var usesGradientBackground: Bool { + self == .colors + } +} + +// MARK: - Home grid tile + +struct CategoryTile: View { + let category: Category + + var body: some View { + ZStack { + if category.usesGradientBackground { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill( + LinearGradient( + colors: [.red, .orange, .yellow, .green, .blue, .purple], + startPoint: .leading, + endPoint: .trailing + ) + ) + .shadow(color: category.color.opacity(0.4), radius: 8, y: 4) + } else { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(category.color.gradient) + .shadow(color: category.color.opacity(0.4), radius: 8, y: 4) + } + + VStack(spacing: 12) { + Image(systemName: category.symbol) + .font(.system(size: 48)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + + Text(category.label) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + } + .accessibilityLabel(category.label) + } +} + +// MARK: - Content view + 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"), + private let columns = [ + GridItem(.flexible(), spacing: 24), + GridItem(.flexible(), spacing: 24) ] 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)) + NavigationStack { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(Category.allCases) { category in + NavigationLink(value: category) { + CategoryTile(category: category) + .aspectRatio(1, contentMode: .fit) } - .foregroundStyle(selectedTab == index ? Color.accentColor : Color.secondary) + .buttonStyle(.plain) + } + } + .padding(24) + + Button { + hasSeenVoiceTip = false + } label: { + Label("Voice Quality Tips", systemImage: "speaker.wave.2.fill") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(tab.label) + .background(Color(.systemGray6)) + .cornerRadius(16) } + .buttonStyle(.plain) + .padding(.horizontal, 24) + .padding(.bottom, 24) } - .padding(.horizontal, 24) - .padding(.bottom, 8) - .background(.bar) - } - .overlay { - if !hasSeenVoiceTip { - VoiceTipOverlay { - withAnimation { hasSeenVoiceTip = true } + .navigationTitle("DayByDay") + .navigationDestination(for: Category.self) { category in + destinationView(for: category) + .navigationTitle(category.label) + } + .overlay { + if !hasSeenVoiceTip { + VoiceTipOverlay { + withAnimation { hasSeenVoiceTip = true } + } } } } } + + @ViewBuilder + private func destinationView(for category: Category) -> some View { + switch category { + case .today: TodayView() + case .days: DaysOfWeekView() + case .months: MonthsOfYearView() + case .seasons: SeasonsView() + case .numbers: NumbersView() + case .colors: ColorsView() + case .shapes: ShapesView() + case .animals: AnimalsView() + case .alphabet: AlphabetView() + case .weather: WeatherView() + case .bodyParts: BodyPartsView() + case .food: FoodView() + } + } } /// A one-time informational overlay for parents explaining how to download @@ -83,7 +201,7 @@ struct VoiceTipOverlay: View { 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.") + 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.") .font(.system(size: 18, design: .rounded)) .multilineTextAlignment(.center) .foregroundStyle(.secondary) diff --git a/DayByDay/DayByDayApp.swift b/DayByDay/DayByDayApp.swift index 2cce4db..860d747 100644 --- a/DayByDay/DayByDayApp.swift +++ b/DayByDay/DayByDayApp.swift @@ -5,10 +5,27 @@ // Created by cmc on 2026-03-09. // +import AVFoundation import SwiftUI @main struct DayByDayApp: App { + init() { + Task.detached(priority: .background) { + // Activate the audio session early so the first real tap doesn't block. + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + + // Force singleton init (voice selection) and prime the TTS engine + // with a silent utterance so subsequent speaks are instant. + await MainActor.run { + let warmup = AVSpeechUtterance(string: "") + warmup.volume = 0 + SpeechSynthesizer.shared.speakUtterance(warmup) + } + } + } + var body: some Scene { WindowGroup { ContentView() diff --git a/DayByDay/Info.plist b/DayByDay/Info.plist new file mode 100644 index 0000000..6aa377b --- /dev/null +++ b/DayByDay/Info.plist @@ -0,0 +1,15 @@ + + + + + UILaunchScreen + + UIColorName + LaunchBackground + UIImageName + AppIcon + UIImageRespectsSafeAreaInsets + + + + diff --git a/DayByDay/SpeechSynthesizer.swift b/DayByDay/SpeechSynthesizer.swift index cac5caf..ab6421b 100644 --- a/DayByDay/SpeechSynthesizer.swift +++ b/DayByDay/SpeechSynthesizer.swift @@ -18,7 +18,16 @@ final class SpeechSynthesizer { self.voice = Self.bestAvailableVoice() } + /// Speaks a pre-configured utterance directly. Used for warmup at launch. + func speakUtterance(_ utterance: AVSpeechUtterance) { + synthesizer.speak(utterance) + } + func speak(_ text: String) { + // Ensure speech plays through the speaker even when the silent switch is on. + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } @@ -32,21 +41,19 @@ final class SpeechSynthesizer { } /// 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. + /// Filters for en-US, excludes novelty voices, then sorts by quality + /// descending so premium (3) > enhanced (2) > default (1). 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") + let best = AVSpeechSynthesisVoice.speechVoices() + .filter { voice in + voice.language.hasPrefix("en-US") + && !voice.voiceTraits.contains(.isNoveltyVoice) + } + .sorted { $0.quality.rawValue > $1.quality.rawValue } + .first + + let selected = best ?? AVSpeechSynthesisVoice(language: "en-US") + print("[DayByDay] Selected voice: \(selected?.name ?? "nil"), quality: \(selected?.quality.rawValue ?? -1)") + return selected } } diff --git a/FoodCard.swift b/FoodCard.swift new file mode 100644 index 0000000..957137e --- /dev/null +++ b/FoodCard.swift @@ -0,0 +1,115 @@ +// +// FoodCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one fruit or vegetable. +/// Tapping the card plays a bounce animation and speaks the food name aloud. +/// Each food displays its emoji at large size for instant recognition. +struct FoodCard: View { + let food: LearnFood + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(food.color.gradient) + .shadow(color: food.color.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 16) { + Text(food.emoji) + .font(.system(size: 72)) + + Text(food.name) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + } + .scaleEffect(isTapped ? 1.12 : 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(food.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(food.name) + .accessibilityAddTraits(.isButton) + } + +} + +// MARK: - Food model + +enum LearnFood: Int, CaseIterable, Identifiable { + case apple, banana, carrot, strawberry, grapes, lemon, + broccoli, watermelon, cherry, orange, corn, pear + + var id: Int { rawValue } + + var name: String { + switch self { + case .apple: "Apple" + case .banana: "Banana" + case .carrot: "Carrot" + case .strawberry: "Strawberry" + case .grapes: "Grapes" + case .lemon: "Lemon" + case .broccoli: "Broccoli" + case .watermelon: "Watermelon" + case .cherry: "Cherry" + case .orange: "Orange" + case .corn: "Corn" + case .pear: "Pear" + } + } + + var emoji: String { + switch self { + case .apple: "🍎" + case .banana: "🍌" + case .carrot: "🥕" + case .strawberry: "🍓" + case .grapes: "🍇" + case .lemon: "🍋" + case .broccoli: "🥦" + case .watermelon: "🍉" + case .cherry: "🍒" + case .orange: "🍊" + case .corn: "🌽" + case .pear: "🍐" + } + } + + var color: Color { + switch self { + case .apple: Color(red: 0.85, green: 0.2, blue: 0.2) // red + case .banana: Color(red: 0.95, green: 0.8, blue: 0.2) // yellow + case .carrot: Color(red: 1.0, green: 0.55, blue: 0.15) // orange + case .strawberry: Color(red: 0.9, green: 0.25, blue: 0.3) // strawberry red + case .grapes: Color(red: 0.55, green: 0.3, blue: 0.7) // purple + case .lemon: Color(red: 0.9, green: 0.8, blue: 0.2) // lemon yellow + case .broccoli: Color(red: 0.3, green: 0.65, blue: 0.3) // green + case .watermelon: Color(red: 0.35, green: 0.7, blue: 0.4) // watermelon green + case .cherry: Color(red: 0.7, green: 0.15, blue: 0.2) // dark red + case .orange: Color(red: 1.0, green: 0.6, blue: 0.15) // orange + case .corn: Color(red: 0.9, green: 0.75, blue: 0.2) // golden yellow + case .pear: Color(red: 0.55, green: 0.75, blue: 0.3) // pear green + } + } +} + +#Preview { + HStack(spacing: 24) { + FoodCard(food: .apple) + .frame(width: 200, height: 260) + FoodCard(food: .carrot) + .frame(width: 200, height: 260) + } + .padding() +} diff --git a/FoodView.swift b/FoodView.swift new file mode 100644 index 0000000..4ee09d2 --- /dev/null +++ b/FoodView.swift @@ -0,0 +1,29 @@ +// +// FoodView.swift +// DayByDay +// + +import SwiftUI + +/// Displays all 12 fruits and vegetables as large, tappable cards in a scrollable grid. +struct FoodView: View { + private let columns = [ + GridItem(.adaptive(minimum: 280), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnFood.allCases) { food in + FoodCard(food: food) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + FoodView() +} diff --git a/LetterCard.swift b/LetterCard.swift new file mode 100644 index 0000000..06183ae --- /dev/null +++ b/LetterCard.swift @@ -0,0 +1,84 @@ +// +// LetterCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one letter of the alphabet. +/// Tapping the card plays a bounce animation and speaks the letter aloud. +struct LetterCard: View { + let letter: LearnLetter + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(letter.color.gradient) + .shadow(color: letter.color.opacity(0.4), radius: 8, y: 4) + + Text(letter.character) + .font(.system(size: 64, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + } + .scaleEffect(isTapped ? 1.12 : 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(letter.spokenName) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(letter.spokenName) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Letter model + +enum LearnLetter: Int, CaseIterable, Identifiable { + case a, b, c, d, e, f, g, h, i, j, k, l, m, + n, o, p, q, r, s, t, u, v, w, x, y, z + + var id: Int { rawValue } + + var character: String { + String(Character(UnicodeScalar(65 + rawValue)!)) + } + + /// Speak the letter name clearly for a child. + /// Lowercase prevents TTS from saying "Capital A". + var spokenName: String { + character.lowercased() + } + + /// Cycle through 8 bright colors to give variety without unique-per-letter mapping. + var color: Color { + let palette: [Color] = [ + Color(red: 0.9, green: 0.3, blue: 0.3), // red + Color(red: 1.0, green: 0.55, blue: 0.2), // orange + Color(red: 1.0, green: 0.8, blue: 0.2), // yellow + Color(red: 0.35, green: 0.75, blue: 0.4), // green + Color(red: 0.2, green: 0.65, blue: 0.9), // blue + Color(red: 0.55, green: 0.4, blue: 0.85), // purple + Color(red: 0.85, green: 0.4, blue: 0.6), // pink + Color(red: 0.2, green: 0.75, blue: 0.75), // teal + ] + return palette[rawValue % palette.count] + } +} + +#Preview { + HStack(spacing: 24) { + LetterCard(letter: .a) + .frame(width: 140, height: 160) + LetterCard(letter: .b) + .frame(width: 140, height: 160) + LetterCard(letter: .c) + .frame(width: 140, height: 160) + } + .padding() +} diff --git a/NumberCard.swift b/NumberCard.swift new file mode 100644 index 0000000..9ae1c39 --- /dev/null +++ b/NumberCard.swift @@ -0,0 +1,136 @@ +// +// NumberCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one number (1–10). +/// Tapping the card plays a bounce animation and speaks the number name aloud. +struct NumberCard: View { + let number: LearnNumber + var isHighlighted = false + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(number.color.gradient) + .shadow(color: number.color.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 10) { + Text("\(number.numeral)") + .font(.system(size: 60, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + + Text(number.name) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + // Dot pattern showing the quantity + DotPattern(count: number.numeral) + .padding(.top, 4) + } + } + .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(number.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(number.name) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Number model + +enum LearnNumber: Int, CaseIterable, Identifiable { + case one = 1, two, three, four, five, six, seven, eight, nine, ten + + var id: Int { rawValue } + + /// Highlight the number matching today's day-of-month if it falls in 1–10. + static var current: LearnNumber? { + let day = Calendar.current.component(.day, from: Date()) + return LearnNumber(rawValue: day) + } + + var numeral: Int { rawValue } + + var name: String { + switch self { + case .one: "One" + case .two: "Two" + case .three: "Three" + case .four: "Four" + case .five: "Five" + case .six: "Six" + case .seven: "Seven" + case .eight: "Eight" + case .nine: "Nine" + case .ten: "Ten" + } + } + + var color: Color { + switch self { + case .one: Color(red: 0.9, green: 0.3, blue: 0.3) // red + case .two: Color(red: 1.0, green: 0.55, blue: 0.2) // orange + case .three: Color(red: 1.0, green: 0.8, blue: 0.2) // yellow + case .four: Color(red: 0.35, green: 0.75, blue: 0.4) // green + case .five: Color(red: 0.2, green: 0.7, blue: 0.85) // sky blue + case .six: Color(red: 0.4, green: 0.45, blue: 0.9) // blue + case .seven: Color(red: 0.6, green: 0.4, blue: 0.85) // purple + case .eight: Color(red: 0.85, green: 0.35, blue: 0.6) // pink + case .nine: Color(red: 0.5, green: 0.75, blue: 0.45) // lime + case .ten: Color(red: 0.25, green: 0.8, blue: 0.75) // teal + } + } +} + +// MARK: - Dot pattern + +/// Displays a grid of filled circles representing a quantity. +/// 1–5 dots use centered rows; 6–10 use a 5-column grid that wraps naturally. +struct DotPattern: View { + let count: Int + + private var columns: [GridItem] { + let maxCols = count <= 5 ? min(count, 5) : 5 + return Array(repeating: GridItem(.fixed(14), spacing: 6), count: maxCols) + } + + var body: some View { + LazyVGrid(columns: columns, spacing: 6) { + ForEach(0.. Path { + Path { path in + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + } + } +} + +#Preview { + HStack(spacing: 24) { + ShapeCard(shape: .triangle) + .frame(width: 200, height: 260) + ShapeCard(shape: .star) + .frame(width: 200, height: 260) + } + .padding() +} diff --git a/ShapesView.swift b/ShapesView.swift new file mode 100644 index 0000000..347dbad --- /dev/null +++ b/ShapesView.swift @@ -0,0 +1,30 @@ +// +// ShapesView.swift +// DayByDay +// + +import SwiftUI + +/// Displays 8 shapes as large, tappable cards in a scrollable grid. +/// Adapts between 1 and 2 columns depending on available width. +struct ShapesView: View { + private let columns = [ + GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnShape.allCases) { shape in + ShapeCard(shape: shape) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + ShapesView() +} diff --git a/TodayView.swift b/TodayView.swift new file mode 100644 index 0000000..4fa0492 --- /dev/null +++ b/TodayView.swift @@ -0,0 +1,84 @@ +// +// TodayView.swift +// DayByDay +// + +import SwiftUI + +/// A single-screen summary of today: day of the week, date, month, and season. +/// The child taps the large card to hear "Today is [day], [month] [date]. It is [season]." +struct TodayView: View { + @State private var isTapped = false + + private var todayText: String { + let day = DayOfWeek.current.name + let month = MonthOfYear.current.name + let date = Calendar.current.component(.day, from: Date()) + let season = Season.current.name + return "Today is \(day), \(month) \(date). It is \(season)." + } + + var body: some View { + ScrollView { + VStack(spacing: 32) { + // Main today card + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.orange.gradient) + .shadow(color: Color.orange.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 20) { + Image(systemName: "sun.horizon.fill") + .font(.system(size: 80)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + + // Day of the week + Text(DayOfWeek.current.name) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + // Date number + let dayNumber = Calendar.current.component(.day, from: Date()) + Text("\(dayNumber)") + .font(.system(size: 72, weight: .heavy, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + + // Month and season + HStack(spacing: 24) { + Label(MonthOfYear.current.name, systemImage: MonthOfYear.current.symbol) + Label(Season.current.name, systemImage: Season.current.symbol) + } + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.vertical, 32) + } + .frame(height: 420) + .scaleEffect(isTapped ? 1.08 : 1.0) + .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) + .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) + .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .onTapGesture { + isTapped = true + SpeechSynthesizer.shared.speak(todayText) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(todayText) + .accessibilityAddTraits(.isButton) + + // Tap hint + Label("Tap to hear today", systemImage: "speaker.wave.2.fill") + .font(.system(size: 22, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + .padding(32) + } + } +} + +#Preview { + TodayView() +} diff --git a/WeatherCard.swift b/WeatherCard.swift new file mode 100644 index 0000000..8b12786 --- /dev/null +++ b/WeatherCard.swift @@ -0,0 +1,102 @@ +// +// WeatherCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one weather type. +/// Tapping the card plays a bounce animation and speaks the weather name aloud. +struct WeatherCard: View { + let weather: LearnWeather + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(weather.color.gradient) + .shadow(color: weather.color.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 16) { + Image(systemName: weather.symbol) + .font(.system(size: 64)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + + Text(weather.name) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + } + .scaleEffect(isTapped ? 1.12 : 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(weather.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(weather.name) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Weather model + +enum LearnWeather: Int, CaseIterable, Identifiable { + case sunny, cloudy, rainy, snowy, windy, stormy, foggy, rainbow + + var id: Int { rawValue } + + var name: String { + switch self { + case .sunny: "Sunny" + case .cloudy: "Cloudy" + case .rainy: "Rainy" + case .snowy: "Snowy" + case .windy: "Windy" + case .stormy: "Stormy" + case .foggy: "Foggy" + case .rainbow: "Rainbow" + } + } + + var symbol: String { + switch self { + case .sunny: "sun.max.fill" + case .cloudy: "cloud.fill" + case .rainy: "cloud.rain.fill" + case .snowy: "cloud.snow.fill" + case .windy: "wind" + case .stormy: "cloud.bolt.rain.fill" + case .foggy: "cloud.fog.fill" + case .rainbow: "rainbow" + } + } + + var color: Color { + switch self { + case .sunny: Color(red: 1.0, green: 0.7, blue: 0.2) // orange + case .cloudy: Color(red: 0.6, green: 0.6, blue: 0.65) // gray + case .rainy: Color(red: 0.3, green: 0.6, blue: 0.9) // blue + case .snowy: Color(red: 0.55, green: 0.8, blue: 0.95) // light blue + case .windy: Color(red: 0.25, green: 0.7, blue: 0.7) // teal + case .stormy: Color(red: 0.2, green: 0.3, blue: 0.6) // dark blue + case .foggy: Color(red: 0.6, green: 0.5, blue: 0.7) // muted purple + case .rainbow: Color(red: 0.9, green: 0.45, blue: 0.6) // pink + } + } +} + +#Preview { + HStack(spacing: 24) { + WeatherCard(weather: .sunny) + .frame(width: 200, height: 260) + WeatherCard(weather: .stormy) + .frame(width: 200, height: 260) + } + .padding() +} diff --git a/WeatherView.swift b/WeatherView.swift new file mode 100644 index 0000000..e3b848b --- /dev/null +++ b/WeatherView.swift @@ -0,0 +1,29 @@ +// +// WeatherView.swift +// DayByDay +// + +import SwiftUI + +/// Displays all 8 weather types as large, tappable cards in a scrollable grid. +struct WeatherView: View { + private let columns = [ + GridItem(.adaptive(minimum: 280), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnWeather.allCases) { weather in + WeatherCard(weather: weather) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + WeatherView() +} diff --git a/claude.md b/claude.md index ae1f595..4be8e25 100644 --- a/claude.md +++ b/claude.md @@ -1,8 +1,9 @@ # DayByDay – Project Context ## What this is -An iPad app for pre-K children (ages 4–5) that teaches days of the week, months -of the year, and seasons through simple, tap-based interactions. +An iOS app for pre-K children (ages 4–5) that teaches days of the week, months +of the year, seasons, numbers, colors, shapes, animals, the alphabet, weather, +body parts, and fruits & vegetables through simple, tap-based interactions. ## Target user A single child, approximately 4–5 years old, preparing to start school. Cannot @@ -13,15 +14,28 @@ independently. - Large tap targets — nothing small or fiddly - Bright, clean colors — simple and friendly, not cluttered - No text-dependent interactions — the child cannot read -- Audio is the primary feedback mechanism (day/month/season name read aloud on - tap) +- Audio is the primary feedback mechanism (names read aloud on tap) - Simple animations on interaction — enough to delight, not enough to distract - No timers, no failure states, no scores — purely exploratory -## Content +## Content (12 categories, accessible from a NavigationStack home grid) +- Today: summary card with current day, date, month, season - Days of the week: Sunday through Saturday - Months of the year: January through December - Seasons: Spring, Summer, Fall, Winter +- Numbers: 1 through 10 (numeral + word + dot pattern) +- Colors: 10 basic colors (Red, Orange, Yellow, Green, Blue, Purple, Pink, + Brown, Black, White) +- Shapes: 8 shapes (Circle, Square, Triangle, Star, Heart, Oval, Diamond, + Rectangle) +- Animals: 12 animals with SF Symbol icons +- Alphabet: A through Z +- Weather: 8 weather types (Sunny, Cloudy, Rainy, Snowy, Windy, Stormy, + Foggy, Rainbow) +- Body Parts: 12 body parts (Head, Eyes, Ears, Nose, Mouth, Hands, Fingers, + Arms, Legs, Feet, Heart, Belly) +- Fruits & Vegetables: 12 foods (Apple, Banana, Carrot, Strawberry, Grapes, + Lemon, Broccoli, Watermelon, Cherry, Orange, Corn, Pear) ## Technical constraints - SwiftUI, iOS only @@ -30,6 +44,7 @@ independently. - Targets latest iOS ## What good looks like -Each concept (day, month, season) should feel like its own tactile card or -object the child can tap and explore. Transitions should be smooth. Audio should -play immediately on tap with no perceptible delay. +Each concept should feel like its own tactile card or object the child can tap +and explore. Transitions should be smooth. Audio should play immediately on tap +with no perceptible delay. The home screen is a colorful 2-column grid of square category +tiles that navigate into each section. -- cgit v1.2.3