Compare commits

...

8 Commits

Author SHA1 Message Date
fish ae33320f51 单词列表页标题改为小标题,缩小列表与导航栏的间距
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:55:46 +08:00
fish 7cc90dce10 精简 App 图标,移除重复的图标尺寸
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:55:46 +08:00
fish cf84d6cce0 修复测验页空白问题,测验入口改为文字按钮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:46:17 +08:00
fish 9067ddb17c 添加 App 图标
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:36:12 +08:00
fish a1c3194570 新增测验模式:支持英选汉和汉选英两种题型,含答题判分和成绩统计
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:35:48 +08:00
fish f5dd7fa724 实现背单词首页功能:内置分类词库,支持分类浏览、单词详情和离线发音
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:20:30 +08:00
fish 1a1563cd11 工程从 UIKit 迁移到 SwiftUI,同时调整 Bundle ID 和版本号等配置
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:10:03 +08:00
fish dedc8f4bcc 初始化 LearnEnglish iOS 项目,使用 Xcode 默认模板搭建基础工程
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:01:38 +08:00
21 changed files with 1180 additions and 0 deletions
@@ -0,0 +1,356 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LearnEnglish.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = LearnEnglish;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
CF43E865300A7CFA00CB9AE3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
CF43E85F300A7CFA00CB9AE3 = {
isa = PBXGroup;
children = (
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */,
CF43E869300A7CFA00CB9AE3 /* Products */,
);
sourceTree = "<group>";
};
CF43E869300A7CFA00CB9AE3 /* Products */ = {
isa = PBXGroup;
children = (
CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
CF43E867300A7CFA00CB9AE3 /* LearnEnglish */ = {
isa = PBXNativeTarget;
buildConfigurationList = CF43E873300A7CFB00CB9AE3 /* Build configuration list for PBXNativeTarget "LearnEnglish" */;
buildPhases = (
CF43E864300A7CFA00CB9AE3 /* Sources */,
CF43E865300A7CFA00CB9AE3 /* Frameworks */,
CF43E866300A7CFA00CB9AE3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */,
);
name = LearnEnglish;
packageProductDependencies = (
);
productName = LearnEnglish;
productReference = CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
CF43E860300A7CFA00CB9AE3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630;
TargetAttributes = {
CF43E867300A7CFA00CB9AE3 = {
CreatedOnToolsVersion = 26.3;
};
};
};
buildConfigurationList = CF43E863300A7CFA00CB9AE3 /* Build configuration list for PBXProject "LearnEnglish" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = CF43E85F300A7CFA00CB9AE3;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = CF43E869300A7CFA00CB9AE3 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
CF43E867300A7CFA00CB9AE3 /* LearnEnglish */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
CF43E866300A7CFA00CB9AE3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
CF43E864300A7CFA00CB9AE3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
CF43E871300A7CFB00CB9AE3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 37HGUEC495;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
CF43E872300A7CFB00CB9AE3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 37HGUEC495;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
CF43E874300A7CFB00CB9AE3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 37HGUEC495;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 26;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fishestlife.english;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26;
};
name = Debug;
};
CF43E875300A7CFB00CB9AE3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 37HGUEC495;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 26;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fishestlife.english;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CF43E863300A7CFA00CB9AE3 /* Build configuration list for PBXProject "LearnEnglish" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CF43E871300A7CFB00CB9AE3 /* Debug */,
CF43E872300A7CFB00CB9AE3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CF43E873300A7CFB00CB9AE3 /* Build configuration list for PBXNativeTarget "LearnEnglish" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CF43E874300A7CFB00CB9AE3 /* Debug */,
CF43E875300A7CFB00CB9AE3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = CF43E860300A7CFA00CB9AE3 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,87 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "logo 3.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,26 @@
import SwiftUI
struct ContentView: View {
@State private var store = WordStore()
@State private var speech = SpeechService()
var body: some View {
Group {
if let error = store.loadError {
ContentUnavailableView(
"词库加载失败",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else {
CategoryListView(categories: store.categories)
}
}
.environment(speech)
.onAppear { store.load() }
}
}
#Preview {
ContentView()
}
@@ -0,0 +1,17 @@
//
// LearnEnglishApp.swift
// LearnEnglish
//
// Created by fish on 2026/7/17.
//
import SwiftUI
@main
struct LearnEnglishApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@@ -0,0 +1,8 @@
import Foundation
enum AppRoute: Hashable {
case category(WordCategory)
case word(Word)
case quizSetup(QuizSetupRequest)
case quiz(QuizConfig)
}
@@ -0,0 +1,47 @@
import Foundation
enum QuizMode: String, Hashable {
case enToZh = "英选汉"
case zhToEn = "汉选英"
}
struct QuizSetupRequest: Hashable {
let mode: QuizMode
let words: [Word]
}
struct QuizConfig: Hashable {
let mode: QuizMode
let words: [Word]
}
struct QuizQuestion: Identifiable {
var id: String { word.id }
let word: Word
let prompt: String
let options: [String]
let answer: String
}
enum QuizGenerator {
static func makeQuestions(mode: QuizMode, words: [Word]) -> [QuizQuestion] {
words.shuffled().map { word in
let distractors = words.filter { $0 != word }.shuffled().prefix(3)
let options = (distractors.map { optionText($0, mode) } + [optionText(word, mode)]).shuffled()
return QuizQuestion(
word: word,
prompt: promptText(word, mode),
options: options,
answer: optionText(word, mode)
)
}
}
private static func promptText(_ word: Word, _ mode: QuizMode) -> String {
mode == .enToZh ? word.word : word.meaning
}
private static func optionText(_ word: Word, _ mode: QuizMode) -> String {
mode == .enToZh ? word.meaning : word.word
}
}
@@ -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,51 @@
import SwiftUI
struct CategoryListView: View {
let categories: [WordCategory]
@State private var path: [AppRoute] = []
var body: some View {
NavigationStack(path: $path) {
List(categories) { category in
NavigationLink(value: AppRoute.category(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: AppRoute.self) { route in
switch route {
case .category(let category):
WordListView(category: category) { path.append($0) }
case .word(let word):
WordDetailView(word: word)
case .quizSetup(let request):
QuizSetupView(request: request)
case .quiz(let config):
QuizView(config: config)
}
}
}
}
}
#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,70 @@
import SwiftUI
struct QuizResultView: View {
let total: Int
let wrongWords: [Word]
let duration: TimeInterval
let onRestart: () -> Void
let onExit: () -> Void
private var correctCount: Int { total - wrongWords.count }
private var durationText: String {
let seconds = Int(duration)
return seconds >= 60 ? "\(seconds / 60)\(seconds % 60)" : "\(seconds)"
}
var body: some View {
List {
Section {
VStack(spacing: 8) {
Text("\(correctCount) / \(total)")
.font(.system(size: 44, weight: .bold))
Text("用时 \(durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
if !wrongWords.isEmpty {
Section("错题(\(wrongWords.count)") {
ForEach(wrongWords) { word in
NavigationLink(value: AppRoute.word(word)) {
HStack {
Text(word.word)
.font(.headline)
Spacer()
Text(word.meaning)
.foregroundStyle(.secondary)
}
}
}
}
}
Section {
Button("再来一轮", action: onRestart)
.frame(maxWidth: .infinity)
Button("返回", action: onExit)
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
}
}
}
}
#Preview {
NavigationStack {
QuizResultView(
total: 10,
wrongWords: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
],
duration: 75,
onRestart: {},
onExit: {}
)
}
}
@@ -0,0 +1,39 @@
import SwiftUI
struct QuizSetupView: View {
let request: QuizSetupRequest
var body: some View {
List {
NavigationLink(value: AppRoute.quiz(QuizConfig(mode: request.mode, words: request.words))) {
VStack(alignment: .leading, spacing: 4) {
Text("全部测试")
.font(.headline)
Text("\(request.words.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
NavigationLink(value: AppRoute.quiz(QuizConfig(mode: request.mode, words: Array(request.words.shuffled().prefix(10))))) {
VStack(alignment: .leading, spacing: 4) {
Text("随机十个")
.font(.headline)
Text("从词库中随机抽取 10 题")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.navigationTitle(request.mode.rawValue)
}
}
#Preview {
NavigationStack {
QuizSetupView(request: QuizSetupRequest(mode: .enToZh, words: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
]))
}
}
@@ -0,0 +1,144 @@
import SwiftUI
struct QuizView: View {
let config: QuizConfig
@Environment(SpeechService.self) private var speech
@Environment(\.dismiss) private var dismiss
@State private var questions: [QuizQuestion]
@State private var currentIndex = 0
@State private var selectedOption: String?
@State private var wrongWords: [Word] = []
@State private var finished = false
@State private var startDate = Date.now
init(config: QuizConfig) {
self.config = config
_questions = State(initialValue: QuizGenerator.makeQuestions(mode: config.mode, words: config.words))
}
private var current: QuizQuestion? {
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
}
var body: some View {
Group {
if finished {
QuizResultView(
total: questions.count,
wrongWords: wrongWords,
duration: Date.now.timeIntervalSince(startDate),
onRestart: restart,
onExit: { dismiss() }
)
} else if let question = current {
questionView(question)
}
}
.navigationTitle(config.mode.rawValue)
.navigationBarTitleDisplayMode(.inline)
}
private func questionView(_ question: QuizQuestion) -> some View {
VStack(spacing: 24) {
VStack(spacing: 8) {
ProgressView(value: Double(currentIndex), total: Double(questions.count))
Text("\(currentIndex + 1) / \(questions.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(spacing: 12) {
Text(question.prompt)
.font(.system(size: config.mode == .enToZh ? 44 : 34, weight: .bold))
if config.mode == .enToZh {
Button {
speech.speak(question.prompt)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title2)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
VStack(spacing: 12) {
ForEach(question.options, id: \.self) { option in
Button {
select(option, for: question)
} label: {
Text(option)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
.tint(optionColor(option, for: question))
.disabled(selectedOption != nil)
}
}
if let selectedOption {
VStack(spacing: 12) {
Text(selectedOption == question.answer ? "回答正确" : "正确答案:\(question.answer)")
.font(.headline)
.foregroundStyle(selectedOption == question.answer ? .green : .red)
Button(currentIndex + 1 < questions.count ? "下一题" : "查看成绩") {
next()
}
.buttonStyle(.borderedProminent)
}
}
Spacer()
}
.padding()
}
private func optionColor(_ option: String, for question: QuizQuestion) -> Color {
guard let selectedOption else { return .accentColor }
if option == question.answer { return .green }
if option == selectedOption { return .red }
return .gray
}
private func select(_ option: String, for question: QuizQuestion) {
guard selectedOption == nil else { return }
selectedOption = option
if option != question.answer {
wrongWords.append(question.word)
}
}
private func next() {
if currentIndex + 1 < questions.count {
currentIndex += 1
selectedOption = nil
} else {
finished = true
}
}
private func restart() {
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
currentIndex = 0
selectedOption = nil
wrongWords = []
finished = false
startDate = .now
}
}
#Preview {
NavigationStack {
QuizView(config: QuizConfig(mode: .enToZh, words: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。"),
Word(word: "blue", phonetic: "/bluː/", meaning: "蓝色", example: "The sky is blue.", exampleMeaning: "天空是蓝色的。"),
Word(word: "green", phonetic: "/ɡriːn/", meaning: "绿色", example: "The grass is green.", exampleMeaning: "草地是绿色的。"),
Word(word: "yellow", phonetic: "/ˈjeloʊ/", meaning: "黄色", example: "The banana is yellow.", exampleMeaning: "香蕉是黄色的。")
]))
.environment(SpeechService())
}
}
@@ -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,50 @@
import SwiftUI
struct WordListView: View {
let category: WordCategory
let onRoute: (AppRoute) -> Void
var body: some View {
List(category.words) { word in
NavigationLink(value: AppRoute.word(word)) {
VStack(alignment: .leading, spacing: 4) {
Text(word.word)
.font(.headline)
Text(word.meaning)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
.contentMargins(.top, 8, for: .scrollContent)
.navigationTitle(category.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("英选汉") { startQuiz(.enToZh) }
Button("汉选英") { startQuiz(.zhToEn) }
} label: {
Text("测验")
}
}
}
}
private func startQuiz(_ mode: QuizMode) {
if category.words.count <= 10 {
onRoute(.quiz(QuizConfig(mode: mode, words: category.words)))
} else {
onRoute(.quizSetup(QuizSetupRequest(mode: mode, words: category.words)))
}
}
}
#Preview {
NavigationStack {
WordListView(category: WordCategory(id: "colors", name: "颜色", icon: "paintpalette.fill", words: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
])) { _ in }
}
}