diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index 13c23aa..0000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -active: true -iteration: 1 -max_iterations: 20 -completion_promise: null -started_at: "2026-01-09T01:11:28Z" ---- - -Try to enhance this app, both UI and logic,make it more better(do not change trigger app crash - logic, this is for debug) --completion_promise DONE diff --git a/Arithmetic.xcodeproj/project.pbxproj b/Arithmetic.xcodeproj/project.pbxproj index 8867f47..50ddae0 100644 --- a/Arithmetic.xcodeproj/project.pbxproj +++ b/Arithmetic.xcodeproj/project.pbxproj @@ -21,6 +21,10 @@ 8A2AE91B2E06FFC900A99591 /* MultiplicationTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A2AE91C2E06FFC900A99591 /* MultiplicationTableView.swift */; }; 8A2AE91D2E06FFC900A99591 /* AboutMeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A2AE91E2E06FFC900A99591 /* AboutMeView.swift */; }; 8A2AE91F2E06FFC900A99591 /* OtherOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A2AE9202E06FFC900A99591 /* OtherOptionsView.swift */; }; + 8A600A332F10930500B4AAD0 /* SoundEffectsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A600A322F10930500B4AAD0 /* SoundEffectsHelper.swift */; }; + 8A600A342F10930500B4AAD0 /* HapticFeedbackHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A600A312F10930500B4AAD0 /* HapticFeedbackHelper.swift */; }; + 8A600A362F10931C00B4AAD0 /* ConfettiCelebrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A600A352F10931C00B4AAD0 /* ConfettiCelebrationView.swift */; }; + 8A600A382F10932400B4AAD0 /* Color+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A600A372F10932400B4AAD0 /* Color+Theme.swift */; }; 8A87B6212EF67C5800366D4D /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 8A87B6202EF67C5800366D4D /* FirebaseAnalytics */; }; 8A87B6232EF67C5800366D4D /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8A87B6222EF67C5800366D4D /* FirebaseCore */; }; 8A87B6252EF67C5800366D4D /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 8A87B6242EF67C5800366D4D /* FirebaseCrashlytics */; }; @@ -71,6 +75,10 @@ 8A2AE91C2E06FFC900A99591 /* MultiplicationTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiplicationTableView.swift; sourceTree = ""; }; 8A2AE91E2E06FFC900A99591 /* AboutMeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutMeView.swift; sourceTree = ""; }; 8A2AE9202E06FFC900A99591 /* OtherOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherOptionsView.swift; sourceTree = ""; }; + 8A600A312F10930500B4AAD0 /* HapticFeedbackHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedbackHelper.swift; sourceTree = ""; }; + 8A600A322F10930500B4AAD0 /* SoundEffectsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEffectsHelper.swift; sourceTree = ""; }; + 8A600A352F10931C00B4AAD0 /* ConfettiCelebrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiCelebrationView.swift; sourceTree = ""; }; + 8A600A372F10932400B4AAD0 /* Color+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Theme.swift"; sourceTree = ""; }; 8A8AEBC72EBB7FDE008D53E6 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 8A8FA9B02EA36B730075A3D1 /* SystemInfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoManager.swift; sourceTree = ""; }; 8A8FA9B22EA36BA90075A3D1 /* SystemInfoComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoComponents.swift; sourceTree = ""; }; @@ -171,6 +179,7 @@ 8AF3AB8F2E05AE3600F67CDE /* Extensions */ = { isa = PBXGroup; children = ( + 8A600A372F10932400B4AAD0 /* Color+Theme.swift */, 8AD2D9502E77175B004603C4 /* View+Navigation.swift */, 8AF3AB8C2E05AE3600F67CDE /* CGFloat+Adaptive.swift */, 8AF3AB8D2E05AE3600F67CDE /* Font+Adaptive.swift */, @@ -200,6 +209,8 @@ 8AF3AB9C2E05AE3600F67CDE /* Utils */ = { isa = PBXGroup; children = ( + 8A600A312F10930500B4AAD0 /* HapticFeedbackHelper.swift */, + 8A600A322F10930500B4AAD0 /* SoundEffectsHelper.swift */, 8A0C5AEE2EC9C9510020200A /* MathBankPDFGenerator.swift */, 8A8FA9B02EA36B730075A3D1 /* SystemInfoManager.swift */, 8AF18A482E8BB16A007B05D0 /* ProgressViewUtils.swift */, @@ -224,6 +235,7 @@ 8AF3ABA32E05AE3600F67CDE /* Views */ = { isa = PBXGroup; children = ( + 8A600A352F10931C00B4AAD0 /* ConfettiCelebrationView.swift */, 8AFF11772EEE7AC300CA7987 /* QrCodeToolView.swift */, 8A0C5AEC2EC9C92D0020200A /* MathBankView.swift */, 8A0C5AE02EC8D88E0020200A /* SettingsView.swift */, @@ -357,6 +369,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8A600A332F10930500B4AAD0 /* SoundEffectsHelper.swift in Sources */, + 8A600A342F10930500B4AAD0 /* HapticFeedbackHelper.swift in Sources */, 8AF3ABA42E05AE3600F67CDE /* ContentView.swift in Sources */, 8A2AE9132E06A88A00A99591 /* GameProgressManager.swift in Sources */, 8A8FA9B32EA36BA90075A3D1 /* SystemInfoComponents.swift in Sources */, @@ -378,6 +392,8 @@ 8AF3ABAB2E05AE3600F67CDE /* LanguageSelectorView.swift in Sources */, 8A0C5AED2EC9C92D0020200A /* MathBankView.swift in Sources */, 8AF3ABAC2E05AE3600F67CDE /* CGFloat+Adaptive.swift in Sources */, + 8A600A362F10931C00B4AAD0 /* ConfettiCelebrationView.swift in Sources */, + 8A600A382F10932400B4AAD0 /* Color+Theme.swift in Sources */, 8ABE41362EA0778E007EC146 /* FormulaGuideView.swift in Sources */, 8AF3ABAD2E05AE3600F67CDE /* Font+Adaptive.swift in Sources */, 8AD2D94F2E77174C004603C4 /* CachedAsyncImageView.swift in Sources */, diff --git a/CoreData/CoreDataManager.swift b/CoreData/CoreDataManager.swift index 84613a9..388fd5a 100644 --- a/CoreData/CoreDataManager.swift +++ b/CoreData/CoreDataManager.swift @@ -3,32 +3,59 @@ import CoreData class CoreDataManager { static let shared = CoreDataManager() - + + private var isInitialized = false + private var initializationError: Error? + private init() { // 确保CoreData模型在初始化时创建 setupCoreDataStack() - - // 迁移现有数据 - migrateExistingData() + + // Only migrate if initialization succeeded + if isInitialized { + // 迁移现有数据 + migrateExistingData() + } } - + private func setupCoreDataStack() { // 初始化CoreData堆栈 _ = persistentContainer + + // If initialization failed, attempt recovery + if initializationError != nil, let storeURL = getStoreURL() { + print("Core Data initialization had error, attempting recovery...") + resetStore(at: storeURL) + } } - + + private func getStoreURL() -> URL? { + let storeURL = persistentContainer.persistentStoreCoordinator.persistentStores.first?.url + + // If no store exists yet, compute the default URL + if storeURL == nil { + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + return directory?.appendingPathComponent("ArithmeticModel.sqlite") + } + + return storeURL + } + // 迁移现有数据以适应模型更改 private func migrateExistingData() { + guard isInitialized else { + print("Cannot migrate: Core Data not initialized") + return + } + // 检查是否有需要迁移的错题 let fetchRequest: NSFetchRequest = WrongQuestionEntity.fetchRequest() - + do { let existingQuestions = try context.fetch(fetchRequest) - + // 为现有错题添加解析方法和步骤 for question in existingQuestions { - // 尝试 accessing solutionMethod property - it should not throw an error - // So we don't need the do-catch block // Ensure that each question has solutionMethod and solutionSteps if question.solutionMethod.isEmpty { if let questionObj = question.toQuestion() { @@ -41,7 +68,7 @@ class CoreDataManager { } } } - + // 保存更改 if context.hasChanges { try context.save() @@ -49,39 +76,45 @@ class CoreDataManager { } } catch { print("迁移数据时出错: \(error)") - + // 如果迁移失败,尝试重置数据库 - resetCoreDataStore() + if let storeURL = getStoreURL() { + resetStore(at: storeURL) + } } } - + // 重置Core Data存储(如果迁移失败) - private func resetCoreDataStore() { + private func resetStore(at storeURL: URL) { print("尝试重置Core Data存储...") - - // 获取持久化存储的URL - guard let storeURL = persistentContainer.persistentStoreCoordinator.persistentStores.first?.url else { - print("无法获取持久化存储URL") - return - } - + do { // 删除所有持久化存储 for store in persistentContainer.persistentStoreCoordinator.persistentStores { try persistentContainer.persistentStoreCoordinator.remove(store) } - - // 删除存储文件 + + // 删除存储文件 and related files try FileManager.default.removeItem(at: storeURL) - + + // Remove WAL and SHM files if they exist + let walURL = storeURL.deletingLastPathComponent().appendingPathComponent(storeURL.lastPathComponent + "-wal") + let shmURL = storeURL.deletingLastPathComponent().appendingPathComponent(storeURL.lastPathComponent + "-shm") + + try? FileManager.default.removeItem(at: walURL) + try? FileManager.default.removeItem(at: shmURL) + // 重新加载持久化存储 try persistentContainer.persistentStoreCoordinator.addPersistentStore( ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, - options: nil + options: [ + NSMigratePersistentStoresAutomaticallyOption: true, + NSInferMappingModelAutomaticallyOption: true + ] ) - + print("Core Data存储已重置") } catch { print("重置Core Data存储失败: \(error)") @@ -267,14 +300,31 @@ class CoreDataManager { lazy var persistentContainer: NSPersistentContainer = { // 创建内存中的模型 let model = createManagedObjectModel() - + // 创建持久化容器 let container = NSPersistentContainer(name: "ArithmeticModel", managedObjectModel: model) + + // Configure persistent store descriptions with migration options + let storeDescription = NSPersistentStoreDescription() + storeDescription.type = NSSQLiteStoreType + storeDescription.shouldInferMappingModelAutomatically = true + storeDescription.shouldMigrateStoreAutomatically = true + + container.persistentStoreDescriptions = [storeDescription] + + // Load persistent stores with proper error handling container.loadPersistentStores { (storeDescription, error) in if let error = error as NSError? { - fatalError("Unresolved error \(error), \(error.userInfo)") + print("Core Data store loading failed: \(error), \(error.userInfo)") + // Don't use fatalError - instead set the error for later handling + self.initializationError = error + self.isInitialized = false + } else { + self.isInitialized = true + print("Core Data store loaded successfully at: \(storeDescription.url?.absoluteString ?? "unknown")") } } + return container }() @@ -283,12 +333,23 @@ class CoreDataManager { } func saveContext() { + guard isInitialized else { + print("Cannot save: Core Data not initialized") + return + } + if context.hasChanges { do { try context.save() + print("Context saved successfully") } catch { let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + print("Failed to save context: \(nserror), \(nserror.userInfo)") + + // Attempt recovery by resetting the store + if let storeURL = getStoreURL() { + resetStore(at: storeURL) + } } } } diff --git a/Extensions/Color+Theme.swift b/Extensions/Color+Theme.swift new file mode 100644 index 0000000..c138fb8 --- /dev/null +++ b/Extensions/Color+Theme.swift @@ -0,0 +1,178 @@ +import SwiftUI + +/// Adaptive color scheme extensions for better dark mode support +extension Color { + /// Creates a color that adapts to light and dark mode + static func adaptive(light: Color, dark: Color) -> Color { + Color(UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) + } + + // MARK: - App-specific adaptive colors + + /// Primary background color for cards and containers + static var adaptiveBackground: Color { + adaptive(light: Color.white, dark: Color(red: 0.15, green: 0.15, blue: 0.17)) + } + + /// Secondary background for nested containers + static var adaptiveSecondaryBackground: Color { + adaptive(light: Color.gray.opacity(0.1), dark: Color(red: 0.12, green: 0.12, blue: 0.14)) + } + + /// Primary text color + static var adaptiveText: Color { + adaptive(light: Color.primary, dark: Color.white) + } + + /// Secondary text color + static var adaptiveSecondaryText: Color { + adaptive(light: Color.secondary, dark: Color(red: 0.6, green: 0.6, blue: 0.6)) + } + + /// Accent color for interactive elements + static var accent: Color { + adaptive(light: Color.blue, dark: Color(red: 0.2, green: 0.5, blue: 1.0)) + } + + /// Success color + static var success: Color { + adaptive(light: Color.green, dark: Color(red: 0.2, green: 0.8, blue: 0.4)) + } + + /// Error color + static var error: Color { + adaptive(light: Color.red, dark: Color(red: 0.9, green: 0.3, blue: 0.3)) + } + + /// Warning color + static var warning: Color { + adaptive(light: Color.orange, dark: Color(red: 1.0, green: 0.6, blue: 0.2)) + } + + /// Border color for cards and containers + static var adaptiveBorder: Color { + adaptive(light: Color.gray.opacity(0.2), dark: Color.gray.opacity(0.3)) + } + + /// Shadow color + static var adaptiveShadow: Color { + adaptive(light: Color.black.opacity(0.1), dark: Color.black.opacity(0.3)) + } + + /// Progress bar gradient colors + static var progressGradientStart: Color { + adaptive(light: Color.blue, dark: Color(red: 0.3, green: 0.6, blue: 1.0)) + } + + static var progressGradientEnd: Color { + adaptive(light: Color.purple, dark: Color(red: 0.6, green: 0.4, blue: 1.0)) + } + + /// Button background color + static var buttonBackground: Color { + adaptive(light: Color.blue, dark: Color(red: 0.2, green: 0.5, blue: 1.0)) + } + + /// Button disabled color + static var buttonDisabled: Color { + adaptive(light: Color.gray, dark: Color(red: 0.4, green: 0.4, blue: 0.4)) + } +} + +/// Theme configuration for the app +struct AppTheme { + /// Standard corner radius for cards + static var cornerRadius: CGFloat { + 12 + } + + /// Small corner radius for buttons + static var smallCornerRadius: CGFloat { + 8 + } + + /// Large corner radius for modals + static var largeCornerRadius: CGFloat { + 20 + } + + /// Standard shadow radius + static var shadowRadius: CGFloat { + 8 + } + + /// Light shadow radius + static var lightShadowRadius: CGFloat { + 4 + } + + /// Card padding + static var cardPadding: CGFloat { + 16 + } + + /// Standard animation duration + static var animationDuration: Double { + 0.3 + } + + /// Spring animation response + static var springResponse: Double { + 0.4 + } + + /// Spring damping fraction + static var springDampingFraction: Double { + 0.7 + } +} + +/// View modifier for adaptive card styling +struct AdaptiveCardStyle: ViewModifier { + var backgroundColor: Color = .adaptiveBackground + var cornerRadius: CGFloat = AppTheme.cornerRadius + var shadowRadius: CGFloat = AppTheme.shadowRadius + + func body(content: Content) -> some View { + content + .padding(AppTheme.cardPadding) + .background(backgroundColor) + .cornerRadius(cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.adaptiveBorder, lineWidth: 1) + ) + .shadow(color: Color.adaptiveShadow, radius: shadowRadius, x: 0, y: 2) + } +} + +/// View modifier for adaptive button styling +struct AdaptiveButtonStyle: ViewModifier { + var isEnabled: Bool = true + var backgroundColor: Color = .buttonBackground + var cornerRadius: CGFloat = AppTheme.smallCornerRadius + + func body(content: Content) -> some View { + content + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(isEnabled ? backgroundColor : .buttonDisabled) + .cornerRadius(cornerRadius) + .shadow(color: isEnabled ? Color.adaptiveShadow : Color.clear, radius: AppTheme.lightShadowRadius, x: 0, y: 2) + } +} + +extension View { + /// Applies adaptive card styling + func adaptiveCard(backgroundColor: Color = .adaptiveBackground, cornerRadius: CGFloat = AppTheme.cornerRadius, shadowRadius: CGFloat = AppTheme.shadowRadius) -> some View { + self.modifier(AdaptiveCardStyle(backgroundColor: backgroundColor, cornerRadius: cornerRadius, shadowRadius: shadowRadius)) + } + + /// Applies adaptive button styling + func adaptiveButton(isEnabled: Bool = true, backgroundColor: Color = .buttonBackground, cornerRadius: CGFloat = AppTheme.smallCornerRadius) -> some View { + self.modifier(AdaptiveButtonStyle(isEnabled: isEnabled, backgroundColor: backgroundColor, cornerRadius: cornerRadius)) + } +} diff --git a/Models/GameState.swift b/Models/GameState.swift index 035357f..d8fc264 100644 --- a/Models/GameState.swift +++ b/Models/GameState.swift @@ -12,6 +12,8 @@ class GameState: ObservableObject { @Published var isCorrect: Bool = false @Published var isPaused: Bool = false @Published var pauseUsed: Bool = false + @Published var streakCount: Int = 0 + @Published var longestStreak: Int = 0 // 游戏设置 let difficultyLevel: DifficultyLevel @@ -99,12 +101,16 @@ class GameState: ObservableObject { func checkAnswer(_ answer: Int) -> Bool { let currentQuestion = questions[currentQuestionIndex] let isCorrect = answer == currentQuestion.correctAnswer - + userAnswers[currentQuestionIndex] = answer - + if isCorrect { score += pointsPerQuestion - + streakCount += 1 + if streakCount > longestStreak { + longestStreak = streakCount + } + // 如果是错题集中的题目,更新统计信息 let wrongQuestionManager = WrongQuestionManager() if wrongQuestionManager.isWrongQuestion(currentQuestion) { @@ -116,11 +122,13 @@ class GameState: ObservableObject { let wrongQuestionManager = WrongQuestionManager() wrongQuestionManager.addWrongQuestion(currentQuestion, for: difficultyLevel) print("Added to wrong questions collection: \(currentQuestion.questionText)") + // Reset streak on wrong answer + streakCount = 0 } - + self.isCorrect = isCorrect self.showingCorrectAnswer = !isCorrect - + return isCorrect } diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 0f2b97f..62ec3b9 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -20,12 +20,25 @@ "result.final_score" = "Final Score"; "result.correct_count" = "%@ correct out of %@"; "result.time_used" = "Time Used"; +"result.longest_streak" = "Longest Streak"; "result.excellent" = "Excellent ⭐⭐⭐"; "result.good" = "Good ⭐⭐"; "result.pass" = "Pass ⭐"; "result.needimprove" = "Need Practice 💪"; "button.restart" = "Restart"; "button.home" = "Home"; + +/* Streak Achievements */ +"streak.title" = "Amazing Streak!"; +"streak.3" = "On Fire!"; +"streak.4" = "Hot Streak!"; +"streak.5" = "Unstoppable!"; +"streak.6" = "Incredible!"; +"streak.7" = "Math Wizard!"; +"streak.8" = "Legendary!"; +"streak.9" = "Phenomenal!"; +"streak.10" = "Godlike!"; + "button.exit" = "Exit"; "button.finish" = "Finish"; "alert.exit_title" = "Confirm Exit"; @@ -42,7 +55,10 @@ "wrong_questions.title" = "Wrong Questions Collection"; "wrong_questions.all_levels" = "All Levels"; -"wrong_questions.empty" = "No wrong questions yet. Keep practicing!"; +"wrong_questions.empty" = "No wrong questions yet!"; +"wrong_questions.empty_subtitle" = "Keep practicing and you'll see your mistakes here for review."; +"wrong_questions.total" = "Total"; +"wrong_questions.mastered" = "Mastered"; "wrong_questions.answer" = "Answer: %@"; "wrong_questions.level" = "Level: %@"; "wrong_questions.stats" = "Attempted %@ times, Wrong %@ times"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 4355c4d..9dbb1c5 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -20,12 +20,25 @@ "result.final_score" = "最终得分"; "result.correct_count" = "答对 %@ 题/共 %@ 题"; "result.time_used" = "用时"; +"result.longest_streak" = "最长连对"; "result.excellent" = "优秀 ⭐⭐⭐"; "result.good" = "良好 ⭐⭐"; "result.pass" = "及格 ⭐"; "result.needimprove" = "菜就要多练💪"; "button.restart" = "重新开始"; "button.home" = "返回主页"; + +/* Streak Achievements */ +"streak.title" = "连对太棒了!"; +"streak.3" = "火力全开!"; +"streak.4" = "状态火热!"; +"streak.5" = "势不可挡!"; +"streak.6" = "太厉害了!"; +"streak.7" = "数学天才!"; +"streak.8" = "传说级!"; +"streak.9" = "神乎其技!"; +"streak.10" = "超神了!"; + "button.exit" = "退出"; "button.finish" = "完成答题"; "alert.exit_title" = "确认退出"; @@ -42,7 +55,10 @@ "wrong_questions.title" = "错题集"; "wrong_questions.all_levels" = "所有难度"; -"wrong_questions.empty" = "暂无错题。继续练习吧!"; +"wrong_questions.empty" = "暂无错题!"; +"wrong_questions.empty_subtitle" = "继续练习,你的错题会出现在这里供复习。"; +"wrong_questions.total" = "错题总数"; +"wrong_questions.mastered" = "已掌握"; "wrong_questions.answer" = "答案:%@"; "wrong_questions.level" = "难度:%@"; "wrong_questions.stats" = "尝试 %@ 次,错误 %@ 次"; diff --git a/Tests/ArithmeticUITests.swift b/Tests/ArithmeticUITests.swift index cbc7dc3..c6793ee 100644 --- a/Tests/ArithmeticUITests.swift +++ b/Tests/ArithmeticUITests.swift @@ -178,21 +178,289 @@ class ArithmeticUITests: XCTestCase { settingsButton.tap() XCTAssertTrue(app.staticTexts["Settings"].waitForExistence(timeout: 5)) } - + // Find and tap the dark mode toggle if it exists let darkModeToggle = app.switches["darkModeToggle"] if darkModeToggle.exists { let initialState = darkModeToggle.value as? Bool ?? false darkModeToggle.tap() - + // Wait a moment for the change to take effect sleep(1) - + // Tap again to restore original state darkModeToggle.tap() - + let finalState = darkModeToggle.value as? Bool ?? !initialState XCTAssertEqual(initialState, finalState) } } + + // MARK: - New Feature Tests - Streak UI + + func testStreakIndicatorDisplay() { + // Start a game + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + // Answer correctly to build streak + let questionText = app.staticTexts.matching(identifier: "questionText").firstMatch + XCTAssertTrue(questionText.waitForExistence(timeout: 5)) + + // Look for streak indicator elements + let flameIcon = app.images["flame.fill"] + // The flame icon appears after 3+ correct answers + if flameIcon.exists { + XCTAssertTrue(flameIcon.exists) + } + } + + func testStreakCelebrationUI() { + // Test that streak celebration elements exist + // This would require answering 3 questions correctly + // In a real UI test, you'd need to parse the question and submit correct answers + + let celebrationExists = app.otherElements["StreakCelebrationView"].exists + // Initially should not exist + XCTAssertFalse(celebrationExists) + } + + func testConfettiAnimationElements() { + // Test for confetti celebration view + let confettiView = app.otherElements["ConfettiCelebrationView"] + // Initially should not be visible + XCTAssertFalse(confettiView.exists) + } + + func testEnhancedProgressBar() { + // Start a game to see progress bar + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + // Look for progress bar elements + let progressIndicator = app.progressIndicators["gameProgress"] + if progressIndicator.exists { + XCTAssertTrue(progressIndicator.exists) + } + } + + func testScoreAnimation() { + // Start a game + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + // Look for score display + let scoreLabel = app.staticTexts.matching(NSPredicate(format: "identifier CONTAINS 'score'")).firstMatch + if scoreLabel.exists { + XCTAssertTrue(scoreLabel.exists) + } + } + + // MARK: - New Feature Tests - Settings + + func testSoundEffectsToggle() { + // Navigate to settings + let settingsButton = app.buttons["Settings"] + if settingsButton.exists { + settingsButton.tap() + XCTAssertTrue(app.staticTexts["Settings"].waitForExistence(timeout: 5)) + } + + // Look for sound effects toggle + let soundToggle = app.switches["isTtsEnabled"] + if soundToggle.exists { + let initialState = soundToggle.value as? Bool ?? true + soundToggle.tap() + + // Wait for change to take effect + sleep(1) + + // Verify toggle state changed + let newState = soundToggle.value as? Bool ?? true + XCTAssertNotEqual(initialState, newState) + + // Restore original state + soundToggle.tap() + } + } + + func testFollowSystemToggle() { + // Navigate to settings + let settingsButton = app.buttons["Settings"] + if settingsButton.exists { + settingsButton.tap() + XCTAssertTrue(app.staticTexts["Settings"].waitForExistence(timeout: 5)) + } + + // Look for follow system toggle + let followSystemToggle = app.switches["followSystem"] + if followSystemToggle.exists { + XCTAssertTrue(followSystemToggle.exists) + } + } + + // MARK: - New Feature Tests - Result View + + func testResultViewShowsLongestStreak() { + // This test would require completing a game + // For now, we just verify the result view structure + + // Navigate to result view (if accessible) + let resultView = app.otherElements["ResultView"] + if resultView.exists { + // Look for longest streak element + let longestStreakLabel = app.staticTexts["result.longest_streak".localized] + if longestStreakLabel.exists { + XCTAssertTrue(longestStreakLabel.exists) + } + } + } + + // MARK: - Enhanced UI Tests + + func testSubmitButtonAnimation() { + // Start a game + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + // Look for submit button + let submitButton = app.buttons["game.submit".localized] + XCTAssertTrue(submitButton.waitForExistence(timeout: 5)) + + // Verify button is disabled when empty + // (Visual state can't be directly tested, but we can check existence) + XCTAssertTrue(submitButton.exists) + } + + func testSolutionToggleButton() { + // Start a game and submit wrong answer + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + // Submit an answer + let answerTextField = app.textFields.element(boundBy: 0) + if answerTextField.exists { + answerTextField.tap() + answerTextField.typeText("999") + + let submitButton = app.buttons["game.submit".localized] + if submitButton.exists { + submitButton.tap() + + // Look for solution toggle button + let solutionButton = app.buttons["button.show_solution".localized] + if solutionButton.exists { + XCTAssertTrue(solutionButton.exists) + + // Tap to show solution + solutionButton.tap() + + // Check if solution content appears + sleep(1) + + // Look for hide button + let hideButton = app.buttons["button.hide_solution".localized] + if hideButton.exists { + XCTAssertTrue(hideButton.exists) + } + } + } + } + } + + func testWrongAnswerShakeAnimation() { + // Start a game + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + // Submit wrong answer to trigger shake animation + let answerTextField = app.textFields.element(boundBy: 0) + if answerTextField.exists { + answerTextField.tap() + answerTextField.typeText("999") + + let submitButton = app.buttons["game.submit".localized] + if submitButton.exists { + submitButton.tap() + + // Look for wrong answer indicator + let wrongAnswerText = app.staticTexts["game.wrong".localized] + if wrongAnswerText.waitForExistence(timeout: 2) { + XCTAssertTrue(wrongAnswerText.exists) + } + } + } + } + + func testNextQuestionButtonEnhancement() { + // Start a game and submit wrong answer + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + let answerTextField = app.textFields.element(boundBy: 0) + if answerTextField.exists { + answerTextField.tap() + answerTextField.typeText("999") + + let submitButton = app.buttons["game.submit".localized] + if submitButton.exists { + submitButton.tap() + + // Look for enhanced next question button + let nextButton = app.buttons["button.next_question".localized] + if nextButton.waitForExistence(timeout: 2) { + XCTAssertTrue(nextButton.exists) + } + } + } + } + + // MARK: - Accessibility Tests + + func testAccessibilityIdentifiers() { + // Verify new accessibility identifiers for enhanced features + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + + // Check that main navigation buttons are accessible + XCTAssertTrue(app.buttons["button.wrong_questions".localized].exists) + XCTAssertTrue(app.buttons["Multiplication Table"].exists) + XCTAssertTrue(app.buttons["Settings"].exists) + } + + // MARK: - Performance Tests + + func testGameLaunchPerformance() { + measure { + app.terminate() + app.launch() + _ = app.buttons["button.start".localized].waitForExistence(timeout: 5) + } + } + + func testAnswerSubmissionPerformance() { + let startButton = app.buttons["button.start".localized] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + measure { + let answerTextField = app.textFields.element(boundBy: 0) + if answerTextField.exists { + answerTextField.tap() + answerTextField.typeText("5") + + let submitButton = app.buttons["game.submit".localized] + if submitButton.exists { + submitButton.tap() + } + } + } + } } \ No newline at end of file diff --git a/Tests/GameViewModelTests.swift b/Tests/GameViewModelTests.swift index 4f50bc1..fb8f330 100644 --- a/Tests/GameViewModelTests.swift +++ b/Tests/GameViewModelTests.swift @@ -198,4 +198,110 @@ class GameViewModelTests: XCTestCase { let savedInfo = GameViewModel.getSavedGameInfo() XCTAssertNil(savedInfo) // Initially should be nil } + + // MARK: - Streak Tracking Tests + + func testStreakCountIncrementsOnCorrectAnswer() { + gameViewModel.startGame() + let initialStreak = gameViewModel.gameState.streakCount + + // Submit a correct answer + let correctAnswer = gameViewModel.gameState.questions[0].correctAnswer + gameViewModel.submitAnswer(correctAnswer) + + XCTAssertEqual(gameViewModel.gameState.streakCount, initialStreak + 1) + } + + func testStreakResetsOnWrongAnswer() { + gameViewModel.startGame() + + // Build up a streak by answering correctly + let correctAnswer = gameViewModel.gameState.questions[0].correctAnswer + gameViewModel.submitAnswer(correctAnswer) + XCTAssertEqual(gameViewModel.gameState.streakCount, 1) + + // Submit wrong answer + gameViewModel.submitAnswer(-1) + + XCTAssertEqual(gameViewModel.gameState.streakCount, 0) + } + + func testLongestStreakUpdatesCorrectly() { + gameViewModel.startGame() + let initialLongestStreak = gameViewModel.gameState.longestStreak + + // Build up a streak + var streakCount = 0 + for index in 0.. Void)? + + init(duration: Double = 2.0, onCompletion: (() -> Void)? = nil) { + self.duration = duration + self.onCompletion = onCompletion + } + + var body: some View { + ZStack { + ForEach(particles) { particle in + ConfettiParticleView(particle: particle) + .offset(x: particle.position.x, y: particle.position.y) + .rotationEffect(.degrees(particle.rotation)) + .opacity(particle.opacity) + .scaleEffect(particle.scale) + } + } + .onAppear { + if isActive { + generateParticles() + animateParticles() + } + } + .onChange(of: isActive) { newValue in + if newValue { + generateParticles() + animateParticles() + } + } + } + + func trigger() { + isActive = true + } + + private func generateParticles() { + particles = (0..<50).map { _ in + let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink, .cyan] + let randomColor = colors.randomElement() ?? .blue + let randomDx = CGFloat.random(in: -200...200) + let randomDy = CGFloat.random(in: -400...200) * -1 + return ConfettiParticle( + id: UUID(), + position: CGPoint(x: 0, y: 0), + color: randomColor, + velocity: CGVector(dx: randomDx, dy: randomDy), + rotation: CGFloat.random(in: 0...360), + rotationSpeed: CGFloat.random(in: -180...180), + scale: CGFloat.random(in: 0.5...1.2), + opacity: 1.0 + ) + } + } + + private func animateParticles() { + withAnimation(.easeOut(duration: duration)) { + for index in particles.indices { + let particle = particles[index] + particles[index].position = CGPoint( + x: particle.position.x + particle.velocity.dx * (duration / 2), + y: particle.position.y + particle.velocity.dy * (duration / 2) + ) + particles[index].rotation += particle.rotationSpeed * duration + particles[index].opacity = 0.0 + particles[index].scale *= 0.5 + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + isActive = false + onCompletion?() + } + } +} + +struct ConfettiParticle: Identifiable { + let id: UUID + var position: CGPoint + let color: Color + let velocity: CGVector + var rotation: CGFloat + let rotationSpeed: CGFloat + var scale: CGFloat + var opacity: Double +} + +struct ConfettiParticleView: View { + let particle: ConfettiParticle + + var body: some View { + RoundedRectangle(cornerRadius: 3) + .fill(particle.color) + .frame(width: 8, height: 12) + .shadow(color: particle.color.opacity(0.5), radius: 2, x: 0, y: 1) + } +} + +/// A streak celebration view that shows animated flame effects +struct StreakCelebrationView: View { + let streakCount: Int + @State private var isAnimating = false + @State private var scale: CGFloat = 0.5 + @State private var opacity: Double = 0.0 + + var body: some View { + VStack(spacing: 8) { + ZStack { + ForEach(0..<3) { index in + Image(systemName: "flame.fill") + .font(.system(size: 60)) + .foregroundColor( + [.orange, .red, .yellow].randomElement() ?? .orange + ) + .opacity(isAnimating ? Double(3 - index) / 3.0 : 0) + .scaleEffect(scale + CGFloat(index) * 0.2) + .rotationEffect(.degrees(isAnimating ? CGFloat(index * 20) : 0)) + .animation( + .spring(response: 0.5, dampingFraction: 0.5) + .delay(Double(index) * 0.1), + value: isAnimating + ) + } + + Text("\(streakCount)") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + } + .frame(width: 80, height: 80) + + Text("streak.title".localized) + .font(.caption) + .foregroundColor(.secondary) + + Text("streak.\(min(streakCount, 10))".localized) + .font(.headline) + .foregroundColor(.orange) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.orange.opacity(0.1)) + .shadow(color: .orange.opacity(0.3), radius: 10, x: 0, y: 5) + ) + .scaleEffect(scale) + .opacity(opacity) + .onAppear { + withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { + isAnimating = true + scale = 1.0 + opacity = 1.0 + } + + // Auto-dismiss after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation(.easeOut(duration: 0.3)) { + opacity = 0.0 + scale = 0.8 + } + } + } + } +} + +// MARK: - Preview +struct ConfettiCelebrationView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray.opacity(0.1) + .ignoresSafeArea() + + VStack(spacing: 40) { + ConfettiCelebrationView(duration: 3.0) + .frame(width: 200, height: 200) + .onAppear { + // Cannot trigger here in preview + } + + StreakCelebrationView(streakCount: 5) + } + } + } +} diff --git a/Views/FormulaGuideView.swift b/Views/FormulaGuideView.swift index f590cae..44d3417 100644 --- a/Views/FormulaGuideView.swift +++ b/Views/FormulaGuideView.swift @@ -31,11 +31,13 @@ struct FormulaGuideView: View { .padding(.bottom, 20) } .navigationBarTitleDisplayMode(.inline) - .navigationBarItems( - leading: Button("back".localized) { - presentationMode.wrappedValue.dismiss() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("back".localized) { + presentationMode.wrappedValue.dismiss() + } } - ) + } } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/Views/GameView.swift b/Views/GameView.swift index 220a36a..09f531e 100644 --- a/Views/GameView.swift +++ b/Views/GameView.swift @@ -10,6 +10,12 @@ struct GameView: View { @State private var showResultsView = false @State private var currentTime: Int = 0 // Local state to track time for UI updates @State private var hasAppeared = false // Track if view has appeared before + @State private var buttonScale: CGFloat = 1.0 + @State private var feedbackOpacity: Double = 0.0 + @State private var feedbackOffset: CGFloat = 0 + @State private var isShaking = false + @State private var showStreakCelebration = false + @State private var showConfetti = false @Environment(\.presentationMode) var presentationMode @EnvironmentObject var localizationManager: LocalizationManager @@ -18,6 +24,10 @@ struct GameView: View { // TTS Helper for question read-aloud private let ttsHelper = TTSHelper.shared + // Haptic feedback helper + private let haptics = HapticFeedbackHelper.shared + // Sound effects helper + private let sounds = SoundEffectsHelper.shared // 创建一个每秒触发一次的计时器发布者 private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -34,6 +44,32 @@ struct GameView: View { // 默认布局(iPhone和iPad竖屏) var defaultLayout: some View { ZStack { + // Confetti celebration overlay + if showConfetti { + ConfettiCelebrationView(duration: 2.0) { + showConfetti = false + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .zIndex(1000) + } + + // Streak celebration overlay + if showStreakCelebration { + VStack { + Spacer() + StreakCelebrationView(streakCount: viewModel.gameState.streakCount) + .padding(.bottom, 100) + Spacer() + } + .allowsHitTesting(false) + .zIndex(999) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + showStreakCelebration = false + } + } + } VStack { // 顶部信息栏 VStack(spacing: 0) { @@ -51,20 +87,63 @@ struct GameView: View { Spacer() // 当前进度 - VStack { + VStack(spacing: 4) { + // Enhanced progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.2)) + .frame(height: 8) + + // Progress fill with gradient + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + gradient: Gradient(colors: [.blue, .purple]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: geometry.size.width * CGFloat(viewModel.gameState.currentQuestionIndex + 1) / CGFloat(viewModel.gameState.totalQuestions), height: 8) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: viewModel.gameState.currentQuestionIndex) + } + } + .frame(height: 8) + Text(viewModel.gameState.progressText) - .font(.adaptiveBody()) + .font(.adaptiveCaption()) + .foregroundColor(.secondary) } + .frame(width: 100) Spacer() // 当前得分 - VStack(alignment: .trailing) { - Text("game.score".localized) - .font(.footnote) + VStack(alignment: .trailing, spacing: 4) { + // Animated score badge + HStack(spacing: 4) { + if viewModel.gameState.streakCount >= 3 { + Image(systemName: "flame.fill") + .foregroundColor(.orange) + .font(.caption) + .scaleEffect(buttonScale) + .animation(.spring(response: 0.3, dampingFraction: 0.5).repeatCount(3), value: buttonScale) + } + Text("game.score".localized) + .font(.caption) + .foregroundColor(.secondary) + } Text("\(viewModel.gameState.score)") - .font(.adaptiveHeadline()) + .font(.adaptiveTitle2()) + .fontWeight(.bold) .foregroundColor(.green) + .contentTransition(.numericText()) + if viewModel.gameState.streakCount >= 2 { + Text("🔥 ×\(viewModel.gameState.streakCount)") + .font(.caption2) + .foregroundColor(.orange) + } } } .padding() @@ -131,39 +210,59 @@ struct GameView: View { // Read the question aloud using speakMathExpression for proper operator pronunciation let questionToRead = "question.read_aloud".localizedFormat(currentQuestion.questionText) ttsHelper.speakMathExpression(questionToRead, language: localizationManager.currentLanguage) + haptics.light() }) { Text(currentQuestion.questionText) .font(.system(size: 40, weight: .bold)) .foregroundColor(.primary) + .scaleEffect(viewModel.gameState.isCorrect ? 1.1 : 1.0) + .animation(.spring(response: 0.4, dampingFraction: 0.6), value: viewModel.gameState.isCorrect) } .buttonStyle(PlainButtonStyle()) .padding() - + // 答案反馈 if viewModel.gameState.showingCorrectAnswer { VStack { - Text("game.wrong".localized) - .foregroundColor(.red) - .font(.adaptiveHeadline()) - + HStack(spacing: 8) { + Image(systemName: "xmark.circle.fill") + .font(.title) + .foregroundColor(.red) + .scaleEffect(isShaking ? 1.2 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.4).repeatCount(3), value: isShaking) + Text("game.wrong".localized) + .foregroundColor(.red) + .font(.adaptiveHeadline()) + } + .offset(x: isShaking ? -5 : 5) + .animation(.spring(response: 0.2, dampingFraction: 0.3).repeatCount(3), value: isShaking) + Text("game.correct_answer".localizedFormat(String(currentQuestion.correctAnswer))) .foregroundColor(.blue) .font(.adaptiveBody()) - + .padding(.vertical, 5) + // 添加解析按钮 Button(action: { - viewModel.showSolutionSteps.toggle() + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + viewModel.showSolutionSteps.toggle() + } + haptics.light() }) { - Text(viewModel.showSolutionSteps ? "button.hide_solution".localized : "button.show_solution".localized) - .font(.adaptiveBody()) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color.green) - .foregroundColor(.white) - .cornerRadius(.adaptiveCornerRadius) + HStack(spacing: 8) { + Image(systemName: viewModel.showSolutionSteps ? "eye.slash.fill" : "eye.fill") + Text(viewModel.showSolutionSteps ? "button.hide_solution".localized : "button.show_solution".localized) + .font(.adaptiveBody()) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(.adaptiveCornerRadius) + .shadow(color: Color.green.opacity(0.3), radius: 4, x: 0, y: 2) } - .padding(.top, 5) - + .padding(.top, 8) + // 显示解析内容 if viewModel.showSolutionSteps { VStack(spacing: 0) { @@ -180,7 +279,7 @@ struct GameView: View { } .padding(.horizontal, 10) .padding(.top, 8) - + // 解析内容区域 ScrollView(.vertical, showsIndicators: true) { Text(currentQuestion.getSolutionSteps(for: viewModel.gameState.difficultyLevel)) @@ -204,29 +303,58 @@ struct GameView: View { .stroke(Color.gray.opacity(0.3), lineWidth: 1) ) .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + .transition(.opacity.combined(with: .scale)) } - + // Next Question button Button(action: { - viewModel.moveToNextQuestion() + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + viewModel.moveToNextQuestion() + } + haptics.medium() }) { - Text("button.next_question".localized) - .font(.adaptiveHeadline()) - .padding() - .frame(width: 200) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(.adaptiveCornerRadius) + HStack(spacing: 8) { + Image(systemName: "arrow.right.circle.fill") + Text("button.next_question".localized) + .font(.adaptiveHeadline()) + } + .padding() + .frame(width: 220) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(.adaptiveCornerRadius) + .shadow(color: Color.blue.opacity(0.3), radius: 4, x: 0, y: 2) } .id(UUID()) // Force view refresh - .padding(.top, 10) + .padding(.top, 12) } .padding() + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) } else if viewModel.gameState.isCorrect { - Text("game.correct".localized) - .foregroundColor(.green) - .font(.adaptiveHeadline()) - .padding() + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.title) + .foregroundColor(.green) + .scaleEffect(buttonScale) + Text("game.correct".localized) + .foregroundColor(.green) + .font(.adaptiveHeadline()) + } + .padding() + .scaleEffect(buttonScale) + .onAppear { + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { + buttonScale = 1.1 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { + buttonScale = 1.0 + } + } + } } // 答案输入框 @@ -258,9 +386,19 @@ struct GameView: View { .background(userInput.isEmpty ? Color.gray : Color.blue) .foregroundColor(.white) .cornerRadius(.adaptiveCornerRadius) + .scaleEffect(buttonScale) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: buttonScale) } .disabled(userInput.isEmpty || viewModel.gameState.showingCorrectAnswer) .padding() + .onLongPressGesture(minimumDuration: 0, pressing: { isPressing in + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + buttonScale = isPressing ? 0.95 : 1.0 + } + if isPressing { + haptics.light() + } + }, perform: {}) } Spacer() @@ -823,8 +961,53 @@ struct GameView: View { // 提交答案 private func submitAnswer() { guard let answer = Int(userInput) else { return } + + // Trigger immediate feedback + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + buttonScale = 0.9 + } + + // Check answer and provide feedback + if viewModel.gameState.questions.count > viewModel.gameState.currentQuestionIndex { + let currentQuestion = viewModel.gameState.questions[viewModel.gameState.currentQuestionIndex] + let isCorrect = answer == currentQuestion.correctAnswer + + if isCorrect { + haptics.correctAnswer() + sounds.playCorrectAnswer() + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { + buttonScale = 1.05 + } + // Check for streak celebration + if viewModel.gameState.streakCount >= 3 && viewModel.gameState.streakCount % 3 == 0 { + haptics.celebrate(count: 2) + sounds.playAchievement() + showStreakCelebration = true + showConfetti = true + } + } else { + haptics.wrongAnswer() + sounds.playWrongAnswer() + withAnimation(.spring(response: 0.3, dampingFraction: 0.4)) { + isShaking.toggle() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.4)) { + isShaking = false + } + } + } + } + viewModel.submitAnswer(answer) userInput = "" + + // Reset button scale + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + buttonScale = 1.0 + } + } } } diff --git a/Views/ResultView.swift b/Views/ResultView.swift index ee0e705..f81b8f3 100644 --- a/Views/ResultView.swift +++ b/Views/ResultView.swift @@ -52,31 +52,50 @@ struct ResultView: View { HStack { Text("result.correct_count".localizedFormat(String(gameState.correctAnswersCount), String(gameState.totalQuestions))) .font(.adaptiveBody()) + .foregroundColor(.primary) Spacer() } - + // 用时 HStack { Text("result.time_used".localized) .font(.adaptiveBody()) + .foregroundColor(.primary) Spacer() Text(gameState.timeUsedText) .font(.adaptiveBody()) .foregroundColor(.blue) } - + // 难度 HStack { Text("difficulty.level".localized) .font(.adaptiveBody()) + .foregroundColor(.primary) Spacer() Text(gameState.difficultyLevel.localizedName) .font(.adaptiveBody()) .foregroundColor(.blue) } + + // 最长连续答对 + if gameState.longestStreak > 0 { + HStack { + Text("result.longest_streak".localized) + .font(.adaptiveBody()) + .foregroundColor(.primary) + Spacer() + HStack(spacing: 4) { + Text("🔥") + Text("\(gameState.longestStreak)") + } + .font(.adaptiveBody()) + .foregroundColor(.orange) + } + } } .padding() - .background(Color.gray.opacity(0.1)) + .background(Color(.secondarySystemBackground)) .cornerRadius(.adaptiveCornerRadius) .padding(.horizontal) diff --git a/Views/SettingsView.swift b/Views/SettingsView.swift index 20f6cca..3e2a7e8 100644 --- a/Views/SettingsView.swift +++ b/Views/SettingsView.swift @@ -96,9 +96,13 @@ struct SettingsView: View { } } .navigationTitle("settings.title".localized) - .navigationBarItems(trailing: Button("Done") { - presentationMode.wrappedValue.dismiss() - }) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } .sheet(isPresented: $navigateToAboutMe) { NavigationView { // Wrap in NavigationView if the destination view needs a navigation bar aboutMeDestination @@ -252,9 +256,13 @@ struct AboutAppView: View { } .navigationTitle("about.arithmetic.title".localized) .navigationBarTitleDisplayMode(.large) - .navigationBarItems(trailing: Button("Done") { - presentationMode.wrappedValue.dismiss() - }) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + presentationMode.wrappedValue.dismiss() + } + } + } .onAppear { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { animateHero = true diff --git a/Views/WrongQuestionsView.swift b/Views/WrongQuestionsView.swift index e684b68..2320a88 100644 --- a/Views/WrongQuestionsView.swift +++ b/Views/WrongQuestionsView.swift @@ -12,230 +12,474 @@ struct WrongQuestionsView: View { @State private var showingDeleteMasteredAlert = false @State private var expandedQuestionIds: [UUID] = [] @State private var refreshTrigger = UUID() + @State private var animateHeader = false @EnvironmentObject var localizationManager: LocalizationManager - + private let wrongQuestionManager = WrongQuestionManager() - + + // MARK: - Gradient Colors for Level Badges + private func gradientForLevel(_ level: DifficultyLevel?) -> LinearGradient { + switch level { + case .level1: + return LinearGradient(colors: [Color.green.opacity(0.8), Color.green], startPoint: .leading, endPoint: .trailing) + case .level2: + return LinearGradient(colors: [Color.blue.opacity(0.8), Color.blue], startPoint: .leading, endPoint: .trailing) + case .level3: + return LinearGradient(colors: [Color.orange.opacity(0.8), Color.orange], startPoint: .leading, endPoint: .trailing) + case .level4: + return LinearGradient(colors: [Color.purple.opacity(0.8), Color.purple], startPoint: .leading, endPoint: .trailing) + case .level5: + return LinearGradient(colors: [Color.red.opacity(0.8), Color.red], startPoint: .leading, endPoint: .trailing) + case .level6: + return LinearGradient(colors: [Color.pink.opacity(0.8), Color.pink], startPoint: .leading, endPoint: .trailing) + case .none: + return LinearGradient(colors: [Color.progressGradientStart, Color.progressGradientEnd], startPoint: .leading, endPoint: .trailing) + } + } + + private func iconForLevel(_ level: DifficultyLevel) -> String { + switch level { + case .level1: return "leaf.fill" + case .level2: return "flame.fill" + case .level3: return "bolt.fill" + case .level4: return "star.fill" + case .level5: return "crown.fill" + case .level6: return "medal.fill" + } + } + var body: some View { - VStack(spacing: 0) { - // 固定顶部区域:标题和难度选择器 - VStack { - // 标题 - Text("wrong_questions.title".localized) - .font(.adaptiveTitle()) - .padding() - - // 难度选择器 - 支持水平滚动 - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - // 全部难度选项 - Button(action: { - selectedLevel = nil - loadWrongQuestions() - }) { - Text("wrong_questions.all_levels".localized) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(selectedLevel == nil ? .white : .primary) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(selectedLevel == nil ? Color.blue : Color(.systemGray5)) - ) - } - .buttonStyle(PlainButtonStyle()) - - // 各个难度级别选项 - ForEach(DifficultyLevel.allCases) { level in - Button(action: { - selectedLevel = level - loadWrongQuestions() - }) { - Text(level.localizedName) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(selectedLevel == level ? .white : .primary) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(selectedLevel == level ? Color.blue : Color(.systemGray5)) - ) - } - .buttonStyle(PlainButtonStyle()) - } + ZStack { + // Background gradient + LinearGradient( + colors: [Color(.systemBackground), Color.adaptiveSecondaryBackground], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + // Header with stats + headerSection + + // Difficulty level selector + levelSelectorSection + + // Main content area + if wrongQuestions.isEmpty { + emptyStateView + } else { + questionsListSection + } + + // Bottom action buttons + bottomActionsSection + } + } + .onAppear { + loadWrongQuestions() + withAnimation(.easeOut(duration: 0.5)) { + animateHeader = true + } + } + .id(refreshTrigger) + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("LanguageChanged"))) { _ in + refreshTrigger = UUID() + refreshSolutionContent() + } + .navigationTitle("wrong_questions.title".localized) + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + Text("button.back".localized) } - .padding(.horizontal, 16) + .foregroundColor(.accent) } - .padding(.bottom, 8) } - .background(Color(.systemBackground)) - - // 可滚动的主要内容区域 - if wrongQuestions.isEmpty { - VStack { - Spacer() - Text("wrong_questions.empty".localized) - .font(.adaptiveBody()) - .foregroundColor(.gray) - Spacer() + } + } + + // MARK: - Header Section + private var headerSection: some View { + HStack(spacing: 16) { + // Total count badge + VStack(spacing: 4) { + Text("\(wrongQuestions.count)") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.error) + Text("wrong_questions.total".localized) + .font(.caption) + .foregroundColor(.adaptiveSecondaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: AppTheme.cornerRadius) + .fill(Color.error.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.cornerRadius) + .stroke(Color.error.opacity(0.3), lineWidth: 1) + ) + + // Mastered indicator + VStack(spacing: 4) { + let masteredCount = wrongQuestions.filter { $0.timesShown >= 3 && $0.timesWrong == 0 }.count + Text("\(masteredCount)") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.success) + Text("wrong_questions.mastered".localized) + .font(.caption) + .foregroundColor(.adaptiveSecondaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: AppTheme.cornerRadius) + .fill(Color.success.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.cornerRadius) + .stroke(Color.success.opacity(0.3), lineWidth: 1) + ) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 12) + .opacity(animateHeader ? 1 : 0) + .offset(y: animateHeader ? 0 : -20) + } + + // MARK: - Level Selector Section + private var levelSelectorSection: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + // All levels option + levelButton(level: nil, isSelected: selectedLevel == nil) + + // Individual level options + ForEach(DifficultyLevel.allCases) { level in + levelButton(level: level, isSelected: selectedLevel == level) } - } else { - List { - ForEach(wrongQuestions) { question in - VStack(alignment: .leading, spacing: 8) { - Text(question.questionText) - .font(.adaptiveHeadline()) - - Text("wrong_questions.answer".localizedFormat(String(question.correctAnswer))) - .font(.adaptiveBody()) - .foregroundColor(.blue) - - // 添加解析按钮 - Button(action: { - if let index = self.expandedQuestionIds.firstIndex(of: question.id) { - self.expandedQuestionIds.remove(at: index) - } else { - self.expandedQuestionIds.append(question.id) - } - }) { - Text(self.expandedQuestionIds.contains(question.id) ? "button.hide_solution".localized : "button.show_solution".localized) - .font(.footnote) - .foregroundColor(.green) - } - - // 显示解析内容 - if self.expandedQuestionIds.contains(question.id) { - VStack(spacing: 0) { - // 解析内容标题栏 - HStack { - Text("solution.content".localized) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - Spacer() - Image(systemName: "scroll") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 20) - .padding(.top, 8) - - // 解析内容区域 - ScrollView(.vertical, showsIndicators: true) { - Text(question.currentLanguageSolutionSteps) - .font(.footnote) - .lineSpacing(2) - .padding(12) - .background(Color.yellow.opacity(0.1)) - .cornerRadius(8) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) // 内部padding,为滚动条留出空间 - } - .frame(height: calculateSolutionHeight(screenHeight: UIScreen.main.bounds.height)) - .padding(.horizontal, 20) // 左右两端20px padding - .padding(.bottom, 8) - } - .background(Color(.systemBackground)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) - } - - HStack { - Text("wrong_questions.level".localizedFormat(question.level.localizedName)) - .font(.footnote) - .foregroundColor(.gray) - - Spacer() - - Text("wrong_questions.stats".localizedFormat( - String(question.timesShown), - String(question.timesWrong) - )) - .font(.footnote) - .foregroundColor(.gray) - } - } - .padding(.vertical, 5) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .background(Color.adaptiveBackground.opacity(0.8)) + } + + private func levelButton(level: DifficultyLevel?, isSelected: Bool) -> some View { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedLevel = level + } + loadWrongQuestions() + }) { + HStack(spacing: 6) { + if let lvl = level { + Image(systemName: iconForLevel(lvl)) + .font(.system(size: 12)) + } else { + Image(systemName: "square.grid.2x2.fill") + .font(.system(size: 12)) + } + Text(level?.localizedName ?? "wrong_questions.all_levels".localized) + .font(.system(size: 14, weight: .semibold)) + } + .foregroundColor(isSelected ? .white : .adaptiveText) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Group { + if isSelected { + gradientForLevel(level) + } else { + LinearGradient(colors: [Color.adaptiveSecondaryBackground], startPoint: .leading, endPoint: .trailing) } - .onDelete(perform: deleteWrongQuestions) + } + ) + .clipShape(Capsule()) + .shadow(color: isSelected ? Color.adaptiveShadow : Color.clear, radius: 4, x: 0, y: 2) + .scaleEffect(isSelected ? 1.05 : 1.0) + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: - Empty State View + private var emptyStateView: some View { + VStack(spacing: 20) { + Spacer() + + // Animated checkmark icon + ZStack { + Circle() + .fill(Color.success.opacity(0.1)) + .frame(width: 120, height: 120) + + Circle() + .fill(Color.success.opacity(0.2)) + .frame(width: 90, height: 90) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.success) + } + + Text("wrong_questions.empty".localized) + .font(.adaptiveTitle2()) + .fontWeight(.semibold) + .foregroundColor(.adaptiveText) + + Text("wrong_questions.empty_subtitle".localized) + .font(.adaptiveBody()) + .foregroundColor(.adaptiveSecondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Spacer() + } + } + + // MARK: - Questions List Section + private var questionsListSection: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(wrongQuestions) { question in + questionCard(question) + .transition(.asymmetric( + insertion: .opacity.combined(with: .slide), + removal: .opacity.combined(with: .scale) + )) } } - - // 固定底部按钮区域 - VStack { - Divider() + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + + private func questionCard(_ question: WrongQuestionViewModel) -> some View { + VStack(alignment: .leading, spacing: 0) { + // Main question content + VStack(alignment: .leading, spacing: 12) { + // Question header with level badge HStack { - Button(action: { - showingDeleteAlert = true - }) { - Text("wrong_questions.delete_all".localized) - .foregroundColor(.red) + // Level badge + HStack(spacing: 4) { + Image(systemName: iconForLevel(question.level)) + .font(.system(size: 10)) + Text(question.level.localizedName) + .font(.system(size: 11, weight: .semibold)) } - .disabled(wrongQuestions.isEmpty) - .alert(isPresented: $showingDeleteAlert) { - Alert( - title: Text("alert.delete_all_title".localized), - message: Text("alert.delete_all_message".localized), - primaryButton: .destructive(Text("alert.delete_confirm".localized)) { - deleteAllWrongQuestions() - }, - secondaryButton: .cancel(Text("alert.cancel".localized)) - ) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(gradientForLevel(question.level)) + .clipShape(Capsule()) + + Spacer() + + // Stats badges + HStack(spacing: 8) { + statBadge(icon: "eye.fill", value: question.timesShown, color: .blue) + statBadge(icon: "xmark.circle.fill", value: question.timesWrong, color: .red) } - + } + + // Question text + Text(question.questionText) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundColor(.adaptiveText) + + // Answer row + HStack { + Text("wrong_questions.answer".localizedFormat(String(question.correctAnswer))) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.success) + Spacer() - + + // Solution toggle button Button(action: { - showingDeleteMasteredAlert = true + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + if let index = expandedQuestionIds.firstIndex(of: question.id) { + expandedQuestionIds.remove(at: index) + } else { + expandedQuestionIds.append(question.id) + } + } }) { - Text("wrong_questions.delete_mastered".localized) - .foregroundColor(.blue) - } - .disabled(wrongQuestions.isEmpty) - .alert(isPresented: $showingDeleteMasteredAlert) { - Alert( - title: Text("alert.delete_mastered_title".localized), - message: Text("alert.delete_mastered_message".localized), - primaryButton: .destructive(Text("alert.delete_confirm".localized)) { - deleteMasteredQuestions() - }, - secondaryButton: .cancel(Text("alert.cancel".localized)) - ) + HStack(spacing: 4) { + Image(systemName: expandedQuestionIds.contains(question.id) ? "lightbulb.fill" : "lightbulb") + .font(.system(size: 14)) + Text(expandedQuestionIds.contains(question.id) ? "button.hide_solution".localized : "button.show_solution".localized) + .font(.system(size: 13, weight: .medium)) + } + .foregroundColor(.warning) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.warning.opacity(0.15)) + .clipShape(Capsule()) } + .buttonStyle(PlainButtonStyle()) } - .padding() } - .background(Color(.systemBackground)) + .padding(16) + + // Expandable solution section + if expandedQuestionIds.contains(question.id) { + solutionSection(question) + } } - .onAppear { - loadWrongQuestions() + .background(Color.adaptiveBackground) + .cornerRadius(AppTheme.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.cornerRadius) + .stroke(Color.adaptiveBorder, lineWidth: 1) + ) + .shadow(color: Color.adaptiveShadow, radius: AppTheme.lightShadowRadius, x: 0, y: 2) + .contextMenu { + Button(role: .destructive, action: { + deleteQuestion(question) + }) { + Label("alert.delete_confirm".localized, systemImage: "trash") + } } - .id(refreshTrigger) // Force refresh when language changes - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("LanguageChanged"))) { _ in - // 当语言变化时,重新生成解析内容并刷新UI - refreshTrigger = UUID() - refreshSolutionContent() + } + + private func statBadge(icon: String, value: Int, color: Color) -> some View { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 10)) + Text("\(value)") + .font(.system(size: 12, weight: .semibold)) } - .navigationTitle("wrong_questions.title".localized) - .navigationBarTitleDisplayMode(.large) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + .foregroundColor(color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.1)) + .clipShape(Capsule()) + } + + private func solutionSection(_ question: WrongQuestionViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.horizontal, 16) + + // Solution header + HStack { + Image(systemName: "sparkles") + .foregroundColor(.warning) + Text("solution.content".localized) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.adaptiveSecondaryText) + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 8) + + // Solution content + ScrollView(.vertical, showsIndicators: true) { + Text(question.currentLanguageSolutionSteps) + .font(.system(size: 14)) + .lineSpacing(4) + .foregroundColor(.adaptiveText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .frame(height: calculateSolutionHeight(screenHeight: UIScreen.main.bounds.height)) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.warning.opacity(0.08)) + ) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + // MARK: - Bottom Actions Section + private var bottomActionsSection: some View { + VStack(spacing: 0) { + Divider() + + HStack(spacing: 16) { + // Delete All Button Button(action: { - presentationMode.wrappedValue.dismiss() + showingDeleteAlert = true }) { - HStack { - Image(systemName: "chevron.left") - Text("button.back".localized) + HStack(spacing: 6) { + Image(systemName: "trash.fill") + .font(.system(size: 14)) + Text("wrong_questions.delete_all".localized) + .font(.system(size: 14, weight: .semibold)) + } + .foregroundColor(wrongQuestions.isEmpty ? .gray : .white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: AppTheme.smallCornerRadius) + .fill(wrongQuestions.isEmpty ? Color.gray.opacity(0.3) : Color.error) + ) + } + .disabled(wrongQuestions.isEmpty) + .alert(isPresented: $showingDeleteAlert) { + Alert( + title: Text("alert.delete_all_title".localized), + message: Text("alert.delete_all_message".localized), + primaryButton: .destructive(Text("alert.delete_confirm".localized)) { + deleteAllWrongQuestions() + }, + secondaryButton: .cancel(Text("alert.cancel".localized)) + ) + } + + // Delete Mastered Button + Button(action: { + showingDeleteMasteredAlert = true + }) { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)) + Text("wrong_questions.delete_mastered".localized) + .font(.system(size: 14, weight: .semibold)) } - .foregroundColor(.blue) + .foregroundColor(wrongQuestions.isEmpty ? .gray : .white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: AppTheme.smallCornerRadius) + .fill(wrongQuestions.isEmpty ? Color.gray.opacity(0.3) : Color.accent) + ) + } + .disabled(wrongQuestions.isEmpty) + .alert(isPresented: $showingDeleteMasteredAlert) { + Alert( + title: Text("alert.delete_mastered_title".localized), + message: Text("alert.delete_mastered_message".localized), + primaryButton: .destructive(Text("alert.delete_confirm".localized)) { + deleteMasteredQuestions() + }, + secondaryButton: .cancel(Text("alert.cancel".localized)) + ) } } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .background(Color.adaptiveBackground) + } + + // MARK: - Helper function to delete a single question + private func deleteQuestion(_ question: WrongQuestionViewModel) { + withAnimation { + wrongQuestionManager.deleteWrongQuestion(with: question.id) + loadWrongQuestions() } } diff --git a/scripts/quick_test.sh b/scripts/quick_test.sh new file mode 100755 index 0000000..5277052 --- /dev/null +++ b/scripts/quick_test.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# ============================================================================== +# Quick Test Script - Fast Unit Tests Only +# ============================================================================== +# This script runs only unit tests for quick feedback during development. +# Skips UI tests and localization checks for faster execution. +# +# Usage: +# ./scripts/quick_test.sh +# +# ============================================================================== + +set -e + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +PROJECT="Arithmetic.xcodeproj" +SCHEME="Arithmetic" + +echo -e "${BLUE}=============================================${NC}" +echo -e "${BLUE}Quick Unit Tests - Arithmetic App${NC}" +echo -e "${BLUE}=============================================${NC}\n" + +# Build and test +if xcodebuild test \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' \ + -only-testing:ArithmeticTests \ + | grep -E "(Test Suite|Test Case|passed|failed|BUILD)" \ + ; then + echo -e "\n${GREEN}✓ Quick tests passed!${NC}\n" + exit 0 +else + echo -e "\n${RED}✗ Quick tests failed${NC}\n" + exit 1 +fi diff --git a/scripts/run_all_tests.sh b/scripts/run_all_tests.sh new file mode 100755 index 0000000..0b4b4f1 --- /dev/null +++ b/scripts/run_all_tests.sh @@ -0,0 +1,229 @@ +#!/bin/bash + +# ============================================================================== +# Arithmetic App - Test Automation Script +# ============================================================================== +# This script runs all tests for the Arithmetic iOS app including: +# - Unit tests +# - UI tests +# - Localization checks +# - Static analysis +# +# Usage: +# ./scripts/run_all_tests.sh [options] +# +# Options: +# --skip-ui Skip UI tests (faster execution) +# --skip-unit Skip unit tests +# --skip-localization Skip localization checks +# --only-ui Run only UI tests +# --only-unit Run only unit tests +# --verbose Enable verbose output +# --help Show this help message +# +# ============================================================================== + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default values +PROJECT="Arithmetic.xcodeproj" +SCHEME="Arithmetic" +DESTINATION="platform=iOS Simulator,name=Any iOS Simulator Device" +SKIP_UI=false +SKIP_UNIT=false +SKIP_LOCALIZATION=false +VERBOSE=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --skip-ui) + SKIP_UI=true + shift + ;; + --skip-unit) + SKIP_UNIT=true + shift + ;; + --skip-localization) + SKIP_LOCALIZATION=true + shift + ;; + --only-ui) + SKIP_UNIT=true + SKIP_LOCALIZATION=true + shift + ;; + --only-unit) + SKIP_UI=true + SKIP_LOCALIZATION=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + --help) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --skip-ui Skip UI tests (faster execution)" + echo " --skip-unit Skip unit tests" + echo " --skip-localization Skip localization checks" + echo " --only-ui Run only UI tests" + echo " --only-unit Run only unit tests" + echo " --verbose Enable verbose output" + echo " --help Show this help message" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Function to print colored output +print_header() { + echo -e "\n${BLUE}=============================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}=============================================${NC}\n" +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +# Function to run a test suite +run_test_suite() { + local test_name="$1" + local test_command="$2" + + print_header "$test_name" + + if $VERBOSE; then + eval "$test_command" + else + eval "$test_command" > /tmp/test_output.log 2>&1 + + if [ $? -eq 0 ]; then + print_success "$test_name passed" + if [ -s /tmp/test_output.log ]; then + echo "Output:" + tail -10 /tmp/test_output.log + fi + else + print_error "$test_name failed" + echo "Error output:" + cat /tmp/test_output.log + return 1 + fi + fi +} + +# Start of test execution +print_header "Arithmetic App - Test Suite" +echo "Project: $PROJECT" +echo "Scheme: $SCHEME" +echo "" + +# Track overall test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# ============================================================================== +# 1. Build Project +# ============================================================================== +print_header "Building Project" +if xcodebuild -project "$PROJECT" -scheme "$SCHEME" -destination "$DESTINATION" clean build > /tmp/build.log 2>&1; then + print_success "Build successful" +else + print_error "Build failed" + cat /tmp/build.log + exit 1 +fi + +# ============================================================================== +# 2. Run Unit Tests +# ============================================================================== +if [ "$SKIP_UNIT" = false ]; then + if run_test_suite "Unit Tests" "xcodebuild test -project '$PROJECT' -scheme '$SCHEME' -destination '$DESTINATION' -only-testing:ArithmeticTests"; then + ((TESTS_PASSED++)) + else + ((TESTS_FAILED++)) + fi +else + print_warning "Skipping unit tests" +fi + +# ============================================================================== +# 3. Run UI Tests +# ============================================================================== +if [ "$SKIP_UI" = false ]; then + if run_test_suite "UI Tests" "xcodebuild test -project '$PROJECT' -scheme '$SCHEME' -destination '$DESTINATION' -only-testing:ArithmeticUITests"; then + ((TESTS_PASSED++)) + else + ((TESTS_FAILED++)) + fi +else + print_warning "Skipping UI tests" +fi + +# ============================================================================== +# 4. Localization Checks +# ============================================================================== +if [ "$SKIP_LOCALIZATION" = false ]; then + print_header "Localization Checks" + if ./scripts/check_localizations.sh > /tmp/localization.log 2>&1; then + print_success "Localization checks passed" + ((TESTS_PASSED++)) + else + print_error "Localization checks failed" + cat /tmp/localization.log + ((TESTS_FAILED++)) + fi +else + print_warning "Skipping localization checks" +fi + +# ============================================================================== +# 5. Static Analysis (Optional) +# ============================================================================== +print_header "Static Analysis" +if xcodebuild analyze -project "$PROJECT" -scheme "$SCHEME" -destination "$DESTINATION" > /tmp/analyze.log 2>&1; then + print_success "Static analysis completed" + # Note: analyze doesn't fail on warnings, so we don't count this as passed/failed +else + print_warning "Static analysis had issues (check log for details)" +fi + +# ============================================================================== +# Test Summary +# ============================================================================== +print_header "Test Summary" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" + +if [ $TESTS_FAILED -eq 0 ]; then + print_success "All tests passed!" + exit 0 +else + print_error "Some tests failed" + exit 1 +fi