实现背单词首页功能:内置分类词库,支持分类浏览、单词详情和离线发音
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,23 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// LearnEnglish
|
|
||||||
//
|
|
||||||
// Created by fish on 2026/7/17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
@State private var store = WordStore()
|
||||||
|
@State private var speech = SpeechService()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
Group {
|
||||||
Image(systemName: "globe")
|
if let error = store.loadError {
|
||||||
.imageScale(.large)
|
ContentUnavailableView(
|
||||||
.foregroundStyle(.tint)
|
"词库加载失败",
|
||||||
Text("Hello, world!")
|
systemImage: "exclamationmark.triangle",
|
||||||
|
description: Text(error)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CategoryListView(categories: store.categories)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.environment(speech)
|
||||||
|
.onAppear { store.load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WordBank: Codable {
|
||||||
|
let version: Int
|
||||||
|
let categories: [WordCategory]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WordCategory: Codable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let icon: String
|
||||||
|
let words: [Word]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Word: Codable, Identifiable, Hashable {
|
||||||
|
var id: String { word }
|
||||||
|
let word: String
|
||||||
|
let phonetic: String
|
||||||
|
let meaning: String
|
||||||
|
let example: String
|
||||||
|
let exampleMeaning: String
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "colors",
|
||||||
|
"name": "颜色",
|
||||||
|
"icon": "paintpalette.fill",
|
||||||
|
"words": [
|
||||||
|
{ "word": "red", "phonetic": "/red/", "meaning": "红色", "example": "The apple is red.", "exampleMeaning": "这个苹果是红色的。" },
|
||||||
|
{ "word": "blue", "phonetic": "/bluː/", "meaning": "蓝色", "example": "The sky is blue.", "exampleMeaning": "天空是蓝色的。" },
|
||||||
|
{ "word": "green", "phonetic": "/ɡriːn/", "meaning": "绿色", "example": "The grass is green.", "exampleMeaning": "草地是绿色的。" },
|
||||||
|
{ "word": "yellow", "phonetic": "/ˈjeloʊ/", "meaning": "黄色", "example": "The banana is yellow.", "exampleMeaning": "香蕉是黄色的。" },
|
||||||
|
{ "word": "black", "phonetic": "/blæk/", "meaning": "黑色", "example": "The cat is black.", "exampleMeaning": "这只猫是黑色的。" },
|
||||||
|
{ "word": "white", "phonetic": "/waɪt/", "meaning": "白色", "example": "The snow is white.", "exampleMeaning": "雪是白色的。" },
|
||||||
|
{ "word": "orange", "phonetic": "/ˈɔːrɪndʒ/", "meaning": "橙色", "example": "The orange is orange.", "exampleMeaning": "橙子是橙色的。" },
|
||||||
|
{ "word": "purple", "phonetic": "/ˈpɜːrpl/", "meaning": "紫色", "example": "The grapes are purple.", "exampleMeaning": "葡萄是紫色的。" },
|
||||||
|
{ "word": "pink", "phonetic": "/pɪŋk/", "meaning": "粉色", "example": "The flower is pink.", "exampleMeaning": "这朵花是粉色的。" },
|
||||||
|
{ "word": "brown", "phonetic": "/braʊn/", "meaning": "棕色", "example": "The bear is brown.", "exampleMeaning": "这只熊是棕色的。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "animals",
|
||||||
|
"name": "动物",
|
||||||
|
"icon": "pawprint.fill",
|
||||||
|
"words": [
|
||||||
|
{ "word": "cat", "phonetic": "/kæt/", "meaning": "猫", "example": "The cat is cute.", "exampleMeaning": "这只猫很可爱。" },
|
||||||
|
{ "word": "dog", "phonetic": "/dɔːɡ/", "meaning": "狗", "example": "The dog is running.", "exampleMeaning": "狗在跑。" },
|
||||||
|
{ "word": "bird", "phonetic": "/bɜːrd/", "meaning": "鸟", "example": "The bird can fly.", "exampleMeaning": "鸟会飞。" },
|
||||||
|
{ "word": "fish", "phonetic": "/fɪʃ/", "meaning": "鱼", "example": "The fish swims fast.", "exampleMeaning": "鱼游得很快。" },
|
||||||
|
{ "word": "rabbit", "phonetic": "/ˈræbɪt/", "meaning": "兔子", "example": "The rabbit likes carrots.", "exampleMeaning": "兔子喜欢胡萝卜。" },
|
||||||
|
{ "word": "tiger", "phonetic": "/ˈtaɪɡər/", "meaning": "老虎", "example": "The tiger is strong.", "exampleMeaning": "老虎很强壮。" },
|
||||||
|
{ "word": "elephant", "phonetic": "/ˈelɪfənt/", "meaning": "大象", "example": "The elephant has a long nose.", "exampleMeaning": "大象有长长的鼻子。" },
|
||||||
|
{ "word": "monkey", "phonetic": "/ˈmʌŋki/", "meaning": "猴子", "example": "The monkey eats bananas.", "exampleMeaning": "猴子吃香蕉。" },
|
||||||
|
{ "word": "panda", "phonetic": "/ˈpændə/", "meaning": "熊猫", "example": "The panda is black and white.", "exampleMeaning": "熊猫是黑白相间的。" },
|
||||||
|
{ "word": "lion", "phonetic": "/ˈlaɪən/", "meaning": "狮子", "example": "The lion is the king of animals.", "exampleMeaning": "狮子是动物之王。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fruits",
|
||||||
|
"name": "水果",
|
||||||
|
"icon": "leaf.fill",
|
||||||
|
"words": [
|
||||||
|
{ "word": "apple", "phonetic": "/ˈæpl/", "meaning": "苹果", "example": "I eat an apple every day.", "exampleMeaning": "我每天吃一个苹果。" },
|
||||||
|
{ "word": "banana", "phonetic": "/bəˈnænə/", "meaning": "香蕉", "example": "Monkeys like bananas.", "exampleMeaning": "猴子喜欢香蕉。" },
|
||||||
|
{ "word": "orange", "phonetic": "/ˈɔːrɪndʒ/", "meaning": "橙子", "example": "The orange is sweet.", "exampleMeaning": "这个橙子很甜。" },
|
||||||
|
{ "word": "grape", "phonetic": "/ɡreɪp/", "meaning": "葡萄", "example": "Grapes can make wine.", "exampleMeaning": "葡萄可以酿酒。" },
|
||||||
|
{ "word": "watermelon", "phonetic": "/ˈwɔːtərmelən/", "meaning": "西瓜", "example": "Watermelon is a summer fruit.", "exampleMeaning": "西瓜是夏天的水果。" },
|
||||||
|
{ "word": "strawberry", "phonetic": "/ˈstrɔːberi/", "meaning": "草莓", "example": "The strawberry is red and sweet.", "exampleMeaning": "草莓又红又甜。" },
|
||||||
|
{ "word": "peach", "phonetic": "/piːtʃ/", "meaning": "桃子", "example": "The peach is juicy.", "exampleMeaning": "桃子多汁。" },
|
||||||
|
{ "word": "pear", "phonetic": "/per/", "meaning": "梨", "example": "The pear is crisp.", "exampleMeaning": "梨很脆。" },
|
||||||
|
{ "word": "mango", "phonetic": "/ˈmæŋɡoʊ/", "meaning": "芒果", "example": "Mangoes grow in hot places.", "exampleMeaning": "芒果生长在炎热的地方。" },
|
||||||
|
{ "word": "cherry", "phonetic": "/ˈtʃeri/", "meaning": "樱桃", "example": "Cherries are small and red.", "exampleMeaning": "樱桃又小又红。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "numbers",
|
||||||
|
"name": "数字",
|
||||||
|
"icon": "number",
|
||||||
|
"words": [
|
||||||
|
{ "word": "one", "phonetic": "/wʌn/", "meaning": "一", "example": "I have one book.", "exampleMeaning": "我有一本书。" },
|
||||||
|
{ "word": "two", "phonetic": "/tuː/", "meaning": "二", "example": "Two birds are singing.", "exampleMeaning": "两只鸟在唱歌。" },
|
||||||
|
{ "word": "three", "phonetic": "/θriː/", "meaning": "三", "example": "Three cats are sleeping.", "exampleMeaning": "三只猫在睡觉。" },
|
||||||
|
{ "word": "four", "phonetic": "/fɔːr/", "meaning": "四", "example": "The table has four legs.", "exampleMeaning": "桌子有四条腿。" },
|
||||||
|
{ "word": "five", "phonetic": "/faɪv/", "meaning": "五", "example": "I have five fingers.", "exampleMeaning": "我有五根手指。" },
|
||||||
|
{ "word": "six", "phonetic": "/sɪks/", "meaning": "六", "example": "Six ducks are swimming.", "exampleMeaning": "六只鸭子在游泳。" },
|
||||||
|
{ "word": "seven", "phonetic": "/ˈsevn/", "meaning": "七", "example": "A week has seven days.", "exampleMeaning": "一周有七天。" },
|
||||||
|
{ "word": "eight", "phonetic": "/eɪt/", "meaning": "八", "example": "The spider has eight legs.", "exampleMeaning": "蜘蛛有八条腿。" },
|
||||||
|
{ "word": "nine", "phonetic": "/naɪn/", "meaning": "九", "example": "Nine students are in the room.", "exampleMeaning": "房间里有九个学生。" },
|
||||||
|
{ "word": "ten", "phonetic": "/ten/", "meaning": "十", "example": "I can count to ten.", "exampleMeaning": "我能数到十。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "family",
|
||||||
|
"name": "家庭",
|
||||||
|
"icon": "house.fill",
|
||||||
|
"words": [
|
||||||
|
{ "word": "father", "phonetic": "/ˈfɑːðər/", "meaning": "爸爸", "example": "My father is tall.", "exampleMeaning": "我爸爸很高。" },
|
||||||
|
{ "word": "mother", "phonetic": "/ˈmʌðər/", "meaning": "妈妈", "example": "My mother cooks well.", "exampleMeaning": "我妈妈做饭很好吃。" },
|
||||||
|
{ "word": "brother", "phonetic": "/ˈbrʌðər/", "meaning": "兄弟", "example": "My brother likes soccer.", "exampleMeaning": "我哥哥喜欢足球。" },
|
||||||
|
{ "word": "sister", "phonetic": "/ˈsɪstər/", "meaning": "姐妹", "example": "My sister can draw.", "exampleMeaning": "我妹妹会画画。" },
|
||||||
|
{ "word": "grandfather", "phonetic": "/ˈɡrænfɑːðər/", "meaning": "爷爷/外公", "example": "My grandfather tells stories.", "exampleMeaning": "我爷爷会讲故事。" },
|
||||||
|
{ "word": "grandmother", "phonetic": "/ˈɡrænmʌðər/", "meaning": "奶奶/外婆", "example": "My grandmother makes dumplings.", "exampleMeaning": "我奶奶会包饺子。" },
|
||||||
|
{ "word": "uncle", "phonetic": "/ˈʌŋkl/", "meaning": "叔叔/舅舅", "example": "My uncle lives in Beijing.", "exampleMeaning": "我叔叔住在北京。" },
|
||||||
|
{ "word": "aunt", "phonetic": "/ænt/", "meaning": "阿姨/姑姑", "example": "My aunt gives me gifts.", "exampleMeaning": "我阿姨送我礼物。" },
|
||||||
|
{ "word": "cousin", "phonetic": "/ˈkʌzn/", "meaning": "堂/表兄弟姐妹", "example": "My cousin and I play together.", "exampleMeaning": "我和我表哥一起玩。" },
|
||||||
|
{ "word": "baby", "phonetic": "/ˈbeɪbi/", "meaning": "婴儿", "example": "The baby is sleeping.", "exampleMeaning": "宝宝在睡觉。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class SpeechService: NSObject {
|
||||||
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
|
private(set) var speakingText: String?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
synthesizer.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func speak(_ text: String) {
|
||||||
|
if synthesizer.isSpeaking {
|
||||||
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
|
}
|
||||||
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
|
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
|
||||||
|
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
|
||||||
|
speakingText = text
|
||||||
|
synthesizer.speak(utterance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SpeechService: AVSpeechSynthesizerDelegate {
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
|
speakingText = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
|
speakingText = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum WordStoreError: Error, LocalizedError {
|
||||||
|
case fileNotFound
|
||||||
|
case decodingFailed(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .fileNotFound: return "词库文件缺失"
|
||||||
|
case .decodingFailed(let error): return "词库解析失败:\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class WordStore {
|
||||||
|
private(set) var bank: WordBank?
|
||||||
|
private(set) var loadError: String?
|
||||||
|
|
||||||
|
var categories: [WordCategory] { bank?.categories ?? [] }
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
guard bank == nil, loadError == nil else { return }
|
||||||
|
guard let url = Bundle.main.url(forResource: "words", withExtension: "json") else {
|
||||||
|
loadError = WordStoreError.fileNotFound.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
bank = try JSONDecoder().decode(WordBank.self, from: data)
|
||||||
|
} catch {
|
||||||
|
loadError = WordStoreError.decodingFailed(error).localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryListView: View {
|
||||||
|
let categories: [WordCategory]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(categories) { category in
|
||||||
|
NavigationLink(value: category) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: category.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(.tint, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(category.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text("\(category.words.count) 个单词")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("单词分类")
|
||||||
|
.navigationDestination(for: WordCategory.self) { category in
|
||||||
|
WordListView(category: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CategoryListView(categories: [
|
||||||
|
WordCategory(id: "colors", name: "颜色", icon: "paintpalette.fill", words: [
|
||||||
|
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
|
||||||
|
])
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WordDetailView: View {
|
||||||
|
let word: Word
|
||||||
|
@Environment(SpeechService.self) private var speech
|
||||||
|
|
||||||
|
private var isSpeaking: Bool {
|
||||||
|
speech.speakingText == word.word
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(word.word)
|
||||||
|
.font(.system(size: 48, weight: .bold))
|
||||||
|
Text(word.phonetic)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
speech.speak(word.word)
|
||||||
|
} label: {
|
||||||
|
Label(isSpeaking ? "朗读中" : "发音", systemImage: isSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isSpeaking)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("释义")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(word.meaning)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("例句")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(word.example)
|
||||||
|
.font(.body)
|
||||||
|
Text(word.exampleMeaning)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
speech.speak(word.example)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "speaker.wave.2")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(word.word)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
WordDetailView(word: Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。"))
|
||||||
|
.environment(SpeechService())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WordListView: View {
|
||||||
|
let category: WordCategory
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(category.words) { word in
|
||||||
|
NavigationLink(value: word) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(word.word)
|
||||||
|
.font(.headline)
|
||||||
|
Text(word.meaning)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(category.name)
|
||||||
|
.navigationDestination(for: Word.self) { word in
|
||||||
|
WordDetailView(word: word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
WordListView(category: WordCategory(id: "colors", name: "颜色", icon: "paintpalette.fill", words: [
|
||||||
|
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user