Compare commits
46 Commits
9ce3991de2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c86e482e68 | |||
| ccf0bc876b | |||
| d14ae41706 | |||
| 2d8caf3550 | |||
| 7728a22048 | |||
| ad0ff1ca7a | |||
| 106b60b58f | |||
| 63f5e7ee76 | |||
| cc032110d4 | |||
| f48fd5c3d8 | |||
| 35300e9aa1 | |||
| ad58d9ea5a | |||
| 673e35aec2 | |||
| 74dcaee335 | |||
| 41841896dd | |||
| 0df02d487e | |||
| e176a83b61 | |||
| d77f4da2ee | |||
| 662590027b | |||
| c24b09ddc4 | |||
| e2f3e5549a | |||
| a01207ae00 | |||
| 0cdc3c49a3 | |||
| ef0acbecc3 | |||
| 0a133593e5 | |||
| d86566d7ed | |||
| 3d2466edba | |||
| 0ed88622af | |||
| 76096c0786 | |||
| bbce352309 | |||
| 5ed8a1fe4c | |||
| 5b43882582 | |||
| 6756528c4f | |||
| a805758dc7 | |||
| 393a9963c2 | |||
| bbafb06a95 | |||
| 486fda8fc6 | |||
| e890350f9a | |||
| 794608d69c | |||
| 797b841f07 | |||
| 28b61d20f1 | |||
| 9b09e85498 | |||
| 8f2152dc18 | |||
| 711693f09a | |||
| 885f56218e | |||
| 36cb048aa1 |
@@ -21,19 +21,3 @@ xcodebuild -project LearnEnglish/LearnEnglish.xcodeproj -scheme LearnEnglish \
|
|||||||
- 部署目标为 iOS 26,模拟器必须是 iOS 26.x 运行时,否则安装时报系统版本过低。
|
- 部署目标为 iOS 26,模拟器必须是 iOS 26.x 运行时,否则安装时报系统版本过低。
|
||||||
- 无测试 target、无 lint 配置,验证方式 = 编译。
|
- 无测试 target、无 lint 配置,验证方式 = 编译。
|
||||||
- Xcode 工程使用同步文件夹(objectVersion 77),在 `LearnEnglish/LearnEnglish/` 下新建 .swift 文件会自动加入 target,无需改 pbxproj。
|
- Xcode 工程使用同步文件夹(objectVersion 77),在 `LearnEnglish/LearnEnglish/` 下新建 .swift 文件会自动加入 target,无需改 pbxproj。
|
||||||
|
|
||||||
## 架构
|
|
||||||
|
|
||||||
**数据流**:`Resources/words.json` → `WordStore.load()`(SceneDelegate 启动时加载一次)→ 通过初始化器逐层注入(`CategoryListViewController(store:)` → `WordListViewController(category:)` → 详情/测验)。词库模型 `WordBank`/`WordCategory`/`Word` 均为 Codable,见 `Models/Word.swift`。
|
|
||||||
|
|
||||||
**words.json 格式**:每个单词单行 JSON 对象,字段为 `word`/`phonetic`/`meaning`/`example`/`exampleMeaning`,追加新词时保持该格式。编辑时注意同名单词可能出现在多个分类(如 orange 既是颜色也是水果),替换要限定在目标分类区块内。
|
|
||||||
|
|
||||||
**页面流**:分类列表 → 单词列表(`WordCardCell`)→ 单词详情;单词列表底部"开始测验" → `QuizSetupViewController`(选模式、题量)→ `QuizViewController` → `QuizResultView`。
|
|
||||||
|
|
||||||
**测验**:四种模式(英选汉/汉选英/拼写/默写,`QuizMode`)由同一个 `QuizViewController` 承载;题目由 `QuizGenerator.makeQuestions` 生成(选择题自动取 3 个干扰项,拼写题按 40% 比例挖空)。拼写/默写分别用 `SpellingInputView`/`DictationInputView` 自定义输入组件。新增测验相关结构放 `Models/Quiz.swift`。
|
|
||||||
|
|
||||||
**发音**:`SpeechService.shared` 单例封装 `AVSpeechSynthesizer`(en-US),播放状态通过 `speakingDidChangeNotification` 广播,各页面监听后刷新喇叭按钮图标与可用状态。
|
|
||||||
|
|
||||||
**设计系统**:`Utilities/Theme.swift` 集中管理色板(`Theme.Color`,含分类调色板 `category(at:)`)、圆角(`Theme.Metrics`)、字体(`Theme.Font`);`UIView.setPressed`/`animateEntry` 为通用按压/入场动画。新 UI 元素应复用这些 token 而不是硬编码数值。`HairlineView` 是适配屏幕缩放的分隔线组件。
|
|
||||||
|
|
||||||
**导航**:`SceneDelegate` 直接构建 `UINavigationController` 根控制器并设置 `window.tintColor`;各页面用 `navigationItem.largeTitleDisplayMode = .never` 关闭大标题。
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LearnedRecord: Codable {
|
||||||
|
let word: Word
|
||||||
|
var learnedAt: Date
|
||||||
|
var count: Int = 1
|
||||||
|
var correctCount: Int = 0
|
||||||
|
|
||||||
|
var accuracy: Int {
|
||||||
|
count > 0 ? Int(Double(correctCount) / Double(count) * 100) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class LearnedStore {
|
||||||
|
static let shared = LearnedStore()
|
||||||
|
private let key = "learned_records"
|
||||||
|
|
||||||
|
private(set) var records: [LearnedRecord] {
|
||||||
|
didSet {
|
||||||
|
guard let data = try? JSONEncoder().encode(records) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let decoded = try? JSONDecoder().decode([LearnedRecord].self, from: data) {
|
||||||
|
records = decoded
|
||||||
|
} else {
|
||||||
|
records = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ word: Word, correct: Bool) {
|
||||||
|
if let index = records.firstIndex(where: { $0.word.id == word.id }) {
|
||||||
|
records[index].learnedAt = Date()
|
||||||
|
records[index].count += 1
|
||||||
|
if correct { records[index].correctCount += 1 }
|
||||||
|
} else {
|
||||||
|
var rec = LearnedRecord(word: word, learnedAt: Date())
|
||||||
|
if correct { rec.correctCount = 1 }
|
||||||
|
records.append(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(for word: Word) -> LearnedRecord? {
|
||||||
|
records.first { $0.word.id == word.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecorded(_ word: Word) -> Bool {
|
||||||
|
records.contains { $0.word.id == word.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sectioned: [(title: String, items: [LearnedRecord])] {
|
||||||
|
let sorted = records.sorted { $0.learnedAt > $1.learnedAt }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
|
||||||
|
var groups: [(String, [LearnedRecord])] = []
|
||||||
|
var seen: Set<String> = []
|
||||||
|
for record in sorted {
|
||||||
|
let dateStr = formatter.string(from: record.learnedAt)
|
||||||
|
let title = sectionTitle(dateStr)
|
||||||
|
if !seen.contains(title) {
|
||||||
|
groups.append((title, []))
|
||||||
|
seen.insert(title)
|
||||||
|
}
|
||||||
|
if let index = groups.firstIndex(where: { $0.0 == title }) {
|
||||||
|
groups[index].1.append(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionTitle(_ dateStr: String) -> String {
|
||||||
|
let today = DateFormatter()
|
||||||
|
today.locale = Locale(identifier: "zh_CN")
|
||||||
|
today.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayStr = today.string(from: Date())
|
||||||
|
if dateStr == todayStr { return "今天" }
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if let yesterday = calendar.date(byAdding: .day, value: -1, to: Date()) {
|
||||||
|
let yesterdayStr = today.string(from: yesterday)
|
||||||
|
if dateStr == yesterdayStr { return "昨天" }
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ enum NumberWords {
|
|||||||
|
|
||||||
static func randomWord() -> Word {
|
static func randomWord() -> Word {
|
||||||
let n = Int.random(in: 0...999_999_999)
|
let n = Int.random(in: 0...999_999_999)
|
||||||
return Word(word: english(n),
|
return Word(categoryId: "numbers", word: english(n),
|
||||||
phonetic: "/\(phonetic(n))/",
|
phonetic: "/\(phonetic(n))/",
|
||||||
meaning: chinese(n),
|
meaning: chinese(n),
|
||||||
example: "The number is \(english(n)).",
|
example: "The number is \(english(n)).",
|
||||||
@@ -163,7 +163,7 @@ enum NumberWords {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func makeWord(_ n: Int) -> Word {
|
private static func makeWord(_ n: Int) -> Word {
|
||||||
Word(word: english(n),
|
Word(categoryId: "numbers", word: english(n),
|
||||||
phonetic: "/\(phonetic(n))/",
|
phonetic: "/\(phonetic(n))/",
|
||||||
meaning: chinese(n),
|
meaning: chinese(n),
|
||||||
example: "I can count to \(english(n)).",
|
example: "I can count to \(english(n)).",
|
||||||
|
|||||||
@@ -51,6 +51,27 @@ enum QuizGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func recase(_ questions: [QuizQuestion], toUpper: Bool) -> [QuizQuestion] {
|
||||||
|
questions.map { q in
|
||||||
|
let word = q.word
|
||||||
|
let text = toUpper ? word.word.uppercased() : word.word.lowercased()
|
||||||
|
let recased = Word(categoryId: word.categoryId, word: text, phonetic: word.phonetic,
|
||||||
|
meaning: word.meaning, example: word.example, exampleMeaning: word.exampleMeaning)
|
||||||
|
let blanked = q.blankedWord.map { ch -> String in
|
||||||
|
ch == "_" ? "_" : ch.uppercased()
|
||||||
|
}.joined()
|
||||||
|
let blankedWord = toUpper ? blanked.uppercased() : blanked.lowercased()
|
||||||
|
return QuizQuestion(
|
||||||
|
word: recased,
|
||||||
|
prompt: q.prompt,
|
||||||
|
options: q.options,
|
||||||
|
answer: text,
|
||||||
|
blankedWord: blankedWord,
|
||||||
|
blankPositions: q.blankPositions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func promptText(_ word: Word, _ mode: QuizMode) -> String {
|
private static func promptText(_ word: Word, _ mode: QuizMode) -> String {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .enToZh: return word.word
|
case .enToZh: return word.word
|
||||||
|
|||||||
@@ -9,14 +9,25 @@ struct WordCategory: Codable, Identifiable, Hashable {
|
|||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let icon: String
|
let icon: String
|
||||||
let words: [Word]
|
var words: [Word]
|
||||||
|
var subcategories: [Subcategory]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Subcategory: Codable, Hashable {
|
||||||
|
let name: String
|
||||||
|
var words: [Word]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Word: Codable, Identifiable, Hashable {
|
struct Word: Codable, Identifiable, Hashable {
|
||||||
var id: String { word }
|
var id: String { "\(categoryId)/\(word)" }
|
||||||
|
var categoryId: String = ""
|
||||||
let word: String
|
let word: String
|
||||||
let phonetic: String
|
let phonetic: String
|
||||||
let meaning: String
|
let meaning: String
|
||||||
let example: String
|
let example: String
|
||||||
let exampleMeaning: String
|
let exampleMeaning: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case word, phonetic, meaning, example, exampleMeaning
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,11 +52,13 @@
|
|||||||
"id": "animals",
|
"id": "animals",
|
||||||
"name": "动物",
|
"name": "动物",
|
||||||
"icon": "pawprint.fill",
|
"icon": "pawprint.fill",
|
||||||
|
"words": [],
|
||||||
|
"subcategories": [
|
||||||
|
{
|
||||||
|
"name": "陆地动物",
|
||||||
"words": [
|
"words": [
|
||||||
{ "word": "cat", "phonetic": "/kæt/", "meaning": "猫", "example": "The cat is cute.", "exampleMeaning": "这只猫很可爱。" },
|
{ "word": "cat", "phonetic": "/kæt/", "meaning": "猫", "example": "The cat is cute.", "exampleMeaning": "这只猫很可爱。" },
|
||||||
{ "word": "dog", "phonetic": "/dɔːɡ/", "meaning": "狗", "example": "The dog is running.", "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": "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": "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": "elephant", "phonetic": "/ˈelɪfənt/", "meaning": "大象", "example": "The elephant has a long nose.", "exampleMeaning": "大象有长长的鼻子。" },
|
||||||
@@ -67,11 +69,9 @@
|
|||||||
{ "word": "sheep", "phonetic": "/ʃiːp/", "meaning": "绵羊", "example": "The sheep eats grass.", "exampleMeaning": "绵羊吃草。" },
|
{ "word": "sheep", "phonetic": "/ʃiːp/", "meaning": "绵羊", "example": "The sheep eats grass.", "exampleMeaning": "绵羊吃草。" },
|
||||||
{ "word": "pig", "phonetic": "/pɪɡ/", "meaning": "猪", "example": "The pig is fat.", "exampleMeaning": "这头猪很胖。" },
|
{ "word": "pig", "phonetic": "/pɪɡ/", "meaning": "猪", "example": "The pig is fat.", "exampleMeaning": "这头猪很胖。" },
|
||||||
{ "word": "cow", "phonetic": "/kaʊ/", "meaning": "奶牛", "example": "The cow gives milk.", "exampleMeaning": "奶牛产奶。" },
|
{ "word": "cow", "phonetic": "/kaʊ/", "meaning": "奶牛", "example": "The cow gives milk.", "exampleMeaning": "奶牛产奶。" },
|
||||||
{ "word": "duck", "phonetic": "/dʌk/", "meaning": "鸭子", "example": "The duck swims in the pond.", "exampleMeaning": "鸭子在池塘里游泳。" },
|
|
||||||
{ "word": "chicken", "phonetic": "/ˈtʃɪkɪn/", "meaning": "鸡", "example": "The chicken lays eggs.", "exampleMeaning": "母鸡下蛋。" },
|
{ "word": "chicken", "phonetic": "/ˈtʃɪkɪn/", "meaning": "鸡", "example": "The chicken lays eggs.", "exampleMeaning": "母鸡下蛋。" },
|
||||||
{ "word": "mouse", "phonetic": "/maʊs/", "meaning": "老鼠", "example": "The mouse likes cheese.", "exampleMeaning": "老鼠喜欢奶酪。" },
|
{ "word": "mouse", "phonetic": "/maʊs/", "meaning": "老鼠", "example": "The mouse likes cheese.", "exampleMeaning": "老鼠喜欢奶酪。" },
|
||||||
{ "word": "snake", "phonetic": "/sneɪk/", "meaning": "蛇", "example": "The snake has no legs.", "exampleMeaning": "蛇没有腿。" },
|
{ "word": "snake", "phonetic": "/sneɪk/", "meaning": "蛇", "example": "The snake has no legs.", "exampleMeaning": "蛇没有腿。" },
|
||||||
{ "word": "frog", "phonetic": "/frɔːɡ/", "meaning": "青蛙", "example": "The frog can jump high.", "exampleMeaning": "青蛙能跳得很高。" },
|
|
||||||
{ "word": "bear", "phonetic": "/ber/", "meaning": "熊", "example": "The bear sleeps in winter.", "exampleMeaning": "熊在冬天睡觉。" },
|
{ "word": "bear", "phonetic": "/ber/", "meaning": "熊", "example": "The bear sleeps in winter.", "exampleMeaning": "熊在冬天睡觉。" },
|
||||||
{ "word": "giraffe", "phonetic": "/dʒəˈræf/", "meaning": "长颈鹿", "example": "The giraffe is tall.", "exampleMeaning": "长颈鹿很高。" },
|
{ "word": "giraffe", "phonetic": "/dʒəˈræf/", "meaning": "长颈鹿", "example": "The giraffe is tall.", "exampleMeaning": "长颈鹿很高。" },
|
||||||
{ "word": "zebra", "phonetic": "/ˈziːbrə/", "meaning": "斑马", "example": "The zebra has stripes.", "exampleMeaning": "斑马身上有条纹。" },
|
{ "word": "zebra", "phonetic": "/ˈziːbrə/", "meaning": "斑马", "example": "The zebra has stripes.", "exampleMeaning": "斑马身上有条纹。" },
|
||||||
@@ -79,14 +79,7 @@
|
|||||||
{ "word": "fox", "phonetic": "/fɑːks/", "meaning": "狐狸", "example": "The fox is clever.", "exampleMeaning": "狐狸很聪明。" },
|
{ "word": "fox", "phonetic": "/fɑːks/", "meaning": "狐狸", "example": "The fox is clever.", "exampleMeaning": "狐狸很聪明。" },
|
||||||
{ "word": "deer", "phonetic": "/dɪr/", "meaning": "鹿", "example": "The deer runs fast.", "exampleMeaning": "鹿跑得很快。" },
|
{ "word": "deer", "phonetic": "/dɪr/", "meaning": "鹿", "example": "The deer runs fast.", "exampleMeaning": "鹿跑得很快。" },
|
||||||
{ "word": "squirrel", "phonetic": "/ˈskwɜːrəl/", "meaning": "松鼠", "example": "The squirrel likes nuts.", "exampleMeaning": "松鼠喜欢坚果。" },
|
{ "word": "squirrel", "phonetic": "/ˈskwɜːrəl/", "meaning": "松鼠", "example": "The squirrel likes nuts.", "exampleMeaning": "松鼠喜欢坚果。" },
|
||||||
{ "word": "penguin", "phonetic": "/ˈpeŋɡwɪn/", "meaning": "企鹅", "example": "The penguin cannot fly.", "exampleMeaning": "企鹅不会飞。" },
|
|
||||||
{ "word": "dolphin", "phonetic": "/ˈdɑːlfɪn/", "meaning": "海豚", "example": "The dolphin swims fast.", "exampleMeaning": "海豚游得很快。" },
|
|
||||||
{ "word": "whale", "phonetic": "/weɪl/", "meaning": "鲸鱼", "example": "The whale is very big.", "exampleMeaning": "鲸鱼非常大。" },
|
|
||||||
{ "word": "shark", "phonetic": "/ʃɑːrk/", "meaning": "鲨鱼", "example": "The shark has sharp teeth.", "exampleMeaning": "鲨鱼有锋利的牙齿。" },
|
|
||||||
{ "word": "turtle", "phonetic": "/ˈtɜːrtl/", "meaning": "乌龟", "example": "The turtle walks slowly.", "exampleMeaning": "乌龟走得很慢。" },
|
{ "word": "turtle", "phonetic": "/ˈtɜːrtl/", "meaning": "乌龟", "example": "The turtle walks slowly.", "exampleMeaning": "乌龟走得很慢。" },
|
||||||
{ "word": "owl", "phonetic": "/aʊl/", "meaning": "猫头鹰", "example": "The owl sleeps in the day.", "exampleMeaning": "猫头鹰白天睡觉。" },
|
|
||||||
{ "word": "bee", "phonetic": "/biː/", "meaning": "蜜蜂", "example": "The bee makes honey.", "exampleMeaning": "蜜蜂酿蜜。" },
|
|
||||||
{ "word": "butterfly", "phonetic": "/ˈbʌtərflaɪ/", "meaning": "蝴蝶", "example": "The butterfly is beautiful.", "exampleMeaning": "蝴蝶很漂亮。" },
|
|
||||||
{ "word": "ant", "phonetic": "/ænt/", "meaning": "蚂蚁", "example": "The ant carries food.", "exampleMeaning": "蚂蚁搬运食物。" },
|
{ "word": "ant", "phonetic": "/ænt/", "meaning": "蚂蚁", "example": "The ant carries food.", "exampleMeaning": "蚂蚁搬运食物。" },
|
||||||
{ "word": "spider", "phonetic": "/ˈspaɪdər/", "meaning": "蜘蛛", "example": "The spider makes a web.", "exampleMeaning": "蜘蛛织网。" },
|
{ "word": "spider", "phonetic": "/ˈspaɪdər/", "meaning": "蜘蛛", "example": "The spider makes a web.", "exampleMeaning": "蜘蛛织网。" },
|
||||||
{ "word": "kangaroo", "phonetic": "/ˌkæŋɡəˈruː/", "meaning": "袋鼠", "example": "The kangaroo jumps high.", "exampleMeaning": "袋鼠跳得很高。" },
|
{ "word": "kangaroo", "phonetic": "/ˌkæŋɡəˈruː/", "meaning": "袋鼠", "example": "The kangaroo jumps high.", "exampleMeaning": "袋鼠跳得很高。" },
|
||||||
@@ -95,6 +88,70 @@
|
|||||||
{ "word": "camel", "phonetic": "/ˈkæml/", "meaning": "骆驼", "example": "The camel lives in the desert.", "exampleMeaning": "骆驼生活在沙漠里。" }
|
{ "word": "camel", "phonetic": "/ˈkæml/", "meaning": "骆驼", "example": "The camel lives in the desert.", "exampleMeaning": "骆驼生活在沙漠里。" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "飞翔动物",
|
||||||
|
"words": [
|
||||||
|
{ "word": "bird", "phonetic": "/bɜːrd/", "meaning": "鸟", "example": "The bird can fly.", "exampleMeaning": "鸟会飞。" },
|
||||||
|
{ "word": "owl", "phonetic": "/aʊl/", "meaning": "猫头鹰", "example": "The owl sleeps in the day.", "exampleMeaning": "猫头鹰白天睡觉。" },
|
||||||
|
{ "word": "bee", "phonetic": "/biː/", "meaning": "蜜蜂", "example": "The bee makes honey.", "exampleMeaning": "蜜蜂酿蜜。" },
|
||||||
|
{ "word": "butterfly", "phonetic": "/ˈbʌtərflaɪ/", "meaning": "蝴蝶", "example": "The butterfly is beautiful.", "exampleMeaning": "蝴蝶很漂亮。" },
|
||||||
|
{ "word": "eagle", "phonetic": "/ˈiːɡl/", "meaning": "鹰", "example": "The eagle flies high.", "exampleMeaning": "鹰飞得很高。" },
|
||||||
|
{ "word": "crow", "phonetic": "/kroʊ/", "meaning": "乌鸦", "example": "The crow is black.", "exampleMeaning": "乌鸦是黑色的。" },
|
||||||
|
{ "word": "pigeon", "phonetic": "/ˈpɪdʒɪn/", "meaning": "鸽子", "example": "The pigeon lives in the city.", "exampleMeaning": "鸽子生活在城市里。" },
|
||||||
|
{ "word": "parrot", "phonetic": "/ˈpærət/", "meaning": "鹦鹉", "example": "The parrot can talk.", "exampleMeaning": "鹦鹉会说话。" },
|
||||||
|
{ "word": "sparrow", "phonetic": "/ˈspæroʊ/", "meaning": "麻雀", "example": "The sparrow is small.", "exampleMeaning": "麻雀很小。" },
|
||||||
|
{ "word": "swallow", "phonetic": "/ˈswɑːloʊ/", "meaning": "燕子", "example": "The swallow flies fast.", "exampleMeaning": "燕子飞得很快。" },
|
||||||
|
{ "word": "bat", "phonetic": "/bæt/", "meaning": "蝙蝠", "example": "The bat flies at night.", "exampleMeaning": "蝙蝠在夜里飞。" },
|
||||||
|
{ "word": "seagull", "phonetic": "/ˈsiːɡʌl/", "meaning": "海鸥", "example": "The seagull flies over the sea.", "exampleMeaning": "海鸥飞过海面。" },
|
||||||
|
{ "word": "hawk", "phonetic": "/hɔːk/", "meaning": "隼", "example": "The hawk can see far.", "exampleMeaning": "隼能看得很远。" },
|
||||||
|
{ "word": "woodpecker", "phonetic": "/ˈwʊdpekər/", "meaning": "啄木鸟", "example": "The woodpecker pecks trees.", "exampleMeaning": "啄木鸟啄树。" },
|
||||||
|
{ "word": "dragonfly", "phonetic": "/ˈdræɡənflaɪ/", "meaning": "蜻蜓", "example": "The dragonfly has four wings.", "exampleMeaning": "蜻蜓有四只翅膀。" },
|
||||||
|
{ "word": "mosquito", "phonetic": "/məˈskiːtoʊ/", "meaning": "蚊子", "example": "The mosquito bites me.", "exampleMeaning": "蚊子咬我。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "水禽",
|
||||||
|
"words": [
|
||||||
|
{ "word": "duck", "phonetic": "/dʌk/", "meaning": "鸭子", "example": "The duck swims in the pond.", "exampleMeaning": "鸭子在池塘里游泳。" },
|
||||||
|
{ "word": "swan", "phonetic": "/swɑːn/", "meaning": "天鹅", "example": "The swan is white.", "exampleMeaning": "天鹅是白色的。" },
|
||||||
|
{ "word": "penguin", "phonetic": "/ˈpeŋɡwɪn/", "meaning": "企鹅", "example": "The penguin cannot fly.", "exampleMeaning": "企鹅不会飞。" },
|
||||||
|
{ "word": "heron", "phonetic": "/ˈhɛrən/", "meaning": "苍鹭", "example": "The heron stands in the river.", "exampleMeaning": "苍鹭站在河里。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "两栖动物",
|
||||||
|
"words": [
|
||||||
|
{ "word": "frog", "phonetic": "/frɔːɡ/", "meaning": "青蛙", "example": "The frog can jump high.", "exampleMeaning": "青蛙能跳得很高。" },
|
||||||
|
{ "word": "tadpole", "phonetic": "/ˈtædpoʊl/", "meaning": "蝌蚪", "example": "The tadpole will become a frog.", "exampleMeaning": "蝌蚪会变成青蛙。" },
|
||||||
|
{ "word": "newt", "phonetic": "/nuːt/", "meaning": "蝾螈", "example": "The newt lives in the pond.", "exampleMeaning": "蝾螈生活在池塘里。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "水中动物",
|
||||||
|
"words": [
|
||||||
|
{ "word": "fish", "phonetic": "/fɪʃ/", "meaning": "鱼", "example": "The fish swims fast.", "exampleMeaning": "鱼游得很快。" },
|
||||||
|
{ "word": "crayfish", "phonetic": "/ˈkreɪfɪʃ/", "meaning": "小龙虾", "example": "The crayfish lives in the river.", "exampleMeaning": "小龙虾生活在河里。" },
|
||||||
|
{ "word": "loach", "phonetic": "/loʊtʃ/", "meaning": "泥鳅", "example": "The loach hides in the mud.", "exampleMeaning": "泥鳅躲在泥里。" },
|
||||||
|
{ "word": "carp", "phonetic": "/kɑːrp/", "meaning": "鲤鱼", "example": "The carp swims in the pond.", "exampleMeaning": "鲤鱼在池塘里游泳。" },
|
||||||
|
{ "word": "goldfish", "phonetic": "/ˈɡoʊldfɪʃ/", "meaning": "金鱼", "example": "The goldfish is orange.", "exampleMeaning": "金鱼是橙色的。" },
|
||||||
|
{ "word": "eel", "phonetic": "/iːl/", "meaning": "鳗鱼", "example": "The eel is long and thin.", "exampleMeaning": "鳗鱼又长又细。" },
|
||||||
|
{ "word": "crab", "phonetic": "/kræb/", "meaning": "螃蟹", "example": "The crab walks sideways.", "exampleMeaning": "螃蟹横着走。" },
|
||||||
|
{ "word": "shrimp", "phonetic": "/ʃrɪmp/", "meaning": "虾", "example": "The shrimp is small.", "exampleMeaning": "虾很小。" },
|
||||||
|
{ "word": "otter", "phonetic": "/ˈɑːtər/", "meaning": "水獭", "example": "The otter swims in the river.", "exampleMeaning": "水獭在河里游泳。" },
|
||||||
|
{ "word": "beaver", "phonetic": "/ˈbiːvər/", "meaning": "河狸", "example": "The beaver builds a dam.", "exampleMeaning": "河狸筑水坝。" },
|
||||||
|
{ "word": "catfish", "phonetic": "/ˈkætfɪʃ/", "meaning": "鲶鱼", "example": "The catfish has whiskers.", "exampleMeaning": "鲶鱼有胡须。" },
|
||||||
|
{ "word": "trout", "phonetic": "/traʊt/", "meaning": "鳟鱼", "example": "The trout swims upstream.", "exampleMeaning": "鳟鱼往上游。" },
|
||||||
|
{ "word": "salmon", "phonetic": "/ˈsæmən/", "meaning": "鲑鱼", "example": "The salmon swims up the river.", "exampleMeaning": "鲑鱼游到上游。" },
|
||||||
|
{ "word": "leech", "phonetic": "/liːtʃ/", "meaning": "水蛭", "example": "The leech lives in the water.", "exampleMeaning": "水蛭生活在水里。" },
|
||||||
|
{ "word": "clam", "phonetic": "/klæm/", "meaning": "蚌", "example": "The clam has a shell.", "exampleMeaning": "蚌有壳。" },
|
||||||
|
{ "word": "sturgeon", "phonetic": "/ˈstɜːrdʒən/", "meaning": "鲟鱼", "example": "The sturgeon is a big fish.", "exampleMeaning": "鲟鱼是一种大鱼。" },
|
||||||
|
{ "word": "dolphin", "phonetic": "/ˈdɑːlfɪn/", "meaning": "海豚", "example": "The dolphin swims fast.", "exampleMeaning": "海豚游得很快。" },
|
||||||
|
{ "word": "whale", "phonetic": "/weɪl/", "meaning": "鲸鱼", "example": "The whale is very big.", "exampleMeaning": "鲸鱼非常大。" },
|
||||||
|
{ "word": "shark", "phonetic": "/ʃɑːrk/", "meaning": "鲨鱼", "example": "The shark has sharp teeth.", "exampleMeaning": "鲨鱼有锋利的牙齿。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "fruits",
|
"id": "fruits",
|
||||||
"name": "水果",
|
"name": "水果",
|
||||||
@@ -208,7 +265,252 @@
|
|||||||
{ "word": "granddaughter", "phonetic": "/ˈɡrændɔːtər/", "meaning": "孙女/外孙女", "example": "His granddaughter can sing.", "exampleMeaning": "他的孙女会唱歌。" },
|
{ "word": "granddaughter", "phonetic": "/ˈɡrændɔːtər/", "meaning": "孙女/外孙女", "example": "His granddaughter can sing.", "exampleMeaning": "他的孙女会唱歌。" },
|
||||||
{ "word": "nephew", "phonetic": "/ˈnefjuː/", "meaning": "侄子/外甥", "example": "My nephew is ten.", "exampleMeaning": "我侄子十岁。" },
|
{ "word": "nephew", "phonetic": "/ˈnefjuː/", "meaning": "侄子/外甥", "example": "My nephew is ten.", "exampleMeaning": "我侄子十岁。" },
|
||||||
{ "word": "niece", "phonetic": "/niːs/", "meaning": "侄女/外甥女", "example": "My niece likes drawing.", "exampleMeaning": "我侄女喜欢画画。" },
|
{ "word": "niece", "phonetic": "/niːs/", "meaning": "侄女/外甥女", "example": "My niece likes drawing.", "exampleMeaning": "我侄女喜欢画画。" },
|
||||||
{ "word": "grandparent", "phonetic": "/ˈɡrænperənt/", "meaning": "祖父母/外祖父母", "example": "My grandparents live in the country.", "exampleMeaning": "我的祖父母住在乡下。" }
|
{ "word": "grandparent", "phonetic": "/ˈɡrænperənt/", "meaning": "祖父母/外祖父母", "example": "My grandparents live in the country.", "exampleMeaning": "我的祖父母住在乡下。" },
|
||||||
|
{ "word": "family", "phonetic": "/ˈfæməli/", "meaning": "家庭", "example": "I love my family.", "exampleMeaning": "我爱我的家庭。" },
|
||||||
|
{ "word": "child", "phonetic": "/tʃaɪld/", "meaning": "孩子", "example": "The child is playing.", "exampleMeaning": "孩子在玩。" },
|
||||||
|
{ "word": "children", "phonetic": "/ˈtʃɪldrən/", "meaning": "孩子们", "example": "The children are at school.", "exampleMeaning": "孩子们在学校。" },
|
||||||
|
{ "word": "sibling", "phonetic": "/ˈsɪblɪŋ/", "meaning": "兄弟姐妹", "example": "I have two siblings.", "exampleMeaning": "我有两个兄弟姐妹。" },
|
||||||
|
{ "word": "relative", "phonetic": "/ˈrelətɪv/", "meaning": "亲戚", "example": "We visit relatives during the holiday.", "exampleMeaning": "我们假期走亲戚。" },
|
||||||
|
{ "word": "couple", "phonetic": "/ˈkʌpl/", "meaning": "夫妻", "example": "The couple is walking hand in hand.", "exampleMeaning": "那对夫妻手牵手散步。" },
|
||||||
|
{ "word": "spouse", "phonetic": "/spaʊs/", "meaning": "配偶", "example": "My spouse and I like to travel.", "exampleMeaning": "我和我配偶喜欢旅行。" },
|
||||||
|
{ "word": "boyfriend", "phonetic": "/ˈbɔɪfrend/", "meaning": "男朋友", "example": "Her boyfriend is kind.", "exampleMeaning": "她男朋友很友善。" },
|
||||||
|
{ "word": "girlfriend", "phonetic": "/ˈɡɜːrlfrend/", "meaning": "女朋友", "example": "His girlfriend is a nurse.", "exampleMeaning": "他女朋友是一名护士。" },
|
||||||
|
{ "word": "father-in-law", "phonetic": "/ˈfɑːðər ɪn lɔː/", "meaning": "岳父/公公", "example": "My father-in-law is retired.", "exampleMeaning": "我岳父退休了。" },
|
||||||
|
{ "word": "mother-in-law", "phonetic": "/ˈmʌðər ɪn lɔː/", "meaning": "岳母/婆婆", "example": "My mother-in-law is a great cook.", "exampleMeaning": "我婆婆做饭很好吃。" },
|
||||||
|
{ "word": "brother-in-law", "phonetic": "/ˈbrʌðər ɪn lɔː/", "meaning": "姐夫/妹夫/大舅子", "example": "My brother-in-law works in Shanghai.", "exampleMeaning": "我姐夫在上海工作。" },
|
||||||
|
{ "word": "sister-in-law", "phonetic": "/ˈsɪstər ɪn lɔː/", "meaning": "嫂子/弟媳/大姑子", "example": "My sister-in-law teaches English.", "exampleMeaning": "我嫂子教英语。" },
|
||||||
|
{ "word": "twin", "phonetic": "/twɪn/", "meaning": "双胞胎", "example": "The twins look the same.", "exampleMeaning": "这对双胞胎长得一样。" },
|
||||||
|
{ "word": "stepfather", "phonetic": "/ˈstepfɑːðər/", "meaning": "继父", "example": "My stepfather is very caring.", "exampleMeaning": "我的继父很体贴。" },
|
||||||
|
{ "word": "stepmother", "phonetic": "/ˈstepmʌðər/", "meaning": "继母", "example": "Her stepmother is nice to her.", "exampleMeaning": "她的继母对她很好。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "food_drink",
|
||||||
|
"name": "饮食",
|
||||||
|
"icon": "fork.knife",
|
||||||
|
"words": [],
|
||||||
|
"subcategories": [
|
||||||
|
{
|
||||||
|
"name": "主食",
|
||||||
|
"words": [
|
||||||
|
{ "word": "rice", "phonetic": "/raɪs/", "meaning": "米饭", "example": "I eat rice every day.", "exampleMeaning": "我每天吃米饭。" },
|
||||||
|
{ "word": "bread", "phonetic": "/bred/", "meaning": "面包", "example": "I have bread for breakfast.", "exampleMeaning": "我早餐吃面包。" },
|
||||||
|
{ "word": "noodle", "phonetic": "/ˈnuːdl/", "meaning": "面条", "example": "I like beef noodles.", "exampleMeaning": "我喜欢牛肉面。" },
|
||||||
|
{ "word": "dumpling", "phonetic": "/ˈdʌmplɪŋ/", "meaning": "饺子", "example": "We make dumplings together.", "exampleMeaning": "我们一起包饺子。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "肉类",
|
||||||
|
"words": [
|
||||||
|
{ "word": "meat", "phonetic": "/miːt/", "meaning": "肉", "example": "I like to eat meat.", "exampleMeaning": "我喜欢吃肉。" },
|
||||||
|
{ "word": "beef", "phonetic": "/biːf/", "meaning": "牛肉", "example": "The beef is tender.", "exampleMeaning": "牛肉很嫩。" },
|
||||||
|
{ "word": "pork", "phonetic": "/pɔːrk/", "meaning": "猪肉", "example": "I don't eat pork.", "exampleMeaning": "我不吃猪肉。" },
|
||||||
|
{ "word": "chicken", "phonetic": "/ˈtʃɪkɪn/", "meaning": "鸡肉", "example": "Fried chicken is popular.", "exampleMeaning": "炸鸡很受欢迎。" },
|
||||||
|
{ "word": "egg", "phonetic": "/eɡ/", "meaning": "鸡蛋", "example": "I have an egg for breakfast.", "exampleMeaning": "我早餐吃一个鸡蛋。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "蔬菜",
|
||||||
|
"words": [
|
||||||
|
{ "word": "vegetable", "phonetic": "/ˈvedʒtəbl/", "meaning": "蔬菜", "example": "Eat more vegetables.", "exampleMeaning": "多吃蔬菜。" },
|
||||||
|
{ "word": "carrot", "phonetic": "/ˈkærət/", "meaning": "胡萝卜", "example": "Rabbits like carrots.", "exampleMeaning": "兔子喜欢胡萝卜。" },
|
||||||
|
{ "word": "tomato", "phonetic": "/təˈmeɪtoʊ/", "meaning": "番茄", "example": "The tomato is red.", "exampleMeaning": "番茄是红色的。" },
|
||||||
|
{ "word": "potato", "phonetic": "/pəˈteɪtoʊ/", "meaning": "土豆", "example": "Potatoes grow underground.", "exampleMeaning": "土豆长在地下。" },
|
||||||
|
{ "word": "onion", "phonetic": "/ˈʌnjən/", "meaning": "洋葱", "example": "The onion makes me cry.", "exampleMeaning": "洋葱让我流泪。" },
|
||||||
|
{ "word": "cucumber", "phonetic": "/ˈkjuːkʌmbər/", "meaning": "黄瓜", "example": "The cucumber is fresh.", "exampleMeaning": "黄瓜很新鲜。" },
|
||||||
|
{ "word": "cabbage", "phonetic": "/ˈkæbɪdʒ/", "meaning": "卷心菜", "example": "Cabbage is good for health.", "exampleMeaning": "卷心菜对健康有好处。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "饮品",
|
||||||
|
"words": [
|
||||||
|
{ "word": "water", "phonetic": "/ˈwɔːtər/", "meaning": "水", "example": "I drink water every day.", "exampleMeaning": "我每天喝水。" },
|
||||||
|
{ "word": "milk", "phonetic": "/mɪlk/", "meaning": "牛奶", "example": "I drink a glass of milk.", "exampleMeaning": "我喝一杯牛奶。" },
|
||||||
|
{ "word": "juice", "phonetic": "/dʒuːs/", "meaning": "果汁", "example": "Orange juice is sweet.", "exampleMeaning": "橙汁很甜。" },
|
||||||
|
{ "word": "tea", "phonetic": "/tiː/", "meaning": "茶", "example": "I drink tea in the afternoon.", "exampleMeaning": "我下午喝茶。" },
|
||||||
|
{ "word": "coffee", "phonetic": "/ˈkɔːfi/", "meaning": "咖啡", "example": "I need a cup of coffee.", "exampleMeaning": "我需要一杯咖啡。" },
|
||||||
|
{ "word": "beer", "phonetic": "/bɪr/", "meaning": "啤酒", "example": "He drinks beer with friends.", "exampleMeaning": "他和朋友喝啤酒。" },
|
||||||
|
{ "word": "wine", "phonetic": "/waɪn/", "meaning": "葡萄酒", "example": "Red wine is good for health.", "exampleMeaning": "红酒对健康有好处。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "调料",
|
||||||
|
"words": [
|
||||||
|
{ "word": "salt", "phonetic": "/sɔːlt/", "meaning": "盐", "example": "Don't add too much salt.", "exampleMeaning": "不要加太多盐。" },
|
||||||
|
{ "word": "sugar", "phonetic": "/ˈʃʊɡər/", "meaning": "糖", "example": "I put sugar in my coffee.", "exampleMeaning": "我在咖啡里加糖。" },
|
||||||
|
{ "word": "oil", "phonetic": "/ɔɪl/", "meaning": "油", "example": "Add some oil to the pan.", "exampleMeaning": "往锅里加点油。" },
|
||||||
|
{ "word": "sauce", "phonetic": "/sɔːs/", "meaning": "酱", "example": "The sauce is delicious.", "exampleMeaning": "这个酱很好吃。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "餐食",
|
||||||
|
"words": [
|
||||||
|
{ "word": "breakfast", "phonetic": "/ˈbrekfəst/", "meaning": "早餐", "example": "Breakfast is the most important meal.", "exampleMeaning": "早餐是最重要的一餐。" },
|
||||||
|
{ "word": "lunch", "phonetic": "/lʌntʃ/", "meaning": "午餐", "example": "I have lunch at twelve.", "exampleMeaning": "我十二点吃午餐。" },
|
||||||
|
{ "word": "dinner", "phonetic": "/ˈdɪnər/", "meaning": "晚餐", "example": "We have dinner at six.", "exampleMeaning": "我们六点吃晚餐。" },
|
||||||
|
{ "word": "morning tea", "phonetic": "/ˈmɔːrnɪŋ tiː/", "meaning": "上午茶", "example": "We had morning tea at ten.", "exampleMeaning": "我们十点喝上午茶。" },
|
||||||
|
{ "word": "afternoon tea", "phonetic": "/ˌæftərˈnuːn tiː/", "meaning": "下午茶", "example": "Afternoon tea is a British tradition.", "exampleMeaning": "下午茶是英国传统。" },
|
||||||
|
{ "word": "midnight snack", "phonetic": "/ˈmɪdnaɪt snæk/", "meaning": "夜宵", "example": "I had a midnight snack before bed.", "exampleMeaning": "我睡前吃了夜宵。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "零食甜品",
|
||||||
|
"words": [
|
||||||
|
{ "word": "cake", "phonetic": "/keɪk/", "meaning": "蛋糕", "example": "I like chocolate cake.", "exampleMeaning": "我喜欢巧克力蛋糕。" },
|
||||||
|
{ "word": "cookie", "phonetic": "/ˈkʊki/", "meaning": "饼干", "example": "The cookie is crunchy.", "exampleMeaning": "饼干很脆。" },
|
||||||
|
{ "word": "ice cream", "phonetic": "/ˌaɪs ˈkriːm/", "meaning": "冰淇淋", "example": "Ice cream is cold and sweet.", "exampleMeaning": "冰淇淋又冰又甜。" },
|
||||||
|
{ "word": "chocolate", "phonetic": "/ˈtʃɔːklət/", "meaning": "巧克力", "example": "I love dark chocolate.", "exampleMeaning": "我爱黑巧克力。" },
|
||||||
|
{ "word": "candy", "phonetic": "/ˈkændi/", "meaning": "糖果", "example": "The child likes candy.", "exampleMeaning": "小孩喜欢糖果。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "其他",
|
||||||
|
"words": [
|
||||||
|
{ "word": "sandwich", "phonetic": "/ˈsænwɪtʃ/", "meaning": "三明治", "example": "I made a sandwich for lunch.", "exampleMeaning": "我做了三明治当午餐。" },
|
||||||
|
{ "word": "pizza", "phonetic": "/ˈpiːtsə/", "meaning": "披萨", "example": "Pizza is from Italy.", "exampleMeaning": "披萨来自意大利。" },
|
||||||
|
{ "word": "hamburger", "phonetic": "/ˈhæmbɜːrɡər/", "meaning": "汉堡", "example": "The hamburger is big.", "exampleMeaning": "这个汉堡很大。" },
|
||||||
|
{ "word": "cheese", "phonetic": "/tʃiːz/", "meaning": "奶酪", "example": "Cheese is made from milk.", "exampleMeaning": "奶酪是用牛奶做的。" },
|
||||||
|
{ "word": "butter", "phonetic": "/ˈbʌtər/", "meaning": "黄油", "example": "I spread butter on bread.", "exampleMeaning": "我在面包上涂黄油。" },
|
||||||
|
{ "word": "soup", "phonetic": "/suːp/", "meaning": "汤", "example": "The soup is hot.", "exampleMeaning": "汤是热的。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "direction",
|
||||||
|
"name": "方位",
|
||||||
|
"icon": "location.north.line",
|
||||||
|
"words": [],
|
||||||
|
"subcategories": [
|
||||||
|
{
|
||||||
|
"name": "罗盘方向",
|
||||||
|
"words": [
|
||||||
|
{ "word": "north", "phonetic": "/nɔːrθ/", "meaning": "北", "example": "The wind blows from the north.", "exampleMeaning": "风从北边吹来。" },
|
||||||
|
{ "word": "south", "phonetic": "/saʊθ/", "meaning": "南", "example": "Birds fly south in winter.", "exampleMeaning": "鸟儿冬天飞向南方。" },
|
||||||
|
{ "word": "east", "phonetic": "/iːst/", "meaning": "东", "example": "The sun rises in the east.", "exampleMeaning": "太阳从东边升起。" },
|
||||||
|
{ "word": "west", "phonetic": "/west/", "meaning": "西", "example": "The sun sets in the west.", "exampleMeaning": "太阳从西边落下。" },
|
||||||
|
{ "word": "northeast", "phonetic": "/ˌnɔːrθˈiːst/", "meaning": "东北", "example": "The wind comes from the northeast.", "exampleMeaning": "风从东北方向吹来。" },
|
||||||
|
{ "word": "northwest", "phonetic": "/ˌnɔːrθˈwest/", "meaning": "西北", "example": "The mountain is northwest of the city.", "exampleMeaning": "山在城市的西北方向。" },
|
||||||
|
{ "word": "southeast", "phonetic": "/ˌsaʊθˈiːst/", "meaning": "东南", "example": "The river flows to the southeast.", "exampleMeaning": "河流向东南方。" },
|
||||||
|
{ "word": "southwest", "phonetic": "/ˌsaʊθˈwest/", "meaning": "西南", "example": "The desert is southwest of here.", "exampleMeaning": "沙漠在这儿的西南方。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "相对方位",
|
||||||
|
"words": [
|
||||||
|
{ "word": "front", "phonetic": "/frʌnt/", "meaning": "前面", "example": "Please stand at the front.", "exampleMeaning": "请站在前面。" },
|
||||||
|
{ "word": "back", "phonetic": "/bæk/", "meaning": "后面", "example": "The garden is at the back.", "exampleMeaning": "花园在后面。" },
|
||||||
|
{ "word": "left", "phonetic": "/left/", "meaning": "左", "example": "Turn left at the corner.", "exampleMeaning": "在拐角处左转。" },
|
||||||
|
{ "word": "right", "phonetic": "/raɪt/", "meaning": "右", "example": "Turn right at the light.", "exampleMeaning": "在红绿灯处右转。" },
|
||||||
|
{ "word": "up", "phonetic": "/ʌp/", "meaning": "上", "example": "Look up at the sky.", "exampleMeaning": "抬头看天空。" },
|
||||||
|
{ "word": "down", "phonetic": "/daʊn/", "meaning": "下", "example": "Go down the stairs.", "exampleMeaning": "走下楼梯。" },
|
||||||
|
{ "word": "top", "phonetic": "/tɑːp/", "meaning": "顶部", "example": "The cat is on top of the table.", "exampleMeaning": "猫在桌子顶部。" },
|
||||||
|
{ "word": "bottom", "phonetic": "/ˈbɑːtəm/", "meaning": "底部", "example": "The shoes are at the bottom.", "exampleMeaning": "鞋子在底部。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "位置关系",
|
||||||
|
"words": [
|
||||||
|
{ "word": "inside", "phonetic": "/ˌɪnˈsaɪd/", "meaning": "里面", "example": "It is warm inside the house.", "exampleMeaning": "房子里面很暖和。" },
|
||||||
|
{ "word": "outside", "phonetic": "/ˌaʊtˈsaɪd/", "meaning": "外面", "example": "Let's play outside.", "exampleMeaning": "我们去外面玩吧。" },
|
||||||
|
{ "word": "middle", "phonetic": "/ˈmɪdl/", "meaning": "中间", "example": "The ball is in the middle.", "exampleMeaning": "球在中间。" },
|
||||||
|
{ "word": "center", "phonetic": "/ˈsentər/", "meaning": "中心", "example": "The statue is at the center.", "exampleMeaning": "雕像在中心。" },
|
||||||
|
{ "word": "side", "phonetic": "/saɪd/", "meaning": "旁边", "example": "He sat by my side.", "exampleMeaning": "他坐在我旁边。" },
|
||||||
|
{ "word": "near", "phonetic": "/nɪr/", "meaning": "附近", "example": "The school is near my house.", "exampleMeaning": "学校在我家附近。" },
|
||||||
|
{ "word": "far", "phonetic": "/fɑːr/", "meaning": "远处", "example": "The mountain is far away.", "exampleMeaning": "山在远处。" },
|
||||||
|
{ "word": "opposite", "phonetic": "/ˈɑːpəzɪt/", "meaning": "对面", "example": "The bank is opposite the park.", "exampleMeaning": "银行在公园对面。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "places",
|
||||||
|
"name": "场所",
|
||||||
|
"icon": "building.2.fill",
|
||||||
|
"words": [],
|
||||||
|
"subcategories": [
|
||||||
|
{
|
||||||
|
"name": "城市建筑",
|
||||||
|
"words": [
|
||||||
|
{ "word": "building", "phonetic": "/ˈbɪldɪŋ/", "meaning": "建筑", "example": "That building is very tall.", "exampleMeaning": "那栋建筑非常高。" },
|
||||||
|
{ "word": "office", "phonetic": "/ˈɔːfɪs/", "meaning": "办公室", "example": "I work in an office.", "exampleMeaning": "我在办公室工作。" },
|
||||||
|
{ "word": "hospital", "phonetic": "/ˈhɑːspɪtl/", "meaning": "医院", "example": "The hospital is near my house.", "exampleMeaning": "医院在我家附近。" },
|
||||||
|
{ "word": "school", "phonetic": "/skuːl/", "meaning": "学校", "example": "The children go to school.", "exampleMeaning": "孩子们去上学。" },
|
||||||
|
{ "word": "library", "phonetic": "/ˈlaɪbreri/", "meaning": "图书馆", "example": "I study at the library.", "exampleMeaning": "我在图书馆学习。" },
|
||||||
|
{ "word": "museum", "phonetic": "/mjuˈziːəm/", "meaning": "博物馆", "example": "The museum has many old things.", "exampleMeaning": "博物馆有很多古老的东西。" },
|
||||||
|
{ "word": "bank", "phonetic": "/bæŋk/", "meaning": "银行", "example": "I need to go to the bank.", "exampleMeaning": "我需要去银行。" },
|
||||||
|
{ "word": "supermarket", "phonetic": "/ˈsuːpərmɑːrkɪt/", "meaning": "超市", "example": "I buy food at the supermarket.", "exampleMeaning": "我在超市买食物。" },
|
||||||
|
{ "word": "mall", "phonetic": "/mɔːl/", "meaning": "商场", "example": "Let's go shopping at the mall.", "exampleMeaning": "我们去商场购物吧。" },
|
||||||
|
{ "word": "restaurant", "phonetic": "/ˈrestərɑːnt/", "meaning": "餐厅", "example": "We had dinner at a restaurant.", "exampleMeaning": "我们在餐厅吃了晚饭。" },
|
||||||
|
{ "word": "hotel", "phonetic": "/hoʊˈtel/", "meaning": "酒店", "example": "We stayed at a nice hotel.", "exampleMeaning": "我们住了一家不错的酒店。" },
|
||||||
|
{ "word": "cinema", "phonetic": "/ˈsɪnəmə/", "meaning": "电影院", "example": "Let's watch a movie at the cinema.", "exampleMeaning": "我们去电影院看电影吧。" },
|
||||||
|
{ "word": "gym", "phonetic": "/dʒɪm/", "meaning": "健身房", "example": "I exercise at the gym.", "exampleMeaning": "我在健身房锻炼。" },
|
||||||
|
{ "word": "church", "phonetic": "/tʃɜːrtʃ/", "meaning": "教堂", "example": "The church is very old.", "exampleMeaning": "这座教堂很古老。" },
|
||||||
|
{ "word": "factory", "phonetic": "/ˈfæktəri/", "meaning": "工厂", "example": "He works in a factory.", "exampleMeaning": "他在工厂工作。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "交通出行",
|
||||||
|
"words": [
|
||||||
|
{ "word": "airport", "phonetic": "/ˈerpɔːrt/", "meaning": "机场", "example": "We arrived at the airport early.", "exampleMeaning": "我们早早到了机场。" },
|
||||||
|
{ "word": "station", "phonetic": "/ˈsteɪʃn/", "meaning": "车站", "example": "The train station is crowded.", "exampleMeaning": "火车站很拥挤。" },
|
||||||
|
{ "word": "bridge", "phonetic": "/brɪdʒ/", "meaning": "桥", "example": "The bridge is very long.", "exampleMeaning": "这座桥很长。" },
|
||||||
|
{ "word": "bus stop", "phonetic": "/bʌs stɑːp/", "meaning": "公交站", "example": "I wait at the bus stop.", "exampleMeaning": "我在公交站等车。" },
|
||||||
|
{ "word": "port", "phonetic": "/pɔːrt/", "meaning": "港口", "example": "The ship arrived at the port.", "exampleMeaning": "船到港了。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "居住户外",
|
||||||
|
"words": [
|
||||||
|
{ "word": "house", "phonetic": "/haʊs/", "meaning": "房子", "example": "I live in a big house.", "exampleMeaning": "我住在一所大房子里。" },
|
||||||
|
{ "word": "apartment", "phonetic": "/əˈpɑːrtmənt/", "meaning": "公寓", "example": "My apartment is on the 5th floor.", "exampleMeaning": "我的公寓在五楼。" },
|
||||||
|
{ "word": "home", "phonetic": "/hoʊm/", "meaning": "家", "example": "I want to go home.", "exampleMeaning": "我想回家。" },
|
||||||
|
{ "word": "park", "phonetic": "/pɑːrk/", "meaning": "公园", "example": "We walk in the park.", "exampleMeaning": "我们在公园散步。" },
|
||||||
|
{ "word": "square", "phonetic": "/skwer/", "meaning": "广场", "example": "People dance at the square.", "exampleMeaning": "人们在广场跳舞。" },
|
||||||
|
{ "word": "garden", "phonetic": "/ˈɡɑːrdn/", "meaning": "花园", "example": "The flowers in the garden are beautiful.", "exampleMeaning": "花园里的花很美。" },
|
||||||
|
{ "word": "playground", "phonetic": "/ˈpleɪɡraʊnd/", "meaning": "游乐场", "example": "The kids are at the playground.", "exampleMeaning": "孩子们在游乐场。" },
|
||||||
|
{ "word": "market", "phonetic": "/ˈmɑːrkɪt/", "meaning": "市场", "example": "I bought fresh fish at the market.", "exampleMeaning": "我在市场买了新鲜的鱼。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "furniture",
|
||||||
|
"name": "家具",
|
||||||
|
"icon": "chair.fill",
|
||||||
|
"words": [],
|
||||||
|
"subcategories": [
|
||||||
|
{
|
||||||
|
"name": "坐卧",
|
||||||
|
"words": [
|
||||||
|
{ "word": "sofa", "phonetic": "/ˈsoʊfə/", "meaning": "沙发", "example": "We sit on the sofa.", "exampleMeaning": "我们坐在沙发上。" },
|
||||||
|
{ "word": "bed", "phonetic": "/bed/", "meaning": "床", "example": "I go to bed at ten.", "exampleMeaning": "我十点上床睡觉。" },
|
||||||
|
{ "word": "chair", "phonetic": "/tʃer/", "meaning": "椅子", "example": "Please sit on the chair.", "exampleMeaning": "请坐在椅子上。" },
|
||||||
|
{ "word": "armchair", "phonetic": "/ˈɑːrmtʃer/", "meaning": "扶手椅", "example": "He fell asleep in the armchair.", "exampleMeaning": "他在扶手椅上睡着了。" },
|
||||||
|
{ "word": "stool", "phonetic": "/stuːl/", "meaning": "凳子", "example": "The child sits on a stool.", "exampleMeaning": "小孩坐在凳子上。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "收纳",
|
||||||
|
"words": [
|
||||||
|
{ "word": "wardrobe", "phonetic": "/ˈwɔːrdroʊb/", "meaning": "衣柜", "example": "I hang my clothes in the wardrobe.", "exampleMeaning": "我把衣服挂在衣柜里。" },
|
||||||
|
{ "word": "cabinet", "phonetic": "/ˈkæbɪnət/", "meaning": "柜子", "example": "The plates are in the cabinet.", "exampleMeaning": "盘子在柜子里。" },
|
||||||
|
{ "word": "bookshelf", "phonetic": "/ˈbʊkʃelf/", "meaning": "书架", "example": "The bookshelf is full of books.", "exampleMeaning": "书架上摆满了书。" },
|
||||||
|
{ "word": "dresser", "phonetic": "/ˈdresər/", "meaning": "梳妆台", "example": "She sits at the dresser.", "exampleMeaning": "她坐在梳妆台前。" },
|
||||||
|
{ "word": "cupboard", "phonetic": "/ˈkʌbərd/", "meaning": "碗柜", "example": "The cups are in the cupboard.", "exampleMeaning": "杯子在碗柜里。" },
|
||||||
|
{ "word": "drawer", "phonetic": "/drɔːr/", "meaning": "抽屉", "example": "The pen is in the drawer.", "exampleMeaning": "笔在抽屉里。" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "桌台",
|
||||||
|
"words": [
|
||||||
|
{ "word": "table", "phonetic": "/ˈteɪbl/", "meaning": "桌子", "example": "The book is on the table.", "exampleMeaning": "书在桌子上。" },
|
||||||
|
{ "word": "desk", "phonetic": "/desk/", "meaning": "书桌", "example": "I study at my desk.", "exampleMeaning": "我在书桌前学习。" },
|
||||||
|
{ "word": "dining table", "phonetic": "/ˈdaɪnɪŋ ˈteɪbl/", "meaning": "餐桌", "example": "We eat at the dining table.", "exampleMeaning": "我们在餐桌吃饭。" },
|
||||||
|
{ "word": "coffee table", "phonetic": "/ˈkɔːfi ˈteɪbl/", "meaning": "茶几", "example": "The magazine is on the coffee table.", "exampleMeaning": "杂志在茶几上。" },
|
||||||
|
{ "word": "nightstand", "phonetic": "/ˈnaɪtstænd/", "meaning": "床头柜", "example": "I put my phone on the nightstand.", "exampleMeaning": "我把手机放在床头柜上。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,12 +28,29 @@ final class WordStore {
|
|||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let decoded = try JSONDecoder().decode(WordBank.self, from: data)
|
let decoded = try JSONDecoder().decode(WordBank.self, from: data)
|
||||||
let categories = decoded.categories.map { category -> WordCategory in
|
let categories = decoded.categories.map { category -> WordCategory in
|
||||||
guard category.id == "numbers" else { return category }
|
var cat = category
|
||||||
var words = NumberWords.makeWords(atoms: category.words)
|
let source: [Word]
|
||||||
let extras = category.words.filter { NumberWords.atomNumbers[$0.word] == nil }
|
if let subs = cat.subcategories, !subs.isEmpty {
|
||||||
|
source = subs.flatMap { $0.words }
|
||||||
|
} else {
|
||||||
|
source = cat.words
|
||||||
|
}
|
||||||
|
let tagged: [Word]
|
||||||
|
if category.id == "numbers" {
|
||||||
|
var words = NumberWords.makeWords(atoms: source)
|
||||||
|
let extras = source.filter { NumberWords.atomNumbers[$0.word] == nil }
|
||||||
words.append(contentsOf: extras)
|
words.append(contentsOf: extras)
|
||||||
return WordCategory(id: category.id, name: category.name, icon: category.icon,
|
tagged = words
|
||||||
words: words)
|
} else {
|
||||||
|
tagged = source
|
||||||
|
}
|
||||||
|
let words = tagged.map { word -> Word in
|
||||||
|
var w = word
|
||||||
|
w.categoryId = category.id
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
cat.words = words
|
||||||
|
return cat
|
||||||
}
|
}
|
||||||
bank = WordBank(version: decoded.version, categories: categories)
|
bank = WordBank(version: decoded.version, categories: categories)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "背单词"
|
title = "背单词"
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "list.bullet.clipboard"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(showHistory)
|
||||||
|
)
|
||||||
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.backgroundColor = .systemBackground
|
tableView.backgroundColor = .systemBackground
|
||||||
@@ -23,6 +29,10 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func showHistory() {
|
||||||
|
navigationController?.pushViewController(HistoryViewController(), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func showError(_ message: String) {
|
private func showError(_ message: String) {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "词库加载失败\n\(message)"
|
label.text = "词库加载失败\n\(message)"
|
||||||
@@ -53,7 +63,12 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let category = store.categories[indexPath.row]
|
let category = store.categories[indexPath.row]
|
||||||
|
if let subs = category.subcategories, !subs.isEmpty {
|
||||||
|
let vc = SubcategoryListViewController(category: category, colorIndex: indexPath.row)
|
||||||
|
navigationController?.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
|
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class HistoryViewController: UITableViewController {
|
||||||
|
private var sections: [(title: String, items: [LearnedRecord])] = []
|
||||||
|
private var speechObserver: NSObjectProtocol?
|
||||||
|
private let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "zh_CN")
|
||||||
|
f.dateFormat = "HH:mm:ss"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(style: .plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let speechObserver {
|
||||||
|
NotificationCenter.default.removeObserver(speechObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "学习记录"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.contentInset.top = 8
|
||||||
|
|
||||||
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
for cell in self?.tableView.visibleCells ?? [] {
|
||||||
|
(cell as? WordCardCell)?.updateSpeakerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
sections = LearnedStore.shared.sectioned
|
||||||
|
if sections.isEmpty {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "暂无学习记录"
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
let bg = UIView()
|
||||||
|
bg.addSubview(label)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
label.centerXAnchor.constraint(equalTo: bg.centerXAnchor),
|
||||||
|
label.centerYAnchor.constraint(equalTo: bg.centerYAnchor)
|
||||||
|
])
|
||||||
|
tableView.backgroundView = bg
|
||||||
|
} else {
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
}
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
sections.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
sections[section].title
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
sections[section].items.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
||||||
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
|
let time = timeFormatter.string(from: record.learnedAt)
|
||||||
|
cell.configure(with: record.word, count: record.count, accuracy: record.accuracy, time: time)
|
||||||
|
cell.onSpeak = { word in
|
||||||
|
SpeechService.shared.speak(word.word)
|
||||||
|
}
|
||||||
|
cell.onLearn = { [weak self] word in
|
||||||
|
guard let self else { return }
|
||||||
|
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
||||||
|
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
Haptics.selection()
|
||||||
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
|
let detail = WordDetailViewController(word: record.word, categoryName: "")
|
||||||
|
navigationController?.pushViewController(detail, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,8 +52,8 @@ final class QuizViewController: UIViewController {
|
|||||||
self.isUppercase = uppercase
|
self.isUppercase = uppercase
|
||||||
let words = config.words.map { word in
|
let words = config.words.map { word in
|
||||||
let text = uppercase ? word.word.uppercased() : word.word.lowercased()
|
let text = uppercase ? word.word.uppercased() : word.word.lowercased()
|
||||||
return Word(word: text, phonetic: word.phonetic, meaning: word.meaning,
|
return Word(categoryId: word.categoryId, word: text, phonetic: word.phonetic,
|
||||||
example: word.example, exampleMeaning: word.exampleMeaning)
|
meaning: word.meaning, example: word.example, exampleMeaning: word.exampleMeaning)
|
||||||
}
|
}
|
||||||
self.questions = QuizGenerator.makeQuestions(mode: config.mode, words: words)
|
self.questions = QuizGenerator.makeQuestions(mode: config.mode, words: words)
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@@ -218,14 +218,17 @@ final class QuizViewController: UIViewController {
|
|||||||
|
|
||||||
if isSpelling {
|
if isSpelling {
|
||||||
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
||||||
|
spellingView.isUppercase = isUppercase
|
||||||
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
||||||
if config.isStudyMode {
|
if config.isStudyMode {
|
||||||
nextButton.isHidden = true
|
nextButton.isHidden = true
|
||||||
spellingView.onChange = { [weak self] in
|
spellingView.onChange = { [weak self] in
|
||||||
guard let self, spellingView.isComplete else { return }
|
guard let self else { return }
|
||||||
submitSpelling(spellingView.typedWord(), for: question)
|
spellingView.confirmButton?.isEnabled = spellingView.isComplete
|
||||||
}
|
}
|
||||||
|
spellingView.confirmButton?.isEnabled = false
|
||||||
} else {
|
} else {
|
||||||
|
spellingView.hideAccessoryView()
|
||||||
spellingView.onChange = { [weak self] in
|
spellingView.onChange = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
nextButton.isEnabled = spellingView.isComplete
|
nextButton.isEnabled = spellingView.isComplete
|
||||||
@@ -234,6 +237,11 @@ final class QuizViewController: UIViewController {
|
|||||||
nextButton.isEnabled = false
|
nextButton.isEnabled = false
|
||||||
nextButton.isHidden = false
|
nextButton.isHidden = false
|
||||||
}
|
}
|
||||||
|
spellingView.onSubmit = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
spellingView.confirmButton?.isEnabled = false
|
||||||
|
primaryButtonTapped()
|
||||||
|
}
|
||||||
spellingView.activate()
|
spellingView.activate()
|
||||||
} else if isDictation {
|
} else if isDictation {
|
||||||
dictationView.reset()
|
dictationView.reset()
|
||||||
@@ -351,11 +359,16 @@ final class QuizViewController: UIViewController {
|
|||||||
guard selectedOption == nil else { return }
|
guard selectedOption == nil else { return }
|
||||||
selectedOption = typed
|
selectedOption = typed
|
||||||
let correct = typed.lowercased() == question.answer.lowercased()
|
let correct = typed.lowercased() == question.answer.lowercased()
|
||||||
|
LearnedStore.shared.record(question.word, correct: correct)
|
||||||
if !correct {
|
if !correct {
|
||||||
wrongWords.append(question.word)
|
wrongWords.append(question.word)
|
||||||
}
|
}
|
||||||
correct ? Haptics.correct() : Haptics.wrong()
|
correct ? Haptics.correct() : Haptics.wrong()
|
||||||
|
if config.isStudyMode {
|
||||||
|
spellingView.freezeInput()
|
||||||
|
} else {
|
||||||
spellingView.deactivate()
|
spellingView.deactivate()
|
||||||
|
}
|
||||||
spellingView.showResultState(correct: correct)
|
spellingView.showResultState(correct: correct)
|
||||||
showResult(correct, for: question)
|
showResult(correct, for: question)
|
||||||
}
|
}
|
||||||
@@ -413,7 +426,9 @@ final class QuizViewController: UIViewController {
|
|||||||
isUppercase.toggle()
|
isUppercase.toggle()
|
||||||
UserDefaults.standard.set(isUppercase, forKey: "spelling_uppercase")
|
UserDefaults.standard.set(isUppercase, forKey: "spelling_uppercase")
|
||||||
updateCaseButton()
|
updateCaseButton()
|
||||||
restart()
|
spellingView.isUppercase = isUppercase
|
||||||
|
questions = QuizGenerator.recase(questions, toUpper: isUppercase)
|
||||||
|
spellingView.updateCase(toUpper: isUppercase)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCaseButton() {
|
private func updateCaseButton() {
|
||||||
@@ -498,8 +513,8 @@ final class QuizViewController: UIViewController {
|
|||||||
view.subviews.compactMap { $0 as? QuizResultView }.forEach { $0.removeFromSuperview() }
|
view.subviews.compactMap { $0 as? QuizResultView }.forEach { $0.removeFromSuperview() }
|
||||||
let words = config.words.map { word in
|
let words = config.words.map { word in
|
||||||
let text = isUppercase ? word.word.uppercased() : word.word.lowercased()
|
let text = isUppercase ? word.word.uppercased() : word.word.lowercased()
|
||||||
return Word(word: text, phonetic: word.phonetic, meaning: word.meaning,
|
return Word(categoryId: word.categoryId, word: text, phonetic: word.phonetic,
|
||||||
example: word.example, exampleMeaning: word.exampleMeaning)
|
meaning: word.meaning, example: word.example, exampleMeaning: word.exampleMeaning)
|
||||||
}
|
}
|
||||||
questions = QuizGenerator.makeQuestions(mode: config.mode, words: words)
|
questions = QuizGenerator.makeQuestions(mode: config.mode, words: words)
|
||||||
currentIndex = 0
|
currentIndex = 0
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import UIKit
|
|||||||
|
|
||||||
final class SpellingInputView: UIView, UITextFieldDelegate {
|
final class SpellingInputView: UIView, UITextFieldDelegate {
|
||||||
var onChange: (() -> Void)?
|
var onChange: (() -> Void)?
|
||||||
|
var onSubmit: (() -> Void)?
|
||||||
|
private(set) var confirmButton: UIButton?
|
||||||
|
|
||||||
private let hiddenField = UITextField()
|
private let hiddenField = UITextField()
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
@@ -15,6 +17,8 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
private var cursorView: UIView?
|
private var cursorView: UIView?
|
||||||
private var inputEnabled = true
|
private var inputEnabled = true
|
||||||
|
|
||||||
|
var isUppercase = false
|
||||||
|
|
||||||
var isComplete: Bool {
|
var isComplete: Bool {
|
||||||
!filledBlanks.isEmpty && filledBlanks.allSatisfy { !$0.isEmpty }
|
!filledBlanks.isEmpty && filledBlanks.allSatisfy { !$0.isEmpty }
|
||||||
}
|
}
|
||||||
@@ -53,9 +57,40 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
hiddenField.autocapitalizationType = .none
|
hiddenField.autocapitalizationType = .none
|
||||||
hiddenField.spellCheckingType = .no
|
hiddenField.spellCheckingType = .no
|
||||||
hiddenField.keyboardType = .asciiCapable
|
hiddenField.keyboardType = .asciiCapable
|
||||||
|
hiddenField.returnKeyType = .done
|
||||||
hiddenField.frame = .zero
|
hiddenField.frame = .zero
|
||||||
addSubview(hiddenField)
|
addSubview(hiddenField)
|
||||||
|
|
||||||
|
let accessory = UIView()
|
||||||
|
accessory.frame = CGRect(x: 0, y: 0, width: 0, height: 74)
|
||||||
|
accessory.autoresizingMask = .flexibleWidth
|
||||||
|
accessory.backgroundColor = .clear
|
||||||
|
|
||||||
|
var glassConfig = UIButton.Configuration.glass()
|
||||||
|
glassConfig.title = "确认"
|
||||||
|
glassConfig.cornerStyle = .fixed
|
||||||
|
glassConfig.background.cornerRadius = 25
|
||||||
|
glassConfig.baseForegroundColor = .label
|
||||||
|
glassConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
let glassButton = UIButton(configuration: glassConfig)
|
||||||
|
confirmButton = glassButton
|
||||||
|
glassButton.addAction(UIAction { [weak self] _ in
|
||||||
|
self?.onSubmit?()
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
glassButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
accessory.addSubview(glassButton)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
glassButton.trailingAnchor.constraint(equalTo: accessory.trailingAnchor, constant: -15),
|
||||||
|
glassButton.topAnchor.constraint(equalTo: accessory.topAnchor, constant: 12),
|
||||||
|
glassButton.widthAnchor.constraint(equalToConstant: 80),
|
||||||
|
glassButton.heightAnchor.constraint(equalToConstant: 50)
|
||||||
|
])
|
||||||
|
hiddenField.inputAccessoryView = accessory
|
||||||
|
|
||||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reactivate)))
|
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reactivate)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +125,30 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
refreshBoxes()
|
refreshBoxes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func freezeInput() {
|
||||||
|
inputEnabled = false
|
||||||
|
refreshBoxes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideAccessoryView() {
|
||||||
|
confirmButton?.isHidden = true
|
||||||
|
hiddenField.inputAccessoryView = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCase(toUpper: Bool) {
|
||||||
|
chars = chars.map {
|
||||||
|
guard $0 != "_" else { return $0 }
|
||||||
|
return toUpper ? $0.uppercased() : $0.lowercased()
|
||||||
|
}
|
||||||
|
filledBlanks = filledBlanks.map {
|
||||||
|
$0.isEmpty ? $0 : (toUpper ? $0.uppercased() : $0.lowercased())
|
||||||
|
}
|
||||||
|
for (index, box) in boxViews.enumerated() where chars[index] != "_" {
|
||||||
|
(box.viewWithTag(100) as? UILabel)?.text = chars[index]
|
||||||
|
}
|
||||||
|
refreshBoxes()
|
||||||
|
}
|
||||||
|
|
||||||
func showResultState(correct: Bool) {
|
func showResultState(correct: Bool) {
|
||||||
let color = correct ? Theme.Color.correct : Theme.Color.wrong
|
let color = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||||
for position in blankPositions {
|
for position in blankPositions {
|
||||||
@@ -126,7 +185,8 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
onChange?()
|
onChange?()
|
||||||
}
|
}
|
||||||
} else if let char = string.last, char.isLetter, cursorIndex < blankPositions.count {
|
} else if let char = string.last, char.isLetter, cursorIndex < blankPositions.count {
|
||||||
filledBlanks[cursorIndex] = String(char).lowercased()
|
let converted = isUppercase ? char.uppercased() : char.lowercased()
|
||||||
|
filledBlanks[cursorIndex] = converted
|
||||||
cursorIndex += 1
|
cursorIndex += 1
|
||||||
refreshBoxes()
|
refreshBoxes()
|
||||||
popBox(atBlank: cursorIndex - 1)
|
popBox(atBlank: cursorIndex - 1)
|
||||||
@@ -136,6 +196,12 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
guard isComplete else { return false }
|
||||||
|
onSubmit?()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Boxes
|
// MARK: - Boxes
|
||||||
|
|
||||||
private func rebuildBoxes() {
|
private func rebuildBoxes() {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class SubcategoryListViewController: UITableViewController {
|
||||||
|
private let category: WordCategory
|
||||||
|
private let colorIndex: Int
|
||||||
|
private let subcategories: [Subcategory]
|
||||||
|
|
||||||
|
init(category: WordCategory, colorIndex: Int) {
|
||||||
|
self.category = category
|
||||||
|
self.colorIndex = colorIndex
|
||||||
|
self.subcategories = category.subcategories ?? []
|
||||||
|
super.init(style: .plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = category.name
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.contentInset.top = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
subcategories.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CategoryCardCell
|
||||||
|
let sub = subcategories[indexPath.row]
|
||||||
|
cell.configure(with: makeSynthetic(sub), color: Theme.Color.category(at: colorIndex))
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
Haptics.selection()
|
||||||
|
let sub = subcategories[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
WordListViewController(category: makeSynthetic(sub), colorIndex: colorIndex), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSynthetic(_ sub: Subcategory) -> WordCategory {
|
||||||
|
WordCategory(id: sub.name, name: sub.name, icon: category.icon,
|
||||||
|
words: sub.words, subcategories: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ final class WordCardCell: UITableViewCell {
|
|||||||
private let wordLabel = UILabel()
|
private let wordLabel = UILabel()
|
||||||
private let phoneticLabel = UILabel()
|
private let phoneticLabel = UILabel()
|
||||||
private let meaningLabel = UILabel()
|
private let meaningLabel = UILabel()
|
||||||
|
private let countLabel = UILabel()
|
||||||
private let learnButton = UIButton(type: .system)
|
private let learnButton = UIButton(type: .system)
|
||||||
private let speakerButton = UIButton(type: .system)
|
private let speakerButton = UIButton(type: .system)
|
||||||
private var word: Word?
|
private var word: Word?
|
||||||
@@ -37,6 +38,10 @@ final class WordCardCell: UITableViewCell {
|
|||||||
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
meaningLabel.textColor = .secondaryLabel
|
meaningLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
countLabel.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
countLabel.textColor = Theme.Color.accent
|
||||||
|
countLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
|
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
|
||||||
textStack.axis = .vertical
|
textStack.axis = .vertical
|
||||||
textStack.spacing = 8
|
textStack.spacing = 8
|
||||||
@@ -57,7 +62,7 @@ final class WordCardCell: UITableViewCell {
|
|||||||
}, for: .touchUpInside)
|
}, for: .touchUpInside)
|
||||||
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
|
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
let row = UIStackView(arrangedSubviews: [textStack, learnButton, speakerButton])
|
let row = UIStackView(arrangedSubviews: [textStack, countLabel, learnButton, speakerButton])
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
row.alignment = .center
|
row.alignment = .center
|
||||||
row.spacing = 8
|
row.spacing = 8
|
||||||
@@ -83,9 +88,16 @@ final class WordCardCell: UITableViewCell {
|
|||||||
wordLabel.text = word.word
|
wordLabel.text = word.word
|
||||||
phoneticLabel.text = word.phonetic
|
phoneticLabel.text = word.phonetic
|
||||||
meaningLabel.text = word.meaning
|
meaningLabel.text = word.meaning
|
||||||
|
countLabel.isHidden = true
|
||||||
updateSpeakerState()
|
updateSpeakerState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configure(with word: Word, count: Int, accuracy: Int, time: String) {
|
||||||
|
configure(with: word)
|
||||||
|
countLabel.text = "\(time) · \(count)次 · \(accuracy)%"
|
||||||
|
countLabel.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
func updateSpeakerState() {
|
func updateSpeakerState() {
|
||||||
guard let word else { return }
|
guard let word else { return }
|
||||||
let speaking = SpeechService.shared.isSpeaking(word.word)
|
let speaking = SpeechService.shared.isSpeaking(word.word)
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ final class WordDetailViewController: UIViewController {
|
|||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
title = categoryName
|
title = categoryName
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "学习", style: .plain, target: self, action: #selector(startSpelling))
|
navigationItem.rightBarButtonItems = [
|
||||||
|
UIBarButtonItem(title: "学习", style: .plain, target: self, action: #selector(startSpelling)),
|
||||||
|
UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), style: .plain, target: self, action: #selector(showHistory))
|
||||||
|
]
|
||||||
buildLayout()
|
buildLayout()
|
||||||
updateSpeakState()
|
updateSpeakState()
|
||||||
speechObserver = NotificationCenter.default.addObserver(
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
@@ -187,6 +190,10 @@ final class WordDetailViewController: UIViewController {
|
|||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func showHistory() {
|
||||||
|
navigationController?.pushViewController(WordHistoryViewController(word: word), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func startSpelling() {
|
@objc private func startSpelling() {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class WordHistoryViewController: UIViewController {
|
||||||
|
private let word: Word
|
||||||
|
private let record: LearnedRecord?
|
||||||
|
|
||||||
|
init(word: Word) {
|
||||||
|
self.word = word
|
||||||
|
record = LearnedStore.shared.record(for: word)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "学习记录"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
buildLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildLayout() {
|
||||||
|
guard let record else {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "暂无学习记录"
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
view.addSubview(label)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let wordCard = makeWordCard(record)
|
||||||
|
let statsCard = makeStatsCard(record)
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [wordCard, statsCard])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 20
|
||||||
|
|
||||||
|
let scrollView = UIScrollView()
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 20),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWordCard(_ record: LearnedRecord) -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
|
||||||
|
let wordLabel = UILabel()
|
||||||
|
wordLabel.text = record.word.word
|
||||||
|
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
|
||||||
|
let phoneticLabel = UILabel()
|
||||||
|
phoneticLabel.text = record.word.phonetic
|
||||||
|
phoneticLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
phoneticLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let meaningLabel = UILabel()
|
||||||
|
meaningLabel.text = record.word.meaning
|
||||||
|
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
meaningLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 8
|
||||||
|
|
||||||
|
card.addSubview(textStack)
|
||||||
|
textStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
textStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
||||||
|
textStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||||
|
textStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||||
|
textStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
|
||||||
|
])
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStatsCard(_ record: LearnedRecord) -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
|
||||||
|
let title = UILabel()
|
||||||
|
title.text = "学习统计"
|
||||||
|
title.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
title.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let countLabel = UILabel()
|
||||||
|
countLabel.text = "\(record.count) 次"
|
||||||
|
countLabel.font = .systemFont(ofSize: 36, weight: .bold)
|
||||||
|
|
||||||
|
let accuracyLabel = UILabel()
|
||||||
|
accuracyLabel.text = "正确率 \(record.accuracy)%"
|
||||||
|
accuracyLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
accuracyLabel.textColor = Theme.Color.correct
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
let dateLabel = UILabel()
|
||||||
|
dateLabel.text = "最近学习:\(formatter.string(from: record.learnedAt))"
|
||||||
|
dateLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
dateLabel.textColor = .tertiaryLabel
|
||||||
|
|
||||||
|
let statsStack = UIStackView(arrangedSubviews: [countLabel, accuracyLabel])
|
||||||
|
statsStack.axis = .horizontal
|
||||||
|
statsStack.spacing = 16
|
||||||
|
statsStack.alignment = .lastBaseline
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [title, statsStack, dateLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
|
||||||
|
card.addSubview(stack)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 16),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16)
|
||||||
|
])
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,22 @@ import UIKit
|
|||||||
final class WordListViewController: UIViewController {
|
final class WordListViewController: UIViewController {
|
||||||
private let category: WordCategory
|
private let category: WordCategory
|
||||||
private let colorIndex: Int
|
private let colorIndex: Int
|
||||||
|
private var words: [Word]
|
||||||
|
private var subcategories: [Subcategory]?
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
private let ctaBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
private let ctaBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||||
private let quizButton = UIButton(type: .system)
|
private let quizButton = UIButton(type: .system)
|
||||||
private var speechObserver: NSObjectProtocol?
|
private var speechObserver: NSObjectProtocol?
|
||||||
|
|
||||||
|
private var displaySubcategories: [Subcategory]? {
|
||||||
|
subcategories.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
init(category: WordCategory, colorIndex: Int) {
|
init(category: WordCategory, colorIndex: Int) {
|
||||||
self.category = category
|
self.category = category
|
||||||
self.colorIndex = colorIndex
|
self.colorIndex = colorIndex
|
||||||
|
self.words = category.words
|
||||||
|
self.subcategories = category.subcategories
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +35,12 @@ final class WordListViewController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = category.name
|
title = category.name
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "arrow.triangle.2.circlepath"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(shuffleWords)
|
||||||
|
)
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
@@ -119,16 +133,41 @@ final class WordListViewController: UIViewController {
|
|||||||
|
|
||||||
private func startQuiz() {
|
private func startQuiz() {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let words: [Word]
|
let quizWords: [Word]
|
||||||
if category.id == "numbers" {
|
if category.id == "numbers" {
|
||||||
words = NumberWords.randomWords(count: 50)
|
quizWords = NumberWords.randomWords(count: 50)
|
||||||
} else {
|
} else {
|
||||||
words = category.words
|
quizWords = words
|
||||||
}
|
}
|
||||||
let request = QuizSetupRequest(words: words)
|
let request = QuizSetupRequest(words: quizWords)
|
||||||
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
|
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func shuffleWords() {
|
||||||
|
Haptics.selection()
|
||||||
|
if var subs = subcategories {
|
||||||
|
subs = subs.shuffled().map { sub in
|
||||||
|
var s = sub
|
||||||
|
s.words = sub.words.shuffled()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
subcategories = subs
|
||||||
|
} else {
|
||||||
|
words.shuffle()
|
||||||
|
}
|
||||||
|
tableView.reloadData()
|
||||||
|
let cells = tableView.visibleCells
|
||||||
|
for (i, cell) in cells.enumerated() {
|
||||||
|
cell.alpha = 0
|
||||||
|
cell.transform = CGAffineTransform(translationX: 0, y: 12)
|
||||||
|
UIView.animate(withDuration: 0.3, delay: Double(i) * 0.03,
|
||||||
|
options: [.curveEaseOut, .allowUserInteraction]) {
|
||||||
|
cell.alpha = 1
|
||||||
|
cell.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateVisibleSpeakers() {
|
private func updateVisibleSpeakers() {
|
||||||
for cell in tableView.visibleCells.compactMap({ $0 as? WordCardCell }) {
|
for cell in tableView.visibleCells.compactMap({ $0 as? WordCardCell }) {
|
||||||
cell.updateSpeakerState()
|
cell.updateSpeakerState()
|
||||||
@@ -137,13 +176,29 @@ final class WordListViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension WordListViewController: UITableViewDataSource, UITableViewDelegate {
|
extension WordListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||||
|
func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
displaySubcategories?.count ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
displaySubcategories?[section].name
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
category.words.count
|
if let subs = displaySubcategories {
|
||||||
|
return subs[section].words.count
|
||||||
|
}
|
||||||
|
return words.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
||||||
let word = category.words[indexPath.row]
|
let word: Word
|
||||||
|
if let subs = displaySubcategories {
|
||||||
|
word = subs[indexPath.section].words[indexPath.row]
|
||||||
|
} else {
|
||||||
|
word = words[indexPath.row]
|
||||||
|
}
|
||||||
cell.configure(with: word)
|
cell.configure(with: word)
|
||||||
cell.onSpeak = { word in
|
cell.onSpeak = { word in
|
||||||
SpeechService.shared.speak(word.word)
|
SpeechService.shared.speak(word.word)
|
||||||
@@ -158,7 +213,13 @@ extension WordListViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let detail = WordDetailViewController(word: category.words[indexPath.row], categoryName: category.name)
|
let word: Word
|
||||||
|
if let subs = displaySubcategories {
|
||||||
|
word = subs[indexPath.section].words[indexPath.row]
|
||||||
|
} else {
|
||||||
|
word = words[indexPath.row]
|
||||||
|
}
|
||||||
|
let detail = WordDetailViewController(word: word, categoryName: category.name)
|
||||||
navigationController?.pushViewController(detail, animated: true)
|
navigationController?.pushViewController(detail, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user