Compare commits
101 Commits
eb984848cd
..
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 | |||
| 9ce3991de2 | |||
| bae0af27ef | |||
| 3f5e4cad41 | |||
| 480404b21b | |||
| 98a7d157f4 | |||
| 6e80a5986a | |||
| 86b5cabe17 | |||
| b693874f69 | |||
| 69a1c44ded | |||
| 9124a49887 | |||
| 09800c5b17 | |||
| a3c7d197b1 | |||
| 01b943185f | |||
| e2c1345d40 | |||
| 342a2765d7 | |||
| 935bafa98a | |||
| b1bcfc173e | |||
| 4d618a24e2 | |||
| 837810cfa8 | |||
| 75b967893b | |||
| cb37b809f0 | |||
| 5b2f51d140 | |||
| d6e75eaede | |||
| 945e71dfa7 | |||
| 7e627df952 | |||
| c793e92e1f | |||
| 5b423592a5 | |||
| 5d3a60fce0 | |||
| 0007459eef | |||
| 152dcd9aac | |||
| 43a26da659 | |||
| f99d34a111 | |||
| 13ca0bcda9 | |||
| 6dd8ebc718 | |||
| 26af66efeb | |||
| def92715ac | |||
| c3a4990984 | |||
| 1c3d62d46c | |||
| 682e7927a9 | |||
| a0141ecab0 | |||
| 71dfa297ed | |||
| 9893d5408a | |||
| 2220f4be52 | |||
| 4e3146f9fe | |||
| b4b71a5075 | |||
| f5324bd57b | |||
| 86a4a3b168 | |||
| 7d123afe79 | |||
| 120dac0ec9 | |||
| 94ef6ad84a | |||
| 1d7b48837c | |||
| 71ab42250e | |||
| d09edd395c | |||
| dc2e6a142e | |||
| 72b38ad416 |
@@ -0,0 +1,23 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
iOS 背单词应用(UIKit,纯代码 UI,无 Storyboard 布局),面向中文用户学习英语词汇。词库覆盖颜色、动物、水果、数字、家庭等分类,支持浏览、发音和四种测验模式。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 编译(项目在 LearnEnglish/ 子目录,非仓库根目录)
|
||||
# 固定使用 iPhone 17 Pro 模拟器作为编译目标
|
||||
xcodebuild -project LearnEnglish/LearnEnglish.xcodeproj -scheme LearnEnglish \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' -configuration Debug build
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- **改完代码只允许编译验证**:`xcodebuild build` 通过即完成,禁止启动模拟器、安装 App 或截图确认(用户明确规则,UI 改动同样适用)。
|
||||
- 部署目标为 iOS 26,模拟器必须是 iOS 26.x 运行时,否则安装时报系统版本过低。
|
||||
- 无测试 target、无 lint 配置,验证方式 = 编译。
|
||||
- Xcode 工程使用同步文件夹(objectVersion 77),在 `LearnEnglish/LearnEnglish/` 下新建 .swift 文件会自动加入 target,无需改 pbxproj。
|
||||
@@ -7,19 +7,32 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LearnEnglish.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CF999C8C300BBDE3004D66A6 /* LearnEnglish.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LearnEnglish.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
CF999C9E300BBDE4004D66A6 /* Exceptions for "LearnEnglish" folder in "LearnEnglish" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = CF999C8B300BBDE3004D66A6 /* LearnEnglish */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */ = {
|
||||
CF999C8E300BBDE3004D66A6 /* LearnEnglish */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CF999C9E300BBDE4004D66A6 /* Exceptions for "LearnEnglish" folder in "LearnEnglish" target */,
|
||||
);
|
||||
path = LearnEnglish;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
CF43E865300A7CFA00CB9AE3 /* Frameworks */ = {
|
||||
CF999C89300BBDE3004D66A6 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -29,18 +42,18 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
CF43E85F300A7CFA00CB9AE3 = {
|
||||
CF999C83300BBDE3004D66A6 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */,
|
||||
CF43E869300A7CFA00CB9AE3 /* Products */,
|
||||
CF999C8E300BBDE3004D66A6 /* LearnEnglish */,
|
||||
CF999C8D300BBDE3004D66A6 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CF43E869300A7CFA00CB9AE3 /* Products */ = {
|
||||
CF999C8D300BBDE3004D66A6 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */,
|
||||
CF999C8C300BBDE3004D66A6 /* LearnEnglish.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -48,64 +61,65 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
CF43E867300A7CFA00CB9AE3 /* LearnEnglish */ = {
|
||||
CF999C8B300BBDE3004D66A6 /* LearnEnglish */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CF43E873300A7CFB00CB9AE3 /* Build configuration list for PBXNativeTarget "LearnEnglish" */;
|
||||
buildConfigurationList = CF999C9F300BBDE4004D66A6 /* Build configuration list for PBXNativeTarget "LearnEnglish" */;
|
||||
buildPhases = (
|
||||
CF43E864300A7CFA00CB9AE3 /* Sources */,
|
||||
CF43E865300A7CFA00CB9AE3 /* Frameworks */,
|
||||
CF43E866300A7CFA00CB9AE3 /* Resources */,
|
||||
CF999C88300BBDE3004D66A6 /* Sources */,
|
||||
CF999C89300BBDE3004D66A6 /* Frameworks */,
|
||||
CF999C8A300BBDE3004D66A6 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */,
|
||||
CF999C8E300BBDE3004D66A6 /* LearnEnglish */,
|
||||
);
|
||||
name = LearnEnglish;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = LearnEnglish;
|
||||
productReference = CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */;
|
||||
productReference = CF999C8C300BBDE3004D66A6 /* LearnEnglish.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
CF43E860300A7CFA00CB9AE3 /* Project object */ = {
|
||||
CF999C84300BBDE3004D66A6 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
CF43E867300A7CFA00CB9AE3 = {
|
||||
CF999C8B300BBDE3004D66A6 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = CF43E863300A7CFA00CB9AE3 /* Build configuration list for PBXProject "LearnEnglish" */;
|
||||
developmentRegion = en;
|
||||
buildConfigurationList = CF999C87300BBDE3004D66A6 /* Build configuration list for PBXProject "LearnEnglish" */;
|
||||
developmentRegion = "zh-Hans";
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
"zh-Hans",
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = CF43E85F300A7CFA00CB9AE3;
|
||||
mainGroup = CF999C83300BBDE3004D66A6;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = CF43E869300A7CFA00CB9AE3 /* Products */;
|
||||
productRefGroup = CF999C8D300BBDE3004D66A6 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
CF43E867300A7CFA00CB9AE3 /* LearnEnglish */,
|
||||
CF999C8B300BBDE3004D66A6 /* LearnEnglish */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
CF43E866300A7CFA00CB9AE3 /* Resources */ = {
|
||||
CF999C8A300BBDE3004D66A6 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -115,7 +129,7 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
CF43E864300A7CFA00CB9AE3 /* Sources */ = {
|
||||
CF999C88300BBDE3004D66A6 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -125,7 +139,73 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
CF43E871300A7CFB00CB9AE3 /* Debug */ = {
|
||||
CF999CA0300BBDE4004D66A6 /* 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;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = LearnEnglish/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "学英语";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.fishestlife.english;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
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";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CF999CA1300BBDE4004D66A6 /* 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;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = LearnEnglish/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "学英语";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.fishestlife.english;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
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";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CF999CA2300BBDE4004D66A6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
@@ -178,16 +258,18 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CF43E872300A7CFB00CB9AE3 /* Release */ = {
|
||||
CF999CA3300BBDE4004D66A6 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
@@ -234,123 +316,38 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
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;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
CF43E863300A7CFA00CB9AE3 /* Build configuration list for PBXProject "LearnEnglish" */ = {
|
||||
CF999C87300BBDE3004D66A6 /* Build configuration list for PBXProject "LearnEnglish" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CF43E871300A7CFB00CB9AE3 /* Debug */,
|
||||
CF43E872300A7CFB00CB9AE3 /* Release */,
|
||||
CF999CA2300BBDE4004D66A6 /* Debug */,
|
||||
CF999CA3300BBDE4004D66A6 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CF43E873300A7CFB00CB9AE3 /* Build configuration list for PBXNativeTarget "LearnEnglish" */ = {
|
||||
CF999C9F300BBDE4004D66A6 /* Build configuration list for PBXNativeTarget "LearnEnglish" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CF43E874300A7CFB00CB9AE3 /* Debug */,
|
||||
CF43E875300A7CFB00CB9AE3 /* Release */,
|
||||
CF999CA0300BBDE4004D66A6 /* Debug */,
|
||||
CF999CA1300BBDE4004D66A6 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = CF43E860300A7CFA00CB9AE3 /* Project object */;
|
||||
rootObject = CF999C84300BBDE3004D66A6 /* Project object */;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// LearnEnglish
|
||||
//
|
||||
// Created by fish on 2026/7/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
// Called when the user discards a scene session.
|
||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -27,57 +27,6 @@
|
||||
"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" : {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 286 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -1,26 +0,0 @@
|
||||
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,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh-Hans</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// LearnEnglishApp.swift
|
||||
// LearnEnglish
|
||||
//
|
||||
// Created by fish on 2026/7/17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct LearnEnglishApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum AppRoute: Hashable {
|
||||
case category(WordCategory)
|
||||
case word(Word)
|
||||
case quizSetup(QuizSetupRequest)
|
||||
case quiz(QuizConfig)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
|
||||
enum NumberWords {
|
||||
static let range = 0...999
|
||||
|
||||
static let atomNumbers: [String: Int] = [
|
||||
"zero": 0,
|
||||
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
|
||||
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
|
||||
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
|
||||
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20,
|
||||
"thirty": 30, "forty": 40, "fifty": 50, "sixty": 60,
|
||||
"seventy": 70, "eighty": 80, "ninety": 90, "hundred": 100
|
||||
]
|
||||
|
||||
private static let ones = [
|
||||
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
|
||||
"eleven", "twelve", "thirteen", "fourteen", "fifteen",
|
||||
"sixteen", "seventeen", "eighteen", "nineteen"
|
||||
]
|
||||
|
||||
private static let onesPhonetics = [
|
||||
"wʌn", "tuː", "θriː", "fɔːr", "faɪv", "sɪks", "ˈsevn", "eɪt", "naɪn", "ten",
|
||||
"ɪˈlevn", "twelv", "ˌθɜːrˈtiːn", "ˌfɔːrˈtiːn", "ˌfɪfˈtiːn",
|
||||
"ˌsɪksˈtiːn", "ˌsevnˈtiːn", "ˌeɪˈtiːn", "ˌnaɪnˈtiːn"
|
||||
]
|
||||
|
||||
private static let tens: [Int: String] = [
|
||||
20: "twenty", 30: "thirty", 40: "forty", 50: "fifty",
|
||||
60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety"
|
||||
]
|
||||
|
||||
private static let tensPhonetics: [Int: String] = [
|
||||
20: "ˈtwenti", 30: "ˈθɜːrti", 40: "ˈfɔːrti", 50: "ˈfɪfti",
|
||||
60: "ˈsɪksti", 70: "ˈsevnti", 80: "ˈeɪti", 90: "ˈnaɪnti"
|
||||
]
|
||||
|
||||
static func makeWords(atoms: [Word]) -> [Word] {
|
||||
var byNumber: [Int: Word] = [:]
|
||||
for atom in atoms {
|
||||
if let number = atomNumbers[atom.word] {
|
||||
byNumber[number] = atom
|
||||
}
|
||||
}
|
||||
return range.filter { n in
|
||||
n <= 9
|
||||
|| (n >= 10 && n <= 99 && n % 10 == 0)
|
||||
|| n == 100
|
||||
}.map { byNumber[$0] ?? makeWord($0) }
|
||||
}
|
||||
|
||||
static func english(_ n: Int) -> String {
|
||||
switch n {
|
||||
case 0:
|
||||
return "zero"
|
||||
case 1...19:
|
||||
return ones[n - 1]
|
||||
case 20...99:
|
||||
let ten = n / 10 * 10
|
||||
let one = n % 10
|
||||
return one == 0 ? tens[ten]! : "\(tens[ten]!)-\(ones[one - 1])"
|
||||
case 100...999:
|
||||
let head = "\(ones[n / 100 - 1]) hundred"
|
||||
let rest = n % 100
|
||||
return rest == 0 ? head : "\(head) \(english(rest))"
|
||||
case 1_000...999_999:
|
||||
let thousands = n / 1_000
|
||||
let rest = n % 1_000
|
||||
let head = "\(english(thousands)) thousand"
|
||||
return rest == 0 ? head : "\(head) \(english(rest))"
|
||||
case 1_000_000...999_999_999:
|
||||
let millions = n / 1_000_000
|
||||
let rest = n % 1_000_000
|
||||
let head = "\(english(millions)) million"
|
||||
return rest == 0 ? head : "\(head) \(english(rest))"
|
||||
default:
|
||||
return "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
static func phonetic(_ n: Int) -> String {
|
||||
switch n {
|
||||
case 0:
|
||||
return "ˈzɪroʊ"
|
||||
case 1...19:
|
||||
return onesPhonetics[n - 1]
|
||||
case 20...99:
|
||||
let ten = n / 10 * 10
|
||||
let one = n % 10
|
||||
return one == 0 ? tensPhonetics[ten]! : "\(tensPhonetics[ten]!) \(onesPhonetics[one - 1])"
|
||||
case 100...999:
|
||||
let head = "\(onesPhonetics[n / 100 - 1]) ˈhʌndrəd"
|
||||
let rest = n % 100
|
||||
return rest == 0 ? head : "\(head) \(phonetic(rest))"
|
||||
case 1_000...999_999:
|
||||
let thousands = n / 1_000
|
||||
let rest = n % 1_000
|
||||
let head = "\(phonetic(thousands)) ˈθaʊznd"
|
||||
return rest == 0 ? head : "\(head) \(phonetic(rest))"
|
||||
case 1_000_000...999_999_999:
|
||||
let millions = n / 1_000_000
|
||||
let rest = n % 1_000_000
|
||||
let head = "\(phonetic(millions)) ˈmɪljən"
|
||||
return rest == 0 ? head : "\(head) \(phonetic(rest))"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func chinese(_ n: Int) -> String {
|
||||
let digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]
|
||||
switch n {
|
||||
case 0...9:
|
||||
return digits[n]
|
||||
case 10...19:
|
||||
return "十" + (n % 10 == 0 ? "" : digits[n % 10])
|
||||
case 20...99:
|
||||
return digits[n / 10] + "十" + (n % 10 == 0 ? "" : digits[n % 10])
|
||||
case 100...999:
|
||||
let head = digits[n / 100] + "百"
|
||||
let rest = n % 100
|
||||
switch rest {
|
||||
case 0:
|
||||
return head
|
||||
case 1...9:
|
||||
return head + "零" + digits[rest]
|
||||
case 10...19:
|
||||
return head + "一十" + (rest % 10 == 0 ? "" : digits[rest % 10])
|
||||
default:
|
||||
return head + digits[rest / 10] + "十" + (rest % 10 == 0 ? "" : digits[rest % 10])
|
||||
}
|
||||
case 1_000...9_999:
|
||||
let head = digits[n / 1_000] + "千"
|
||||
let rest = n % 1_000
|
||||
guard rest > 0 else { return head }
|
||||
return rest < 100 ? head + "零" + chinese(rest) : head + chinese(rest)
|
||||
case 10_000...99_999_999:
|
||||
let head = chinese(n / 10_000) + "万"
|
||||
let rest = n % 10_000
|
||||
guard rest > 0 else { return head }
|
||||
return rest < 1_000 ? head + "零" + chinese(rest) : head + chinese(rest)
|
||||
case 100_000_000...999_999_999:
|
||||
let head = digits[n / 100_000_000] + "亿"
|
||||
let rest = n % 100_000_000
|
||||
guard rest > 0 else { return head }
|
||||
return rest < 10_000_000 ? head + "零" + chinese(rest) : head + chinese(rest)
|
||||
default:
|
||||
return "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
static func randomWord() -> Word {
|
||||
let n = Int.random(in: 0...999_999_999)
|
||||
return Word(categoryId: "numbers", word: english(n),
|
||||
phonetic: "/\(phonetic(n))/",
|
||||
meaning: chinese(n),
|
||||
example: "The number is \(english(n)).",
|
||||
exampleMeaning: "这个数字是\(chinese(n))。")
|
||||
}
|
||||
|
||||
static func randomWords(count: Int) -> [Word] {
|
||||
(0..<count).map { _ in randomWord() }
|
||||
}
|
||||
|
||||
private static func makeWord(_ n: Int) -> Word {
|
||||
Word(categoryId: "numbers", word: english(n),
|
||||
phonetic: "/\(phonetic(n))/",
|
||||
meaning: chinese(n),
|
||||
example: "I can count to \(english(n)).",
|
||||
exampleMeaning: "我能数到\(chinese(n))。")
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,24 @@ import Foundation
|
||||
enum QuizMode: String, Hashable {
|
||||
case enToZh = "英选汉"
|
||||
case zhToEn = "汉选英"
|
||||
case spelling = "拼写"
|
||||
case dictation = "默写"
|
||||
}
|
||||
|
||||
struct QuizSetupRequest: Hashable {
|
||||
let mode: QuizMode
|
||||
let words: [Word]
|
||||
}
|
||||
|
||||
struct QuizConfig: Hashable {
|
||||
let mode: QuizMode
|
||||
let words: [Word]
|
||||
let isStudyMode: Bool
|
||||
|
||||
init(mode: QuizMode, words: [Word], isStudyMode: Bool = false) {
|
||||
self.mode = mode
|
||||
self.words = words
|
||||
self.isStudyMode = isStudyMode
|
||||
}
|
||||
}
|
||||
|
||||
struct QuizQuestion: Identifiable {
|
||||
@@ -21,6 +29,8 @@ struct QuizQuestion: Identifiable {
|
||||
let prompt: String
|
||||
let options: [String]
|
||||
let answer: String
|
||||
let blankedWord: String
|
||||
let blankPositions: [Int]
|
||||
}
|
||||
|
||||
enum QuizGenerator {
|
||||
@@ -28,20 +38,62 @@ enum QuizGenerator {
|
||||
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()
|
||||
let blankedWord = mode == .spelling ? blankWord(word.word) : ""
|
||||
let blankPositions = blankedWord.enumerated().filter { $0.element == "_" }.map { $0.offset }
|
||||
return QuizQuestion(
|
||||
word: word,
|
||||
prompt: promptText(word, mode),
|
||||
options: options,
|
||||
answer: optionText(word, mode)
|
||||
answer: optionText(word, mode),
|
||||
blankedWord: blankedWord,
|
||||
blankPositions: blankPositions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
mode == .enToZh ? word.word : word.meaning
|
||||
switch mode {
|
||||
case .enToZh: return word.word
|
||||
case .zhToEn: return word.meaning
|
||||
case .spelling, .dictation: return word.meaning
|
||||
}
|
||||
}
|
||||
|
||||
private static func optionText(_ word: Word, _ mode: QuizMode) -> String {
|
||||
mode == .enToZh ? word.meaning : word.word
|
||||
switch mode {
|
||||
case .enToZh: return word.meaning
|
||||
case .zhToEn: return word.word
|
||||
case .spelling, .dictation: return word.word
|
||||
}
|
||||
}
|
||||
|
||||
private static func blankWord(_ word: String) -> String {
|
||||
let chars = Array(word)
|
||||
let letterPositions = chars.indices.filter { chars[$0].isLetter }
|
||||
guard !letterPositions.isEmpty else { return word }
|
||||
let blankCount = Int.random(in: 1...letterPositions.count)
|
||||
let indices = Set(letterPositions.shuffled().prefix(blankCount))
|
||||
return chars.enumerated().map { indices.contains($0.offset) ? "_" : String($0.element) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,25 @@ struct WordCategory: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: 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 {
|
||||
var id: String { word }
|
||||
var id: String { "\(categoryId)/\(word)" }
|
||||
var categoryId: String = ""
|
||||
let word: String
|
||||
let phonetic: String
|
||||
let meaning: String
|
||||
let example: String
|
||||
let exampleMeaning: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case word, phonetic, meaning, example, exampleMeaning
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,18 +25,40 @@
|
||||
{ "word": "turquoise", "phonetic": "/ˈtɜːrkwɔɪz/", "meaning": "绿松石色", "example": "The lake is turquoise.", "exampleMeaning": "湖水是绿松石色的。" },
|
||||
{ "word": "violet", "phonetic": "/ˈvaɪələt/", "meaning": "紫罗兰色", "example": "The violet flower is beautiful.", "exampleMeaning": "紫罗兰色的花很美。" },
|
||||
{ "word": "navy", "phonetic": "/ˈneɪvi/", "meaning": "藏青色", "example": "He wears a navy coat.", "exampleMeaning": "他穿着一件藏青色外套。" },
|
||||
{ "word": "ivory", "phonetic": "/ˈaɪvəri/", "meaning": "象牙色", "example": "The keys are ivory.", "exampleMeaning": "琴键是象牙色的。" }
|
||||
{ "word": "ivory", "phonetic": "/ˈaɪvəri/", "meaning": "象牙色", "example": "The keys are ivory.", "exampleMeaning": "琴键是象牙色的。" },
|
||||
{ "word": "maroon", "phonetic": "/məˈruːn/", "meaning": "栗色", "example": "He wears a maroon scarf.", "exampleMeaning": "他戴着一条栗色围巾。" },
|
||||
{ "word": "olive", "phonetic": "/ˈɑːlɪv/", "meaning": "橄榄绿", "example": "The olive jacket looks nice.", "exampleMeaning": "这件橄榄绿的夹克很好看。" },
|
||||
{ "word": "lime", "phonetic": "/laɪm/", "meaning": "青柠绿", "example": "She has a lime bike.", "exampleMeaning": "她有一辆青柠绿的自行车。" },
|
||||
{ "word": "teal", "phonetic": "/tiːl/", "meaning": "蓝绿色", "example": "The teal curtain is beautiful.", "exampleMeaning": "蓝绿色的窗帘很漂亮。" },
|
||||
{ "word": "coral", "phonetic": "/ˈkɔːrəl/", "meaning": "珊瑚色", "example": "The coral reef is colorful.", "exampleMeaning": "珊瑚礁五彩缤纷。" },
|
||||
{ "word": "salmon", "phonetic": "/ˈsæmən/", "meaning": "鲑鱼粉", "example": "She wears a salmon dress.", "exampleMeaning": "她穿着鲑鱼粉的连衣裙。" },
|
||||
{ "word": "khaki", "phonetic": "/ˈkɑːki/", "meaning": "卡其色", "example": "He wears khaki pants.", "exampleMeaning": "他穿着卡其色的裤子。" },
|
||||
{ "word": "lavender", "phonetic": "/ˈlævəndər/", "meaning": "淡紫色", "example": "The lavender field is beautiful.", "exampleMeaning": "淡紫色的薰衣草田很美。" },
|
||||
{ "word": "indigo", "phonetic": "/ˈɪndɪɡoʊ/", "meaning": "靛蓝色", "example": "The indigo jeans are new.", "exampleMeaning": "这条靛蓝色的牛仔裤是新的。" },
|
||||
{ "word": "crimson", "phonetic": "/ˈkrɪmzn/", "meaning": "深红色", "example": "The maple leaves turn crimson.", "exampleMeaning": "枫叶变成了深红色。" },
|
||||
{ "word": "scarlet", "phonetic": "/ˈskɑːrlət/", "meaning": "猩红色", "example": "The scarlet bird is beautiful.", "exampleMeaning": "这只猩红色的鸟很美。" },
|
||||
{ "word": "amber", "phonetic": "/ˈæmbər/", "meaning": "琥珀色", "example": "The amber light means wait.", "exampleMeaning": "琥珀色的灯表示等待。" },
|
||||
{ "word": "bronze", "phonetic": "/brɑːnz/", "meaning": "古铜色", "example": "She won a bronze medal.", "exampleMeaning": "她赢得了一枚古铜色奖牌。" },
|
||||
{ "word": "charcoal", "phonetic": "/ˈtʃɑːrkoʊl/", "meaning": "炭灰色", "example": "The charcoal coat is warm.", "exampleMeaning": "这件炭灰色的外套很暖和。" },
|
||||
{ "word": "mint", "phonetic": "/mɪnt/", "meaning": "薄荷绿", "example": "The mint ice cream is green.", "exampleMeaning": "薄荷绿的冰淇淋是绿色的。" },
|
||||
{ "word": "peach", "phonetic": "/piːtʃ/", "meaning": "桃色", "example": "She likes her peach dress.", "exampleMeaning": "她喜欢她的桃色连衣裙。" },
|
||||
{ "word": "emerald", "phonetic": "/ˈemərəld/", "meaning": "翠绿色", "example": "The emerald stone is beautiful.", "exampleMeaning": "这颗翠绿色的宝石很漂亮。" },
|
||||
{ "word": "ruby", "phonetic": "/ˈruːbi/", "meaning": "宝石红", "example": "The ruby is red.", "exampleMeaning": "这颗宝石红的石头是红色的。" },
|
||||
{ "word": "sapphire", "phonetic": "/ˈsæfaɪər/", "meaning": "宝石蓝", "example": "The sapphire is blue.", "exampleMeaning": "这颗宝石蓝的石头是蓝色的。" },
|
||||
{ "word": "cream", "phonetic": "/kriːm/", "meaning": "奶油色", "example": "The cream sofa is soft.", "exampleMeaning": "奶油色的沙发很柔软。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "animals",
|
||||
"name": "动物",
|
||||
"icon": "pawprint.fill",
|
||||
"words": [],
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "陆地动物",
|
||||
"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": "大象有长长的鼻子。" },
|
||||
@@ -47,12 +69,87 @@
|
||||
{ "word": "sheep", "phonetic": "/ʃiːp/", "meaning": "绵羊", "example": "The sheep eats grass.", "exampleMeaning": "绵羊吃草。" },
|
||||
{ "word": "pig", "phonetic": "/pɪɡ/", "meaning": "猪", "example": "The pig is fat.", "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": "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": "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": "zebra", "phonetic": "/ˈziːbrə/", "meaning": "斑马", "example": "The zebra has stripes.", "exampleMeaning": "斑马身上有条纹。" },
|
||||
{ "word": "wolf", "phonetic": "/wʊlf/", "meaning": "狼", "example": "The wolf howls at night.", "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": "squirrel", "phonetic": "/ˈskwɜːrəl/", "meaning": "松鼠", "example": "The squirrel likes nuts.", "exampleMeaning": "松鼠喜欢坚果。" },
|
||||
{ "word": "turtle", "phonetic": "/ˈtɜːrtl/", "meaning": "乌龟", "example": "The turtle walks slowly.", "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": "kangaroo", "phonetic": "/ˌkæŋɡəˈruː/", "meaning": "袋鼠", "example": "The kangaroo jumps high.", "exampleMeaning": "袋鼠跳得很高。" },
|
||||
{ "word": "koala", "phonetic": "/koʊˈɑːlə/", "meaning": "考拉", "example": "The koala sleeps a lot.", "exampleMeaning": "考拉睡很多觉。" },
|
||||
{ "word": "crocodile", "phonetic": "/ˈkrɑːkədaɪl/", "meaning": "鳄鱼", "example": "The crocodile has a big mouth.", "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": "bear", "phonetic": "/ber/", "meaning": "熊", "example": "The bear sleeps in winter.", "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": "鲨鱼有锋利的牙齿。" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -79,7 +176,27 @@
|
||||
{ "word": "fig", "phonetic": "/fɪɡ/", "meaning": "无花果", "example": "Figs are sweet.", "exampleMeaning": "无花果很甜。" },
|
||||
{ "word": "blueberry", "phonetic": "/ˈbluːberi/", "meaning": "蓝莓", "example": "Blueberries are good for eyes.", "exampleMeaning": "蓝莓对眼睛有好处。" },
|
||||
{ "word": "pomegranate", "phonetic": "/ˈpɑːmɪɡrænɪt/", "meaning": "石榴", "example": "The pomegranate has many seeds.", "exampleMeaning": "石榴有很多籽。" },
|
||||
{ "word": "papaya", "phonetic": "/pəˈpaɪə/", "meaning": "木瓜", "example": "The papaya is orange inside.", "exampleMeaning": "木瓜里面是橙色的。" }
|
||||
{ "word": "papaya", "phonetic": "/pəˈpaɪə/", "meaning": "木瓜", "example": "The papaya is orange inside.", "exampleMeaning": "木瓜里面是橙色的。" },
|
||||
{ "word": "dragon fruit", "phonetic": "/ˈdræɡən fruːt/", "meaning": "火龙果", "example": "The dragon fruit is pink.", "exampleMeaning": "火龙果是粉色的。" },
|
||||
{ "word": "durian", "phonetic": "/ˈdʊriən/", "meaning": "榴莲", "example": "The durian smells strong.", "exampleMeaning": "榴莲气味很浓。" },
|
||||
{ "word": "lychee", "phonetic": "/ˈliːtʃiː/", "meaning": "荔枝", "example": "The lychee is sweet.", "exampleMeaning": "荔枝很甜。" },
|
||||
{ "word": "longan", "phonetic": "/ˈlɔːŋɡən/", "meaning": "龙眼", "example": "The longan is small and sweet.", "exampleMeaning": "龙眼又小又甜。" },
|
||||
{ "word": "mangosteen", "phonetic": "/ˈmæŋɡəstiːn/", "meaning": "山竹", "example": "The mangosteen is white inside.", "exampleMeaning": "山竹里面是白色的。" },
|
||||
{ "word": "passion fruit", "phonetic": "/ˈpæʃn fruːt/", "meaning": "百香果", "example": "The passion fruit is sour.", "exampleMeaning": "百香果是酸的。" },
|
||||
{ "word": "starfruit", "phonetic": "/ˈstɑːrfruːt/", "meaning": "杨桃", "example": "The starfruit looks like a star.", "exampleMeaning": "杨桃看起来像星星。" },
|
||||
{ "word": "grapefruit", "phonetic": "/ˈɡreɪpfruːt/", "meaning": "西柚", "example": "The grapefruit is sour.", "exampleMeaning": "西柚是酸的。" },
|
||||
{ "word": "cantaloupe", "phonetic": "/ˈkæntəloʊp/", "meaning": "哈密瓜", "example": "The cantaloupe is sweet.", "exampleMeaning": "哈密瓜很甜。" },
|
||||
{ "word": "honeydew", "phonetic": "/ˈhʌniduː/", "meaning": "蜜瓜", "example": "The honeydew is green.", "exampleMeaning": "蜜瓜是绿色的。" },
|
||||
{ "word": "raspberry", "phonetic": "/ˈræzberi/", "meaning": "树莓", "example": "The raspberry is red.", "exampleMeaning": "树莓是红色的。" },
|
||||
{ "word": "blackberry", "phonetic": "/ˈblækberi/", "meaning": "黑莓", "example": "The blackberry is dark.", "exampleMeaning": "黑莓是深色的。" },
|
||||
{ "word": "cranberry", "phonetic": "/ˈkrænberi/", "meaning": "蔓越莓", "example": "The cranberry is sour.", "exampleMeaning": "蔓越莓是酸的。" },
|
||||
{ "word": "persimmon", "phonetic": "/pərˈsɪmən/", "meaning": "柿子", "example": "The persimmon is orange.", "exampleMeaning": "柿子是橙色的。" },
|
||||
{ "word": "loquat", "phonetic": "/ˈloʊkwɑːt/", "meaning": "枇杷", "example": "The loquat is yellow.", "exampleMeaning": "枇杷是黄色的。" },
|
||||
{ "word": "guava", "phonetic": "/ˈɡwɑːvə/", "meaning": "番石榴", "example": "The guava is green.", "exampleMeaning": "番石榴是绿色的。" },
|
||||
{ "word": "jackfruit", "phonetic": "/ˈdʒækfruːt/", "meaning": "菠萝蜜", "example": "The jackfruit is very big.", "exampleMeaning": "菠萝蜜非常大。" },
|
||||
{ "word": "avocado", "phonetic": "/ˌævəˈkɑːdoʊ/", "meaning": "牛油果", "example": "The avocado is green.", "exampleMeaning": "牛油果是绿色的。" },
|
||||
{ "word": "tangerine", "phonetic": "/ˌtændʒəˈriːn/", "meaning": "橘子", "example": "The tangerine is easy to peel.", "exampleMeaning": "橘子很好剥皮。" },
|
||||
{ "word": "bayberry", "phonetic": "/ˈbeɪberi/", "meaning": "杨梅", "example": "The bayberry is red and sour.", "exampleMeaning": "杨梅又红又酸。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -87,6 +204,7 @@
|
||||
"name": "数字",
|
||||
"icon": "number",
|
||||
"words": [
|
||||
{ "word": "zero", "phonetic": "/ˈzɪroʊ/", "meaning": "零", "example": "Zero means nothing.", "exampleMeaning": "零表示没有。" },
|
||||
{ "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": "三只猫在睡觉。" },
|
||||
@@ -106,7 +224,21 @@
|
||||
{ "word": "seventeen", "phonetic": "/ˌsevnˈtiːn/", "meaning": "十七", "example": "He got seventeen points.", "exampleMeaning": "他得了十七分。" },
|
||||
{ "word": "eighteen", "phonetic": "/ˌeɪˈtiːn/", "meaning": "十八", "example": "She is eighteen years old.", "exampleMeaning": "她十八岁。" },
|
||||
{ "word": "nineteen", "phonetic": "/ˌnaɪnˈtiːn/", "meaning": "十九", "example": "Nineteen people came to the party.", "exampleMeaning": "十九个人来参加了聚会。" },
|
||||
{ "word": "twenty", "phonetic": "/ˈtwenti/", "meaning": "二十", "example": "I have twenty yuan.", "exampleMeaning": "我有二十元。" }
|
||||
{ "word": "twenty", "phonetic": "/ˈtwenti/", "meaning": "二十", "example": "I have twenty yuan.", "exampleMeaning": "我有二十元。" },
|
||||
{ "word": "thirty", "phonetic": "/ˈθɜːrti/", "meaning": "三十", "example": "She has thirty stamps.", "exampleMeaning": "她有三十张邮票。" },
|
||||
{ "word": "forty", "phonetic": "/ˈfɔːrti/", "meaning": "四十", "example": "There are forty students.", "exampleMeaning": "有四十个学生。" },
|
||||
{ "word": "fifty", "phonetic": "/ˈfɪfti/", "meaning": "五十", "example": "My grandfather is fifty.", "exampleMeaning": "我爷爷五十岁。" },
|
||||
{ "word": "sixty", "phonetic": "/ˈsɪksti/", "meaning": "六十", "example": "An hour has sixty minutes.", "exampleMeaning": "一小时有六十分钟。" },
|
||||
{ "word": "seventy", "phonetic": "/ˈsevnti/", "meaning": "七十", "example": "My grandmother is seventy.", "exampleMeaning": "我奶奶七十岁。" },
|
||||
{ "word": "eighty", "phonetic": "/ˈeɪti/", "meaning": "八十", "example": "He can count to eighty.", "exampleMeaning": "他能数到八十。" },
|
||||
{ "word": "ninety", "phonetic": "/ˈnaɪnti/", "meaning": "九十", "example": "The book has ninety pages.", "exampleMeaning": "这本书有九十页。" },
|
||||
{ "word": "hundred", "phonetic": "/ˈhʌndrəd/", "meaning": "百", "example": "One hundred people came.", "exampleMeaning": "来了一百个人。" },
|
||||
{ "word": "thousand", "phonetic": "/ˈθaʊznd/", "meaning": "千", "example": "This library has thousands of books.", "exampleMeaning": "这个图书馆有数千本书。" },
|
||||
{ "word": "ten thousand", "phonetic": "/ten ˈθaʊznd/", "meaning": "一万", "example": "Ten thousand people ran in the race.", "exampleMeaning": "一万人参加了赛跑。" },
|
||||
{ "word": "hundred thousand", "phonetic": "/ˈhʌndrəd ˈθaʊznd/", "meaning": "十万", "example": "The house is worth two hundred thousand dollars.", "exampleMeaning": "这房子价值二十万美元。" },
|
||||
{ "word": "million", "phonetic": "/ˈmɪljən/", "meaning": "百万", "example": "The city has a population of one million.", "exampleMeaning": "这个城市有一百万人口。" },
|
||||
{ "word": "ten million", "phonetic": "/ten ˈmɪljən/", "meaning": "千万", "example": "Ten million viewers watched the show.", "exampleMeaning": "一千万观众看了这个节目。" },
|
||||
{ "word": "hundred million", "phonetic": "/ˈhʌndrəd ˈmɪljən/", "meaning": "亿", "example": "Over a hundred million people speak Japanese.", "exampleMeaning": "超过一亿人说日语。" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -133,7 +265,252 @@
|
||||
{ "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": "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": "我把手机放在床头柜上。" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// SceneDelegate.swift
|
||||
// LearnEnglish
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.tintColor = Theme.Color.accent
|
||||
let store = WordStore()
|
||||
store.load()
|
||||
let root = CategoryListViewController(store: store)
|
||||
let navigation = UINavigationController(rootViewController: root)
|
||||
navigation.navigationBar.prefersLargeTitles = true
|
||||
window.rootViewController = navigation
|
||||
window.makeKeyAndVisible()
|
||||
window.backgroundColor = .systemBackground
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import AVFoundation
|
||||
|
||||
@Observable
|
||||
final class SpeechService: NSObject {
|
||||
static let shared = SpeechService()
|
||||
static let speakingDidChangeNotification = Notification.Name("SpeechServiceSpeakingDidChange")
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private(set) var speakingText: String?
|
||||
|
||||
@@ -10,6 +12,10 @@ final class SpeechService: NSObject {
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
func isSpeaking(_ text: String) -> Bool {
|
||||
speakingText == text
|
||||
}
|
||||
|
||||
func speak(_ text: String) {
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
@@ -18,16 +24,23 @@ final class SpeechService: NSObject {
|
||||
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
|
||||
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
|
||||
speakingText = text
|
||||
notifySpeakingChanged()
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
private func notifySpeakingChanged() {
|
||||
NotificationCenter.default.post(name: Self.speakingDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension SpeechService: AVSpeechSynthesizerDelegate {
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
speakingText = nil
|
||||
notifySpeakingChanged()
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
speakingText = nil
|
||||
notifySpeakingChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ enum WordStoreError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class WordStore {
|
||||
private(set) var bank: WordBank?
|
||||
private(set) var loadError: String?
|
||||
@@ -27,7 +26,33 @@ final class WordStore {
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
bank = try JSONDecoder().decode(WordBank.self, from: data)
|
||||
let decoded = try JSONDecoder().decode(WordBank.self, from: data)
|
||||
let categories = decoded.categories.map { category -> WordCategory in
|
||||
var cat = category
|
||||
let source: [Word]
|
||||
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)
|
||||
tagged = 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)
|
||||
} catch {
|
||||
loadError = WordStoreError.decodingFailed(error).localizedDescription
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import UIKit
|
||||
|
||||
enum Haptics {
|
||||
static func selection() {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
static func correct() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
}
|
||||
|
||||
static func wrong() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import UIKit
|
||||
|
||||
enum Theme {
|
||||
enum Color {
|
||||
static let accent = UIColor.systemIndigo
|
||||
static let cardBackground = UIColor.secondarySystemBackground
|
||||
static let correct = UIColor.systemGreen
|
||||
static let wrong = UIColor.systemRed
|
||||
|
||||
private static let categoryPalette: [UIColor] = [
|
||||
.systemIndigo, .systemTeal, .systemOrange, .systemPurple, .systemGreen
|
||||
]
|
||||
|
||||
static func category(at index: Int) -> UIColor {
|
||||
categoryPalette[index % categoryPalette.count]
|
||||
}
|
||||
}
|
||||
|
||||
enum Metrics {
|
||||
static let cardRadius: CGFloat = 16
|
||||
static let controlRadius: CGFloat = 12
|
||||
}
|
||||
|
||||
enum Font {
|
||||
static func wordHero(size: CGFloat = 44) -> UIFont {
|
||||
rounded(size: size, weight: .bold)
|
||||
}
|
||||
|
||||
static func rounded(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
||||
let base = UIFont.systemFont(ofSize: size, weight: weight)
|
||||
guard let descriptor = base.fontDescriptor.withDesign(.rounded) else { return base }
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
func setPressed(_ pressed: Bool, animated: Bool = true) {
|
||||
let update = {
|
||||
self.transform = pressed ? CGAffineTransform(scaleX: 0.97, y: 0.97) : .identity
|
||||
}
|
||||
guard animated else {
|
||||
update()
|
||||
return
|
||||
}
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6,
|
||||
initialSpringVelocity: 0, options: [.allowUserInteraction, .beginFromCurrentState],
|
||||
animations: update)
|
||||
}
|
||||
|
||||
func animateEntry(distance: CGFloat = 12, duration: TimeInterval = 0.3, delay: TimeInterval = 0) {
|
||||
alpha = 0
|
||||
transform = CGAffineTransform(translationX: 0, y: distance)
|
||||
UIView.animate(withDuration: duration, delay: delay,
|
||||
options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) {
|
||||
self.alpha = 1
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import UIKit
|
||||
|
||||
final class CategoryCardCell: UITableViewCell {
|
||||
private let cardView = UIView()
|
||||
private let iconTile = UIView()
|
||||
private let iconView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let countLabel = UILabel()
|
||||
private let chevronView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
cardView.backgroundColor = Theme.Color.cardBackground
|
||||
cardView.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||
contentView.addSubview(cardView)
|
||||
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6),
|
||||
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
|
||||
])
|
||||
|
||||
iconTile.layer.cornerRadius = 12
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconTile.addSubview(iconView)
|
||||
iconTile.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
iconTile.widthAnchor.constraint(equalToConstant: 44),
|
||||
iconTile.heightAnchor.constraint(equalToConstant: 44),
|
||||
iconView.centerXAnchor.constraint(equalTo: iconTile.centerXAnchor),
|
||||
iconView.centerYAnchor.constraint(equalTo: iconTile.centerYAnchor)
|
||||
])
|
||||
|
||||
nameLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
countLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
countLabel.textColor = .secondaryLabel
|
||||
|
||||
let textStack = UIStackView(arrangedSubviews: [nameLabel, countLabel])
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 2
|
||||
|
||||
chevronView.image = UIImage(systemName: "chevron.right")
|
||||
chevronView.tintColor = .tertiaryLabel
|
||||
chevronView.contentMode = .scaleAspectFit
|
||||
chevronView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [iconTile, textStack, chevronView])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
cardView.addSubview(row)
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
row.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12),
|
||||
row.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
|
||||
row.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14),
|
||||
row.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -12)
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(with category: WordCategory, color: UIColor) {
|
||||
nameLabel.text = category.name
|
||||
countLabel.text = "\(category.words.count) 个单词"
|
||||
iconView.image = UIImage(systemName: category.icon,
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
|
||||
iconView.tintColor = color
|
||||
iconTile.backgroundColor = color.withAlphaComponent(0.12)
|
||||
}
|
||||
|
||||
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
super.setHighlighted(highlighted, animated: animated)
|
||||
cardView.setPressed(highlighted, animated: animated)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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,74 @@
|
||||
import UIKit
|
||||
|
||||
final class CategoryListViewController: UITableViewController {
|
||||
private let store: WordStore
|
||||
|
||||
init(store: WordStore) {
|
||||
self.store = store
|
||||
super.init(style: .plain)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "背单词"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "list.bullet.clipboard"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(showHistory)
|
||||
)
|
||||
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .systemBackground
|
||||
tableView.contentInset.top = 8
|
||||
if let error = store.loadError {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showHistory() {
|
||||
navigationController?.pushViewController(HistoryViewController(), animated: true)
|
||||
}
|
||||
|
||||
private func showError(_ message: String) {
|
||||
let label = UILabel()
|
||||
label.text = "词库加载失败\n\(message)"
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
let background = UIView()
|
||||
background.addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
label.centerXAnchor.constraint(equalTo: background.centerXAnchor),
|
||||
label.centerYAnchor.constraint(equalTo: background.centerYAnchor),
|
||||
label.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 24)
|
||||
])
|
||||
tableView.backgroundView = background
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
store.categories.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CategoryCardCell
|
||||
cell.configure(with: store.categories[indexPath.row], color: Theme.Color.category(at: indexPath.row))
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
Haptics.selection()
|
||||
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(
|
||||
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import UIKit
|
||||
|
||||
final class DictationInputView: UIView, UITextFieldDelegate {
|
||||
var onChange: (() -> Void)?
|
||||
var onSubmit: (() -> Void)?
|
||||
|
||||
private let textField = UITextField()
|
||||
private let bottomLine = HairlineView()
|
||||
|
||||
var text: String { textField.text?.trimmingCharacters(in: .whitespaces) ?? "" }
|
||||
var isEmpty: Bool { text.isEmpty }
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
textField.borderStyle = .none
|
||||
textField.font = .systemFont(ofSize: 28, weight: .bold)
|
||||
textField.textAlignment = .center
|
||||
textField.autocorrectionType = .no
|
||||
textField.autocapitalizationType = .none
|
||||
textField.spellCheckingType = .no
|
||||
textField.keyboardType = .asciiCapable
|
||||
textField.placeholder = "输入完整单词"
|
||||
textField.delegate = self
|
||||
textField.returnKeyType = .done
|
||||
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
|
||||
|
||||
bottomLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(textField)
|
||||
addSubview(bottomLine)
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
textField.topAnchor.constraint(equalTo: topAnchor),
|
||||
textField.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
textField.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
textField.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16),
|
||||
textField.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
|
||||
textField.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||
textField.heightAnchor.constraint(equalToConstant: 48),
|
||||
|
||||
bottomLine.leadingAnchor.constraint(equalTo: textField.leadingAnchor),
|
||||
bottomLine.trailingAnchor.constraint(equalTo: textField.trailingAnchor),
|
||||
bottomLine.bottomAnchor.constraint(equalTo: textField.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
@objc private func textChanged() {
|
||||
onChange?()
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
onSubmit?()
|
||||
return false
|
||||
}
|
||||
|
||||
func activate() { textField.becomeFirstResponder() }
|
||||
func deactivate() { textField.resignFirstResponder() }
|
||||
|
||||
func reset() {
|
||||
textField.text = ""
|
||||
bottomLine.backgroundColor = .separator
|
||||
}
|
||||
|
||||
func showResultState(correct: Bool) {
|
||||
bottomLine.backgroundColor = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import UIKit
|
||||
|
||||
final class HairlineView: UIView {
|
||||
private var heightConstraint: NSLayoutConstraint?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .separator
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
let constraint = heightAnchor.constraint(equalToConstant: 0.5)
|
||||
constraint.isActive = true
|
||||
heightConstraint = constraint
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
let scale = window?.windowScene?.screen.scale ?? 2
|
||||
heightConstraint?.constant = 1.0 / scale
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,262 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct QuizResultView: View {
|
||||
let total: Int
|
||||
let wrongWords: [Word]
|
||||
let duration: TimeInterval
|
||||
let onRestart: () -> Void
|
||||
let onExit: () -> Void
|
||||
final class QuizResultView: UIView {
|
||||
private let total: Int
|
||||
private let wrongWords: [Word]
|
||||
private let duration: TimeInterval
|
||||
private let onRestart: () -> Void
|
||||
private let onExit: () -> Void
|
||||
private let onSelectWord: (Word) -> Void
|
||||
|
||||
private let ringView = UIView()
|
||||
private let ringTrackLayer = CAShapeLayer()
|
||||
private let ringProgressLayer = CAShapeLayer()
|
||||
private let scoreLabel = UILabel()
|
||||
private var animationsStarted = false
|
||||
private var displayLink: CADisplayLink?
|
||||
private var countStartTime: CFTimeInterval = 0
|
||||
|
||||
private var correctCount: Int { total - wrongWords.count }
|
||||
private var accuracy: Double { total > 0 ? Double(correctCount) / Double(total) : 0 }
|
||||
|
||||
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)
|
||||
init(total: Int, wrongWords: [Word], duration: TimeInterval,
|
||||
onRestart: @escaping () -> Void, onExit: @escaping () -> Void,
|
||||
onSelectWord: @escaping (Word) -> Void) {
|
||||
self.total = total
|
||||
self.wrongWords = wrongWords
|
||||
self.duration = duration
|
||||
self.onRestart = onRestart
|
||||
self.onExit = onExit
|
||||
self.onSelectWord = onSelectWord
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .systemBackground
|
||||
buildLayout()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard ringView.bounds.width > 0 else { return }
|
||||
ringTrackLayer.path = ringPath()
|
||||
ringProgressLayer.path = ringPath()
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard window != nil, !animationsStarted else { return }
|
||||
animationsStarted = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startAnimations()
|
||||
}
|
||||
}
|
||||
|
||||
private func ringPath() -> CGPath {
|
||||
let lineWidth = ringTrackLayer.lineWidth
|
||||
let radius = min(ringView.bounds.width, ringView.bounds.height) / 2 - lineWidth / 2
|
||||
let center = CGPoint(x: ringView.bounds.midX, y: ringView.bounds.midY)
|
||||
return UIBezierPath(arcCenter: center, radius: radius,
|
||||
startAngle: -.pi / 2, endAngle: .pi * 1.5, clockwise: true).cgPath
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
let animation = CABasicAnimation(keyPath: "strokeEnd")
|
||||
animation.fromValue = 0
|
||||
animation.toValue = accuracy
|
||||
animation.duration = 0.8
|
||||
animation.beginTime = CACurrentMediaTime() + 0.15
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
animation.fillMode = .forwards
|
||||
animation.isRemovedOnCompletion = false
|
||||
ringProgressLayer.add(animation, forKey: "progress")
|
||||
|
||||
countStartTime = CACurrentMediaTime() + 0.15
|
||||
let link = CADisplayLink(target: self, selector: #selector(countTick(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
|
||||
@objc private func countTick(_ link: CADisplayLink) {
|
||||
let progress = max(0, min(1, (CACurrentMediaTime() - countStartTime) / 0.6))
|
||||
scoreLabel.text = "\(Int((Double(correctCount) * progress).rounded()))/\(total)"
|
||||
if progress >= 1 {
|
||||
link.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func buildLayout() {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.alwaysBounceVertical = true
|
||||
addSubview(scrollView)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 24
|
||||
scrollView.addSubview(stack)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 32),
|
||||
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: -24),
|
||||
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||
])
|
||||
|
||||
stack.addArrangedSubview(makeScoreSection())
|
||||
|
||||
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)
|
||||
stack.addArrangedSubview(makeWrongWordsSection())
|
||||
}
|
||||
|
||||
let restartButton = makeButton(title: "再来一轮", prominent: true) { [weak self] in
|
||||
self?.onRestart()
|
||||
}
|
||||
let exitButton = makeButton(title: "返回", prominent: false) { [weak self] in
|
||||
self?.onExit()
|
||||
}
|
||||
let buttonStack = UIStackView(arrangedSubviews: [restartButton, exitButton])
|
||||
buttonStack.axis = .vertical
|
||||
buttonStack.spacing = 12
|
||||
stack.addArrangedSubview(buttonStack)
|
||||
}
|
||||
|
||||
private func makeScoreSection() -> UIView {
|
||||
ringTrackLayer.fillColor = UIColor.clear.cgColor
|
||||
ringTrackLayer.strokeColor = UIColor.tertiarySystemFill.cgColor
|
||||
ringTrackLayer.lineWidth = 10
|
||||
|
||||
ringProgressLayer.fillColor = UIColor.clear.cgColor
|
||||
ringProgressLayer.strokeColor = Theme.Color.accent.cgColor
|
||||
ringProgressLayer.lineWidth = 10
|
||||
ringProgressLayer.lineCap = .round
|
||||
ringProgressLayer.strokeEnd = 0
|
||||
|
||||
ringView.layer.addSublayer(ringTrackLayer)
|
||||
ringView.layer.addSublayer(ringProgressLayer)
|
||||
ringView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
ringView.widthAnchor.constraint(equalToConstant: 132),
|
||||
ringView.heightAnchor.constraint(equalToConstant: 132)
|
||||
])
|
||||
|
||||
scoreLabel.text = "0/\(total)"
|
||||
scoreLabel.font = Theme.Font.rounded(size: 26, weight: .bold)
|
||||
scoreLabel.textAlignment = .center
|
||||
ringView.addSubview(scoreLabel)
|
||||
scoreLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
scoreLabel.centerXAnchor.constraint(equalTo: ringView.centerXAnchor),
|
||||
scoreLabel.centerYAnchor.constraint(equalTo: ringView.centerYAnchor)
|
||||
])
|
||||
|
||||
let captionLabel = UILabel()
|
||||
captionLabel.text = "答对 \(correctCount) 题 · 用时 \(durationText)"
|
||||
captionLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
captionLabel.textColor = .secondaryLabel
|
||||
captionLabel.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [ringView, captionLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.alignment = .center
|
||||
return stack
|
||||
}
|
||||
|
||||
private func makeWrongWordsSection() -> UIView {
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "错题(\(wrongWords.count))"
|
||||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = Theme.Color.cardBackground
|
||||
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||
|
||||
let cardStack = UIStackView()
|
||||
cardStack.axis = .vertical
|
||||
card.addSubview(cardStack)
|
||||
cardStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
cardStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4),
|
||||
cardStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||
cardStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||
cardStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4)
|
||||
])
|
||||
|
||||
for (index, word) in wrongWords.enumerated() {
|
||||
cardStack.addArrangedSubview(makeWrongWordRow(word))
|
||||
if index < wrongWords.count - 1 {
|
||||
cardStack.addArrangedSubview(HairlineView())
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("再来一轮", action: onRestart)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button("返回", action: onExit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundStyle(.secondary)
|
||||
let section = UIStackView(arrangedSubviews: [titleLabel, card])
|
||||
section.axis = .vertical
|
||||
section.spacing = 8
|
||||
return section
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
QuizResultView(
|
||||
total: 10,
|
||||
wrongWords: [
|
||||
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。")
|
||||
],
|
||||
duration: 75,
|
||||
onRestart: {},
|
||||
onExit: {}
|
||||
)
|
||||
private func makeWrongWordRow(_ word: Word) -> UIView {
|
||||
let button = UIButton(type: .system)
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.contentInsets = .init(top: 10, leading: 2, bottom: 10, trailing: 2)
|
||||
button.configuration = config
|
||||
button.contentHorizontalAlignment = .fill
|
||||
|
||||
let wordLabel = UILabel()
|
||||
wordLabel.text = word.word
|
||||
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
let meaningLabel = UILabel()
|
||||
meaningLabel.text = word.meaning
|
||||
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
meaningLabel.textColor = .secondaryLabel
|
||||
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
|
||||
chevron.tintColor = .tertiaryLabel
|
||||
chevron.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [wordLabel, UIView(), meaningLabel, chevron])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 8
|
||||
row.isUserInteractionEnabled = false
|
||||
button.addSubview(row)
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
row.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
|
||||
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 2),
|
||||
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -2),
|
||||
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
|
||||
])
|
||||
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.onSelectWord(word)
|
||||
}, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeButton(title: String, prominent: Bool, action: @escaping () -> Void) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
var config = prominent ? UIButton.Configuration.borderedProminent() : UIButton.Configuration.plain()
|
||||
config.title = title
|
||||
config.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||
if prominent {
|
||||
config.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
}
|
||||
button.configuration = config
|
||||
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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,218 @@
|
||||
import UIKit
|
||||
|
||||
final class QuizSetupViewController: UIViewController {
|
||||
private struct ModeItem {
|
||||
let mode: QuizMode
|
||||
let detail: String
|
||||
let icon: String
|
||||
}
|
||||
|
||||
private let request: QuizSetupRequest
|
||||
private let items: [ModeItem] = [
|
||||
ModeItem(mode: .enToZh, detail: "看英文选中文释义", icon: "character"),
|
||||
ModeItem(mode: .zhToEn, detail: "看中文选英文单词", icon: "character.bubble"),
|
||||
ModeItem(mode: .spelling, detail: "补全单词空缺字母", icon: "keyboard"),
|
||||
ModeItem(mode: .dictation, detail: "根据释义默写全词", icon: "square.and.pencil")
|
||||
]
|
||||
|
||||
private var selectedIndex: Int?
|
||||
private var cardViews: [UIView] = []
|
||||
private var checkViews: [UIImageView] = []
|
||||
private let countControl = UISegmentedControl()
|
||||
private let startButton = UIButton(type: .system)
|
||||
|
||||
init(request: QuizSetupRequest) {
|
||||
self.request = request
|
||||
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
|
||||
|
||||
let contentStack = UIStackView()
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
|
||||
contentStack.addArrangedSubview(makeCaption("选择模式"))
|
||||
for (index, item) in items.enumerated() {
|
||||
let card = makeModeCard(item: item, index: index)
|
||||
cardViews.append(card)
|
||||
contentStack.addArrangedSubview(card)
|
||||
}
|
||||
|
||||
contentStack.setCustomSpacing(24, after: cardViews.last!)
|
||||
contentStack.addArrangedSubview(makeCaption("题量"))
|
||||
setupCountControl()
|
||||
contentStack.addArrangedSubview(countControl)
|
||||
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8),
|
||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||
contentStack.bottomAnchor.constraint(lessThanOrEqualTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||
])
|
||||
|
||||
var buttonConfig = UIButton.Configuration.borderedProminent()
|
||||
buttonConfig.title = "开始"
|
||||
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
|
||||
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
buttonConfig.titleTextAttributesTransformer = .init { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||
return outgoing
|
||||
}
|
||||
startButton.configuration = buttonConfig
|
||||
startButton.isEnabled = false
|
||||
startButton.addAction(UIAction { [weak self] _ in self?.startQuiz() }, for: .touchUpInside)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
view.addSubview(startButton)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
startButton.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: startButton.topAnchor, constant: -12),
|
||||
|
||||
startButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
startButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
startButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12)
|
||||
])
|
||||
|
||||
updateCards(animated: false)
|
||||
}
|
||||
|
||||
private func makeCaption(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .preferredFont(forTextStyle: .caption1)
|
||||
label.textColor = .secondaryLabel
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeModeCard(item: ModeItem, index: Int) -> UIView {
|
||||
let card = UIView()
|
||||
card.tag = index
|
||||
card.backgroundColor = Theme.Color.cardBackground
|
||||
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||
card.layer.borderWidth = 1.5
|
||||
card.layer.borderColor = UIColor.clear.cgColor
|
||||
|
||||
let tile = UIView()
|
||||
tile.backgroundColor = Theme.Color.accent.withAlphaComponent(0.12)
|
||||
tile.layer.cornerRadius = 10
|
||||
let icon = UIImageView(image: UIImage(systemName: item.icon,
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)))
|
||||
icon.tintColor = Theme.Color.accent
|
||||
tile.addSubview(icon)
|
||||
tile.translatesAutoresizingMaskIntoConstraints = false
|
||||
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tile.widthAnchor.constraint(equalToConstant: 38),
|
||||
tile.heightAnchor.constraint(equalToConstant: 38),
|
||||
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
||||
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor)
|
||||
])
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = item.mode.rawValue
|
||||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
let detailLabel = UILabel()
|
||||
detailLabel.text = item.detail
|
||||
detailLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
detailLabel.textColor = .secondaryLabel
|
||||
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 2
|
||||
|
||||
let check = UIImageView(image: UIImage(systemName: "circle",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)))
|
||||
check.tintColor = .tertiaryLabel
|
||||
check.setContentHuggingPriority(.required, for: .horizontal)
|
||||
checkViews.append(check)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [tile, textStack, check])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
row.isUserInteractionEnabled = false
|
||||
card.addSubview(row)
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
row.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
|
||||
row.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||
row.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||
row.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
|
||||
])
|
||||
|
||||
card.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped(_:))))
|
||||
return card
|
||||
}
|
||||
|
||||
private func setupCountControl() {
|
||||
if request.words.count > 50 {
|
||||
countControl.insertSegment(withTitle: "随机 10 题", at: 0, animated: false)
|
||||
countControl.insertSegment(withTitle: "随机 20 题", at: 1, animated: false)
|
||||
} else {
|
||||
countControl.insertSegment(withTitle: "全部 \(request.words.count) 题", at: 0, animated: false)
|
||||
if request.words.count > 10 {
|
||||
countControl.insertSegment(withTitle: "随机 10 题", at: 1, animated: false)
|
||||
}
|
||||
}
|
||||
countControl.selectedSegmentIndex = 0
|
||||
}
|
||||
|
||||
@objc private func cardTapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard let card = gesture.view, selectedIndex != card.tag else { return }
|
||||
selectedIndex = card.tag
|
||||
Haptics.selection()
|
||||
updateCards(animated: true)
|
||||
startButton.isEnabled = true
|
||||
}
|
||||
|
||||
private func updateCards(animated: Bool) {
|
||||
for (index, card) in cardViews.enumerated() {
|
||||
let selected = index == selectedIndex
|
||||
let apply = {
|
||||
card.backgroundColor = selected ? Theme.Color.accent.withAlphaComponent(0.12) : Theme.Color.cardBackground
|
||||
card.layer.borderColor = selected ? Theme.Color.accent.cgColor : UIColor.clear.cgColor
|
||||
self.checkViews[index].image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
|
||||
self.checkViews[index].tintColor = selected ? Theme.Color.accent : .tertiaryLabel
|
||||
}
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.2, animations: apply)
|
||||
} else {
|
||||
apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startQuiz() {
|
||||
guard let selectedIndex else { return }
|
||||
Haptics.selection()
|
||||
let words: [Word]
|
||||
if request.words.count > 50 {
|
||||
let count = countControl.selectedSegmentIndex == 0 ? 10 : 20
|
||||
words = Array(request.words.shuffled().prefix(count))
|
||||
} else if countControl.selectedSegmentIndex == 0 {
|
||||
words = request.words
|
||||
} else {
|
||||
words = Array(request.words.shuffled().prefix(10))
|
||||
}
|
||||
let config = QuizConfig(mode: items[selectedIndex].mode, words: words)
|
||||
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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,539 @@
|
||||
import UIKit
|
||||
|
||||
final class QuizViewController: UIViewController {
|
||||
private let config: QuizConfig
|
||||
private let speech = SpeechService.shared
|
||||
|
||||
private var questions: [QuizQuestion]
|
||||
private var currentIndex = 0
|
||||
private var selectedOption: String?
|
||||
private var wrongWords: [Word] = []
|
||||
private var startDate = Date.now
|
||||
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
private let countLabel = UILabel()
|
||||
private let promptLabel = UILabel()
|
||||
private let phoneticLabel = UILabel()
|
||||
private let speakerButton = UIButton(type: .system)
|
||||
private let optionsStack = UIStackView()
|
||||
private let spellingView = SpellingInputView()
|
||||
private let dictationView = DictationInputView()
|
||||
private let spellingHintLabel = UILabel()
|
||||
private let resultLabel = UILabel()
|
||||
private let resultPhoneticLabel = UILabel()
|
||||
private let countdownLabel = UILabel()
|
||||
private let nextButton = UIButton(type: .system)
|
||||
private var optionButtons: [UIButton] = []
|
||||
private var optionIcons: [UIImageView] = []
|
||||
private var countdownTimer: Timer?
|
||||
|
||||
private let promptStack = UIStackView()
|
||||
private let inputStack = UIStackView()
|
||||
private let resultStack = UIStackView()
|
||||
private let navSpeakerItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "speaker.wave.2.fill"),
|
||||
style: .plain,
|
||||
target: nil,
|
||||
action: nil
|
||||
)
|
||||
private lazy var caseToggleItem = UIBarButtonItem(
|
||||
title: "a", style: .plain, target: self, action: #selector(toggleCase)
|
||||
)
|
||||
|
||||
private var current: QuizQuestion? {
|
||||
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
|
||||
}
|
||||
|
||||
private var isUppercase: Bool
|
||||
|
||||
init(config: QuizConfig) {
|
||||
self.config = config
|
||||
let uppercase = UserDefaults.standard.bool(forKey: "spelling_uppercase")
|
||||
self.isUppercase = uppercase
|
||||
let words = config.words.map { word in
|
||||
let text = uppercase ? word.word.uppercased() : word.word.lowercased()
|
||||
return Word(categoryId: word.categoryId, word: text, phonetic: word.phonetic,
|
||||
meaning: word.meaning, example: word.example, exampleMeaning: word.exampleMeaning)
|
||||
}
|
||||
self.questions = QuizGenerator.makeQuestions(mode: config.mode, words: words)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit {
|
||||
countdownTimer?.invalidate()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = config.mode.rawValue
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
buildLayout()
|
||||
showQuestion()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if config.mode == .spelling, selectedOption == nil {
|
||||
spellingView.activate()
|
||||
} else if config.mode == .dictation, selectedOption == nil {
|
||||
dictationView.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func buildLayout() {
|
||||
progressView.transform = CGAffineTransform(scaleX: 1, y: 0.75)
|
||||
progressView.trackTintColor = .tertiarySystemFill
|
||||
progressView.progressTintColor = Theme.Color.accent
|
||||
|
||||
countLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
countLabel.textColor = .secondaryLabel
|
||||
countLabel.textAlignment = .center
|
||||
|
||||
promptLabel.font = config.mode == .enToZh
|
||||
? Theme.Font.wordHero(size: 40)
|
||||
: .systemFont(ofSize: 34, weight: .bold)
|
||||
promptLabel.textAlignment = .center
|
||||
promptLabel.numberOfLines = 0
|
||||
|
||||
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
||||
phoneticLabel.textColor = .secondaryLabel
|
||||
phoneticLabel.textAlignment = .center
|
||||
|
||||
speakerButton.setImage(UIImage(systemName: "speaker.wave.2.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)),
|
||||
for: .normal)
|
||||
speakerButton.tintColor = Theme.Color.accent
|
||||
speakerButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self, let question = current else { return }
|
||||
speech.speak(question.prompt)
|
||||
}, for: .touchUpInside)
|
||||
|
||||
optionsStack.axis = .vertical
|
||||
optionsStack.spacing = 12
|
||||
|
||||
spellingHintLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
spellingHintLabel.textColor = .tertiaryLabel
|
||||
spellingHintLabel.textAlignment = .center
|
||||
|
||||
resultLabel.font = .preferredFont(forTextStyle: .title2).withWeight(.bold)
|
||||
resultLabel.textAlignment = .center
|
||||
|
||||
resultPhoneticLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
resultPhoneticLabel.textColor = .secondaryLabel
|
||||
resultPhoneticLabel.textAlignment = .center
|
||||
|
||||
var nextConfig = UIButton.Configuration.borderedProminent()
|
||||
nextConfig.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||
nextConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
nextButton.configuration = nextConfig
|
||||
nextButton.addAction(UIAction { [weak self] _ in self?.primaryButtonTapped() }, for: .touchUpInside)
|
||||
|
||||
navSpeakerItem.target = self
|
||||
navSpeakerItem.action = #selector(speakCurrentWord)
|
||||
navigationItem.rightBarButtonItem = navSpeakerItem
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [progressView, countLabel])
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = 8
|
||||
|
||||
promptStack.axis = .vertical
|
||||
promptStack.spacing = 12
|
||||
promptStack.alignment = .center
|
||||
[promptLabel, phoneticLabel, speakerButton].forEach { promptStack.addArrangedSubview($0) }
|
||||
|
||||
inputStack.axis = .vertical
|
||||
inputStack.spacing = 16
|
||||
[optionsStack, spellingView, dictationView, spellingHintLabel].forEach { inputStack.addArrangedSubview($0) }
|
||||
|
||||
resultStack.axis = .vertical
|
||||
resultStack.spacing = 12
|
||||
countdownLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
countdownLabel.textColor = .tertiaryLabel
|
||||
countdownLabel.textAlignment = .center
|
||||
countdownLabel.isHidden = true
|
||||
[resultLabel, resultPhoneticLabel, countdownLabel, nextButton].forEach { resultStack.addArrangedSubview($0) }
|
||||
|
||||
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
|
||||
mainStack.axis = .vertical
|
||||
mainStack.spacing = 24
|
||||
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.alwaysBounceVertical = true
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(mainStack)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
mainStack.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.safeAreaLayoutGuide.bottomAnchor),
|
||||
|
||||
mainStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
||||
mainStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||
mainStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||
mainStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||
mainStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Question flow
|
||||
|
||||
private func showQuestion() {
|
||||
guard let question = current else { return }
|
||||
selectedOption = nil
|
||||
|
||||
progressView.setProgress(Float(currentIndex) / Float(questions.count), animated: true)
|
||||
countLabel.text = "\(currentIndex + 1) / \(questions.count)"
|
||||
promptLabel.text = question.prompt
|
||||
phoneticLabel.isHidden = config.mode != .enToZh
|
||||
speakerButton.isHidden = config.mode != .enToZh
|
||||
phoneticLabel.text = question.word.phonetic
|
||||
|
||||
resultLabel.isHidden = true
|
||||
resultPhoneticLabel.isHidden = true
|
||||
|
||||
if config.mode == .spelling {
|
||||
updateCaseButton()
|
||||
navigationItem.rightBarButtonItems = [navSpeakerItem, caseToggleItem]
|
||||
} else if config.mode == .zhToEn {
|
||||
navigationItem.rightBarButtonItems = nil
|
||||
} else {
|
||||
navigationItem.rightBarButtonItem = navSpeakerItem
|
||||
}
|
||||
|
||||
let isSpelling = config.mode == .spelling
|
||||
let isDictation = config.mode == .dictation
|
||||
let isChoice = !isSpelling && !isDictation
|
||||
optionsStack.isHidden = !isChoice
|
||||
spellingView.isHidden = !isSpelling
|
||||
dictationView.isHidden = !isDictation
|
||||
spellingHintLabel.isHidden = !isSpelling
|
||||
|
||||
if isSpelling {
|
||||
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
||||
spellingView.isUppercase = isUppercase
|
||||
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
||||
if config.isStudyMode {
|
||||
nextButton.isHidden = true
|
||||
spellingView.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
spellingView.confirmButton?.isEnabled = spellingView.isComplete
|
||||
}
|
||||
spellingView.confirmButton?.isEnabled = false
|
||||
} else {
|
||||
spellingView.hideAccessoryView()
|
||||
spellingView.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
nextButton.isEnabled = spellingView.isComplete
|
||||
}
|
||||
nextButton.configuration?.title = "确认"
|
||||
nextButton.isEnabled = false
|
||||
nextButton.isHidden = false
|
||||
}
|
||||
spellingView.onSubmit = { [weak self] in
|
||||
guard let self else { return }
|
||||
spellingView.confirmButton?.isEnabled = false
|
||||
primaryButtonTapped()
|
||||
}
|
||||
spellingView.activate()
|
||||
} else if isDictation {
|
||||
dictationView.reset()
|
||||
dictationView.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
nextButton.isEnabled = !dictationView.isEmpty
|
||||
}
|
||||
dictationView.onSubmit = { [weak self] in
|
||||
guard let self else { return }
|
||||
primaryButtonTapped()
|
||||
}
|
||||
nextButton.configuration?.title = "确认"
|
||||
nextButton.isEnabled = false
|
||||
nextButton.isHidden = false
|
||||
dictationView.activate()
|
||||
} else {
|
||||
nextButton.isHidden = true
|
||||
buildOptionButtons(for: question)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildOptionButtons(for question: QuizQuestion) {
|
||||
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
optionButtons = []
|
||||
optionIcons = []
|
||||
for option in question.options {
|
||||
let button = UIButton(type: .system)
|
||||
var buttonConfig = UIButton.Configuration.bordered()
|
||||
buttonConfig.title = option
|
||||
buttonConfig.titleAlignment = .leading
|
||||
buttonConfig.baseForegroundColor = .label
|
||||
buttonConfig.background.backgroundColor = Theme.Color.cardBackground
|
||||
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
buttonConfig.background.strokeColor = .separator
|
||||
buttonConfig.background.strokeWidth = 1
|
||||
if self.config.mode == .zhToEn, let w = self.config.words.first(where: { $0.word == option }) {
|
||||
buttonConfig.subtitle = w.phonetic
|
||||
}
|
||||
buttonConfig.titleTextAttributesTransformer = .init { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||
return outgoing
|
||||
}
|
||||
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 44)
|
||||
button.configuration = buttonConfig
|
||||
button.contentHorizontalAlignment = .leading
|
||||
|
||||
let icon = UIImageView()
|
||||
icon.isHidden = true
|
||||
icon.contentMode = .scaleAspectFit
|
||||
button.addSubview(icon)
|
||||
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
icon.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
|
||||
icon.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
||||
])
|
||||
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.selectOption(option, for: question)
|
||||
}, for: .touchUpInside)
|
||||
optionsStack.addArrangedSubview(button)
|
||||
optionButtons.append(button)
|
||||
optionIcons.append(icon)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOption(_ option: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = option
|
||||
let correct = option == question.answer
|
||||
if !correct {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
correct ? Haptics.correct() : Haptics.wrong()
|
||||
|
||||
for (index, button) in optionButtons.enumerated() {
|
||||
let value = question.options[index]
|
||||
button.isUserInteractionEnabled = false
|
||||
var buttonConfig = button.configuration
|
||||
if value == question.answer {
|
||||
buttonConfig?.background.strokeColor = Theme.Color.correct
|
||||
buttonConfig?.background.strokeWidth = 2
|
||||
buttonConfig?.background.backgroundColor = Theme.Color.correct.withAlphaComponent(0.12)
|
||||
buttonConfig?.baseForegroundColor = Theme.Color.correct
|
||||
optionIcons[index].image = UIImage(systemName: "checkmark.circle.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
||||
optionIcons[index].tintColor = Theme.Color.correct
|
||||
optionIcons[index].isHidden = false
|
||||
} else if value == option {
|
||||
buttonConfig?.background.strokeColor = Theme.Color.wrong
|
||||
buttonConfig?.background.strokeWidth = 2
|
||||
buttonConfig?.background.backgroundColor = Theme.Color.wrong.withAlphaComponent(0.12)
|
||||
buttonConfig?.baseForegroundColor = Theme.Color.wrong
|
||||
optionIcons[index].image = UIImage(systemName: "xmark.circle.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
||||
optionIcons[index].tintColor = Theme.Color.wrong
|
||||
optionIcons[index].isHidden = false
|
||||
} else {
|
||||
button.alpha = 0.4
|
||||
}
|
||||
button.configuration = buttonConfig
|
||||
}
|
||||
for icon in optionIcons where !icon.isHidden {
|
||||
icon.transform = CGAffineTransform(scaleX: 0.4, y: 0.4)
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.5,
|
||||
initialSpringVelocity: 0, options: .allowUserInteraction) {
|
||||
icon.transform = .identity
|
||||
}
|
||||
}
|
||||
navigationItem.rightBarButtonItem = navSpeakerItem
|
||||
showResult(correct, for: question)
|
||||
}
|
||||
|
||||
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = typed
|
||||
let correct = typed.lowercased() == question.answer.lowercased()
|
||||
LearnedStore.shared.record(question.word, correct: correct)
|
||||
if !correct {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
correct ? Haptics.correct() : Haptics.wrong()
|
||||
if config.isStudyMode {
|
||||
spellingView.freezeInput()
|
||||
} else {
|
||||
spellingView.deactivate()
|
||||
}
|
||||
spellingView.showResultState(correct: correct)
|
||||
showResult(correct, for: question)
|
||||
}
|
||||
|
||||
private func submitDictation(_ typed: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = typed
|
||||
let correct = typed.lowercased() == question.answer.lowercased()
|
||||
if !correct {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
correct ? Haptics.correct() : Haptics.wrong()
|
||||
dictationView.deactivate()
|
||||
dictationView.showResultState(correct: correct)
|
||||
showResult(correct, for: question)
|
||||
}
|
||||
|
||||
private func showResult(_ correct: Bool, for question: QuizQuestion) {
|
||||
if config.mode == .spelling || config.mode == .dictation {
|
||||
resultLabel.text = correct ? "正确" : "正确答案:\(question.answer)"
|
||||
resultPhoneticLabel.text = question.word.phonetic
|
||||
resultPhoneticLabel.isHidden = false
|
||||
} else {
|
||||
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
|
||||
}
|
||||
resultLabel.textColor = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||
resultLabel.isHidden = false
|
||||
|
||||
if config.isStudyMode {
|
||||
nextButton.isHidden = true
|
||||
let delay: TimeInterval = correct ? 1.0 : 3.0
|
||||
countdownLabel.text = "\(Int(delay)) 秒后继续"
|
||||
countdownLabel.isHidden = false
|
||||
countdownTimer?.invalidate()
|
||||
var remaining = Int(delay)
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
|
||||
remaining -= 1
|
||||
if remaining > 0 {
|
||||
self?.countdownLabel.text = "\(remaining) 秒后继续"
|
||||
} else {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.restart()
|
||||
}
|
||||
} else {
|
||||
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
|
||||
nextButton.isEnabled = true
|
||||
nextButton.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func toggleCase() {
|
||||
isUppercase.toggle()
|
||||
UserDefaults.standard.set(isUppercase, forKey: "spelling_uppercase")
|
||||
updateCaseButton()
|
||||
spellingView.isUppercase = isUppercase
|
||||
questions = QuizGenerator.recase(questions, toUpper: isUppercase)
|
||||
spellingView.updateCase(toUpper: isUppercase)
|
||||
}
|
||||
|
||||
private func updateCaseButton() {
|
||||
caseToggleItem.title = isUppercase ? "A" : "a"
|
||||
caseToggleItem.tintColor = .label
|
||||
}
|
||||
|
||||
@objc private func speakCurrentWord() {
|
||||
guard let question = current else { return }
|
||||
speech.speak(question.word.word)
|
||||
}
|
||||
|
||||
private func primaryButtonTapped() {
|
||||
if config.isStudyMode, selectedOption != nil {
|
||||
restart()
|
||||
} else if config.mode == .spelling, selectedOption == nil {
|
||||
guard let question = current, spellingView.isComplete else { return }
|
||||
submitSpelling(spellingView.typedWord(), for: question)
|
||||
} else if config.mode == .dictation, selectedOption == nil {
|
||||
guard let question = current, !dictationView.isEmpty else { return }
|
||||
submitDictation(dictationView.text, for: question)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
private func next() {
|
||||
nextButton.isEnabled = false
|
||||
if currentIndex + 1 < questions.count {
|
||||
currentIndex += 1
|
||||
animateQuestionSwap()
|
||||
} else {
|
||||
showQuizResult()
|
||||
}
|
||||
}
|
||||
|
||||
private func animateQuestionSwap() {
|
||||
let stacks = [promptStack, inputStack, resultStack]
|
||||
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseIn) {
|
||||
stacks.forEach { $0.alpha = 0 }
|
||||
} completion: { _ in
|
||||
self.showQuestion()
|
||||
stacks.forEach { $0.transform = CGAffineTransform(translationX: 0, y: 12) }
|
||||
UIView.animate(withDuration: 0.3, delay: 0,
|
||||
options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) {
|
||||
stacks.forEach {
|
||||
$0.alpha = 1
|
||||
$0.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showQuizResult() {
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
progressView.setProgress(1, animated: true)
|
||||
let resultView = QuizResultView(
|
||||
total: questions.count,
|
||||
wrongWords: wrongWords,
|
||||
duration: Date.now.timeIntervalSince(startDate),
|
||||
onRestart: { [weak self] in self?.restart() },
|
||||
onExit: { [weak self] in self?.navigationController?.popViewController(animated: true) },
|
||||
onSelectWord: { [weak self] word in
|
||||
self?.navigationController?.pushViewController(WordDetailViewController(word: word), animated: true)
|
||||
}
|
||||
)
|
||||
resultView.frame = view.bounds
|
||||
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
resultView.alpha = 0
|
||||
resultView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||
view.addSubview(resultView)
|
||||
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0, options: .curveEaseOut) {
|
||||
resultView.alpha = 1
|
||||
resultView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
countdownTimer?.invalidate()
|
||||
countdownLabel.isHidden = true
|
||||
view.subviews.compactMap { $0 as? QuizResultView }.forEach { $0.removeFromSuperview() }
|
||||
let words = config.words.map { word in
|
||||
let text = isUppercase ? word.word.uppercased() : word.word.lowercased()
|
||||
return Word(categoryId: word.categoryId, word: text, phonetic: word.phonetic,
|
||||
meaning: word.meaning, example: word.example, exampleMeaning: word.exampleMeaning)
|
||||
}
|
||||
questions = QuizGenerator.makeQuestions(mode: config.mode, words: words)
|
||||
currentIndex = 0
|
||||
wrongWords = []
|
||||
startDate = .now
|
||||
showQuestion()
|
||||
if config.mode == .spelling {
|
||||
spellingView.activate()
|
||||
} else if config.mode == .dictation {
|
||||
dictationView.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIFont {
|
||||
func withWeight(_ weight: UIFont.Weight) -> UIFont {
|
||||
let descriptor = fontDescriptor.addingAttributes([
|
||||
.traits: [UIFontDescriptor.TraitKey.weight: weight]
|
||||
])
|
||||
return UIFont(descriptor: descriptor, size: pointSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import UIKit
|
||||
|
||||
final class SpellingInputView: UIView, UITextFieldDelegate {
|
||||
var onChange: (() -> Void)?
|
||||
var onSubmit: (() -> Void)?
|
||||
private(set) var confirmButton: UIButton?
|
||||
|
||||
private let hiddenField = UITextField()
|
||||
private let stack = UIStackView()
|
||||
private let scrollView = UIScrollView()
|
||||
|
||||
private var chars: [String] = []
|
||||
private var blankPositions: [Int] = []
|
||||
private var filledBlanks: [String] = []
|
||||
private var cursorIndex = 0
|
||||
private var boxViews: [UIView] = []
|
||||
private var cursorView: UIView?
|
||||
private var inputEnabled = true
|
||||
|
||||
var isUppercase = false
|
||||
|
||||
var isComplete: Bool {
|
||||
!filledBlanks.isEmpty && filledBlanks.allSatisfy { !$0.isEmpty }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.clipsToBounds = false
|
||||
addSubview(scrollView)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.topAnchor.constraint(equalTo: topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
])
|
||||
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 6
|
||||
stack.alignment = .center
|
||||
stack.isUserInteractionEnabled = true
|
||||
scrollView.addSubview(stack)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor)
|
||||
])
|
||||
|
||||
hiddenField.delegate = self
|
||||
hiddenField.autocorrectionType = .no
|
||||
hiddenField.autocapitalizationType = .none
|
||||
hiddenField.spellCheckingType = .no
|
||||
hiddenField.keyboardType = .asciiCapable
|
||||
hiddenField.returnKeyType = .done
|
||||
hiddenField.frame = .zero
|
||||
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)))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(blankedWord: String, blankPositions: [Int]) {
|
||||
chars = blankedWord.map { String($0) }
|
||||
self.blankPositions = blankPositions
|
||||
filledBlanks = Array(repeating: "", count: blankPositions.count)
|
||||
cursorIndex = 0
|
||||
inputEnabled = true
|
||||
rebuildBoxes()
|
||||
}
|
||||
|
||||
func typedWord() -> String {
|
||||
var rebuilt = chars
|
||||
for (i, pos) in blankPositions.enumerated() {
|
||||
rebuilt[pos] = filledBlanks[i]
|
||||
}
|
||||
return rebuilt.joined().lowercased()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard inputEnabled, !hiddenField.isFirstResponder else { return }
|
||||
hiddenField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
inputEnabled = false
|
||||
hiddenField.resignFirstResponder()
|
||||
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) {
|
||||
let color = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||
for position in blankPositions {
|
||||
boxViews[position].layer.borderColor = color.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func popBox(atBlank blankIndex: Int) {
|
||||
guard blankPositions.indices.contains(blankIndex) else { return }
|
||||
let box = boxViews[blankPositions[blankIndex]]
|
||||
UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.5,
|
||||
initialSpringVelocity: 0, options: .allowUserInteraction) {
|
||||
box.transform = CGAffineTransform(scaleX: 1.12, y: 1.12)
|
||||
} completion: { _ in
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
box.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func reactivate() {
|
||||
activate()
|
||||
}
|
||||
|
||||
// MARK: - UITextFieldDelegate
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
guard inputEnabled else { return false }
|
||||
if string.isEmpty {
|
||||
if cursorIndex > 0 {
|
||||
cursorIndex -= 1
|
||||
filledBlanks[cursorIndex] = ""
|
||||
refreshBoxes()
|
||||
onChange?()
|
||||
}
|
||||
} else if let char = string.last, char.isLetter, cursorIndex < blankPositions.count {
|
||||
let converted = isUppercase ? char.uppercased() : char.lowercased()
|
||||
filledBlanks[cursorIndex] = converted
|
||||
cursorIndex += 1
|
||||
refreshBoxes()
|
||||
popBox(atBlank: cursorIndex - 1)
|
||||
onChange?()
|
||||
}
|
||||
textField.text = nil
|
||||
return false
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
guard isComplete else { return false }
|
||||
onSubmit?()
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Boxes
|
||||
|
||||
private func rebuildBoxes() {
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
let boxWidth: CGFloat = 32
|
||||
let fontSize: CGFloat = 20
|
||||
stack.spacing = 6
|
||||
boxViews = chars.enumerated().map { index, ch in
|
||||
let box = makeBox(fontSize: fontSize)
|
||||
box.tag = index
|
||||
if ch != "_", let label = box.viewWithTag(100) as? UILabel {
|
||||
label.text = ch
|
||||
label.isHidden = false
|
||||
}
|
||||
box.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(boxTapped(_:))))
|
||||
stack.addArrangedSubview(box)
|
||||
NSLayoutConstraint.activate([
|
||||
box.widthAnchor.constraint(equalToConstant: boxWidth),
|
||||
box.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
return box
|
||||
}
|
||||
refreshBoxes()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateAlignment()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAlignment() {
|
||||
let totalWidth = CGFloat(chars.count) * 32 + CGFloat(max(chars.count - 1, 0)) * 6
|
||||
let available = scrollView.bounds.width
|
||||
if totalWidth <= available {
|
||||
let inset = max(0, (available - totalWidth) / 2)
|
||||
scrollView.contentInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
|
||||
scrollView.isScrollEnabled = false
|
||||
} else {
|
||||
scrollView.contentInset = .zero
|
||||
scrollView.isScrollEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func boxTapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard inputEnabled, let box = gesture.view else { return }
|
||||
let index = box.tag
|
||||
guard chars[index] == "_", let blankIndex = blankPositions.firstIndex(of: index) else {
|
||||
activate()
|
||||
return
|
||||
}
|
||||
filledBlanks[blankIndex] = ""
|
||||
cursorIndex = blankIndex
|
||||
refreshBoxes()
|
||||
onChange?()
|
||||
activate()
|
||||
}
|
||||
|
||||
private func makeBox(fontSize: CGFloat) -> UIView {
|
||||
let box = UIView()
|
||||
box.backgroundColor = .quaternarySystemFill.withAlphaComponent(0.15)
|
||||
box.layer.cornerRadius = 8
|
||||
box.layer.borderWidth = 1
|
||||
box.layer.borderColor = UIColor.separator.cgColor
|
||||
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: fontSize, weight: .bold)
|
||||
label.isHidden = true
|
||||
label.tag = 100
|
||||
box.addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
label.centerXAnchor.constraint(equalTo: box.centerXAnchor),
|
||||
label.centerYAnchor.constraint(equalTo: box.centerYAnchor)
|
||||
])
|
||||
return box
|
||||
}
|
||||
|
||||
private func refreshBoxes() {
|
||||
cursorView?.removeFromSuperview()
|
||||
cursorView = nil
|
||||
|
||||
for (index, box) in boxViews.enumerated() {
|
||||
guard chars[index] == "_", let blankIndex = blankPositions.firstIndex(of: index) else {
|
||||
continue
|
||||
}
|
||||
let label = box.viewWithTag(100) as? UILabel
|
||||
let filled = filledBlanks[blankIndex]
|
||||
label?.text = filled
|
||||
label?.isHidden = filled.isEmpty
|
||||
|
||||
let isCurrent = inputEnabled && blankIndex == cursorIndex
|
||||
box.layer.borderColor = UIColor.tintColor.cgColor
|
||||
box.layer.borderWidth = isCurrent ? 2 : 1
|
||||
|
||||
if isCurrent, filled.isEmpty {
|
||||
let cursor = UIView()
|
||||
cursor.backgroundColor = .tintColor
|
||||
cursor.isUserInteractionEnabled = false
|
||||
box.addSubview(cursor)
|
||||
cursor.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
cursor.centerXAnchor.constraint(equalTo: box.centerXAnchor),
|
||||
cursor.centerYAnchor.constraint(equalTo: box.centerYAnchor),
|
||||
cursor.widthAnchor.constraint(equalToConstant: 2),
|
||||
cursor.heightAnchor.constraint(equalToConstant: 22)
|
||||
])
|
||||
cursorView = cursor
|
||||
startBlinking(cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startBlinking(_ cursor: UIView) {
|
||||
cursor.alpha = 1
|
||||
UIView.animate(withDuration: 0.6, delay: 0, options: [.repeat, .autoreverse, .allowUserInteraction]) {
|
||||
cursor.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import UIKit
|
||||
|
||||
final class WordCardCell: UITableViewCell {
|
||||
private let cardView = UIView()
|
||||
private let wordLabel = UILabel()
|
||||
private let phoneticLabel = UILabel()
|
||||
private let meaningLabel = UILabel()
|
||||
private let countLabel = UILabel()
|
||||
private let learnButton = UIButton(type: .system)
|
||||
private let speakerButton = UIButton(type: .system)
|
||||
private var word: Word?
|
||||
|
||||
var onSpeak: ((Word) -> Void)?
|
||||
var onLearn: ((Word) -> Void)?
|
||||
|
||||
var zoomSourceView: UIView { cardView }
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
cardView.backgroundColor = Theme.Color.cardBackground
|
||||
cardView.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||
contentView.addSubview(cardView)
|
||||
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
|
||||
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5)
|
||||
])
|
||||
|
||||
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
phoneticLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
phoneticLabel.textColor = .secondaryLabel
|
||||
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
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])
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 8
|
||||
|
||||
learnButton.setImage(UIImage(systemName: "book",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)),
|
||||
for: .normal)
|
||||
learnButton.tintColor = Theme.Color.accent
|
||||
learnButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self, let word else { return }
|
||||
onLearn?(word)
|
||||
}, for: .touchUpInside)
|
||||
|
||||
speakerButton.tintColor = Theme.Color.accent
|
||||
speakerButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self, let word else { return }
|
||||
onSpeak?(word)
|
||||
}, for: .touchUpInside)
|
||||
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [textStack, countLabel, learnButton, speakerButton])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 8
|
||||
cardView.addSubview(row)
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
row.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
|
||||
row.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
|
||||
row.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -8),
|
||||
row.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
|
||||
learnButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
learnButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
speakerButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
speakerButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(with word: Word) {
|
||||
self.word = word
|
||||
wordLabel.text = word.word
|
||||
phoneticLabel.text = word.phonetic
|
||||
meaningLabel.text = word.meaning
|
||||
countLabel.isHidden = true
|
||||
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() {
|
||||
guard let word else { return }
|
||||
let speaking = SpeechService.shared.isSpeaking(word.word)
|
||||
let symbol = speaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill"
|
||||
speakerButton.setImage(UIImage(systemName: symbol,
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)),
|
||||
for: .normal)
|
||||
speakerButton.isEnabled = !speaking
|
||||
}
|
||||
|
||||
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
super.setHighlighted(highlighted, animated: animated)
|
||||
cardView.setPressed(highlighted, animated: animated)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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,218 @@
|
||||
import SafariServices
|
||||
import UIKit
|
||||
|
||||
final class WordDetailViewController: UIViewController {
|
||||
private let word: Word
|
||||
private let categoryName: String
|
||||
private let speech = SpeechService.shared
|
||||
|
||||
private let speakButton = UIButton(type: .system)
|
||||
private let exampleSpeakButton = UIButton(type: .system)
|
||||
private let contentStack = UIStackView()
|
||||
private var speechObserver: NSObjectProtocol?
|
||||
|
||||
init(word: Word, categoryName: String = "") {
|
||||
self.word = word
|
||||
self.categoryName = categoryName
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit {
|
||||
if let speechObserver {
|
||||
NotificationCenter.default.removeObserver(speechObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = categoryName
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
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()
|
||||
updateSpeakState()
|
||||
speechObserver = NotificationCenter.default.addObserver(
|
||||
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.updateSpeakState()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSpeakState() {
|
||||
let wordSpeaking = speech.isSpeaking(word.word)
|
||||
speakButton.configuration?.image = UIImage(
|
||||
systemName: wordSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
||||
speakButton.isEnabled = !wordSpeaking
|
||||
|
||||
let exampleSpeaking = speech.isSpeaking(word.example)
|
||||
exampleSpeakButton.configuration?.image = UIImage(
|
||||
systemName: exampleSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))
|
||||
exampleSpeakButton.isEnabled = !exampleSpeaking
|
||||
}
|
||||
|
||||
private func buildLayout() {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.alwaysBounceVertical = true
|
||||
view.addSubview(scrollView)
|
||||
scrollView.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)
|
||||
])
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 32
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 24),
|
||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||
contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||
])
|
||||
|
||||
contentStack.addArrangedSubview(makeHeader())
|
||||
contentStack.addArrangedSubview(makeCard())
|
||||
}
|
||||
|
||||
private func makeHeader() -> UIView {
|
||||
let wordLabel = UILabel()
|
||||
wordLabel.text = word.word
|
||||
wordLabel.font = Theme.Font.wordHero()
|
||||
wordLabel.textAlignment = .center
|
||||
|
||||
let phoneticLabel = UILabel()
|
||||
phoneticLabel.text = word.phonetic
|
||||
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
||||
phoneticLabel.textColor = .secondaryLabel
|
||||
|
||||
var buttonConfig = UIButton.Configuration.filled()
|
||||
buttonConfig.baseBackgroundColor = Theme.Color.accent
|
||||
buttonConfig.baseForegroundColor = .white
|
||||
buttonConfig.cornerStyle = .capsule
|
||||
speakButton.configuration = buttonConfig
|
||||
speakButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self else { return }
|
||||
speech.speak(word.word)
|
||||
}, for: .touchUpInside)
|
||||
speakButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
speakButton.widthAnchor.constraint(equalToConstant: 56),
|
||||
speakButton.heightAnchor.constraint(equalToConstant: 56)
|
||||
])
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, speakButton])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.alignment = .center
|
||||
return stack
|
||||
}
|
||||
|
||||
private func makeCard() -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = Theme.Color.cardBackground
|
||||
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||
|
||||
let meaningTitle = makeCaption("释义")
|
||||
let meaningLabel = UILabel()
|
||||
meaningLabel.text = word.meaning
|
||||
meaningLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
meaningLabel.numberOfLines = 0
|
||||
|
||||
var searchConfig = UIButton.Configuration.plain()
|
||||
searchConfig.baseForegroundColor = Theme.Color.accent
|
||||
searchConfig.image = UIImage(systemName: "magnifyingglass",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))
|
||||
let searchButton = UIButton(type: .system)
|
||||
searchButton.configuration = searchConfig
|
||||
searchButton.addAction(UIAction { [weak self] _ in self?.searchMeaning() }, for: .touchUpInside)
|
||||
searchButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
let meaningRow = UIStackView(arrangedSubviews: [meaningLabel, searchButton])
|
||||
meaningRow.axis = .horizontal
|
||||
meaningRow.alignment = .top
|
||||
meaningRow.spacing = 8
|
||||
|
||||
let divider = HairlineView()
|
||||
|
||||
let exampleTitle = makeCaption("例句")
|
||||
let exampleLabel = UILabel()
|
||||
exampleLabel.text = word.example
|
||||
exampleLabel.numberOfLines = 0
|
||||
let exampleMeaningLabel = UILabel()
|
||||
exampleMeaningLabel.text = word.exampleMeaning
|
||||
exampleMeaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
exampleMeaningLabel.textColor = .secondaryLabel
|
||||
exampleMeaningLabel.numberOfLines = 0
|
||||
|
||||
var ghostConfig = UIButton.Configuration.plain()
|
||||
ghostConfig.baseForegroundColor = Theme.Color.accent
|
||||
exampleSpeakButton.configuration = ghostConfig
|
||||
exampleSpeakButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self else { return }
|
||||
speech.speak(word.example)
|
||||
}, for: .touchUpInside)
|
||||
exampleSpeakButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
let exampleTextStack = UIStackView(arrangedSubviews: [exampleLabel, exampleMeaningLabel])
|
||||
exampleTextStack.axis = .vertical
|
||||
exampleTextStack.spacing = 4
|
||||
let exampleRow = UIStackView(arrangedSubviews: [exampleTextStack, exampleSpeakButton])
|
||||
exampleRow.axis = .horizontal
|
||||
exampleRow.alignment = .top
|
||||
exampleRow.spacing = 8
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [meaningTitle, meaningRow, divider, exampleTitle, exampleRow])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.setCustomSpacing(6, after: meaningTitle)
|
||||
stack.setCustomSpacing(6, after: exampleTitle)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@objc private func showHistory() {
|
||||
navigationController?.pushViewController(WordHistoryViewController(word: word), animated: true)
|
||||
}
|
||||
|
||||
@objc private func startSpelling() {
|
||||
Haptics.selection()
|
||||
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
||||
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
||||
}
|
||||
|
||||
private func searchMeaning() {
|
||||
Haptics.selection()
|
||||
let text = "\(word.meaning) 是什么意思"
|
||||
guard let query = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "https://www.google.com/search?q=\(query)") else { return }
|
||||
present(SFSafariViewController(url: url), animated: true)
|
||||
}
|
||||
|
||||
private func makeCaption(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .preferredFont(forTextStyle: .caption1)
|
||||
label.textColor = .secondaryLabel
|
||||
return label
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import UIKit
|
||||
|
||||
final class WordListViewController: UIViewController {
|
||||
private let category: WordCategory
|
||||
private let colorIndex: Int
|
||||
private var words: [Word]
|
||||
private var subcategories: [Subcategory]?
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let ctaBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
private let quizButton = UIButton(type: .system)
|
||||
private var speechObserver: NSObjectProtocol?
|
||||
|
||||
private var displaySubcategories: [Subcategory]? {
|
||||
subcategories.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
|
||||
init(category: WordCategory, colorIndex: Int) {
|
||||
self.category = category
|
||||
self.colorIndex = colorIndex
|
||||
self.words = category.words
|
||||
self.subcategories = category.subcategories
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit {
|
||||
if let speechObserver {
|
||||
NotificationCenter.default.removeObserver(speechObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = category.name
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "arrow.triangle.2.circlepath"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(shuffleWords)
|
||||
)
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .systemBackground
|
||||
tableView.tableHeaderView = makeHeader()
|
||||
tableView.contentInset.bottom = 92
|
||||
tableView.verticalScrollIndicatorInsets.bottom = 92
|
||||
|
||||
var buttonConfig = UIButton.Configuration.borderedProminent()
|
||||
buttonConfig.title = "开始测验"
|
||||
buttonConfig.image = UIImage(systemName: "pencil.and.list.clipboard")
|
||||
buttonConfig.imagePadding = 6
|
||||
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
|
||||
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
buttonConfig.titleTextAttributesTransformer = .init { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||
return outgoing
|
||||
}
|
||||
quizButton.configuration = buttonConfig
|
||||
quizButton.addAction(UIAction { [weak self] _ in self?.startQuiz() }, for: .touchUpInside)
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(ctaBackground)
|
||||
ctaBackground.contentView.addSubview(quizButton)
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
ctaBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||
quizButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
ctaBackground.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
ctaBackground.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
ctaBackground.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
quizButton.topAnchor.constraint(equalTo: ctaBackground.contentView.topAnchor, constant: 12),
|
||||
quizButton.leadingAnchor.constraint(equalTo: ctaBackground.contentView.leadingAnchor, constant: 16),
|
||||
quizButton.trailingAnchor.constraint(equalTo: ctaBackground.contentView.trailingAnchor, constant: -16),
|
||||
quizButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12)
|
||||
])
|
||||
|
||||
speechObserver = NotificationCenter.default.addObserver(
|
||||
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.updateVisibleSpeakers()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeader() -> UIView {
|
||||
let color = Theme.Color.category(at: colorIndex)
|
||||
let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 56))
|
||||
|
||||
let tile = UIView()
|
||||
tile.backgroundColor = color.withAlphaComponent(0.12)
|
||||
tile.layer.cornerRadius = 10
|
||||
let icon = UIImageView(image: UIImage(systemName: category.icon,
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)))
|
||||
icon.tintColor = color
|
||||
icon.contentMode = .scaleAspectFit
|
||||
tile.addSubview(icon)
|
||||
header.addSubview(tile)
|
||||
tile.translatesAutoresizingMaskIntoConstraints = false
|
||||
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "共 \(category.words.count) 个单词"
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.textColor = .secondaryLabel
|
||||
header.addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
tile.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 16),
|
||||
tile.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
||||
tile.widthAnchor.constraint(equalToConstant: 32),
|
||||
tile.heightAnchor.constraint(equalToConstant: 32),
|
||||
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
||||
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
||||
label.leadingAnchor.constraint(equalTo: tile.trailingAnchor, constant: 10),
|
||||
label.centerYAnchor.constraint(equalTo: header.centerYAnchor)
|
||||
])
|
||||
return header
|
||||
}
|
||||
|
||||
private func startQuiz() {
|
||||
Haptics.selection()
|
||||
let quizWords: [Word]
|
||||
if category.id == "numbers" {
|
||||
quizWords = NumberWords.randomWords(count: 50)
|
||||
} else {
|
||||
quizWords = words
|
||||
}
|
||||
let request = QuizSetupRequest(words: quizWords)
|
||||
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() {
|
||||
for cell in tableView.visibleCells.compactMap({ $0 as? WordCardCell }) {
|
||||
cell.updateSpeakerState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if let subs = displaySubcategories {
|
||||
return subs[section].words.count
|
||||
}
|
||||
return words.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
||||
let word: Word
|
||||
if let subs = displaySubcategories {
|
||||
word = subs[indexPath.section].words[indexPath.row]
|
||||
} else {
|
||||
word = words[indexPath.row]
|
||||
}
|
||||
cell.configure(with: word)
|
||||
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
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
Haptics.selection()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user