Compare commits

..

2 Commits

Author SHA1 Message Date
fish 2220f4be52 应用改用 UIKit 重写,删除 SwiftUI 版本
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 22:24:12 +08:00
fish 4e3146f9fe 拼写改为在空缺字母格内直接填字母,填完自动提交
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 21:56:58 +08:00
24 changed files with 1148 additions and 743 deletions
@@ -7,19 +7,32 @@
objects = { objects = {
/* Begin PBXFileReference section */ /* 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 */ /* 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 */ /* Begin PBXFileSystemSynchronizedRootGroup section */
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */ = { CF999C8E300BBDE3004D66A6 /* LearnEnglish */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CF999C9E300BBDE4004D66A6 /* Exceptions for "LearnEnglish" folder in "LearnEnglish" target */,
);
path = LearnEnglish; path = LearnEnglish;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
CF43E865300A7CFA00CB9AE3 /* Frameworks */ = { CF999C89300BBDE3004D66A6 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -29,18 +42,18 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
CF43E85F300A7CFA00CB9AE3 = { CF999C83300BBDE3004D66A6 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */, CF999C8E300BBDE3004D66A6 /* LearnEnglish */,
CF43E869300A7CFA00CB9AE3 /* Products */, CF999C8D300BBDE3004D66A6 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
CF43E869300A7CFA00CB9AE3 /* Products */ = { CF999C8D300BBDE3004D66A6 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */, CF999C8C300BBDE3004D66A6 /* LearnEnglish.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -48,64 +61,64 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
CF43E867300A7CFA00CB9AE3 /* LearnEnglish */ = { CF999C8B300BBDE3004D66A6 /* LearnEnglish */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = CF43E873300A7CFB00CB9AE3 /* Build configuration list for PBXNativeTarget "LearnEnglish" */; buildConfigurationList = CF999C9F300BBDE4004D66A6 /* Build configuration list for PBXNativeTarget "LearnEnglish" */;
buildPhases = ( buildPhases = (
CF43E864300A7CFA00CB9AE3 /* Sources */, CF999C88300BBDE3004D66A6 /* Sources */,
CF43E865300A7CFA00CB9AE3 /* Frameworks */, CF999C89300BBDE3004D66A6 /* Frameworks */,
CF43E866300A7CFA00CB9AE3 /* Resources */, CF999C8A300BBDE3004D66A6 /* Resources */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
CF43E86A300A7CFA00CB9AE3 /* LearnEnglish */, CF999C8E300BBDE3004D66A6 /* LearnEnglish */,
); );
name = LearnEnglish; name = LearnEnglish;
packageProductDependencies = ( packageProductDependencies = (
); );
productName = LearnEnglish; productName = LearnEnglish;
productReference = CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */; productReference = CF999C8C300BBDE3004D66A6 /* LearnEnglish.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
CF43E860300A7CFA00CB9AE3 /* Project object */ = { CF999C84300BBDE3004D66A6 /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630; LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630; LastUpgradeCheck = 2630;
TargetAttributes = { TargetAttributes = {
CF43E867300A7CFA00CB9AE3 = { CF999C8B300BBDE3004D66A6 = {
CreatedOnToolsVersion = 26.3; CreatedOnToolsVersion = 26.3;
}; };
}; };
}; };
buildConfigurationList = CF43E863300A7CFA00CB9AE3 /* Build configuration list for PBXProject "LearnEnglish" */; buildConfigurationList = CF999C87300BBDE3004D66A6 /* Build configuration list for PBXProject "LearnEnglish" */;
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
en, en,
Base, Base,
); );
mainGroup = CF43E85F300A7CFA00CB9AE3; mainGroup = CF999C83300BBDE3004D66A6;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = CF43E869300A7CFA00CB9AE3 /* Products */; productRefGroup = CF999C8D300BBDE3004D66A6 /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
CF43E867300A7CFA00CB9AE3 /* LearnEnglish */, CF999C8B300BBDE3004D66A6 /* LearnEnglish */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
CF43E866300A7CFA00CB9AE3 /* Resources */ = { CF999C8A300BBDE3004D66A6 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -115,7 +128,7 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
CF43E864300A7CFA00CB9AE3 /* Sources */ = { CF999C88300BBDE3004D66A6 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -125,7 +138,71 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration 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_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_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; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
@@ -178,16 +255,18 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
name = Debug; name = Debug;
}; };
CF43E872300A7CFB00CB9AE3 /* Release */ = { CF999CA3300BBDE4004D66A6 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
@@ -234,123 +313,38 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; VALIDATE_PRODUCT = YES;
name = Release;
};
CF43E874300A7CFB00CB9AE3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 37HGUEC495;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 26;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fishestlife.english;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26;
};
name = Debug;
};
CF43E875300A7CFB00CB9AE3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 37HGUEC495;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 26;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.fishestlife.english;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26;
}; };
name = Release; name = Release;
}; };
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
CF43E863300A7CFA00CB9AE3 /* Build configuration list for PBXProject "LearnEnglish" */ = { CF999C87300BBDE3004D66A6 /* Build configuration list for PBXProject "LearnEnglish" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
CF43E871300A7CFB00CB9AE3 /* Debug */, CF999CA2300BBDE4004D66A6 /* Debug */,
CF43E872300A7CFB00CB9AE3 /* Release */, CF999CA3300BBDE4004D66A6 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
CF43E873300A7CFB00CB9AE3 /* Build configuration list for PBXNativeTarget "LearnEnglish" */ = { CF999C9F300BBDE4004D66A6 /* Build configuration list for PBXNativeTarget "LearnEnglish" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
CF43E874300A7CFB00CB9AE3 /* Debug */, CF999CA0300BBDE4004D66A6 /* Debug */,
CF43E875300A7CFB00CB9AE3 /* Release */, CF999CA1300BBDE4004D66A6 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "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" : { "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()
}
+23
View File
@@ -0,0 +1,23 @@
<?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>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,22 @@
//
// 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)
let store = WordStore()
store.load()
let root = CategoryListViewController(store: store)
window.rootViewController = UINavigationController(rootViewController: root)
window.makeKeyAndVisible()
self.window = window
}
}
@@ -1,9 +1,11 @@
import AVFoundation import AVFoundation
@Observable
final class SpeechService: NSObject { final class SpeechService: NSObject {
static let shared = SpeechService()
private let synthesizer = AVSpeechSynthesizer() private let synthesizer = AVSpeechSynthesizer()
private(set) var speakingText: String? private(set) var speakingText: String?
var onSpeakingChanged: (() -> Void)?
override init() { override init() {
super.init() super.init()
@@ -18,6 +20,7 @@ final class SpeechService: NSObject {
utterance.voice = AVSpeechSynthesisVoice(language: "en-US") utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9 utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
speakingText = text speakingText = text
onSpeakingChanged?()
synthesizer.speak(utterance) synthesizer.speak(utterance)
} }
} }
@@ -25,9 +28,11 @@ final class SpeechService: NSObject {
extension SpeechService: AVSpeechSynthesizerDelegate { extension SpeechService: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
speakingText = nil speakingText = nil
onSpeakingChanged?()
} }
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
speakingText = nil speakingText = nil
onSpeakingChanged?()
} }
} }
@@ -12,7 +12,6 @@ enum WordStoreError: Error, LocalizedError {
} }
} }
@Observable
final class WordStore { final class WordStore {
private(set) var bank: WordBank? private(set) var bank: WordBank?
private(set) var loadError: String? private(set) var loadError: String?
@@ -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,66 @@
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 = "单词分类"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
if let error = store.loadError {
showError(error)
}
}
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
tableView.separatorStyle = .none
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
store.categories.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let category = store.categories[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = category.name
content.textProperties.font = .preferredFont(forTextStyle: .headline)
content.secondaryText = "\(category.words.count) 个单词"
content.secondaryTextProperties.color = .secondaryLabel
content.image = UIImage(systemName: category.icon)
content.imageProperties.tintColor = .tintColor
content.imageProperties.preferredSymbolConfiguration = .init(font: .preferredFont(forTextStyle: .title2))
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let category = store.categories[indexPath.row]
navigationController?.pushViewController(WordListViewController(category: category), animated: true)
}
}
@@ -1,11 +1,12 @@
import SwiftUI import UIKit
struct QuizResultView: View { final class QuizResultView: UIView {
let total: Int private let total: Int
let wrongWords: [Word] private let wrongWords: [Word]
let duration: TimeInterval private let duration: TimeInterval
let onRestart: () -> Void private let onRestart: () -> Void
let onExit: () -> Void private let onExit: () -> Void
private let onSelectWord: (Word) -> Void
private var correctCount: Int { total - wrongWords.count } private var correctCount: Int { total - wrongWords.count }
@@ -14,57 +15,140 @@ struct QuizResultView: View {
return seconds >= 60 ? "\(seconds / 60)\(seconds % 60)" : "\(seconds)" return seconds >= 60 ? "\(seconds / 60)\(seconds % 60)" : "\(seconds)"
} }
var body: some View { init(total: Int, wrongWords: [Word], duration: TimeInterval,
List { onRestart: @escaping () -> Void, onExit: @escaping () -> Void,
Section { onSelectWord: @escaping (Word) -> Void) {
VStack(spacing: 8) { self.total = total
Text("\(correctCount) / \(total)") self.wrongWords = wrongWords
.font(.system(size: 44, weight: .bold)) self.duration = duration
Text("用时 \(durationText)") self.onRestart = onRestart
.font(.subheadline) self.onExit = onExit
.foregroundStyle(.secondary) self.onSelectWord = onSelectWord
} super.init(frame: .zero)
.frame(maxWidth: .infinity) backgroundColor = .systemBackground
.padding(.vertical, 16) buildLayout()
} }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func buildLayout() {
let scrollView = UIScrollView()
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: 24),
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)
])
let scoreLabel = UILabel()
scoreLabel.text = "\(correctCount) / \(total)"
scoreLabel.font = .systemFont(ofSize: 44, weight: .bold)
scoreLabel.textAlignment = .center
let durationLabel = UILabel()
durationLabel.text = "用时 \(durationText)"
durationLabel.font = .preferredFont(forTextStyle: .subheadline)
durationLabel.textColor = .secondaryLabel
durationLabel.textAlignment = .center
let scoreStack = UIStackView(arrangedSubviews: [scoreLabel, durationLabel])
scoreStack.axis = .vertical
scoreStack.spacing = 8
stack.addArrangedSubview(scoreStack)
if !wrongWords.isEmpty { if !wrongWords.isEmpty {
Section("错题(\(wrongWords.count)") { stack.addArrangedSubview(makeWrongWordsSection())
ForEach(wrongWords) { word in
NavigationLink(value: AppRoute.word(word)) {
HStack {
Text(word.word)
.font(.headline)
Spacer()
Text(word.meaning)
.foregroundStyle(.secondary)
}
}
}
}
} }
Section { let restartButton = makeButton(title: "再来一轮", prominent: true) { [weak self] in
Button("再来一轮", action: onRestart) self?.onRestart()
.frame(maxWidth: .infinity)
Button("返回", action: onExit)
.frame(maxWidth: .infinity)
.foregroundStyle(.secondary)
} }
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)
} }
}
#Preview { private func makeWrongWordsSection() -> UIView {
NavigationStack { let titleLabel = UILabel()
QuizResultView( titleLabel.text = "错题(\(wrongWords.count)"
total: 10, titleLabel.font = .preferredFont(forTextStyle: .headline)
wrongWords: [
Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。") let section = UIStackView(arrangedSubviews: [titleLabel])
], section.axis = .vertical
duration: 75, section.spacing = 8
onRestart: {},
onExit: {} for word in wrongWords {
) let button = UIButton(type: .system)
var config = UIButton.Configuration.plain()
config.contentInsets = .init(top: 8, leading: 12, bottom: 8, trailing: 12)
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.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: 8),
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -8)
])
button.addAction(UIAction { [weak self] _ in
self?.onSelectWord(word)
}, for: .touchUpInside)
section.addArrangedSubview(button)
let divider = UIView()
divider.backgroundColor = .separator
divider.translatesAutoresizingMaskIntoConstraints = false
divider.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale).isActive = true
section.addArrangedSubview(divider)
}
return section
}
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)
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,44 @@
import UIKit
final class QuizSetupViewController: UITableViewController {
private let request: QuizSetupRequest
init(request: QuizSetupRequest) {
self.request = request
super.init(style: .plain)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
title = request.mode.rawValue
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.textProperties.font = .preferredFont(forTextStyle: .headline)
content.secondaryTextProperties.color = .secondaryLabel
if indexPath.row == 0 {
content.text = "全部随机测试"
content.secondaryText = "\(request.words.count)"
} else {
content.text = "随机十个"
content.secondaryText = "从词库中随机抽取 10 题"
}
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let words = indexPath.row == 0 ? request.words : Array(request.words.shuffled().prefix(10))
navigationController?.pushViewController(QuizViewController(config: QuizConfig(mode: request.mode, words: words)), animated: true)
}
}
@@ -1,242 +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 userInput = ""
@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 {
Text(question.word.phonetic)
.font(.title3)
.foregroundStyle(.secondary)
Button {
speech.speak(question.prompt)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title2)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
if config.mode == .spelling {
spellingView(question)
} else {
optionsView(question)
}
if let selectedOption {
VStack(spacing: 12) {
if config.mode == .spelling {
Text(selectedOption == question.answer ? "正确" : "正确答案:\(question.answer)")
.font(.title2.weight(.bold))
.foregroundStyle(selectedOption == question.answer ? .green : .red)
Text(question.word.phonetic)
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text(selectedOption == question.answer ? "回答正确" : "正确答案:\(question.answer)")
.font(.headline)
.foregroundStyle(selectedOption == question.answer ? .green : .red)
}
Button {
next()
} label: {
Text(currentIndex + 1 < questions.count ? "下一题" : "查看成绩")
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.borderedProminent)
.font(.headline)
}
}
Spacer()
}
.padding()
}
private func optionsView(_ question: QuizQuestion) -> some View {
VStack(spacing: 12) {
ForEach(question.options, id: \.self) { option in
Button {
select(option, for: question)
} label: {
VStack(spacing: 4) {
Text(option)
.font(.headline)
if config.mode == .zhToEn, let w = config.words.first(where: { $0.word == option }) {
Text(w.phonetic)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(optionColor(option, for: question))
.disabled(selectedOption != nil)
}
}
}
private func spellingView(_ question: QuizQuestion) -> some View {
VStack(spacing: 24) {
Text(question.prompt)
.font(.title.weight(.medium))
.foregroundStyle(.secondary)
VStack(spacing: 8) {
HStack(spacing: 6) {
ForEach(Array(question.blankedWord.enumerated()), id: \.offset) { i, ch in
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary.opacity(0.15))
.frame(width: 36, height: 44)
RoundedRectangle(cornerRadius: 8)
.stroke(.quaternary, lineWidth: 1)
.frame(width: 36, height: 44)
if ch == "_" {
Rectangle()
.fill(.secondary.opacity(0.5))
.frame(width: 16, height: 2)
.offset(y: 12)
} else {
Text(ch.uppercased())
.font(.title2.weight(.bold))
}
}
}
}
Text("\(question.word.word.count) 个字母,填写完整单词")
.font(.caption)
.foregroundStyle(.tertiary)
}
TextField("在此输入完整单词", text: $userInput)
.font(.body)
.autocapitalization(.none)
.disableAutocorrection(true)
.disabled(selectedOption != nil)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.quaternary.opacity(0.2), in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(.quaternary, lineWidth: 1)
)
Button("确认") {
let answer = userInput.trimmingCharacters(in: .whitespaces).lowercased()
guard !answer.isEmpty else { return }
selectedOption = answer
if answer != question.answer {
wrongWords.append(question.word)
}
}
.buttonStyle(.borderedProminent)
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 44)
.disabled(userInput.trimmingCharacters(in: .whitespaces).isEmpty || selectedOption != nil)
}
}
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
userInput = ""
} else {
finished = true
}
}
private func restart() {
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
currentIndex = 0
selectedOption = nil
userInput = ""
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,273 @@
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 spellingHintLabel = UILabel()
private let resultLabel = UILabel()
private let resultPhoneticLabel = UILabel()
private let nextButton = UIButton(type: .system)
private var optionButtons: [UIButton] = []
private var current: QuizQuestion? {
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
}
init(config: QuizConfig) {
self.config = config
self.questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = config.mode.rawValue
buildLayout()
showQuestion()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if config.mode == .spelling, selectedOption == nil {
spellingView.activate()
}
}
// MARK: - Layout
private func buildLayout() {
countLabel.font = .preferredFont(forTextStyle: .caption1)
countLabel.textColor = .secondaryLabel
countLabel.textAlignment = .center
promptLabel.font = .systemFont(ofSize: config.mode == .enToZh ? 44 : 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"), for: .normal)
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)
nextButton.configuration = nextConfig
nextButton.addAction(UIAction { [weak self] _ in self?.next() }, for: .touchUpInside)
let headerStack = UIStackView(arrangedSubviews: [progressView, countLabel])
headerStack.axis = .vertical
headerStack.spacing = 8
let promptStack = UIStackView(arrangedSubviews: [promptLabel, phoneticLabel, speakerButton])
promptStack.axis = .vertical
promptStack.spacing = 12
promptStack.alignment = .center
let inputStack = UIStackView(arrangedSubviews: [optionsStack, spellingView, spellingHintLabel])
inputStack.axis = .vertical
inputStack.spacing = 16
let resultStack = UIStackView(arrangedSubviews: [resultLabel, resultPhoneticLabel, nextButton])
resultStack.axis = .vertical
resultStack.spacing = 12
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
mainStack.axis = .vertical
mainStack.spacing = 24
view.addSubview(mainStack)
mainStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
mainStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
mainStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
mainStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
mainStack.bottomAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
])
}
// MARK: - Question flow
private func showQuestion() {
guard let question = current else { return }
selectedOption = nil
progressView.progress = Float(currentIndex) / Float(questions.count)
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
nextButton.isHidden = true
let isSpelling = config.mode == .spelling
optionsStack.isHidden = isSpelling
spellingView.isHidden = !isSpelling
spellingHintLabel.isHidden = !isSpelling
if isSpelling {
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
spellingView.onComplete = { [weak self] typed in
self?.submitSpelling(typed, for: question)
}
spellingView.activate()
} else {
buildOptionButtons(for: question)
}
}
private func buildOptionButtons(for question: QuizQuestion) {
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
optionButtons = question.options.map { option in
let button = UIButton(type: .system)
var config = UIButton.Configuration.bordered()
config.title = option
config.titleTextAttributesTransformer = .init { incoming in
var outgoing = incoming
outgoing.font = .preferredFont(forTextStyle: .headline)
return outgoing
}
if self.config.mode == .zhToEn, let w = self.config.words.first(where: { $0.word == option }) {
config.subtitle = w.phonetic
}
config.contentInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
button.configuration = config
button.addAction(UIAction { [weak self] _ in
self?.selectOption(option, for: question)
}, for: .touchUpInside)
optionsStack.addArrangedSubview(button)
return button
}
}
private func selectOption(_ option: String, for question: QuizQuestion) {
guard selectedOption == nil else { return }
selectedOption = option
if option != question.answer {
wrongWords.append(question.word)
}
for (index, button) in optionButtons.enumerated() {
let value = question.options[index]
button.isEnabled = false
if value == question.answer {
button.configuration?.baseForegroundColor = .systemGreen
} else if value == option {
button.configuration?.baseForegroundColor = .systemRed
} else {
button.configuration?.baseForegroundColor = .systemGray
}
}
showResult(option == question.answer, for: question)
}
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
guard selectedOption == nil else { return }
selectedOption = typed
if typed != question.answer {
wrongWords.append(question.word)
}
spellingView.deactivate()
showResult(typed == question.answer, for: question)
}
private func showResult(_ correct: Bool, for question: QuizQuestion) {
if config.mode == .spelling {
resultLabel.text = correct ? "正确" : "正确答案:\(question.answer)"
resultPhoneticLabel.text = question.word.phonetic
resultPhoneticLabel.isHidden = false
} else {
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
}
resultLabel.textColor = correct ? .systemGreen : .systemRed
resultLabel.isHidden = false
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
nextButton.isHidden = false
}
private func next() {
if currentIndex + 1 < questions.count {
currentIndex += 1
showQuestion()
} else {
showQuizResult()
}
}
private func showQuizResult() {
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.translatesAutoresizingMaskIntoConstraints = false
resultView.frame = view.bounds
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(resultView)
}
private func restart() {
view.subviews.compactMap { $0 as? QuizResultView }.forEach { $0.removeFromSuperview() }
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
currentIndex = 0
wrongWords = []
startDate = .now
showQuestion()
if config.mode == .spelling {
spellingView.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,177 @@
import UIKit
final class SpellingInputView: UIView, UITextFieldDelegate {
var onComplete: ((String) -> Void)?
private let hiddenField = UITextField()
private let stack = UIStackView()
private var chars: [String] = []
private var blankPositions: [Int] = []
private var filledBlanks: [String] = []
private var currentBlankIndex = 0
private var boxViews: [UIView] = []
private var cursorView: UIView?
private var inputEnabled = true
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .horizontal
stack.spacing = 6
stack.alignment = .center
addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor)
])
hiddenField.delegate = self
hiddenField.autocorrectionType = .no
hiddenField.autocapitalizationType = .none
hiddenField.spellCheckingType = .no
hiddenField.keyboardType = .asciiCapable
hiddenField.frame = .zero
addSubview(hiddenField)
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)
currentBlankIndex = 0
inputEnabled = true
rebuildBoxes()
}
func activate() {
guard inputEnabled, !hiddenField.isFirstResponder else { return }
hiddenField.becomeFirstResponder()
}
func deactivate() {
inputEnabled = false
hiddenField.resignFirstResponder()
refreshBoxes()
}
@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 currentBlankIndex > 0 {
currentBlankIndex -= 1
filledBlanks[currentBlankIndex] = ""
refreshBoxes()
}
} else if let char = string.last, char.isLetter, currentBlankIndex < blankPositions.count {
filledBlanks[currentBlankIndex] = String(char).lowercased()
currentBlankIndex += 1
refreshBoxes()
if currentBlankIndex == blankPositions.count {
var rebuilt = chars
for (i, pos) in blankPositions.enumerated() {
rebuilt[pos] = filledBlanks[i]
}
onComplete?(rebuilt.joined().lowercased())
}
}
textField.text = nil
return false
}
// MARK: - Boxes
private func rebuildBoxes() {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
boxViews = chars.enumerated().map { index, ch in
let box = makeBox()
if ch != "_", let label = box.subviews.compactMap({ $0 as? UILabel }).first {
label.text = ch.uppercased()
label.isHidden = false
}
stack.addArrangedSubview(box)
NSLayoutConstraint.activate([
box.widthAnchor.constraint(equalToConstant: 36),
box.heightAnchor.constraint(equalToConstant: 44)
])
return box
}
refreshBoxes()
}
private func makeBox() -> 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 = .preferredFont(forTextStyle: .title2)
label.font = .systemFont(ofSize: label.font.pointSize, 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.uppercased()
label?.isHidden = filled.isEmpty
let isCurrent = inputEnabled && blankIndex == currentBlankIndex
box.layer.borderColor = isCurrent ? UIColor.tintColor.cgColor : UIColor.separator.cgColor
box.layer.borderWidth = isCurrent ? 2 : 1
if isCurrent, filled.isEmpty {
let cursor = UIView()
cursor.backgroundColor = .tintColor
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
}
}
}
@@ -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,164 @@
import UIKit
final class WordDetailViewController: UIViewController {
private let word: Word
private let speech = SpeechService.shared
private let speakButton = UIButton(type: .system)
private var isSpeaking: Bool { speech.speakingText == word.word }
init(word: Word) {
self.word = word
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = word.word
navigationItem.largeTitleDisplayMode = .never
buildLayout()
updateSpeakButton()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
speech.onSpeakingChanged = { [weak self] in
self?.updateSpeakButton()
}
}
private func updateSpeakButton() {
var config = speakButton.configuration
config?.title = isSpeaking ? "朗读中" : "发音"
config?.image = UIImage(systemName: isSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill")
speakButton.configuration = config
speakButton.isEnabled = !isSpeaking
}
private func buildLayout() {
let scrollView = UIScrollView()
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)
])
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 32
scrollView.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
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)
])
stack.addArrangedSubview(makeHeader())
stack.addArrangedSubview(makeCard())
}
private func makeHeader() -> UIView {
let wordLabel = UILabel()
wordLabel.text = word.word
wordLabel.font = .systemFont(ofSize: 48, weight: .bold)
wordLabel.textAlignment = .center
let phoneticLabel = UILabel()
phoneticLabel.text = word.phonetic
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
phoneticLabel.textColor = .secondaryLabel
phoneticLabel.textAlignment = .center
var config = UIButton.Configuration.borderedProminent()
config.imagePadding = 6
config.contentInsets = .init(top: 10, leading: 24, bottom: 10, trailing: 24)
speakButton.configuration = config
speakButton.addAction(UIAction { [weak self] _ in
guard let self else { return }
speech.speak(word.word)
}, for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, speakButton])
stack.axis = .vertical
stack.spacing = 12
stack.alignment = .center
return stack
}
private func makeCard() -> UIView {
let card = UIView()
card.backgroundColor = .quaternarySystemFill.withAlphaComponent(0.5)
card.layer.cornerRadius = 16
let meaningTitle = makeCaption("释义")
let meaningLabel = UILabel()
meaningLabel.text = word.meaning
meaningLabel.font = .preferredFont(forTextStyle: .title2)
meaningLabel.numberOfLines = 0
let divider = UIView()
divider.backgroundColor = .separator
divider.translatesAutoresizingMaskIntoConstraints = false
divider.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale).isActive = true
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
let exampleSpeakButton = UIButton(type: .system)
exampleSpeakButton.setImage(UIImage(systemName: "speaker.wave.2"), for: .normal)
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, meaningLabel, 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
}
private func makeCaption(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabel
return label
}
}
@@ -1,51 +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) }
Button("拼写") { startQuiz(.spelling) }
} 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,59 @@
import UIKit
final class WordListViewController: UITableViewController {
private let category: WordCategory
init(category: WordCategory) {
self.category = category
super.init(style: .plain)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
title = category.name
navigationItem.largeTitleDisplayMode = .never
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
let quizMenu = UIMenu(title: "", children: [
UIAction(title: QuizMode.enToZh.rawValue) { [weak self] _ in self?.startQuiz(.enToZh) },
UIAction(title: QuizMode.zhToEn.rawValue) { [weak self] _ in self?.startQuiz(.zhToEn) },
UIAction(title: QuizMode.spelling.rawValue) { [weak self] _ in self?.startQuiz(.spelling) }
])
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "测验", menu: quizMenu)
}
private func startQuiz(_ mode: QuizMode) {
if category.words.count <= 10 {
let config = QuizConfig(mode: mode, words: category.words)
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
} else {
let request = QuizSetupRequest(mode: mode, words: category.words)
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
category.words.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let word = category.words[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = word.word
content.textProperties.font = .preferredFont(forTextStyle: .headline)
content.secondaryText = word.meaning
content.secondaryTextProperties.color = .secondaryLabel
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
navigationController?.pushViewController(WordDetailViewController(word: category.words[indexPath.row]), animated: true)
}
}