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