From c53a320698c17d98477c0384f1f644da9735f645 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 14 Mar 2026 09:56:39 -0500 Subject: implement more learning categories --- .../LaunchBackground.colorset/Contents.json | 38 ++++ DayByDay/ContentView.swift | 202 ++++++++++++++++----- DayByDay/DayByDayApp.swift | 17 ++ DayByDay/Info.plist | 15 ++ DayByDay/SpeechSynthesizer.swift | 37 ++-- 5 files changed, 252 insertions(+), 57 deletions(-) create mode 100644 DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 DayByDay/Info.plist (limited to 'DayByDay') diff --git a/DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json b/DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..0425637 --- /dev/null +++ b/DayByDay/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DayByDay/ContentView.swift b/DayByDay/ContentView.swift index c3ede7a..9924c5d 100644 --- a/DayByDay/ContentView.swift +++ b/DayByDay/ContentView.swift @@ -7,62 +7,180 @@ import SwiftUI +// MARK: - Category model + +enum Category: Int, CaseIterable, Identifiable { + case today, days, months, seasons, numbers, colors, shapes, animals, alphabet, + weather, bodyParts, food + + var id: Int { rawValue } + + var label: String { + switch self { + case .today: "Today" + case .days: "Days" + case .months: "Months" + case .seasons: "Seasons" + case .numbers: "Numbers" + case .colors: "Colors" + case .shapes: "Shapes" + case .animals: "Animals" + case .alphabet: "Alphabet" + case .weather: "Weather" + case .bodyParts: "Body" + case .food: "Food" + } + } + + var symbol: String { + switch self { + case .today: "sun.horizon.fill" + case .days: "calendar" + case .months: "calendar.badge.clock" + case .seasons: "leaf.fill" + case .numbers: "123.rectangle.fill" + case .colors: "paintpalette.fill" + case .shapes: "pentagon.fill" + case .animals: "pawprint.fill" + case .alphabet: "abc" + case .weather: "cloud.sun.rain.fill" + case .bodyParts: "figure.stand" + case .food: "carrot.fill" + } + } + + var color: Color { + switch self { + case .today: .orange + case .days: .blue + case .months: .purple + case .seasons: .green + case .numbers: .red + case .colors: .pink + case .shapes: .teal + case .animals: Color(red: 0.7, green: 0.45, blue: 0.2) + case .alphabet: .indigo + case .weather: Color(red: 0.4, green: 0.75, blue: 0.95) + case .bodyParts: Color(red: 0.9, green: 0.5, blue: 0.6) + case .food: Color(red: 0.95, green: 0.6, blue: 0.2) + } + } + + /// Whether this tile uses a special gradient background instead of the solid color. + var usesGradientBackground: Bool { + self == .colors + } +} + +// MARK: - Home grid tile + +struct CategoryTile: View { + let category: Category + + var body: some View { + ZStack { + if category.usesGradientBackground { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill( + LinearGradient( + colors: [.red, .orange, .yellow, .green, .blue, .purple], + startPoint: .leading, + endPoint: .trailing + ) + ) + .shadow(color: category.color.opacity(0.4), radius: 8, y: 4) + } else { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(category.color.gradient) + .shadow(color: category.color.opacity(0.4), radius: 8, y: 4) + } + + VStack(spacing: 12) { + Image(systemName: category.symbol) + .font(.system(size: 48)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + + Text(category.label) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + } + .accessibilityLabel(category.label) + } +} + +// MARK: - Content view + struct ContentView: View { - @State private var selectedTab = 0 @AppStorage("hasSeenVoiceTip") private var hasSeenVoiceTip = false - private let tabs: [(label: String, symbol: String)] = [ - ("Days", "calendar"), - ("Months", "calendar.badge.clock"), - ("Seasons", "leaf.fill"), + private let columns = [ + GridItem(.flexible(), spacing: 24), + GridItem(.flexible(), spacing: 24) ] var body: some View { - VStack(spacing: 0) { - Group { - switch selectedTab { - case 0: DaysOfWeekView() - case 1: MonthsOfYearView() - default: SeasonsView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - - // Bottom tab bar with large tap targets - HStack(spacing: 0) { - ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in - Button { - selectedTab = index - } label: { - VStack(spacing: 6) { - Image(systemName: tab.symbol) - .font(.system(size: 32)) - Text(tab.label) - .font(.system(size: 18, weight: .semibold, design: .rounded)) + NavigationStack { + ScrollView { + LazyVGrid(columns: columns, spacing: 24) { + ForEach(Category.allCases) { category in + NavigationLink(value: category) { + CategoryTile(category: category) + .aspectRatio(1, contentMode: .fit) } - .foregroundStyle(selectedTab == index ? Color.accentColor : Color.secondary) + .buttonStyle(.plain) + } + } + .padding(24) + + Button { + hasSeenVoiceTip = false + } label: { + Label("Voice Quality Tips", systemImage: "speaker.wave.2.fill") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(tab.label) + .background(Color(.systemGray6)) + .cornerRadius(16) } + .buttonStyle(.plain) + .padding(.horizontal, 24) + .padding(.bottom, 24) } - .padding(.horizontal, 24) - .padding(.bottom, 8) - .background(.bar) - } - .overlay { - if !hasSeenVoiceTip { - VoiceTipOverlay { - withAnimation { hasSeenVoiceTip = true } + .navigationTitle("DayByDay") + .navigationDestination(for: Category.self) { category in + destinationView(for: category) + .navigationTitle(category.label) + } + .overlay { + if !hasSeenVoiceTip { + VoiceTipOverlay { + withAnimation { hasSeenVoiceTip = true } + } } } } } + + @ViewBuilder + private func destinationView(for category: Category) -> some View { + switch category { + case .today: TodayView() + case .days: DaysOfWeekView() + case .months: MonthsOfYearView() + case .seasons: SeasonsView() + case .numbers: NumbersView() + case .colors: ColorsView() + case .shapes: ShapesView() + case .animals: AnimalsView() + case .alphabet: AlphabetView() + case .weather: WeatherView() + case .bodyParts: BodyPartsView() + case .food: FoodView() + } + } } /// A one-time informational overlay for parents explaining how to download @@ -83,7 +201,7 @@ struct VoiceTipOverlay: View { Text("Better Voices Available") .font(.system(size: 28, weight: .bold, design: .rounded)) - Text("For the best experience, download an enhanced voice on your iPad.\n\nSettings → Accessibility → Spoken Content → Voices → English → tap a voice marked Enhanced or Premium to download it.") + Text("For the best experience, download an enhanced voice on your device.\n\nSettings → Accessibility → Read & Speak → Voices → English → tap a voice marked Enhanced or Premium to download it.") .font(.system(size: 18, design: .rounded)) .multilineTextAlignment(.center) .foregroundStyle(.secondary) diff --git a/DayByDay/DayByDayApp.swift b/DayByDay/DayByDayApp.swift index 2cce4db..860d747 100644 --- a/DayByDay/DayByDayApp.swift +++ b/DayByDay/DayByDayApp.swift @@ -5,10 +5,27 @@ // Created by cmc on 2026-03-09. // +import AVFoundation import SwiftUI @main struct DayByDayApp: App { + init() { + Task.detached(priority: .background) { + // Activate the audio session early so the first real tap doesn't block. + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + + // Force singleton init (voice selection) and prime the TTS engine + // with a silent utterance so subsequent speaks are instant. + await MainActor.run { + let warmup = AVSpeechUtterance(string: "") + warmup.volume = 0 + SpeechSynthesizer.shared.speakUtterance(warmup) + } + } + } + var body: some Scene { WindowGroup { ContentView() diff --git a/DayByDay/Info.plist b/DayByDay/Info.plist new file mode 100644 index 0000000..6aa377b --- /dev/null +++ b/DayByDay/Info.plist @@ -0,0 +1,15 @@ + + + + + UILaunchScreen + + UIColorName + LaunchBackground + UIImageName + AppIcon + UIImageRespectsSafeAreaInsets + + + + diff --git a/DayByDay/SpeechSynthesizer.swift b/DayByDay/SpeechSynthesizer.swift index cac5caf..ab6421b 100644 --- a/DayByDay/SpeechSynthesizer.swift +++ b/DayByDay/SpeechSynthesizer.swift @@ -18,7 +18,16 @@ final class SpeechSynthesizer { self.voice = Self.bestAvailableVoice() } + /// Speaks a pre-configured utterance directly. Used for warmup at launch. + func speakUtterance(_ utterance: AVSpeechUtterance) { + synthesizer.speak(utterance) + } + func speak(_ text: String) { + // Ensure speech plays through the speaker even when the silent switch is on. + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } @@ -32,21 +41,19 @@ final class SpeechSynthesizer { } /// Picks the best en-US voice on the device. - /// Preference order: premium > enhanced > default quality. - /// Skips novelty voices so the child always hears a natural-sounding voice. + /// Filters for en-US, excludes novelty voices, then sorts by quality + /// descending so premium (3) > enhanced (2) > default (1). private static func bestAvailableVoice() -> AVSpeechSynthesisVoice? { - let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in - voice.language.hasPrefix("en-US") - && !voice.voiceTraits.contains(.isNoveltyVoice) - } - - if let premium = candidates.first(where: { $0.quality == .premium }) { - return premium - } - if let enhanced = candidates.first(where: { $0.quality == .enhanced }) { - return enhanced - } - // Fall back to the best default-quality voice. - return candidates.first ?? AVSpeechSynthesisVoice(language: "en-US") + let best = AVSpeechSynthesisVoice.speechVoices() + .filter { voice in + voice.language.hasPrefix("en-US") + && !voice.voiceTraits.contains(.isNoveltyVoice) + } + .sorted { $0.quality.rawValue > $1.quality.rawValue } + .first + + let selected = best ?? AVSpeechSynthesisVoice(language: "en-US") + print("[DayByDay] Selected voice: \(selected?.name ?? "nil"), quality: \(selected?.quality.rawValue ?? -1)") + return selected } } -- cgit v1.2.3