diff --git a/LearnEnglish/LearnEnglish.xcodeproj/project.pbxproj b/LearnEnglish/LearnEnglish.xcodeproj/project.pbxproj index d7611d5..6c43197 100644 --- a/LearnEnglish/LearnEnglish.xcodeproj/project.pbxproj +++ b/LearnEnglish/LearnEnglish.xcodeproj/project.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 = ""; }; /* 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 = ""; }; - CF43E869300A7CFA00CB9AE3 /* Products */ = { + CF999C8D300BBDE3004D66A6 /* Products */ = { isa = PBXGroup; children = ( - CF43E868300A7CFA00CB9AE3 /* LearnEnglish.app */, + CF999C8C300BBDE3004D66A6 /* LearnEnglish.app */, ); name = Products; sourceTree = ""; @@ -48,64 +61,64 @@ /* 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" */; + buildConfigurationList = CF999C87300BBDE3004D66A6 /* Build configuration list for PBXProject "LearnEnglish" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( 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 +128,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - CF43E864300A7CFA00CB9AE3 /* Sources */ = { + CF999C88300BBDE3004D66A6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -125,7 +138,71 @@ /* 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_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; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -178,16 +255,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 +313,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 */; } diff --git a/LearnEnglish/LearnEnglish/AppDelegate.swift b/LearnEnglish/LearnEnglish/AppDelegate.swift new file mode 100644 index 0000000..558e11b --- /dev/null +++ b/LearnEnglish/LearnEnglish/AppDelegate.swift @@ -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) { + // 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. + } + + +} + diff --git a/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/Contents.json b/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/Contents.json index fde0e47..7ed679b 100644 --- a/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" : { diff --git a/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/logo 3.png b/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/logo 3.png deleted file mode 100644 index 9d5f910..0000000 Binary files a/LearnEnglish/LearnEnglish/Assets.xcassets/AppIcon.appiconset/logo 3.png and /dev/null differ diff --git a/LearnEnglish/LearnEnglish/Base.lproj/LaunchScreen.storyboard b/LearnEnglish/LearnEnglish/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LearnEnglish/LearnEnglish/ContentView.swift b/LearnEnglish/LearnEnglish/ContentView.swift deleted file mode 100644 index 363dd2b..0000000 --- a/LearnEnglish/LearnEnglish/ContentView.swift +++ /dev/null @@ -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() -} diff --git a/LearnEnglish/LearnEnglish/Info.plist b/LearnEnglish/LearnEnglish/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/LearnEnglish/LearnEnglish/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/LearnEnglish/LearnEnglish/LearnEnglishApp.swift b/LearnEnglish/LearnEnglish/LearnEnglishApp.swift deleted file mode 100644 index eb81531..0000000 --- a/LearnEnglish/LearnEnglish/LearnEnglishApp.swift +++ /dev/null @@ -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() - } - } -} diff --git a/LearnEnglish/LearnEnglish/Models/AppRoute.swift b/LearnEnglish/LearnEnglish/Models/AppRoute.swift deleted file mode 100644 index 1bd8b58..0000000 --- a/LearnEnglish/LearnEnglish/Models/AppRoute.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -enum AppRoute: Hashable { - case category(WordCategory) - case word(Word) - case quizSetup(QuizSetupRequest) - case quiz(QuizConfig) -} diff --git a/LearnEnglish/LearnEnglish/SceneDelegate.swift b/LearnEnglish/LearnEnglish/SceneDelegate.swift new file mode 100644 index 0000000..6b262e5 --- /dev/null +++ b/LearnEnglish/LearnEnglish/SceneDelegate.swift @@ -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 + } +} diff --git a/LearnEnglish/LearnEnglish/Services/SpeechService.swift b/LearnEnglish/LearnEnglish/Services/SpeechService.swift index 4392c81..06ed606 100644 --- a/LearnEnglish/LearnEnglish/Services/SpeechService.swift +++ b/LearnEnglish/LearnEnglish/Services/SpeechService.swift @@ -1,9 +1,11 @@ import AVFoundation -@Observable final class SpeechService: NSObject { + static let shared = SpeechService() + private let synthesizer = AVSpeechSynthesizer() private(set) var speakingText: String? + var onSpeakingChanged: (() -> Void)? override init() { super.init() @@ -18,6 +20,7 @@ final class SpeechService: NSObject { utterance.voice = AVSpeechSynthesisVoice(language: "en-US") utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9 speakingText = text + onSpeakingChanged?() synthesizer.speak(utterance) } } @@ -25,9 +28,11 @@ final class SpeechService: NSObject { extension SpeechService: AVSpeechSynthesizerDelegate { func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { speakingText = nil + onSpeakingChanged?() } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { speakingText = nil + onSpeakingChanged?() } } diff --git a/LearnEnglish/LearnEnglish/Services/WordStore.swift b/LearnEnglish/LearnEnglish/Services/WordStore.swift index 49df848..d2119e4 100644 --- a/LearnEnglish/LearnEnglish/Services/WordStore.swift +++ b/LearnEnglish/LearnEnglish/Services/WordStore.swift @@ -12,7 +12,6 @@ enum WordStoreError: Error, LocalizedError { } } -@Observable final class WordStore { private(set) var bank: WordBank? private(set) var loadError: String? diff --git a/LearnEnglish/LearnEnglish/Views/CategoryListView.swift b/LearnEnglish/LearnEnglish/Views/CategoryListView.swift deleted file mode 100644 index 2ed7f77..0000000 --- a/LearnEnglish/LearnEnglish/Views/CategoryListView.swift +++ /dev/null @@ -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: "苹果是红色的。") - ]) - ]) -} diff --git a/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift b/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift new file mode 100644 index 0000000..9c23e3a --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift @@ -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) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/QuizResultView.swift b/LearnEnglish/LearnEnglish/Views/QuizResultView.swift index 1f4b834..a89baa8 100644 --- a/LearnEnglish/LearnEnglish/Views/QuizResultView.swift +++ b/LearnEnglish/LearnEnglish/Views/QuizResultView.swift @@ -1,11 +1,12 @@ -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 var correctCount: Int { total - wrongWords.count } @@ -14,57 +15,140 @@ struct QuizResultView: View { return seconds >= 60 ? "\(seconds / 60) 分 \(seconds % 60) 秒" : "\(seconds) 秒" } - var body: some View { - List { - Section { - VStack(spacing: 8) { - Text("\(correctCount) / \(total)") - .font(.system(size: 44, weight: .bold)) - Text("用时 \(durationText)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } + 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() + } - 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) - } - } - } - } - } + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } - Section { - Button("再来一轮", action: onRestart) - .frame(maxWidth: .infinity) - Button("返回", action: onExit) - .frame(maxWidth: .infinity) - .foregroundStyle(.secondary) - } + 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 { + stack.addArrangedSubview(makeWrongWordsSection()) } - } -} -#Preview { - NavigationStack { - QuizResultView( - total: 10, - wrongWords: [ - Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。") - ], - duration: 75, - onRestart: {}, - onExit: {} - ) + 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 makeWrongWordsSection() -> UIView { + let titleLabel = UILabel() + titleLabel.text = "错题(\(wrongWords.count))" + titleLabel.font = .preferredFont(forTextStyle: .headline) + + let section = UIStackView(arrangedSubviews: [titleLabel]) + section.axis = .vertical + section.spacing = 8 + + 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 } } diff --git a/LearnEnglish/LearnEnglish/Views/QuizSetupView.swift b/LearnEnglish/LearnEnglish/Views/QuizSetupView.swift deleted file mode 100644 index f2e7123..0000000 --- a/LearnEnglish/LearnEnglish/Views/QuizSetupView.swift +++ /dev/null @@ -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: "苹果是红色的。") - ])) - } -} diff --git a/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift b/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift new file mode 100644 index 0000000..a7a3ebc --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift @@ -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) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/QuizView.swift b/LearnEnglish/LearnEnglish/Views/QuizView.swift deleted file mode 100644 index 9562cb8..0000000 --- a/LearnEnglish/LearnEnglish/Views/QuizView.swift +++ /dev/null @@ -1,216 +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 filledBlanks: [String] = [] - @State private var currentBlankIndex = 0 - @State private var wrongWords: [Word] = [] - @State private var finished = false - @State private var startDate = Date.now - - init(config: QuizConfig) { - self.config = config - let questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) - _questions = State(initialValue: questions) - _filledBlanks = State(initialValue: Array(repeating: "", count: questions.first?.blankPositions.count ?? 0)) - } - - 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: 16) { - SpellingWordInput( - blankedWord: question.blankedWord, - blankPositions: question.blankPositions, - filledBlanks: $filledBlanks, - currentBlankIndex: $currentBlankIndex, - isActive: selectedOption == nil, - onComplete: { submitSpelling(question) } - ) - - Text("\(question.word.word.count) 个字母,补全空缺部分") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - - private func submitSpelling(_ question: QuizQuestion) { - var chars = question.blankedWord.map { String($0) } - for (i, pos) in question.blankPositions.enumerated() where i < filledBlanks.count { - chars[pos] = filledBlanks[i] - } - let typed = chars.joined().lowercased() - selectedOption = typed - if typed != question.answer { - wrongWords.append(question.word) - } - } - - 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 - resetBlanks() - } else { - finished = true - } - } - - private func resetBlanks() { - filledBlanks = Array(repeating: "", count: current?.blankPositions.count ?? 0) - currentBlankIndex = 0 - } - - private func restart() { - questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) - currentIndex = 0 - selectedOption = nil - resetBlanks() - 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()) - } -} diff --git a/LearnEnglish/LearnEnglish/Views/QuizViewController.swift b/LearnEnglish/LearnEnglish/Views/QuizViewController.swift new file mode 100644 index 0000000..9f1c71c --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/QuizViewController.swift @@ -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) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift b/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift new file mode 100644 index 0000000..75cfd79 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift @@ -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 + } + } +} diff --git a/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift b/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift deleted file mode 100644 index 496fb79..0000000 --- a/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift +++ /dev/null @@ -1,154 +0,0 @@ -import SwiftUI -import UIKit - -struct KeystrokeCaptureField: UIViewRepresentable { - let isActive: Bool - var onChar: (Character) -> Void - var onBackspace: () -> Void - - func makeUIView(context: Context) -> UITextField { - let tf = UITextField() - tf.delegate = context.coordinator - tf.autocorrectionType = .no - tf.autocapitalizationType = .none - tf.spellCheckingType = .no - tf.keyboardType = .asciiCapable - return tf - } - - func updateUIView(_ uiView: UITextField, context: Context) { - context.coordinator.onChar = onChar - context.coordinator.onBackspace = onBackspace - // becomeFirstResponder must not run synchronously inside a SwiftUI update - // cycle — it triggers keyboard/layout feedback that recurses AttributeGraph. - if isActive, !uiView.isFirstResponder { - DispatchQueue.main.async { [weak uiView] in - guard let uiView, !uiView.isFirstResponder, uiView.window != nil else { return } - uiView.becomeFirstResponder() - } - } else if !isActive, uiView.isFirstResponder { - DispatchQueue.main.async { [weak uiView] in - uiView?.resignFirstResponder() - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(onChar: onChar, onBackspace: onBackspace) - } - - class Coordinator: NSObject, UITextFieldDelegate { - var onChar: (Character) -> Void - var onBackspace: () -> Void - - init(onChar: @escaping (Character) -> Void, onBackspace: @escaping () -> Void) { - self.onChar = onChar - self.onBackspace = onBackspace - } - - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if string.isEmpty { - onBackspace() - } else if let char = string.last { - onChar(char) - } - textField.text = nil - return false - } - } -} - -private struct BlinkingCursor: View { - var body: some View { - TimelineView(.periodic(from: .now, by: 0.6)) { timeline in - let visible = Int(timeline.date.timeIntervalSinceReferenceDate / 0.6) % 2 == 0 - Rectangle() - .fill(.tint) - .frame(width: 2, height: 22) - .opacity(visible ? 1 : 0) - } - } -} - -struct SpellingWordInput: View { - let blankedWord: String - let blankPositions: [Int] - @Binding var filledBlanks: [String] - @Binding var currentBlankIndex: Int - let isActive: Bool - let onComplete: () -> Void - - private var chars: [String] { blankedWord.map { String($0) } } - - var body: some View { - KeystrokeCaptureField( - isActive: isActive, - onChar: { char in - guard char.isLetter, currentBlankIndex < blankPositions.count else { return } - filledBlanks[currentBlankIndex] = String(char).lowercased() - currentBlankIndex += 1 - if currentBlankIndex == blankPositions.count { - onComplete() - } - }, - onBackspace: { - guard currentBlankIndex > 0 else { return } - currentBlankIndex -= 1 - filledBlanks[currentBlankIndex] = "" - } - ) - .frame(width: 0, height: 0) - .overlay(alignment: .top) { - HStack(spacing: 6) { - ForEach(Array(chars.enumerated()), id: \.offset) { index, ch in - charView(at: index, ch: ch) - } - } - } - .allowsHitTesting(false) - } - - @ViewBuilder - private func charView(at index: Int, ch: String) -> some View { - if ch == "_" { - blankSlot(at: index) - } else { - slotBox(isCurrent: false) { - Text(ch.uppercased()) - .font(.title2.weight(.bold)) - } - } - } - - private func blankSlot(at index: Int) -> some View { - let blankIndex = blankPositions.firstIndex(of: index) ?? 0 - let isCurrent = isActive && blankIndex == currentBlankIndex - let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : "" - - return slotBox(isCurrent: isCurrent) { - if !filled.isEmpty { - Text(filled.uppercased()) - .font(.title2.weight(.bold)) - } else if isCurrent { - BlinkingCursor() - } else { - Rectangle() - .fill(.secondary.opacity(0.5)) - .frame(width: 16, height: 2) - .offset(y: 12) - } - } - } - - private func slotBox(isCurrent: Bool, @ViewBuilder content: () -> Content) -> some View { - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(.quaternary.opacity(0.15)) - .frame(width: 36, height: 44) - RoundedRectangle(cornerRadius: 8) - .stroke(isCurrent ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary), lineWidth: isCurrent ? 2 : 1) - .frame(width: 36, height: 44) - content() - } - } -} diff --git a/LearnEnglish/LearnEnglish/Views/WordDetailView.swift b/LearnEnglish/LearnEnglish/Views/WordDetailView.swift deleted file mode 100644 index a18cd90..0000000 --- a/LearnEnglish/LearnEnglish/Views/WordDetailView.swift +++ /dev/null @@ -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()) - } -} diff --git a/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift b/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift new file mode 100644 index 0000000..70c07eb --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift @@ -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 + } +} diff --git a/LearnEnglish/LearnEnglish/Views/WordListView.swift b/LearnEnglish/LearnEnglish/Views/WordListView.swift deleted file mode 100644 index 8827511..0000000 --- a/LearnEnglish/LearnEnglish/Views/WordListView.swift +++ /dev/null @@ -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 } - } -} diff --git a/LearnEnglish/LearnEnglish/Views/WordListViewController.swift b/LearnEnglish/LearnEnglish/Views/WordListViewController.swift new file mode 100644 index 0000000..77aad5f --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/WordListViewController.swift @@ -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) + } +}