diff options
| author | Christian Cleberg <[email protected]> | 2026-03-14 09:56:39 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-14 09:56:39 -0500 |
| commit | c53a320698c17d98477c0384f1f644da9735f645 (patch) | |
| tree | 9c59fa0a2b2840d4531c562e79a89ed526096874 | |
| parent | 8e4dea12423d7de762d665d58ab31abb1fe1d4f0 (diff) | |
| download | daybyday-c53a320698c17d98477c0384f1f644da9735f645.tar.gz daybyday-c53a320698c17d98477c0384f1f644da9735f645.tar.bz2 daybyday-c53a320698c17d98477c0384f1f644da9735f645.zip | |
implement more learning categories
| -rw-r--r-- | AlphabetView.swift | 30 | ||||
| -rw-r--r-- | AnimalCard.swift | 103 | ||||
| -rw-r--r-- | AnimalsView.swift | 30 | ||||
| -rw-r--r-- | BodyPartCard.swift | 114 | ||||
| -rw-r--r-- | BodyPartsView.swift | 29 | ||||
| -rw-r--r-- | ColorCard.swift | 86 | ||||
| -rw-r--r-- | ColorsView.swift | 30 | ||||
| -rw-r--r-- | DayByDay.xcodeproj/project.pbxproj | 93 | ||||
| -rw-r--r-- | DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | DayByDay/ContentView.swift | 202 | ||||
| -rw-r--r-- | DayByDay/DayByDayApp.swift | 17 | ||||
| -rw-r--r-- | DayByDay/Info.plist | 15 | ||||
| -rw-r--r-- | DayByDay/SpeechSynthesizer.swift | 37 | ||||
| -rw-r--r-- | FoodCard.swift | 115 | ||||
| -rw-r--r-- | FoodView.swift | 29 | ||||
| -rw-r--r-- | LetterCard.swift | 84 | ||||
| -rw-r--r-- | NumberCard.swift | 136 | ||||
| -rw-r--r-- | NumbersView.swift | 33 | ||||
| -rw-r--r-- | Screenshots/ipad.png | bin | 1350388 -> 1273841 bytes | |||
| -rw-r--r-- | Screenshots/iphone.png | bin | 1176013 -> 1601412 bytes | |||
| -rw-r--r-- | ShapeCard.swift | 130 | ||||
| -rw-r--r-- | ShapesView.swift | 30 | ||||
| -rw-r--r-- | TodayView.swift | 84 | ||||
| -rw-r--r-- | WeatherCard.swift | 102 | ||||
| -rw-r--r-- | WeatherView.swift | 29 | ||||
| -rw-r--r-- | claude.md | 31 |
26 files changed, 1556 insertions, 71 deletions
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 = "<group>"; }; + 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherView.swift; sourceTree = "<group>"; }; + 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyPartCard.swift; sourceTree = "<group>"; }; + 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyPartsView.swift; sourceTree = "<group>"; }; + 8B1B50002F659AD3005C246F /* FoodCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodCard.swift; sourceTree = "<group>"; }; + 8B1B50022F659AD4005C246F /* FoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodView.swift; sourceTree = "<group>"; }; + 8B57F7272F63BBB4001F6F5E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; }; + 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberCard.swift; sourceTree = "<group>"; }; + 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersView.swift; sourceTree = "<group>"; }; + 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorCard.swift; sourceTree = "<group>"; }; + 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorsView.swift; sourceTree = "<group>"; }; + 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeCard.swift; sourceTree = "<group>"; }; + 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapesView.swift; sourceTree = "<group>"; }; + 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimalCard.swift; sourceTree = "<group>"; }; + 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimalsView.swift; sourceTree = "<group>"; }; + 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterCard.swift; sourceTree = "<group>"; }; + 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlphabetView.swift; sourceTree = "<group>"; }; 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 = "<group>"; }; /* 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 = "<group>"; }; @@ -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 = "<group>"; }; @@ -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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>UILaunchScreen</key> + <dict> + <key>UIColorName</key> + <string>LaunchBackground</string> + <key>UIImageName</key> + <string>AppIcon</string> + <key>UIImageRespectsSafeAreaInsets</key> + <false/> + </dict> +</dict> +</plist> 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..<count, id: \.self) { _ in + Circle() + .fill(.white.opacity(0.8)) + .frame(width: 14, height: 14) + } + } + .frame(maxWidth: .infinity) + } +} + +#Preview { + HStack(spacing: 24) { + NumberCard(number: .three, isHighlighted: true) + .frame(width: 280, height: 300) + NumberCard(number: .seven) + .frame(width: 280, height: 300) + } + .padding() +} diff --git a/NumbersView.swift b/NumbersView.swift new file mode 100644 index 0000000..b6d5de6 --- /dev/null +++ b/NumbersView.swift @@ -0,0 +1,33 @@ +// +// NumbersView.swift +// DayByDay +// + +import SwiftUI + +/// Displays numbers 1–10 as large, tappable cards in a scrollable grid. +/// Adapts between 1 and 2 columns depending on available width. +struct NumbersView: View { + private let columns = [ + GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(LearnNumber.allCases) { number in + NumberCard( + number: number, + isHighlighted: number == .current + ) + .frame(height: 200) + } + } + .padding(32) + } + } +} + +#Preview { + NumbersView() +} diff --git a/Screenshots/ipad.png b/Screenshots/ipad.png Binary files differindex b840167..b68d40f 100644 --- a/Screenshots/ipad.png +++ b/Screenshots/ipad.png diff --git a/Screenshots/iphone.png b/Screenshots/iphone.png Binary files differindex b767322..0818b5c 100644 --- a/Screenshots/iphone.png +++ b/Screenshots/iphone.png diff --git a/ShapeCard.swift b/ShapeCard.swift new file mode 100644 index 0000000..ebc71e8 --- /dev/null +++ b/ShapeCard.swift @@ -0,0 +1,130 @@ +// +// ShapeCard.swift +// DayByDay +// + +import SwiftUI + +/// A single large, colorful card representing one shape. +/// Tapping the card plays a bounce animation and speaks the shape name aloud. +struct ShapeCard: View { + let shape: LearnShape + @State private var isTapped = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(shape.color.gradient) + .shadow(color: shape.color.opacity(0.4), radius: 8, y: 4) + + VStack(spacing: 16) { + shape.shapeView + .frame(width: 80, height: 80) + .foregroundStyle(.white) + + Text(shape.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(shape.name) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isTapped = false + } + } + .accessibilityLabel(shape.name) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Shape model + +enum LearnShape: Int, CaseIterable, Identifiable { + case circle, square, triangle, star, heart, oval, diamond, rectangle + + var id: Int { rawValue } + + var name: String { + switch self { + case .circle: "Circle" + case .square: "Square" + case .triangle: "Triangle" + case .star: "Star" + case .heart: "Heart" + case .oval: "Oval" + case .diamond: "Diamond" + case .rectangle: "Rectangle" + } + } + + @ViewBuilder + var shapeView: some View { + switch self { + case .circle: + Circle().fill(.white) + case .square: + SwiftUI.Rectangle().fill(.white) + case .triangle: + TriangleShape().fill(.white) + case .star: + Image(systemName: "star.fill") + .font(.system(size: 72)) + .foregroundStyle(.white) + case .heart: + Image(systemName: "heart.fill") + .font(.system(size: 72)) + .foregroundStyle(.white) + case .oval: + Ellipse().fill(.white) + .frame(width: 90, height: 60) + case .diamond: + SwiftUI.Rectangle().fill(.white) + .frame(width: 60, height: 60) + .rotationEffect(.degrees(45)) + case .rectangle: + SwiftUI.Rectangle().fill(.white) + .frame(width: 90, height: 55) + } + } + + var color: Color { + switch self { + case .circle: Color(red: 0.9, green: 0.3, blue: 0.3) // red + case .square: Color(red: 0.3, green: 0.5, blue: 0.9) // blue + case .triangle: Color(red: 0.35, green: 0.75, blue: 0.45) // green + case .star: Color(red: 1.0, green: 0.75, blue: 0.15) // gold + case .heart: Color(red: 0.9, green: 0.35, blue: 0.5) // pink + case .oval: Color(red: 0.55, green: 0.4, blue: 0.85) // purple + case .diamond: Color(red: 0.2, green: 0.75, blue: 0.8) // teal + case .rectangle: Color(red: 1.0, green: 0.55, blue: 0.2) // orange + } + } +} + +/// A simple triangle drawn with a Path. +struct TriangleShape: Shape { + func path(in rect: CGRect) -> 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() +} @@ -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. |
