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