Commit 51f1b74a4d
Unsigned
Layout: unified · split
AlphabetView.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | // | |
| 2 | // AlphabetView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays all 26 letters A–Z as large, tappable cards in a scrollable grid. | |
| 9 | /// Uses a smaller minimum size to fit more letters across the screen. | |
| 10 | struct AlphabetView: View { | |
| 11 | private let columns = [ | |
| 12 | GridItem(.adaptive(minimum: 140, maximum: 300), spacing: 20) | |
| 13 | ] | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ScrollView { | |
| 17 | LazyVGrid(columns: columns, spacing: 20) { | |
| 18 | ForEach(LearnLetter.allCases) { letter in | |
| 19 | LetterCard(letter: letter) | |
| 20 | .frame(height: 160) | |
| 21 | } | |
| 22 | } | |
| 23 | .padding(32) | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | #Preview { | |
| 29 | AlphabetView() | |
| 30 | } | |
AnimalCard.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | // | |
| 2 | // AnimalCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one animal. | |
| 9 | /// Tapping the card plays a bounce animation and speaks the animal name aloud. | |
| 10 | struct AnimalCard: View { | |
| 11 | let animal: LearnAnimal | |
| 12 | @State private var isTapped = false | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ZStack { | |
| 16 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 17 | .fill(animal.color.gradient) | |
| 18 | .shadow(color: animal.color.opacity(0.4), radius: 8, y: 4) | |
| 19 | ||
| 20 | VStack(spacing: 16) { | |
| 21 | Image(systemName: animal.symbol) | |
| 22 | .font(.system(size: 64)) | |
| 23 | .foregroundStyle(.white) | |
| 24 | .symbolRenderingMode(.hierarchical) | |
| 25 | ||
| 26 | Text(animal.name) | |
| 27 | .font(.system(size: 32, weight: .bold, design: .rounded)) | |
| 28 | .foregroundStyle(.white) | |
| 29 | } | |
| 30 | } | |
| 31 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 32 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 33 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 34 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 35 | .onTapGesture { | |
| 36 | isTapped = true | |
| 37 | SpeechSynthesizer.shared.speak(animal.name) | |
| 38 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 39 | isTapped = false | |
| 40 | } | |
| 41 | } | |
| 42 | .accessibilityLabel(animal.name) | |
| 43 | .accessibilityAddTraits(.isButton) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Animal model | |
| 48 | ||
| 49 | enum LearnAnimal: Int, CaseIterable, Identifiable { | |
| 50 | case cat, dog, bird, fish, rabbit, turtle, ladybug, ant, lizard | |
| 51 | ||
| 52 | var id: Int { rawValue } | |
| 53 | ||
| 54 | var name: String { | |
| 55 | switch self { | |
| 56 | case .cat: "Cat" | |
| 57 | case .dog: "Dog" | |
| 58 | case .bird: "Bird" | |
| 59 | case .fish: "Fish" | |
| 60 | case .rabbit: "Rabbit" | |
| 61 | case .turtle: "Turtle" | |
| 62 | case .ladybug: "Ladybug" | |
| 63 | case .ant: "Ant" | |
| 64 | case .lizard: "Lizard" | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | var symbol: String { | |
| 69 | switch self { | |
| 70 | case .cat: "cat.fill" | |
| 71 | case .dog: "dog.fill" | |
| 72 | case .bird: "bird.fill" | |
| 73 | case .fish: "fish.fill" | |
| 74 | case .rabbit: "hare.fill" | |
| 75 | case .turtle: "tortoise.fill" | |
| 76 | case .ladybug: "ladybug.fill" | |
| 77 | case .ant: "ant.fill" | |
| 78 | case .lizard: "lizard.fill" | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | var color: Color { | |
| 83 | switch self { | |
| 84 | case .cat: Color(red: 0.9, green: 0.55, blue: 0.2) // orange | |
| 85 | case .dog: Color(red: 0.6, green: 0.45, blue: 0.3) // brown | |
| 86 | case .bird: Color(red: 0.3, green: 0.65, blue: 0.9) // sky blue | |
| 87 | case .fish: Color(red: 0.2, green: 0.75, blue: 0.8) // teal | |
| 88 | case .rabbit: Color(red: 0.75, green: 0.6, blue: 0.8) // lavender | |
| 89 | case .turtle: Color(red: 0.35, green: 0.7, blue: 0.4) // green | |
| 90 | case .ladybug: Color(red: 0.9, green: 0.25, blue: 0.25) // red | |
| 91 | case .ant: Color(red: 0.3, green: 0.3, blue: 0.3) // dark gray | |
| 92 | case .lizard: Color(red: 0.45, green: 0.75, blue: 0.35) // lime | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | #Preview { | |
| 98 | HStack(spacing: 24) { | |
| 99 | AnimalCard(animal: .cat) | |
| 100 | .frame(width: 200, height: 260) | |
| 101 | } | |
| 102 | .padding() | |
| 103 | } | |
AnimalsView.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | // | |
| 2 | // AnimalsView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays 12 animals as large, tappable cards in a scrollable grid. | |
| 9 | /// Adapts between 1 and 2 columns depending on available width. | |
| 10 | struct AnimalsView: View { | |
| 11 | private let columns = [ | |
| 12 | GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) | |
| 13 | ] | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ScrollView { | |
| 17 | LazyVGrid(columns: columns, spacing: 24) { | |
| 18 | ForEach(LearnAnimal.allCases) { animal in | |
| 19 | AnimalCard(animal: animal) | |
| 20 | .frame(height: 200) | |
| 21 | } | |
| 22 | } | |
| 23 | .padding(32) | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | #Preview { | |
| 29 | AnimalsView() | |
| 30 | } | |
BodyPartCard.swift added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | // | |
| 2 | // BodyPartCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one body part. | |
| 9 | /// Tapping the card plays a bounce animation and speaks the body part name aloud. | |
| 10 | struct BodyPartCard: View { | |
| 11 | let bodyPart: LearnBodyPart | |
| 12 | @State private var isTapped = false | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ZStack { | |
| 16 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 17 | .fill(bodyPart.color.gradient) | |
| 18 | .shadow(color: bodyPart.color.opacity(0.4), radius: 8, y: 4) | |
| 19 | ||
| 20 | VStack(spacing: 16) { | |
| 21 | Image(systemName: bodyPart.symbol) | |
| 22 | .font(.system(size: 64)) | |
| 23 | .foregroundStyle(.white) | |
| 24 | .symbolRenderingMode(.hierarchical) | |
| 25 | ||
| 26 | Text(bodyPart.name) | |
| 27 | .font(.system(size: 32, weight: .bold, design: .rounded)) | |
| 28 | .foregroundStyle(.white) | |
| 29 | } | |
| 30 | } | |
| 31 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 32 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 33 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 34 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 35 | .onTapGesture { | |
| 36 | isTapped = true | |
| 37 | SpeechSynthesizer.shared.speak(bodyPart.name) | |
| 38 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 39 | isTapped = false | |
| 40 | } | |
| 41 | } | |
| 42 | .accessibilityLabel(bodyPart.name) | |
| 43 | .accessibilityAddTraits(.isButton) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Body part model | |
| 48 | ||
| 49 | enum LearnBodyPart: Int, CaseIterable, Identifiable { | |
| 50 | case head, eyes, ears, nose, mouth, hands, fingers, arms, legs, feet, heart, belly | |
| 51 | ||
| 52 | var id: Int { rawValue } | |
| 53 | ||
| 54 | var name: String { | |
| 55 | switch self { | |
| 56 | case .head: "Head" | |
| 57 | case .eyes: "Eyes" | |
| 58 | case .ears: "Ears" | |
| 59 | case .nose: "Nose" | |
| 60 | case .mouth: "Mouth" | |
| 61 | case .hands: "Hands" | |
| 62 | case .fingers: "Fingers" | |
| 63 | case .arms: "Arms" | |
| 64 | case .legs: "Legs" | |
| 65 | case .feet: "Feet" | |
| 66 | case .heart: "Heart" | |
| 67 | case .belly: "Belly" | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | var symbol: String { | |
| 72 | switch self { | |
| 73 | case .head: "brain.head.profile" | |
| 74 | case .eyes: "eye.fill" | |
| 75 | case .ears: "ear.fill" | |
| 76 | case .nose: "nose.fill" | |
| 77 | case .mouth: "mouth.fill" | |
| 78 | case .hands: "hand.raised.fill" | |
| 79 | case .fingers: "hand.point.up.fill" | |
| 80 | case .arms: "figure.arms.open" | |
| 81 | case .legs: "figure.walk" | |
| 82 | case .feet: "shoeprints.fill" | |
| 83 | case .heart: "heart.fill" | |
| 84 | case .belly: "circle.fill" | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | var color: Color { | |
| 89 | switch self { | |
| 90 | case .head: Color(red: 0.9, green: 0.5, blue: 0.3) // warm orange | |
| 91 | case .eyes: Color(red: 0.3, green: 0.65, blue: 0.9) // blue | |
| 92 | case .ears: Color(red: 0.6, green: 0.45, blue: 0.85) // purple | |
| 93 | case .nose: Color(red: 0.9, green: 0.4, blue: 0.5) // rose | |
| 94 | case .mouth: Color(red: 0.9, green: 0.3, blue: 0.35) // red | |
| 95 | case .hands: Color(red: 0.95, green: 0.7, blue: 0.3) // golden | |
| 96 | case .fingers: Color(red: 0.35, green: 0.75, blue: 0.5) // green | |
| 97 | case .arms: Color(red: 0.25, green: 0.7, blue: 0.7) // teal | |
| 98 | case .legs: Color(red: 0.5, green: 0.4, blue: 0.8) // indigo | |
| 99 | case .feet: Color(red: 0.7, green: 0.5, blue: 0.3) // brown | |
| 100 | case .heart: Color(red: 0.85, green: 0.3, blue: 0.45) // deep pink | |
| 101 | case .belly: Color(red: 1.0, green: 0.6, blue: 0.4) // peach | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | #Preview { | |
| 107 | HStack(spacing: 24) { | |
| 108 | BodyPartCard(bodyPart: .head) | |
| 109 | .frame(width: 200, height: 260) | |
| 110 | BodyPartCard(bodyPart: .hands) | |
| 111 | .frame(width: 200, height: 260) | |
| 112 | } | |
| 113 | .padding() | |
| 114 | } | |
BodyPartsView.swift added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | // | |
| 2 | // BodyPartsView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays all 12 body parts as large, tappable cards in a scrollable grid. | |
| 9 | struct BodyPartsView: View { | |
| 10 | private let columns = [ | |
| 11 | GridItem(.adaptive(minimum: 280), spacing: 24) | |
| 12 | ] | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ScrollView { | |
| 16 | LazyVGrid(columns: columns, spacing: 24) { | |
| 17 | ForEach(LearnBodyPart.allCases) { part in | |
| 18 | BodyPartCard(bodyPart: part) | |
| 19 | .frame(height: 200) | |
| 20 | } | |
| 21 | } | |
| 22 | .padding(32) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | #Preview { | |
| 28 | BodyPartsView() | |
| 29 | } | |
ColorCard.swift added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | // | |
| 2 | // ColorCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large card filled with a color. Tapping speaks the color name aloud. | |
| 9 | struct ColorCard: View { | |
| 10 | let learnColor: LearnColor | |
| 11 | @State private var isTapped = false | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | ZStack { | |
| 15 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 16 | .fill(learnColor.color.gradient) | |
| 17 | .shadow(color: learnColor.color.opacity(0.4), radius: 8, y: 4) | |
| 18 | ||
| 19 | Text(learnColor.name) | |
| 20 | .font(.system(size: 36, weight: .bold, design: .rounded)) | |
| 21 | .foregroundStyle(.white) | |
| 22 | .shadow(color: .black.opacity(0.3), radius: 4, y: 2) | |
| 23 | } | |
| 24 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 25 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 26 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 27 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 28 | .onTapGesture { | |
| 29 | isTapped = true | |
| 30 | SpeechSynthesizer.shared.speak(learnColor.name) | |
| 31 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 32 | isTapped = false | |
| 33 | } | |
| 34 | } | |
| 35 | .accessibilityLabel(learnColor.name) | |
| 36 | .accessibilityAddTraits(.isButton) | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | // MARK: - Color model | |
| 41 | ||
| 42 | enum LearnColor: Int, CaseIterable, Identifiable { | |
| 43 | case red, orange, yellow, green, blue, purple, pink, brown, black, white | |
| 44 | ||
| 45 | var id: Int { rawValue } | |
| 46 | ||
| 47 | var name: String { | |
| 48 | switch self { | |
| 49 | case .red: "Red" | |
| 50 | case .orange: "Orange" | |
| 51 | case .yellow: "Yellow" | |
| 52 | case .green: "Green" | |
| 53 | case .blue: "Blue" | |
| 54 | case .purple: "Purple" | |
| 55 | case .pink: "Pink" | |
| 56 | case .brown: "Brown" | |
| 57 | case .black: "Black" | |
| 58 | case .white: "White" | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | var color: Color { | |
| 63 | switch self { | |
| 64 | case .red: Color(red: 0.9, green: 0.2, blue: 0.2) | |
| 65 | case .orange: Color(red: 1.0, green: 0.55, blue: 0.15) | |
| 66 | case .yellow: Color(red: 1.0, green: 0.82, blue: 0.15) | |
| 67 | case .green: Color(red: 0.2, green: 0.75, blue: 0.35) | |
| 68 | case .blue: Color(red: 0.2, green: 0.45, blue: 0.9) | |
| 69 | case .purple: Color(red: 0.55, green: 0.3, blue: 0.85) | |
| 70 | case .pink: Color(red: 0.95, green: 0.4, blue: 0.6) | |
| 71 | case .brown: Color(red: 0.55, green: 0.35, blue: 0.2) | |
| 72 | case .black: Color(red: 0.15, green: 0.15, blue: 0.15) | |
| 73 | case .white: Color(red: 0.92, green: 0.92, blue: 0.92) | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | #Preview { | |
| 79 | HStack(spacing: 24) { | |
| 80 | ColorCard(learnColor: .red) | |
| 81 | .frame(width: 200, height: 200) | |
| 82 | ColorCard(learnColor: .blue) | |
| 83 | .frame(width: 200, height: 200) | |
| 84 | } | |
| 85 | .padding() | |
| 86 | } | |
ColorsView.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | // | |
| 2 | // ColorsView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays 10 colors as large, tappable cards in a scrollable grid. | |
| 9 | /// Adapts between 1 and 2 columns depending on available width. | |
| 10 | struct ColorsView: View { | |
| 11 | private let columns = [ | |
| 12 | GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) | |
| 13 | ] | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ScrollView { | |
| 17 | LazyVGrid(columns: columns, spacing: 24) { | |
| 18 | ForEach(LearnColor.allCases) { color in | |
| 19 | ColorCard(learnColor: color) | |
| 20 | .frame(height: 200) | |
| 21 | } | |
| 22 | } | |
| 23 | .padding(32) | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | #Preview { | |
| 29 | ColorsView() | |
| 30 | } | |
DayByDay.xcodeproj/project.pbxproj +87 −6
| @@ -7,17 +7,64 @@ | ||
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | 9 | /* Begin PBXBuildFile section */ |
| 10 | 8B1B4FF92F659AD1005C246F /* WeatherCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FF82F659AD1005C246F /* WeatherCard.swift */; }; | |
| 11 | 8B1B4FFB2F659AD1005C246F /* WeatherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */; }; | |
| 12 | 8B1B4FFD2F659AD2005C246F /* BodyPartCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */; }; | |
| 13 | 8B1B4FFF2F659AD2005C246F /* BodyPartsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */; }; | |
| 14 | 8B1B50012F659AD3005C246F /* FoodCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B50002F659AD3005C246F /* FoodCard.swift */; }; | |
| 15 | 8B1B50032F659AD4005C246F /* FoodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1B50022F659AD4005C246F /* FoodView.swift */; }; | |
| 16 | 8B57F7282F63BBB4001F6F5E /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7272F63BBB4001F6F5E /* TodayView.swift */; }; | |
| 17 | 8B57F72A2F63BBB5001F6F5E /* NumberCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */; }; | |
| 18 | 8B57F72C2F63BBB6001F6F5E /* NumbersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */; }; | |
| 19 | 8B57F72E2F63BBB7001F6F5E /* ColorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */; }; | |
| 20 | 8B57F7302F63BBB8001F6F5E /* ColorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */; }; | |
| 21 | 8B57F7322F63BBB9001F6F5E /* ShapeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */; }; | |
| 22 | 8B57F7342F63BBBA001F6F5E /* ShapesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */; }; | |
| 23 | 8B57F7362F63BBBA001F6F5E /* AnimalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */; }; | |
| 24 | 8B57F7382F63BBBB001F6F5E /* AnimalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */; }; | |
| 25 | 8B57F73A2F63BBBD001F6F5E /* LetterCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */; }; | |
| 26 | 8B57F73C2F63BBBD001F6F5E /* AlphabetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */; }; | |
| 10 | 27 | 8B7051C12F5F90CD00491D58 /* claude.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B7051C02F5F90C800491D58 /* claude.md */; }; |
| 11 | 28 | /* End PBXBuildFile section */ |
| 12 | 29 | |
| 13 | 30 | /* Begin PBXFileReference section */ |
| 31 | 8B1B4FF82F659AD1005C246F /* WeatherCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherCard.swift; sourceTree = "<group>"; }; | |
| 32 | 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherView.swift; sourceTree = "<group>"; }; | |
| 33 | 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyPartCard.swift; sourceTree = "<group>"; }; | |
| 34 | 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyPartsView.swift; sourceTree = "<group>"; }; | |
| 35 | 8B1B50002F659AD3005C246F /* FoodCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodCard.swift; sourceTree = "<group>"; }; | |
| 36 | 8B1B50022F659AD4005C246F /* FoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodView.swift; sourceTree = "<group>"; }; | |
| 37 | 8B57F7272F63BBB4001F6F5E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; }; | |
| 38 | 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberCard.swift; sourceTree = "<group>"; }; | |
| 39 | 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersView.swift; sourceTree = "<group>"; }; | |
| 40 | 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorCard.swift; sourceTree = "<group>"; }; | |
| 41 | 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorsView.swift; sourceTree = "<group>"; }; | |
| 42 | 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeCard.swift; sourceTree = "<group>"; }; | |
| 43 | 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapesView.swift; sourceTree = "<group>"; }; | |
| 44 | 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimalCard.swift; sourceTree = "<group>"; }; | |
| 45 | 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimalsView.swift; sourceTree = "<group>"; }; | |
| 46 | 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterCard.swift; sourceTree = "<group>"; }; | |
| 47 | 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlphabetView.swift; sourceTree = "<group>"; }; | |
| 14 | 48 | 8B7051B22F5F908800491D58 /* DayByDay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DayByDay.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 15 | 49 | 8B7051C02F5F90C800491D58 /* claude.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = claude.md; sourceTree = "<group>"; }; |
| 16 | 50 | /* End PBXFileReference section */ |
| 17 | 51 | |
| 52 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | |
| 53 | 8B5214E62F621E0200E1D35A /* Exceptions for "DayByDay" folder in "DayByDay" target */ = { | |
| 54 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | |
| 55 | membershipExceptions = ( | |
| 56 | Info.plist, | |
| 57 | ); | |
| 58 | target = 8B7051B12F5F908800491D58 /* DayByDay */; | |
| 59 | }; | |
| 60 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | |
| 61 | ||
| 18 | 62 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| 19 | 63 | 8B7051B42F5F908800491D58 /* DayByDay */ = { |
| 20 | 64 | isa = PBXFileSystemSynchronizedRootGroup; |
| 65 | exceptions = ( | |
| 66 | 8B5214E62F621E0200E1D35A /* Exceptions for "DayByDay" folder in "DayByDay" target */, | |
| 67 | ); | |
| 21 | 68 | path = DayByDay; |
| 22 | 69 | sourceTree = "<group>"; |
| 23 | 70 | }; |
| @@ -40,6 +87,23 @@ | ||
| 40 | 87 | 8B7051C02F5F90C800491D58 /* claude.md */, |
| 41 | 88 | 8B7051B42F5F908800491D58 /* DayByDay */, |
| 42 | 89 | 8B7051B32F5F908800491D58 /* Products */, |
| 90 | 8B57F7272F63BBB4001F6F5E /* TodayView.swift */, | |
| 91 | 8B57F7292F63BBB5001F6F5E /* NumberCard.swift */, | |
| 92 | 8B57F72B2F63BBB6001F6F5E /* NumbersView.swift */, | |
| 93 | 8B57F72D2F63BBB7001F6F5E /* ColorCard.swift */, | |
| 94 | 8B57F72F2F63BBB8001F6F5E /* ColorsView.swift */, | |
| 95 | 8B57F7312F63BBB9001F6F5E /* ShapeCard.swift */, | |
| 96 | 8B57F7332F63BBBA001F6F5E /* ShapesView.swift */, | |
| 97 | 8B57F7352F63BBBA001F6F5E /* AnimalCard.swift */, | |
| 98 | 8B57F7372F63BBBB001F6F5E /* AnimalsView.swift */, | |
| 99 | 8B57F7392F63BBBD001F6F5E /* LetterCard.swift */, | |
| 100 | 8B57F73B2F63BBBD001F6F5E /* AlphabetView.swift */, | |
| 101 | 8B1B4FF82F659AD1005C246F /* WeatherCard.swift */, | |
| 102 | 8B1B4FFA2F659AD1005C246F /* WeatherView.swift */, | |
| 103 | 8B1B4FFC2F659AD2005C246F /* BodyPartCard.swift */, | |
| 104 | 8B1B4FFE2F659AD2005C246F /* BodyPartsView.swift */, | |
| 105 | 8B1B50002F659AD3005C246F /* FoodCard.swift */, | |
| 106 | 8B1B50022F659AD4005C246F /* FoodView.swift */, | |
| 43 | 107 | ); |
| 44 | 108 | sourceTree = "<group>"; |
| 45 | 109 | }; |
| @@ -126,6 +190,23 @@ | ||
| 126 | 190 | isa = PBXSourcesBuildPhase; |
| 127 | 191 | buildActionMask = 2147483647; |
| 128 | 192 | files = ( |
| 193 | 8B1B50032F659AD4005C246F /* FoodView.swift in Sources */, | |
| 194 | 8B57F72E2F63BBB7001F6F5E /* ColorCard.swift in Sources */, | |
| 195 | 8B57F73C2F63BBBD001F6F5E /* AlphabetView.swift in Sources */, | |
| 196 | 8B57F73A2F63BBBD001F6F5E /* LetterCard.swift in Sources */, | |
| 197 | 8B57F7382F63BBBB001F6F5E /* AnimalsView.swift in Sources */, | |
| 198 | 8B57F72C2F63BBB6001F6F5E /* NumbersView.swift in Sources */, | |
| 199 | 8B57F72A2F63BBB5001F6F5E /* NumberCard.swift in Sources */, | |
| 200 | 8B1B50012F659AD3005C246F /* FoodCard.swift in Sources */, | |
| 201 | 8B1B4FF92F659AD1005C246F /* WeatherCard.swift in Sources */, | |
| 202 | 8B1B4FFB2F659AD1005C246F /* WeatherView.swift in Sources */, | |
| 203 | 8B1B4FFD2F659AD2005C246F /* BodyPartCard.swift in Sources */, | |
| 204 | 8B1B4FFF2F659AD2005C246F /* BodyPartsView.swift in Sources */, | |
| 205 | 8B57F7342F63BBBA001F6F5E /* ShapesView.swift in Sources */, | |
| 206 | 8B57F7362F63BBBA001F6F5E /* AnimalCard.swift in Sources */, | |
| 207 | 8B57F7322F63BBB9001F6F5E /* ShapeCard.swift in Sources */, | |
| 208 | 8B57F7282F63BBB4001F6F5E /* TodayView.swift in Sources */, | |
| 209 | 8B57F7302F63BBB8001F6F5E /* ColorsView.swift in Sources */, | |
| 129 | 210 | ); |
| 130 | 211 | runOnlyForDeploymentPostprocessing = 0; |
| 131 | 212 | }; |
| @@ -259,21 +340,21 @@ | ||
| 259 | 340 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 260 | 341 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 261 | 342 | CODE_SIGN_STYLE = Automatic; |
| 262 | CURRENT_PROJECT_VERSION = 1; | |
| 343 | CURRENT_PROJECT_VERSION = 3; | |
| 263 | 344 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 264 | 345 | ENABLE_PREVIEWS = YES; |
| 265 | 346 | GENERATE_INFOPLIST_FILE = YES; |
| 347 | INFOPLIST_FILE = DayByDay/Info.plist; | |
| 266 | 348 | INFOPLIST_KEY_CFBundleDisplayName = "Day By Day"; |
| 267 | 349 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; |
| 268 | 350 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
| 269 | 351 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; |
| 270 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | |
| 271 | 352 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; |
| 272 | 353 | LD_RUNPATH_SEARCH_PATHS = ( |
| 273 | 354 | "$(inherited)", |
| 274 | 355 | "@executable_path/Frameworks", |
| 275 | 356 | ); |
| 276 | MARKETING_VERSION = 1.0; | |
| 357 | MARKETING_VERSION = 1.2; | |
| 277 | 358 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DayByDay; |
| 278 | 359 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 279 | 360 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -292,21 +373,21 @@ | ||
| 292 | 373 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 293 | 374 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 294 | 375 | CODE_SIGN_STYLE = Automatic; |
| 295 | CURRENT_PROJECT_VERSION = 1; | |
| 376 | CURRENT_PROJECT_VERSION = 3; | |
| 296 | 377 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 297 | 378 | ENABLE_PREVIEWS = YES; |
| 298 | 379 | GENERATE_INFOPLIST_FILE = YES; |
| 380 | INFOPLIST_FILE = DayByDay/Info.plist; | |
| 299 | 381 | INFOPLIST_KEY_CFBundleDisplayName = "Day By Day"; |
| 300 | 382 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; |
| 301 | 383 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
| 302 | 384 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; |
| 303 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | |
| 304 | 385 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; |
| 305 | 386 | LD_RUNPATH_SEARCH_PATHS = ( |
| 306 | 387 | "$(inherited)", |
| 307 | 388 | "@executable_path/Frameworks", |
| 308 | 389 | ); |
| 309 | MARKETING_VERSION = 1.0; | |
| 390 | MARKETING_VERSION = 1.2; | |
| 310 | 391 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DayByDay; |
| 311 | 392 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 312 | 393 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | { | |
| 2 | "colors" : [ | |
| 3 | { | |
| 4 | "color" : { | |
| 5 | "color-space" : "srgb", | |
| 6 | "components" : { | |
| 7 | "alpha" : "1.000", | |
| 8 | "blue" : "1.000", | |
| 9 | "green" : "1.000", | |
| 10 | "red" : "1.000" | |
| 11 | } | |
| 12 | }, | |
| 13 | "idiom" : "universal" | |
| 14 | }, | |
| 15 | { | |
| 16 | "appearances" : [ | |
| 17 | { | |
| 18 | "appearance" : "luminosity", | |
| 19 | "value" : "dark" | |
| 20 | } | |
| 21 | ], | |
| 22 | "color" : { | |
| 23 | "color-space" : "srgb", | |
| 24 | "components" : { | |
| 25 | "alpha" : "1.000", | |
| 26 | "blue" : "0.000", | |
| 27 | "green" : "0.000", | |
| 28 | "red" : "0.000" | |
| 29 | } | |
| 30 | }, | |
| 31 | "idiom" : "universal" | |
| 32 | } | |
| 33 | ], | |
| 34 | "info" : { | |
| 35 | "author" : "xcode", | |
| 36 | "version" : 1 | |
| 37 | } | |
| 38 | } | |
DayByDay/ContentView.swift +160 −42
| @@ -7,62 +7,180 @@ | ||
| 7 | 7 | |
| 8 | 8 | import SwiftUI |
| 9 | 9 | |
| 10 | // MARK: - Category model | |
| 11 | ||
| 12 | enum Category: Int, CaseIterable, Identifiable { | |
| 13 | case today, days, months, seasons, numbers, colors, shapes, animals, alphabet, | |
| 14 | weather, bodyParts, food | |
| 15 | ||
| 16 | var id: Int { rawValue } | |
| 17 | ||
| 18 | var label: String { | |
| 19 | switch self { | |
| 20 | case .today: "Today" | |
| 21 | case .days: "Days" | |
| 22 | case .months: "Months" | |
| 23 | case .seasons: "Seasons" | |
| 24 | case .numbers: "Numbers" | |
| 25 | case .colors: "Colors" | |
| 26 | case .shapes: "Shapes" | |
| 27 | case .animals: "Animals" | |
| 28 | case .alphabet: "Alphabet" | |
| 29 | case .weather: "Weather" | |
| 30 | case .bodyParts: "Body" | |
| 31 | case .food: "Food" | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | var symbol: String { | |
| 36 | switch self { | |
| 37 | case .today: "sun.horizon.fill" | |
| 38 | case .days: "calendar" | |
| 39 | case .months: "calendar.badge.clock" | |
| 40 | case .seasons: "leaf.fill" | |
| 41 | case .numbers: "123.rectangle.fill" | |
| 42 | case .colors: "paintpalette.fill" | |
| 43 | case .shapes: "pentagon.fill" | |
| 44 | case .animals: "pawprint.fill" | |
| 45 | case .alphabet: "abc" | |
| 46 | case .weather: "cloud.sun.rain.fill" | |
| 47 | case .bodyParts: "figure.stand" | |
| 48 | case .food: "carrot.fill" | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | var color: Color { | |
| 53 | switch self { | |
| 54 | case .today: .orange | |
| 55 | case .days: .blue | |
| 56 | case .months: .purple | |
| 57 | case .seasons: .green | |
| 58 | case .numbers: .red | |
| 59 | case .colors: .pink | |
| 60 | case .shapes: .teal | |
| 61 | case .animals: Color(red: 0.7, green: 0.45, blue: 0.2) | |
| 62 | case .alphabet: .indigo | |
| 63 | case .weather: Color(red: 0.4, green: 0.75, blue: 0.95) | |
| 64 | case .bodyParts: Color(red: 0.9, green: 0.5, blue: 0.6) | |
| 65 | case .food: Color(red: 0.95, green: 0.6, blue: 0.2) | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | /// Whether this tile uses a special gradient background instead of the solid color. | |
| 70 | var usesGradientBackground: Bool { | |
| 71 | self == .colors | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | // MARK: - Home grid tile | |
| 76 | ||
| 77 | struct CategoryTile: View { | |
| 78 | let category: Category | |
| 79 | ||
| 80 | var body: some View { | |
| 81 | ZStack { | |
| 82 | if category.usesGradientBackground { | |
| 83 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 84 | .fill( | |
| 85 | LinearGradient( | |
| 86 | colors: [.red, .orange, .yellow, .green, .blue, .purple], | |
| 87 | startPoint: .leading, | |
| 88 | endPoint: .trailing | |
| 89 | ) | |
| 90 | ) | |
| 91 | .shadow(color: category.color.opacity(0.4), radius: 8, y: 4) | |
| 92 | } else { | |
| 93 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 94 | .fill(category.color.gradient) | |
| 95 | .shadow(color: category.color.opacity(0.4), radius: 8, y: 4) | |
| 96 | } | |
| 97 | ||
| 98 | VStack(spacing: 12) { | |
| 99 | Image(systemName: category.symbol) | |
| 100 | .font(.system(size: 48)) | |
| 101 | .foregroundStyle(.white) | |
| 102 | .symbolRenderingMode(.hierarchical) | |
| 103 | ||
| 104 | Text(category.label) | |
| 105 | .font(.system(size: 24, weight: .bold, design: .rounded)) | |
| 106 | .foregroundStyle(.white) | |
| 107 | } | |
| 108 | } | |
| 109 | .accessibilityLabel(category.label) | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | // MARK: - Content view | |
| 114 | ||
| 10 | 115 | struct ContentView: View { |
| 11 | @State private var selectedTab = 0 | |
| 12 | 116 | @AppStorage("hasSeenVoiceTip") private var hasSeenVoiceTip = false |
| 13 | 117 | |
| 14 | private let tabs: [(label: String, symbol: String)] = [ | |
| 15 | ("Days", "calendar"), | |
| 16 | ("Months", "calendar.badge.clock"), | |
| 17 | ("Seasons", "leaf.fill"), | |
| 118 | private let columns = [ | |
| 119 | GridItem(.flexible(), spacing: 24), | |
| 120 | GridItem(.flexible(), spacing: 24) | |
| 18 | 121 | ] |
| 19 | 122 | |
| 20 | 123 | var body: some View { |
| 21 | VStack(spacing: 0) { | |
| 22 | Group { | |
| 23 | switch selectedTab { | |
| 24 | case 0: DaysOfWeekView() | |
| 25 | case 1: MonthsOfYearView() | |
| 26 | default: SeasonsView() | |
| 27 | } | |
| 28 | } | |
| 29 | .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 30 | ||
| 31 | Divider() | |
| 32 | ||
| 33 | // Bottom tab bar with large tap targets | |
| 34 | HStack(spacing: 0) { | |
| 35 | ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in | |
| 36 | Button { | |
| 37 | selectedTab = index | |
| 38 | } label: { | |
| 39 | VStack(spacing: 6) { | |
| 40 | Image(systemName: tab.symbol) | |
| 41 | .font(.system(size: 32)) | |
| 42 | Text(tab.label) | |
| 43 | .font(.system(size: 18, weight: .semibold, design: .rounded)) | |
| 124 | NavigationStack { | |
| 125 | ScrollView { | |
| 126 | LazyVGrid(columns: columns, spacing: 24) { | |
| 127 | ForEach(Category.allCases) { category in | |
| 128 | NavigationLink(value: category) { | |
| 129 | CategoryTile(category: category) | |
| 130 | .aspectRatio(1, contentMode: .fit) | |
| 44 | 131 | } |
| 45 | .foregroundStyle(selectedTab == index ? Color.accentColor : Color.secondary) | |
| 132 | .buttonStyle(.plain) | |
| 133 | } | |
| 134 | } | |
| 135 | .padding(24) | |
| 136 | ||
| 137 | Button { | |
| 138 | hasSeenVoiceTip = false | |
| 139 | } label: { | |
| 140 | Label("Voice Quality Tips", systemImage: "speaker.wave.2.fill") | |
| 141 | .font(.system(size: 16, weight: .medium, design: .rounded)) | |
| 142 | .foregroundStyle(.secondary) | |
| 46 | 143 | .frame(maxWidth: .infinity) |
| 47 | 144 | .padding(.vertical, 14) |
| 48 | .contentShape(Rectangle()) | |
| 49 | } | |
| 50 | .buttonStyle(.plain) | |
| 51 | .accessibilityLabel(tab.label) | |
| 145 | .background(Color(.systemGray6)) | |
| 146 | .cornerRadius(16) | |
| 52 | 147 | } |
| 148 | .buttonStyle(.plain) | |
| 149 | .padding(.horizontal, 24) | |
| 150 | .padding(.bottom, 24) | |
| 53 | 151 | } |
| 54 | .padding(.horizontal, 24) | |
| 55 | .padding(.bottom, 8) | |
| 56 | .background(.bar) | |
| 57 | } | |
| 58 | .overlay { | |
| 59 | if !hasSeenVoiceTip { | |
| 60 | VoiceTipOverlay { | |
| 61 | withAnimation { hasSeenVoiceTip = true } | |
| 152 | .navigationTitle("DayByDay") | |
| 153 | .navigationDestination(for: Category.self) { category in | |
| 154 | destinationView(for: category) | |
| 155 | .navigationTitle(category.label) | |
| 156 | } | |
| 157 | .overlay { | |
| 158 | if !hasSeenVoiceTip { | |
| 159 | VoiceTipOverlay { | |
| 160 | withAnimation { hasSeenVoiceTip = true } | |
| 161 | } | |
| 62 | 162 | } |
| 63 | 163 | } |
| 64 | 164 | } |
| 65 | 165 | } |
| 166 | ||
| 167 | @ViewBuilder | |
| 168 | private func destinationView(for category: Category) -> some View { | |
| 169 | switch category { | |
| 170 | case .today: TodayView() | |
| 171 | case .days: DaysOfWeekView() | |
| 172 | case .months: MonthsOfYearView() | |
| 173 | case .seasons: SeasonsView() | |
| 174 | case .numbers: NumbersView() | |
| 175 | case .colors: ColorsView() | |
| 176 | case .shapes: ShapesView() | |
| 177 | case .animals: AnimalsView() | |
| 178 | case .alphabet: AlphabetView() | |
| 179 | case .weather: WeatherView() | |
| 180 | case .bodyParts: BodyPartsView() | |
| 181 | case .food: FoodView() | |
| 182 | } | |
| 183 | } | |
| 66 | 184 | } |
| 67 | 185 | |
| 68 | 186 | /// A one-time informational overlay for parents explaining how to download |
| @@ -83,7 +201,7 @@ struct VoiceTipOverlay: View { | ||
| 83 | 201 | Text("Better Voices Available") |
| 84 | 202 | .font(.system(size: 28, weight: .bold, design: .rounded)) |
| 85 | 203 | |
| 86 | 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.") | |
| 204 | Text("For the best experience, download an enhanced voice on your device.\n\nSettings → Accessibility → Read & Speak → Voices → English → tap a voice marked Enhanced or Premium to download it.") | |
| 87 | 205 | .font(.system(size: 18, design: .rounded)) |
| 88 | 206 | .multilineTextAlignment(.center) |
| 89 | 207 | .foregroundStyle(.secondary) |
DayByDay/DayByDayApp.swift +17
| @@ -5,10 +5,27 @@ | ||
| 5 | 5 | // Created by cmc on 2026-03-09. |
| 6 | 6 | // |
| 7 | 7 | |
| 8 | import AVFoundation | |
| 8 | 9 | import SwiftUI |
| 9 | 10 | |
| 10 | 11 | @main |
| 11 | 12 | struct DayByDayApp: App { |
| 13 | init() { | |
| 14 | Task.detached(priority: .background) { | |
| 15 | // Activate the audio session early so the first real tap doesn't block. | |
| 16 | try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) | |
| 17 | try? AVAudioSession.sharedInstance().setActive(true) | |
| 18 | ||
| 19 | // Force singleton init (voice selection) and prime the TTS engine | |
| 20 | // with a silent utterance so subsequent speaks are instant. | |
| 21 | await MainActor.run { | |
| 22 | let warmup = AVSpeechUtterance(string: "") | |
| 23 | warmup.volume = 0 | |
| 24 | SpeechSynthesizer.shared.speakUtterance(warmup) | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 12 | 29 | var body: some Scene { |
| 13 | 30 | WindowGroup { |
| 14 | 31 | ContentView() |
DayByDay/Info.plist added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | <plist version="1.0"> | |
| 4 | <dict> | |
| 5 | <key>UILaunchScreen</key> | |
| 6 | <dict> | |
| 7 | <key>UIColorName</key> | |
| 8 | <string>LaunchBackground</string> | |
| 9 | <key>UIImageName</key> | |
| 10 | <string>AppIcon</string> | |
| 11 | <key>UIImageRespectsSafeAreaInsets</key> | |
| 12 | <false/> | |
| 13 | </dict> | |
| 14 | </dict> | |
| 15 | </plist> | |
DayByDay/SpeechSynthesizer.swift +22 −15
| @@ -18,7 +18,16 @@ final class SpeechSynthesizer { | ||
| 18 | 18 | self.voice = Self.bestAvailableVoice() |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | /// Speaks a pre-configured utterance directly. Used for warmup at launch. | |
| 22 | func speakUtterance(_ utterance: AVSpeechUtterance) { | |
| 23 | synthesizer.speak(utterance) | |
| 24 | } | |
| 25 | ||
| 21 | 26 | func speak(_ text: String) { |
| 27 | // Ensure speech plays through the speaker even when the silent switch is on. | |
| 28 | try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) | |
| 29 | try? AVAudioSession.sharedInstance().setActive(true) | |
| 30 | ||
| 22 | 31 | if synthesizer.isSpeaking { |
| 23 | 32 | synthesizer.stopSpeaking(at: .immediate) |
| 24 | 33 | } |
| @@ -32,21 +41,19 @@ final class SpeechSynthesizer { | ||
| 32 | 41 | } |
| 33 | 42 | |
| 34 | 43 | /// Picks the best en-US voice on the device. |
| 35 | /// Preference order: premium > enhanced > default quality. | |
| 36 | /// Skips novelty voices so the child always hears a natural-sounding voice. | |
| 44 | /// Filters for en-US, excludes novelty voices, then sorts by quality | |
| 45 | /// descending so premium (3) > enhanced (2) > default (1). | |
| 37 | 46 | private static func bestAvailableVoice() -> AVSpeechSynthesisVoice? { |
| 38 | let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in | |
| 39 | voice.language.hasPrefix("en-US") | |
| 40 | && !voice.voiceTraits.contains(.isNoveltyVoice) | |
| 41 | } | |
| 42 | ||
| 43 | if let premium = candidates.first(where: { $0.quality == .premium }) { | |
| 44 | return premium | |
| 45 | } | |
| 46 | if let enhanced = candidates.first(where: { $0.quality == .enhanced }) { | |
| 47 | return enhanced | |
| 48 | } | |
| 49 | // Fall back to the best default-quality voice. | |
| 50 | return candidates.first ?? AVSpeechSynthesisVoice(language: "en-US") | |
| 47 | let best = AVSpeechSynthesisVoice.speechVoices() | |
| 48 | .filter { voice in | |
| 49 | voice.language.hasPrefix("en-US") | |
| 50 | && !voice.voiceTraits.contains(.isNoveltyVoice) | |
| 51 | } | |
| 52 | .sorted { $0.quality.rawValue > $1.quality.rawValue } | |
| 53 | .first | |
| 54 | ||
| 55 | let selected = best ?? AVSpeechSynthesisVoice(language: "en-US") | |
| 56 | print("[DayByDay] Selected voice: \(selected?.name ?? "nil"), quality: \(selected?.quality.rawValue ?? -1)") | |
| 57 | return selected | |
| 51 | 58 | } |
| 52 | 59 | } |
FoodCard.swift added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | // | |
| 2 | // FoodCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one fruit or vegetable. | |
| 9 | /// Tapping the card plays a bounce animation and speaks the food name aloud. | |
| 10 | /// Each food displays its emoji at large size for instant recognition. | |
| 11 | struct FoodCard: View { | |
| 12 | let food: LearnFood | |
| 13 | @State private var isTapped = false | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ZStack { | |
| 17 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 18 | .fill(food.color.gradient) | |
| 19 | .shadow(color: food.color.opacity(0.4), radius: 8, y: 4) | |
| 20 | ||
| 21 | VStack(spacing: 16) { | |
| 22 | Text(food.emoji) | |
| 23 | .font(.system(size: 72)) | |
| 24 | ||
| 25 | Text(food.name) | |
| 26 | .font(.system(size: 28, weight: .bold, design: .rounded)) | |
| 27 | .foregroundStyle(.white) | |
| 28 | } | |
| 29 | } | |
| 30 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 31 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 32 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 33 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 34 | .onTapGesture { | |
| 35 | isTapped = true | |
| 36 | SpeechSynthesizer.shared.speak(food.name) | |
| 37 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 38 | isTapped = false | |
| 39 | } | |
| 40 | } | |
| 41 | .accessibilityLabel(food.name) | |
| 42 | .accessibilityAddTraits(.isButton) | |
| 43 | } | |
| 44 | ||
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Food model | |
| 48 | ||
| 49 | enum LearnFood: Int, CaseIterable, Identifiable { | |
| 50 | case apple, banana, carrot, strawberry, grapes, lemon, | |
| 51 | broccoli, watermelon, cherry, orange, corn, pear | |
| 52 | ||
| 53 | var id: Int { rawValue } | |
| 54 | ||
| 55 | var name: String { | |
| 56 | switch self { | |
| 57 | case .apple: "Apple" | |
| 58 | case .banana: "Banana" | |
| 59 | case .carrot: "Carrot" | |
| 60 | case .strawberry: "Strawberry" | |
| 61 | case .grapes: "Grapes" | |
| 62 | case .lemon: "Lemon" | |
| 63 | case .broccoli: "Broccoli" | |
| 64 | case .watermelon: "Watermelon" | |
| 65 | case .cherry: "Cherry" | |
| 66 | case .orange: "Orange" | |
| 67 | case .corn: "Corn" | |
| 68 | case .pear: "Pear" | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | var emoji: String { | |
| 73 | switch self { | |
| 74 | case .apple: "🍎" | |
| 75 | case .banana: "🍌" | |
| 76 | case .carrot: "🥕" | |
| 77 | case .strawberry: "🍓" | |
| 78 | case .grapes: "🍇" | |
| 79 | case .lemon: "🍋" | |
| 80 | case .broccoli: "🥦" | |
| 81 | case .watermelon: "🍉" | |
| 82 | case .cherry: "🍒" | |
| 83 | case .orange: "🍊" | |
| 84 | case .corn: "🌽" | |
| 85 | case .pear: "🍐" | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | var color: Color { | |
| 90 | switch self { | |
| 91 | case .apple: Color(red: 0.85, green: 0.2, blue: 0.2) // red | |
| 92 | case .banana: Color(red: 0.95, green: 0.8, blue: 0.2) // yellow | |
| 93 | case .carrot: Color(red: 1.0, green: 0.55, blue: 0.15) // orange | |
| 94 | case .strawberry: Color(red: 0.9, green: 0.25, blue: 0.3) // strawberry red | |
| 95 | case .grapes: Color(red: 0.55, green: 0.3, blue: 0.7) // purple | |
| 96 | case .lemon: Color(red: 0.9, green: 0.8, blue: 0.2) // lemon yellow | |
| 97 | case .broccoli: Color(red: 0.3, green: 0.65, blue: 0.3) // green | |
| 98 | case .watermelon: Color(red: 0.35, green: 0.7, blue: 0.4) // watermelon green | |
| 99 | case .cherry: Color(red: 0.7, green: 0.15, blue: 0.2) // dark red | |
| 100 | case .orange: Color(red: 1.0, green: 0.6, blue: 0.15) // orange | |
| 101 | case .corn: Color(red: 0.9, green: 0.75, blue: 0.2) // golden yellow | |
| 102 | case .pear: Color(red: 0.55, green: 0.75, blue: 0.3) // pear green | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | #Preview { | |
| 108 | HStack(spacing: 24) { | |
| 109 | FoodCard(food: .apple) | |
| 110 | .frame(width: 200, height: 260) | |
| 111 | FoodCard(food: .carrot) | |
| 112 | .frame(width: 200, height: 260) | |
| 113 | } | |
| 114 | .padding() | |
| 115 | } | |
FoodView.swift added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | // | |
| 2 | // FoodView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays all 12 fruits and vegetables as large, tappable cards in a scrollable grid. | |
| 9 | struct FoodView: View { | |
| 10 | private let columns = [ | |
| 11 | GridItem(.adaptive(minimum: 280), spacing: 24) | |
| 12 | ] | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ScrollView { | |
| 16 | LazyVGrid(columns: columns, spacing: 24) { | |
| 17 | ForEach(LearnFood.allCases) { food in | |
| 18 | FoodCard(food: food) | |
| 19 | .frame(height: 200) | |
| 20 | } | |
| 21 | } | |
| 22 | .padding(32) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | #Preview { | |
| 28 | FoodView() | |
| 29 | } | |
LetterCard.swift added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | // | |
| 2 | // LetterCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one letter of the alphabet. | |
| 9 | /// Tapping the card plays a bounce animation and speaks the letter aloud. | |
| 10 | struct LetterCard: View { | |
| 11 | let letter: LearnLetter | |
| 12 | @State private var isTapped = false | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ZStack { | |
| 16 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 17 | .fill(letter.color.gradient) | |
| 18 | .shadow(color: letter.color.opacity(0.4), radius: 8, y: 4) | |
| 19 | ||
| 20 | Text(letter.character) | |
| 21 | .font(.system(size: 64, weight: .heavy, design: .rounded)) | |
| 22 | .foregroundStyle(.white) | |
| 23 | } | |
| 24 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 25 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 26 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 27 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 28 | .onTapGesture { | |
| 29 | isTapped = true | |
| 30 | SpeechSynthesizer.shared.speak(letter.spokenName) | |
| 31 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 32 | isTapped = false | |
| 33 | } | |
| 34 | } | |
| 35 | .accessibilityLabel(letter.spokenName) | |
| 36 | .accessibilityAddTraits(.isButton) | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | // MARK: - Letter model | |
| 41 | ||
| 42 | enum LearnLetter: Int, CaseIterable, Identifiable { | |
| 43 | case a, b, c, d, e, f, g, h, i, j, k, l, m, | |
| 44 | n, o, p, q, r, s, t, u, v, w, x, y, z | |
| 45 | ||
| 46 | var id: Int { rawValue } | |
| 47 | ||
| 48 | var character: String { | |
| 49 | String(Character(UnicodeScalar(65 + rawValue)!)) | |
| 50 | } | |
| 51 | ||
| 52 | /// Speak the letter name clearly for a child. | |
| 53 | /// Lowercase prevents TTS from saying "Capital A". | |
| 54 | var spokenName: String { | |
| 55 | character.lowercased() | |
| 56 | } | |
| 57 | ||
| 58 | /// Cycle through 8 bright colors to give variety without unique-per-letter mapping. | |
| 59 | var color: Color { | |
| 60 | let palette: [Color] = [ | |
| 61 | Color(red: 0.9, green: 0.3, blue: 0.3), // red | |
| 62 | Color(red: 1.0, green: 0.55, blue: 0.2), // orange | |
| 63 | Color(red: 1.0, green: 0.8, blue: 0.2), // yellow | |
| 64 | Color(red: 0.35, green: 0.75, blue: 0.4), // green | |
| 65 | Color(red: 0.2, green: 0.65, blue: 0.9), // blue | |
| 66 | Color(red: 0.55, green: 0.4, blue: 0.85), // purple | |
| 67 | Color(red: 0.85, green: 0.4, blue: 0.6), // pink | |
| 68 | Color(red: 0.2, green: 0.75, blue: 0.75), // teal | |
| 69 | ] | |
| 70 | return palette[rawValue % palette.count] | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | #Preview { | |
| 75 | HStack(spacing: 24) { | |
| 76 | LetterCard(letter: .a) | |
| 77 | .frame(width: 140, height: 160) | |
| 78 | LetterCard(letter: .b) | |
| 79 | .frame(width: 140, height: 160) | |
| 80 | LetterCard(letter: .c) | |
| 81 | .frame(width: 140, height: 160) | |
| 82 | } | |
| 83 | .padding() | |
| 84 | } | |
NumberCard.swift added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | // | |
| 2 | // NumberCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one number (1–10). | |
| 9 | /// Tapping the card plays a bounce animation and speaks the number name aloud. | |
| 10 | struct NumberCard: View { | |
| 11 | let number: LearnNumber | |
| 12 | var isHighlighted = false | |
| 13 | @State private var isTapped = false | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ZStack { | |
| 17 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 18 | .fill(number.color.gradient) | |
| 19 | .shadow(color: number.color.opacity(0.4), radius: 8, y: 4) | |
| 20 | ||
| 21 | VStack(spacing: 10) { | |
| 22 | Text("\(number.numeral)") | |
| 23 | .font(.system(size: 60, weight: .heavy, design: .rounded)) | |
| 24 | .foregroundStyle(.white) | |
| 25 | ||
| 26 | Text(number.name) | |
| 27 | .font(.system(size: 28, weight: .bold, design: .rounded)) | |
| 28 | .foregroundStyle(.white) | |
| 29 | ||
| 30 | // Dot pattern showing the quantity | |
| 31 | DotPattern(count: number.numeral) | |
| 32 | .padding(.top, 4) | |
| 33 | } | |
| 34 | } | |
| 35 | .overlay { | |
| 36 | if isHighlighted { | |
| 37 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 38 | .strokeBorder(.white, lineWidth: 5) | |
| 39 | } | |
| 40 | } | |
| 41 | .shadow(color: isHighlighted ? .white.opacity(0.8) : .clear, radius: 12) | |
| 42 | .scaleEffect(isTapped ? 1.12 : (isHighlighted ? 1.04 : 1.0)) | |
| 43 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 44 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 45 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 46 | .onTapGesture { | |
| 47 | isTapped = true | |
| 48 | SpeechSynthesizer.shared.speak(number.name) | |
| 49 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 50 | isTapped = false | |
| 51 | } | |
| 52 | } | |
| 53 | .accessibilityLabel(number.name) | |
| 54 | .accessibilityAddTraits(.isButton) | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | // MARK: - Number model | |
| 59 | ||
| 60 | enum LearnNumber: Int, CaseIterable, Identifiable { | |
| 61 | case one = 1, two, three, four, five, six, seven, eight, nine, ten | |
| 62 | ||
| 63 | var id: Int { rawValue } | |
| 64 | ||
| 65 | /// Highlight the number matching today's day-of-month if it falls in 1–10. | |
| 66 | static var current: LearnNumber? { | |
| 67 | let day = Calendar.current.component(.day, from: Date()) | |
| 68 | return LearnNumber(rawValue: day) | |
| 69 | } | |
| 70 | ||
| 71 | var numeral: Int { rawValue } | |
| 72 | ||
| 73 | var name: String { | |
| 74 | switch self { | |
| 75 | case .one: "One" | |
| 76 | case .two: "Two" | |
| 77 | case .three: "Three" | |
| 78 | case .four: "Four" | |
| 79 | case .five: "Five" | |
| 80 | case .six: "Six" | |
| 81 | case .seven: "Seven" | |
| 82 | case .eight: "Eight" | |
| 83 | case .nine: "Nine" | |
| 84 | case .ten: "Ten" | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | var color: Color { | |
| 89 | switch self { | |
| 90 | case .one: Color(red: 0.9, green: 0.3, blue: 0.3) // red | |
| 91 | case .two: Color(red: 1.0, green: 0.55, blue: 0.2) // orange | |
| 92 | case .three: Color(red: 1.0, green: 0.8, blue: 0.2) // yellow | |
| 93 | case .four: Color(red: 0.35, green: 0.75, blue: 0.4) // green | |
| 94 | case .five: Color(red: 0.2, green: 0.7, blue: 0.85) // sky blue | |
| 95 | case .six: Color(red: 0.4, green: 0.45, blue: 0.9) // blue | |
| 96 | case .seven: Color(red: 0.6, green: 0.4, blue: 0.85) // purple | |
| 97 | case .eight: Color(red: 0.85, green: 0.35, blue: 0.6) // pink | |
| 98 | case .nine: Color(red: 0.5, green: 0.75, blue: 0.45) // lime | |
| 99 | case .ten: Color(red: 0.25, green: 0.8, blue: 0.75) // teal | |
| 100 | } | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | // MARK: - Dot pattern | |
| 105 | ||
| 106 | /// Displays a grid of filled circles representing a quantity. | |
| 107 | /// 1–5 dots use centered rows; 6–10 use a 5-column grid that wraps naturally. | |
| 108 | struct DotPattern: View { | |
| 109 | let count: Int | |
| 110 | ||
| 111 | private var columns: [GridItem] { | |
| 112 | let maxCols = count <= 5 ? min(count, 5) : 5 | |
| 113 | return Array(repeating: GridItem(.fixed(14), spacing: 6), count: maxCols) | |
| 114 | } | |
| 115 | ||
| 116 | var body: some View { | |
| 117 | LazyVGrid(columns: columns, spacing: 6) { | |
| 118 | ForEach(0..<count, id: \.self) { _ in | |
| 119 | Circle() | |
| 120 | .fill(.white.opacity(0.8)) | |
| 121 | .frame(width: 14, height: 14) | |
| 122 | } | |
| 123 | } | |
| 124 | .frame(maxWidth: .infinity) | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | #Preview { | |
| 129 | HStack(spacing: 24) { | |
| 130 | NumberCard(number: .three, isHighlighted: true) | |
| 131 | .frame(width: 280, height: 300) | |
| 132 | NumberCard(number: .seven) | |
| 133 | .frame(width: 280, height: 300) | |
| 134 | } | |
| 135 | .padding() | |
| 136 | } | |
NumbersView.swift added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | // | |
| 2 | // NumbersView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays numbers 1–10 as large, tappable cards in a scrollable grid. | |
| 9 | /// Adapts between 1 and 2 columns depending on available width. | |
| 10 | struct NumbersView: View { | |
| 11 | private let columns = [ | |
| 12 | GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) | |
| 13 | ] | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ScrollView { | |
| 17 | LazyVGrid(columns: columns, spacing: 24) { | |
| 18 | ForEach(LearnNumber.allCases) { number in | |
| 19 | NumberCard( | |
| 20 | number: number, | |
| 21 | isHighlighted: number == .current | |
| 22 | ) | |
| 23 | .frame(height: 200) | |
| 24 | } | |
| 25 | } | |
| 26 | .padding(32) | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | #Preview { | |
| 32 | NumbersView() | |
| 33 | } | |
Screenshots/ipad.png
Binary file not shown.
Screenshots/iphone.png
Binary file not shown.
ShapeCard.swift added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | // | |
| 2 | // ShapeCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one shape. | |
| 9 | /// Tapping the card plays a bounce animation and speaks the shape name aloud. | |
| 10 | struct ShapeCard: View { | |
| 11 | let shape: LearnShape | |
| 12 | @State private var isTapped = false | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ZStack { | |
| 16 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 17 | .fill(shape.color.gradient) | |
| 18 | .shadow(color: shape.color.opacity(0.4), radius: 8, y: 4) | |
| 19 | ||
| 20 | VStack(spacing: 16) { | |
| 21 | shape.shapeView | |
| 22 | .frame(width: 80, height: 80) | |
| 23 | .foregroundStyle(.white) | |
| 24 | ||
| 25 | Text(shape.name) | |
| 26 | .font(.system(size: 32, weight: .bold, design: .rounded)) | |
| 27 | .foregroundStyle(.white) | |
| 28 | } | |
| 29 | } | |
| 30 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 31 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 32 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 33 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 34 | .onTapGesture { | |
| 35 | isTapped = true | |
| 36 | SpeechSynthesizer.shared.speak(shape.name) | |
| 37 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 38 | isTapped = false | |
| 39 | } | |
| 40 | } | |
| 41 | .accessibilityLabel(shape.name) | |
| 42 | .accessibilityAddTraits(.isButton) | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | // MARK: - Shape model | |
| 47 | ||
| 48 | enum LearnShape: Int, CaseIterable, Identifiable { | |
| 49 | case circle, square, triangle, star, heart, oval, diamond, rectangle | |
| 50 | ||
| 51 | var id: Int { rawValue } | |
| 52 | ||
| 53 | var name: String { | |
| 54 | switch self { | |
| 55 | case .circle: "Circle" | |
| 56 | case .square: "Square" | |
| 57 | case .triangle: "Triangle" | |
| 58 | case .star: "Star" | |
| 59 | case .heart: "Heart" | |
| 60 | case .oval: "Oval" | |
| 61 | case .diamond: "Diamond" | |
| 62 | case .rectangle: "Rectangle" | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | @ViewBuilder | |
| 67 | var shapeView: some View { | |
| 68 | switch self { | |
| 69 | case .circle: | |
| 70 | Circle().fill(.white) | |
| 71 | case .square: | |
| 72 | SwiftUI.Rectangle().fill(.white) | |
| 73 | case .triangle: | |
| 74 | TriangleShape().fill(.white) | |
| 75 | case .star: | |
| 76 | Image(systemName: "star.fill") | |
| 77 | .font(.system(size: 72)) | |
| 78 | .foregroundStyle(.white) | |
| 79 | case .heart: | |
| 80 | Image(systemName: "heart.fill") | |
| 81 | .font(.system(size: 72)) | |
| 82 | .foregroundStyle(.white) | |
| 83 | case .oval: | |
| 84 | Ellipse().fill(.white) | |
| 85 | .frame(width: 90, height: 60) | |
| 86 | case .diamond: | |
| 87 | SwiftUI.Rectangle().fill(.white) | |
| 88 | .frame(width: 60, height: 60) | |
| 89 | .rotationEffect(.degrees(45)) | |
| 90 | case .rectangle: | |
| 91 | SwiftUI.Rectangle().fill(.white) | |
| 92 | .frame(width: 90, height: 55) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | var color: Color { | |
| 97 | switch self { | |
| 98 | case .circle: Color(red: 0.9, green: 0.3, blue: 0.3) // red | |
| 99 | case .square: Color(red: 0.3, green: 0.5, blue: 0.9) // blue | |
| 100 | case .triangle: Color(red: 0.35, green: 0.75, blue: 0.45) // green | |
| 101 | case .star: Color(red: 1.0, green: 0.75, blue: 0.15) // gold | |
| 102 | case .heart: Color(red: 0.9, green: 0.35, blue: 0.5) // pink | |
| 103 | case .oval: Color(red: 0.55, green: 0.4, blue: 0.85) // purple | |
| 104 | case .diamond: Color(red: 0.2, green: 0.75, blue: 0.8) // teal | |
| 105 | case .rectangle: Color(red: 1.0, green: 0.55, blue: 0.2) // orange | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | /// A simple triangle drawn with a Path. | |
| 111 | struct TriangleShape: Shape { | |
| 112 | func path(in rect: CGRect) -> Path { | |
| 113 | Path { path in | |
| 114 | path.move(to: CGPoint(x: rect.midX, y: rect.minY)) | |
| 115 | path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) | |
| 116 | path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) | |
| 117 | path.closeSubpath() | |
| 118 | } | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | #Preview { | |
| 123 | HStack(spacing: 24) { | |
| 124 | ShapeCard(shape: .triangle) | |
| 125 | .frame(width: 200, height: 260) | |
| 126 | ShapeCard(shape: .star) | |
| 127 | .frame(width: 200, height: 260) | |
| 128 | } | |
| 129 | .padding() | |
| 130 | } | |
ShapesView.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | // | |
| 2 | // ShapesView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays 8 shapes as large, tappable cards in a scrollable grid. | |
| 9 | /// Adapts between 1 and 2 columns depending on available width. | |
| 10 | struct ShapesView: View { | |
| 11 | private let columns = [ | |
| 12 | GridItem(.adaptive(minimum: 300, maximum: 500), spacing: 24) | |
| 13 | ] | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ScrollView { | |
| 17 | LazyVGrid(columns: columns, spacing: 24) { | |
| 18 | ForEach(LearnShape.allCases) { shape in | |
| 19 | ShapeCard(shape: shape) | |
| 20 | .frame(height: 200) | |
| 21 | } | |
| 22 | } | |
| 23 | .padding(32) | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | #Preview { | |
| 29 | ShapesView() | |
| 30 | } | |
TodayView.swift added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | // | |
| 2 | // TodayView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single-screen summary of today: day of the week, date, month, and season. | |
| 9 | /// The child taps the large card to hear "Today is [day], [month] [date]. It is [season]." | |
| 10 | struct TodayView: View { | |
| 11 | @State private var isTapped = false | |
| 12 | ||
| 13 | private var todayText: String { | |
| 14 | let day = DayOfWeek.current.name | |
| 15 | let month = MonthOfYear.current.name | |
| 16 | let date = Calendar.current.component(.day, from: Date()) | |
| 17 | let season = Season.current.name | |
| 18 | return "Today is \(day), \(month) \(date). It is \(season)." | |
| 19 | } | |
| 20 | ||
| 21 | var body: some View { | |
| 22 | ScrollView { | |
| 23 | VStack(spacing: 32) { | |
| 24 | // Main today card | |
| 25 | ZStack { | |
| 26 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 27 | .fill(Color.orange.gradient) | |
| 28 | .shadow(color: Color.orange.opacity(0.4), radius: 8, y: 4) | |
| 29 | ||
| 30 | VStack(spacing: 20) { | |
| 31 | Image(systemName: "sun.horizon.fill") | |
| 32 | .font(.system(size: 80)) | |
| 33 | .foregroundStyle(.white) | |
| 34 | .symbolRenderingMode(.hierarchical) | |
| 35 | ||
| 36 | // Day of the week | |
| 37 | Text(DayOfWeek.current.name) | |
| 38 | .font(.system(size: 48, weight: .bold, design: .rounded)) | |
| 39 | .foregroundStyle(.white) | |
| 40 | ||
| 41 | // Date number | |
| 42 | let dayNumber = Calendar.current.component(.day, from: Date()) | |
| 43 | Text("\(dayNumber)") | |
| 44 | .font(.system(size: 72, weight: .heavy, design: .rounded)) | |
| 45 | .foregroundStyle(.white.opacity(0.9)) | |
| 46 | ||
| 47 | // Month and season | |
| 48 | HStack(spacing: 24) { | |
| 49 | Label(MonthOfYear.current.name, systemImage: MonthOfYear.current.symbol) | |
| 50 | Label(Season.current.name, systemImage: Season.current.symbol) | |
| 51 | } | |
| 52 | .font(.system(size: 24, weight: .semibold, design: .rounded)) | |
| 53 | .foregroundStyle(.white.opacity(0.9)) | |
| 54 | } | |
| 55 | .padding(.vertical, 32) | |
| 56 | } | |
| 57 | .frame(height: 420) | |
| 58 | .scaleEffect(isTapped ? 1.08 : 1.0) | |
| 59 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 60 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 61 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 62 | .onTapGesture { | |
| 63 | isTapped = true | |
| 64 | SpeechSynthesizer.shared.speak(todayText) | |
| 65 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 66 | isTapped = false | |
| 67 | } | |
| 68 | } | |
| 69 | .accessibilityLabel(todayText) | |
| 70 | .accessibilityAddTraits(.isButton) | |
| 71 | ||
| 72 | // Tap hint | |
| 73 | Label("Tap to hear today", systemImage: "speaker.wave.2.fill") | |
| 74 | .font(.system(size: 22, weight: .medium, design: .rounded)) | |
| 75 | .foregroundStyle(.secondary) | |
| 76 | } | |
| 77 | .padding(32) | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | #Preview { | |
| 83 | TodayView() | |
| 84 | } | |
WeatherCard.swift added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | // | |
| 2 | // WeatherCard.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// A single large, colorful card representing one weather type. | |
| 9 | /// Tapping the card plays a bounce animation and speaks the weather name aloud. | |
| 10 | struct WeatherCard: View { | |
| 11 | let weather: LearnWeather | |
| 12 | @State private var isTapped = false | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ZStack { | |
| 16 | RoundedRectangle(cornerRadius: 32, style: .continuous) | |
| 17 | .fill(weather.color.gradient) | |
| 18 | .shadow(color: weather.color.opacity(0.4), radius: 8, y: 4) | |
| 19 | ||
| 20 | VStack(spacing: 16) { | |
| 21 | Image(systemName: weather.symbol) | |
| 22 | .font(.system(size: 64)) | |
| 23 | .foregroundStyle(.white) | |
| 24 | .symbolRenderingMode(.hierarchical) | |
| 25 | ||
| 26 | Text(weather.name) | |
| 27 | .font(.system(size: 32, weight: .bold, design: .rounded)) | |
| 28 | .foregroundStyle(.white) | |
| 29 | } | |
| 30 | } | |
| 31 | .scaleEffect(isTapped ? 1.12 : 1.0) | |
| 32 | .contentShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) | |
| 33 | .rotation3DEffect(.degrees(isTapped ? 6 : 0), axis: (x: 1, y: 0, z: 0)) | |
| 34 | .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isTapped) | |
| 35 | .onTapGesture { | |
| 36 | isTapped = true | |
| 37 | SpeechSynthesizer.shared.speak(weather.name) | |
| 38 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| 39 | isTapped = false | |
| 40 | } | |
| 41 | } | |
| 42 | .accessibilityLabel(weather.name) | |
| 43 | .accessibilityAddTraits(.isButton) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Weather model | |
| 48 | ||
| 49 | enum LearnWeather: Int, CaseIterable, Identifiable { | |
| 50 | case sunny, cloudy, rainy, snowy, windy, stormy, foggy, rainbow | |
| 51 | ||
| 52 | var id: Int { rawValue } | |
| 53 | ||
| 54 | var name: String { | |
| 55 | switch self { | |
| 56 | case .sunny: "Sunny" | |
| 57 | case .cloudy: "Cloudy" | |
| 58 | case .rainy: "Rainy" | |
| 59 | case .snowy: "Snowy" | |
| 60 | case .windy: "Windy" | |
| 61 | case .stormy: "Stormy" | |
| 62 | case .foggy: "Foggy" | |
| 63 | case .rainbow: "Rainbow" | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | var symbol: String { | |
| 68 | switch self { | |
| 69 | case .sunny: "sun.max.fill" | |
| 70 | case .cloudy: "cloud.fill" | |
| 71 | case .rainy: "cloud.rain.fill" | |
| 72 | case .snowy: "cloud.snow.fill" | |
| 73 | case .windy: "wind" | |
| 74 | case .stormy: "cloud.bolt.rain.fill" | |
| 75 | case .foggy: "cloud.fog.fill" | |
| 76 | case .rainbow: "rainbow" | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | var color: Color { | |
| 81 | switch self { | |
| 82 | case .sunny: Color(red: 1.0, green: 0.7, blue: 0.2) // orange | |
| 83 | case .cloudy: Color(red: 0.6, green: 0.6, blue: 0.65) // gray | |
| 84 | case .rainy: Color(red: 0.3, green: 0.6, blue: 0.9) // blue | |
| 85 | case .snowy: Color(red: 0.55, green: 0.8, blue: 0.95) // light blue | |
| 86 | case .windy: Color(red: 0.25, green: 0.7, blue: 0.7) // teal | |
| 87 | case .stormy: Color(red: 0.2, green: 0.3, blue: 0.6) // dark blue | |
| 88 | case .foggy: Color(red: 0.6, green: 0.5, blue: 0.7) // muted purple | |
| 89 | case .rainbow: Color(red: 0.9, green: 0.45, blue: 0.6) // pink | |
| 90 | } | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | #Preview { | |
| 95 | HStack(spacing: 24) { | |
| 96 | WeatherCard(weather: .sunny) | |
| 97 | .frame(width: 200, height: 260) | |
| 98 | WeatherCard(weather: .stormy) | |
| 99 | .frame(width: 200, height: 260) | |
| 100 | } | |
| 101 | .padding() | |
| 102 | } | |
WeatherView.swift added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | // | |
| 2 | // WeatherView.swift | |
| 3 | // DayByDay | |
| 4 | // | |
| 5 | ||
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// Displays all 8 weather types as large, tappable cards in a scrollable grid. | |
| 9 | struct WeatherView: View { | |
| 10 | private let columns = [ | |
| 11 | GridItem(.adaptive(minimum: 280), spacing: 24) | |
| 12 | ] | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ScrollView { | |
| 16 | LazyVGrid(columns: columns, spacing: 24) { | |
| 17 | ForEach(LearnWeather.allCases) { weather in | |
| 18 | WeatherCard(weather: weather) | |
| 19 | .frame(height: 200) | |
| 20 | } | |
| 21 | } | |
| 22 | .padding(32) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | #Preview { | |
| 28 | WeatherView() | |
| 29 | } | |
claude.md +23 −8
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | # DayByDay – Project Context |
| 2 | 2 | |
| 3 | 3 | ## What this is |
| 4 | An iPad app for pre-K children (ages 4–5) that teaches days of the week, months | |
| 5 | of the year, and seasons through simple, tap-based interactions. | |
| 4 | An iOS app for pre-K children (ages 4–5) that teaches days of the week, months | |
| 5 | of the year, seasons, numbers, colors, shapes, animals, the alphabet, weather, | |
| 6 | body parts, and fruits & vegetables through simple, tap-based interactions. | |
| 6 | 7 | |
| 7 | 8 | ## Target user |
| 8 | 9 | A single child, approximately 4–5 years old, preparing to start school. Cannot |
| @@ -13,15 +14,28 @@ independently. | ||
| 13 | 14 | - Large tap targets — nothing small or fiddly |
| 14 | 15 | - Bright, clean colors — simple and friendly, not cluttered |
| 15 | 16 | - No text-dependent interactions — the child cannot read |
| 16 | - Audio is the primary feedback mechanism (day/month/season name read aloud on | |
| 17 | tap) | |
| 17 | - Audio is the primary feedback mechanism (names read aloud on tap) | |
| 18 | 18 | - Simple animations on interaction — enough to delight, not enough to distract |
| 19 | 19 | - No timers, no failure states, no scores — purely exploratory |
| 20 | 20 | |
| 21 | ## Content | |
| 21 | ## Content (12 categories, accessible from a NavigationStack home grid) | |
| 22 | - Today: summary card with current day, date, month, season | |
| 22 | 23 | - Days of the week: Sunday through Saturday |
| 23 | 24 | - Months of the year: January through December |
| 24 | 25 | - Seasons: Spring, Summer, Fall, Winter |
| 26 | - Numbers: 1 through 10 (numeral + word + dot pattern) | |
| 27 | - Colors: 10 basic colors (Red, Orange, Yellow, Green, Blue, Purple, Pink, | |
| 28 | Brown, Black, White) | |
| 29 | - Shapes: 8 shapes (Circle, Square, Triangle, Star, Heart, Oval, Diamond, | |
| 30 | Rectangle) | |
| 31 | - Animals: 12 animals with SF Symbol icons | |
| 32 | - Alphabet: A through Z | |
| 33 | - Weather: 8 weather types (Sunny, Cloudy, Rainy, Snowy, Windy, Stormy, | |
| 34 | Foggy, Rainbow) | |
| 35 | - Body Parts: 12 body parts (Head, Eyes, Ears, Nose, Mouth, Hands, Fingers, | |
| 36 | Arms, Legs, Feet, Heart, Belly) | |
| 37 | - Fruits & Vegetables: 12 foods (Apple, Banana, Carrot, Strawberry, Grapes, | |
| 38 | Lemon, Broccoli, Watermelon, Cherry, Orange, Corn, Pear) | |
| 25 | 39 | |
| 26 | 40 | ## Technical constraints |
| 27 | 41 | - SwiftUI, iOS only |
| @@ -30,6 +44,7 @@ independently. | ||
| 30 | 44 | - Targets latest iOS |
| 31 | 45 | |
| 32 | 46 | ## What good looks like |
| 33 | Each concept (day, month, season) should feel like its own tactile card or | |
| 34 | object the child can tap and explore. Transitions should be smooth. Audio should | |
| 35 | play immediately on tap with no perceptible delay. | |
| 47 | Each concept should feel like its own tactile card or object the child can tap | |
| 48 | and explore. Transitions should be smooth. Audio should play immediately on tap | |
| 49 | with no perceptible delay. The home screen is a colorful 2-column grid of square category | |
| 50 | tiles that navigate into each section. | |