diff --git a/docs/superpowers/specs/2026-04-06-open-source-design.md b/docs/superpowers/specs/2026-04-06-open-source-design.md new file mode 100644 index 0000000..b4d84d0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-open-source-design.md @@ -0,0 +1,123 @@ +# Open Source Geni — Design Spec + +**Date:** 2026-04-06 +**Author:** Yago Martinez +**License:** MIT +**Goal:** Make the Geni iOS app + marketing site fully open source — transparent, contributor-ready, and useful as a reference project. + +--- + +## Context + +Geni is a published iOS reading/learning app for kids (App Store ID: 6761134405). The repo is currently private on GitHub. The goal is to open source it for three reasons: transparency, community contributions, and as a reference for other developers. The app has zero external dependencies (pure SwiftUI + native Apple frameworks), making it a clean codebase to share. + +--- + +## Approach + +**Standard open source setup (Option B):** Security cleanup + LICENSE + README + CONTRIBUTING + issue templates + Code of Conduct. No CI for now — can be added later. + +--- + +## Section 1: Security Cleanup + +**Files to remove from working tree and add to `.gitignore`:** +- `.asc/config.json` — contains App Store Connect API key ID and issuer ID +- `ios/profiles/*.mobileprovision` — provisioning profile +- `ios/ExportOptions.plist` — contains Apple Team ID (`ZBRDR5Q455`) +- `.wrangler/` — Cloudflare Workers local state + +**Git history:** Check if `.asc/config.json` was ever committed. If yes, purge with `git filter-repo --path .asc/config.json --invert-paths`. + +**`.gitignore` additions:** +``` +# App Store Connect +.asc/config.json + +# Signing +ios/profiles/*.mobileprovision +ios/ExportOptions.plist + +# Cloudflare +.wrangler/ +``` + +--- + +## Section 2: LICENSE + +Create `LICENSE` at repo root: +- Type: MIT +- Copyright: `Copyright 2026 Yago Martinez` + +--- + +## Section 3: README.md + +Location: repo root +Sections: +1. App name + one-line description + App Store badge +2. **What is Geni?** — reading/learning app for kids +3. **Features** — key feature list +4. **Privacy** — zero-backend, all local storage (CloudKit/iCloud), no custom servers +5. **Tech stack** — SwiftUI, CloudKit, Speech framework, AVFoundation, native only +6. **Building locally** — Xcode requirements, clone + open `.xcodeproj`, run on simulator/device +7. **Project structure** — `ios/Geni/{Views,ViewModels,Services,Models}`, `site/` +8. **Contributing** — link to `CONTRIBUTING.md` +9. **License** — MIT + +--- + +## Section 4: CONTRIBUTING.md + +Location: repo root +Sections: +- How to report bugs → GitHub Issues (bug template) +- How to suggest features → GitHub Issues (feature template) +- Dev setup: Xcode (no dependencies, just open `.xcodeproj`) +- PR process: fork → feature branch → PR against `main` +- Code style: follow existing SwiftUI/MVVM patterns in codebase + +--- + +## Section 5: GitHub Issue Templates + +Location: `.github/ISSUE_TEMPLATE/` + +**`bug_report.md`:** +- Steps to reproduce +- Expected vs actual behavior +- iOS version + device model +- Xcode version (if building from source) + +**`feature_request.md`:** +- Problem description +- Proposed solution +- Alternatives considered + +--- + +## Section 6: Code of Conduct + +Location: `CODE_OF_CONDUCT.md` +Standard: Contributor Covenant v2.1 +Enforcement contact: `hello@geni.kids` + +--- + +## Section 7: Go Public + +1. Commit all new files: `"Open source Geni under MIT license"` +2. Flip GitHub repo from private to public (done manually in GitHub settings) + +--- + +## Verification + +- [ ] `.asc/config.json` no longer tracked by git (`git ls-files .asc/config.json` returns empty) +- [ ] `git log --all --full-history -- .asc/config.json` returns no commits (or history purged) +- [ ] `LICENSE` exists at root with correct copyright +- [ ] `README.md` renders correctly on GitHub (check badge, links) +- [ ] Issue templates appear when creating new issue on GitHub +- [ ] `CODE_OF_CONDUCT.md` references `hello@geni.kids` +- [ ] Repo is publicly accessible at `github.com/yagomp/geni` diff --git a/ios/Geni.xcodeproj/project.pbxproj b/ios/Geni.xcodeproj/project.pbxproj index 37b13bc..a4e9412 100644 --- a/ios/Geni.xcodeproj/project.pbxproj +++ b/ios/Geni.xcodeproj/project.pbxproj @@ -397,7 +397,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ZBRDR5Q455; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -414,7 +414,7 @@ "@executable_path/Frameworks", ); CODE_SIGN_ENTITLEMENTS = Geni/Geni.entitlements; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = com.yagomp.geni; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -435,7 +435,7 @@ CODE_SIGN_ENTITLEMENTS = Geni/Geni.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ZBRDR5Q455; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = com.yagomp.geni; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Geni AppStore"; @@ -470,11 +470,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ZBRDR5Q455; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -492,11 +492,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ZBRDR5Q455; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -513,10 +513,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ZBRDR5Q455; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -533,10 +533,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ZBRDR5Q455; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.1.2; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = "com.yagomp.geni.uitests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/ios/Geni/Models/ChapterProgress.swift b/ios/Geni/Models/ChapterProgress.swift index 6b86a82..e2847f4 100644 --- a/ios/Geni/Models/ChapterProgress.swift +++ b/ios/Geni/Models/ChapterProgress.swift @@ -6,6 +6,7 @@ nonisolated struct ChapterProgress: Codable, Identifiable, Sendable { let date: String var chapterType: ChapterType var status: ChapterStatus + var completedExerciseCount: Int var exerciseResults: [ExerciseResult] var stars: Int var coinsEarned: Int @@ -17,11 +18,39 @@ nonisolated struct ChapterProgress: Codable, Identifiable, Sendable { self.date = date self.chapterType = chapterType self.status = .inProgress + self.completedExerciseCount = 0 self.exerciseResults = [] self.stars = 0 self.coinsEarned = 0 } + private enum CodingKeys: String, CodingKey { + case id + case childId + case date + case chapterType + case status + case completedExerciseCount + case exerciseResults + case stars + case coinsEarned + case completedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + childId = try container.decode(String.self, forKey: .childId) + date = try container.decode(String.self, forKey: .date) + chapterType = try container.decodeIfPresent(ChapterType.self, forKey: .chapterType) ?? .daily + status = try container.decode(ChapterStatus.self, forKey: .status) + exerciseResults = try container.decodeIfPresent([ExerciseResult].self, forKey: .exerciseResults) ?? [] + completedExerciseCount = try container.decodeIfPresent(Int.self, forKey: .completedExerciseCount) ?? exerciseResults.count + stars = try container.decodeIfPresent(Int.self, forKey: .stars) ?? 0 + coinsEarned = try container.decodeIfPresent(Int.self, forKey: .coinsEarned) ?? 0 + completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt) + } + var correctCount: Int { exerciseResults.filter(\.wasCorrect).count } @@ -35,7 +64,7 @@ nonisolated struct ChapterProgress: Codable, Identifiable, Sendable { } var isComplete: Bool { - exerciseResults.count >= 20 + completedExerciseCount >= 20 } mutating func calculateRewards() { diff --git a/ios/Geni/Models/Exercise.swift b/ios/Geni/Models/Exercise.swift index 800da5c..6bcc54b 100644 --- a/ios/Geni/Models/Exercise.swift +++ b/ios/Geni/Models/Exercise.swift @@ -146,27 +146,14 @@ nonisolated struct Exercise: Identifiable, Sendable { self.proposedAnswer = nil self.comparisonLeft = (operand1, operation, operand2) - let rightAnswer = answer + Int.random(in: -3...3) - let safeRight = max(rightAnswer, 0) - let rOp2 = max(Int.random(in: 1...max(operand2, 2)), 1) - let rOp1: Int - switch operation { - case .addition: rOp1 = safeRight - rOp2 - case .subtraction: rOp1 = safeRight + rOp2 - case .multiplication: rOp1 = rOp2 > 0 ? safeRight / max(rOp2, 1) : safeRight - case .division: rOp1 = safeRight * max(rOp2, 1) - } - let safeROp1 = max(rOp1, 0) - self.comparisonRight = (safeROp1, operation, rOp2) - let leftVal = answer - let rightVal: Int - switch operation { - case .addition: rightVal = safeROp1 + rOp2 - case .subtraction: rightVal = safeROp1 - rOp2 - case .multiplication: rightVal = safeROp1 * rOp2 - case .division: rightVal = rOp2 > 0 ? safeROp1 / rOp2 : 0 - } + let rightVal = Self.distinctComparisonTarget(from: leftVal) + let rightOperands = Self.comparisonOperands( + for: rightVal, + operation: operation, + referenceOperand2: operand2 + ) + self.comparisonRight = (rightOperands.0, operation, rightOperands.1) if leftVal >= rightVal { self.options = [0, 1] @@ -502,6 +489,43 @@ nonisolated struct Exercise: Identifiable, Sendable { var trueFalseIsCorrect: Bool { proposedAnswer == correctAnswer } + + private static func distinctComparisonTarget(from answer: Int) -> Int { + let lowerBound = max(0, answer - 3) + let upperBound = answer + 3 + let candidates = Array(lowerBound...upperBound).filter { $0 != answer } + return candidates.randomElement() ?? (answer + 1) + } + + private static func comparisonOperands( + for target: Int, + operation: MathOperation, + referenceOperand2: Int + ) -> (Int, Int) { + switch operation { + case .addition: + let right = target > 0 ? Int.random(in: 0...target) : 0 + return (target - right, right) + + case .subtraction: + let right = Int.random(in: 0...max(referenceOperand2 + 3, 3)) + return (target + right, right) + + case .multiplication: + if target == 0 { + let factor = Int.random(in: 1...max(referenceOperand2, 2)) + return (0, factor) + } + + let divisors = (1...target).filter { target.isMultiple(of: $0) } + let right = divisors.randomElement() ?? 1 + return (target / right, right) + + case .division: + let divisor = Int.random(in: 1...max(referenceOperand2, 2)) + return (target * divisor, divisor) + } + } } nonisolated enum ExerciseDifficulty: Int, Codable, Sendable { diff --git a/ios/Geni/Services/ExerciseGenerator.swift b/ios/Geni/Services/ExerciseGenerator.swift index 8b2f729..3f69ba7 100644 --- a/ios/Geni/Services/ExerciseGenerator.swift +++ b/ios/Geni/Services/ExerciseGenerator.swift @@ -38,7 +38,7 @@ enum ExerciseGenerator { if format == .matchConnect { let exercise = generateMatchConnectExercise(difficulty: difficulty, ageGroup: profile.ageGroup, ops: ops) exercises.append(exercise) - i += 3 + i += 1 } else if emojiFormats.contains(format) { exercises.append(generateEmojiExercise(format: format, difficulty: difficulty)) i += 1 @@ -73,7 +73,7 @@ enum ExerciseGenerator { if format == .matchConnect { exercises.append(generateMatchConnectExercise(difficulty: difficulty, ageGroup: profile.ageGroup, ops: ops)) - i += 3 + i += 1 } else if emojiFormats.contains(format) { exercises.append(generateEmojiExercise(format: format, difficulty: difficulty)) i += 1 @@ -597,39 +597,23 @@ enum ExerciseGenerator { static func generateMatchConnectExercise(difficulty: ExerciseDifficulty, ageGroup: AgeGroup, ops: [MathOperation]) -> Exercise { let pairCount = difficulty == .warmup ? 3 : 4 - var leftLabels: [String] = [] - var rightLabels: [String] = [] - var answers: [Int] = [] + let availableOps = ops.isEmpty ? [.addition] : ops + var pairs: [(label: String, answer: Int)] = [] + var usedAnswers = Set() for _ in 0..() - for i in 0.. + ) -> (label: String, answer: Int) { + for _ in 0..<100 { + let operation = ops.randomElement() ?? .addition + let (lhs, rhs) = generateOperands(operation: operation, difficulty: difficulty, ageGroup: ageGroup) + let answer = evaluate(lhs, rhs, using: operation) + + guard !usedAnswers.contains(answer) else { continue } + return ("\(lhs) \(operation.symbol) \(rhs)", answer) + } + + return fallbackMatchConnectPair(usedAnswers: usedAnswers, preferredOperations: ops) + } + + private static func fallbackMatchConnectPair( + usedAnswers: Set, + preferredOperations: [MathOperation] + ) -> (label: String, answer: Int) { + let operations = preferredOperations.isEmpty ? [.addition] : preferredOperations + + for answer in 0...500 where !usedAnswers.contains(answer) { + let operation = operations.first ?? .addition + return (matchConnectLabel(for: answer, operation: operation), answer) + } + + let answer = (usedAnswers.max() ?? -1) + 1 + let operation = operations.first ?? .addition + return (matchConnectLabel(for: answer, operation: operation), answer) + } + + private static func matchConnectLabel(for answer: Int, operation: MathOperation) -> String { + switch operation { + case .addition: + let rhs = answer == 0 ? 0 : min(answer, 3) + return "\(answer - rhs) \(operation.symbol) \(rhs)" + case .subtraction: + let rhs = max(1, min(3, answer + 1)) + return "\(answer + rhs) \(operation.symbol) \(rhs)" + case .multiplication: + return "\(answer) \(operation.symbol) 1" + case .division: + return "\(answer) \(operation.symbol) 1" + } + } + static func generateNumberBondExercise(difficulty: ExerciseDifficulty) -> Exercise { let whole: Int switch difficulty { @@ -714,6 +747,19 @@ enum ExerciseGenerator { } } + private static func evaluate(_ lhs: Int, _ rhs: Int, using operation: MathOperation) -> Int { + switch operation { + case .addition: + lhs + rhs + case .subtraction: + lhs - rhs + case .multiplication: + lhs * rhs + case .division: + lhs / rhs + } + } + private static func additionOperands(difficulty: ExerciseDifficulty, ageGroup: AgeGroup) -> (Int, Int) { switch (ageGroup, difficulty) { case (.young, .warmup): diff --git a/ios/Geni/Services/SpeechRecognitionService.swift b/ios/Geni/Services/SpeechRecognitionService.swift index 02b9403..b531fbb 100644 --- a/ios/Geni/Services/SpeechRecognitionService.swift +++ b/ios/Geni/Services/SpeechRecognitionService.swift @@ -9,6 +9,8 @@ class SpeechRecognitionService { var isListening: Bool = false var isAvailable: Bool = false var error: String? + var transcriptVersion: Int = 0 + var lastTranscriptUpdateAt: Date? private var recognizer: SFSpeechRecognizer? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? @@ -34,6 +36,7 @@ class SpeechRecognitionService { } func startListening() { + error = nil let locale = Locale(identifier: L.speechLocaleIdentifier) recognizer = SFSpeechRecognizer(locale: locale) isAvailable = recognizer?.isAvailable ?? false @@ -43,7 +46,7 @@ class SpeechRecognitionService { return } - stopListening() + stopListening(clearTranscript: false) let engine = AVAudioEngine() self.audioEngine = engine @@ -71,18 +74,17 @@ class SpeechRecognitionService { Task { @MainActor in guard let self else { return } if let result { - self.recognizedText = result.bestTranscription.formattedString - self.recognizedWords = result.bestTranscription.formattedString - .lowercased() - .components(separatedBy: " ") - .filter { !$0.isEmpty } + self.updateTranscript(result.bestTranscription.formattedString) } if result?.isFinal == true { - // Recognition ended naturally — restart to keep listening - self.stopListening() + // Recognition ended naturally — restart without clearing progress. + self.stopListening(clearTranscript: false) self.startListening() } else if err != nil { - self.stopListening() + self.stopListening(clearTranscript: false) + if self.isAvailable { + self.startListening() + } } } } @@ -97,7 +99,7 @@ class SpeechRecognitionService { } } - func stopListening() { + func stopListening(clearTranscript: Bool = true) { audioEngine?.stop() audioEngine?.inputNode.removeTap(onBus: 0) recognitionRequest?.endAudio() @@ -106,8 +108,19 @@ class SpeechRecognitionService { recognitionRequest = nil recognitionTask = nil isListening = false + if clearTranscript { + resetTranscript() + } + } + + func resetTranscript(clearMisreads: Bool = false) { recognizedWords = [] recognizedText = "" + lastTranscriptUpdateAt = nil + transcriptVersion += 1 + if clearMisreads { + misreadIndices.removeAll() + } } var misreadIndices: Set = [] @@ -116,55 +129,50 @@ class SpeechRecognitionService { let cleanExpected = expected.map { normalizedWord($0) } let cleanRecognized = recognizedWords.map { normalizedWord($0) }.filter { !$0.isEmpty } var matched = startFrom - var rIdx = 0 misreadIndices = misreadIndices.filter { $0 < cleanExpected.count } - for eIdx in startFrom.. matched { + for skipped in matched.. String { - word.lowercased().filter { $0.isLetter || $0.isNumber } + word + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: L.speechLocaleIdentifier)) + .filter { $0.isLetter || $0.isNumber } } private func isIgnorableRecognitionNoise(_ word: String) -> Bool { @@ -221,4 +229,16 @@ class SpeechRecognitionService { return distances[bArr.count] } + + private func updateTranscript(_ text: String) { + guard text != recognizedText else { return } + + recognizedText = text + recognizedWords = text + .components(separatedBy: .whitespacesAndNewlines) + .map(normalizedWord) + .filter { !$0.isEmpty } + lastTranscriptUpdateAt = Date() + transcriptVersion += 1 + } } diff --git a/ios/Geni/ViewModels/AppViewModel.swift b/ios/Geni/ViewModels/AppViewModel.swift index 78ef822..6ce24ef 100644 --- a/ios/Geni/ViewModels/AppViewModel.swift +++ b/ios/Geni/ViewModels/AppViewModel.swift @@ -131,7 +131,7 @@ class AppViewModel { } else { exercises = ExerciseGenerator.generateChapter(profile: profile) } - let startIndex = min(existing.exerciseResults.count, exercises.count) + let startIndex = min(existing.completedExerciseCount, exercises.count) if startIndex >= exercises.count { completeChapter(existing) return @@ -183,10 +183,16 @@ class AppViewModel { func completeChapter(_ chapter: ChapterProgress) { guard let profile = persistence.activeProfile else { return } + guard chapter.status != .completed else { + chapterViewModel?.completedChapter = chapter + currentScreen = .chapterComplete + return + } var final = chapter final.status = .completed final.completedAt = Date() + final.completedExerciseCount = max(final.completedExerciseCount, final.exerciseResults.count) final.calculateRewards() persistence.saveChapterProgress(final) @@ -436,7 +442,7 @@ class AppViewModel { var todayChapterExercisesCompleted: Int { guard let profile = persistence.activeProfile else { return 0 } - return persistence.todayChapter(for: profile.id)?.exerciseResults.count ?? 0 + return persistence.todayChapter(for: profile.id)?.completedExerciseCount ?? 0 } var completedChapterCount: Int { diff --git a/ios/Geni/ViewModels/ChapterViewModel.swift b/ios/Geni/ViewModels/ChapterViewModel.swift index 0428698..fbf4c27 100644 --- a/ios/Geni/ViewModels/ChapterViewModel.swift +++ b/ios/Geni/ViewModels/ChapterViewModel.swift @@ -1,5 +1,11 @@ import SwiftUI +struct MatchStroke: Equatable { + let leftIndex: Int + let rightIndex: Int + let points: [CGPoint] +} + @Observable @MainActor class ChapterViewModel { @@ -16,20 +22,21 @@ class ChapterViewModel { var completedChapter: ChapterProgress? = nil var xpEarned: Int = 0 var dragAnswer: Int? = nil - var completedMatches: [(Int, Int)] = [] + var completedMatches: [MatchStroke] = [] var activeDragSource: Int? = nil - var activeDragPosition: CGPoint? = nil - var wrongMatchPair: (Int, Int)? = nil + var activeDragPoints: [CGPoint] = [] + var wrongMatchStroke: MatchStroke? = nil var evenOddStep: Int = 0 var timeRemaining: Int = 60 var isTimedMode: Bool = false var timerTask: Task? = nil + var isAwaitingAdvance: Bool = false init(profile: ChildProfile, chapter: ChapterProgress, exercises: [Exercise], startIndex: Int = 0) { self.profile = profile self.chapter = chapter self.exercises = exercises - self.currentIndex = startIndex + self.currentIndex = min(startIndex, exercises.count) self.isTimedMode = chapter.chapterType == .timeAttack if isTimedMode { @@ -42,16 +49,21 @@ class ChapterViewModel { return exercises[currentIndex] } + var totalExercises: Int { + max(exercises.count, 1) + } + var progress: Double { - Double(currentIndex) / 20.0 + Double(min(currentIndex, totalExercises)) / Double(totalExercises) } var isLastExercise: Bool { - currentIndex >= 19 + currentIndex >= totalExercises - 1 } func submitAnswer(_ answer: Int, persistence: PersistenceService) { guard let exercise = currentExercise else { return } + guard !isAwaitingAdvance else { return } attempts += 1 let actualCorrectAnswer: Int @@ -116,8 +128,7 @@ class ChapterViewModel { secondCorrect: attempts == 2 ? true : nil, attempts: attempts ) - chapter.exerciseResults.append(result) - persistence.saveChapterProgress(chapter) + recordCompletedExercise(result, persistence: persistence) advanceAfterDelay() } else if attempts >= 2 { @@ -134,8 +145,7 @@ class ChapterViewModel { secondCorrect: false, attempts: attempts ) - chapter.exerciseResults.append(result) - persistence.saveChapterProgress(chapter) + recordCompletedExercise(result, persistence: persistence) advanceAfterDelay() } else { @@ -152,12 +162,52 @@ class ChapterViewModel { } } - func submitMatch(leftIndex: Int, rightIndex: Int, persistence: PersistenceService) { + func beginMatchDrag(from leftIndex: Int, startPoint: CGPoint) { + guard !isAwaitingAdvance else { return } + activeDragSource = leftIndex + activeDragPoints = [startPoint] + } + + func updateMatchDrag(to point: CGPoint) { + guard activeDragSource != nil else { return } + + if let lastPoint = activeDragPoints.last { + let distance = hypot(point.x - lastPoint.x, point.y - lastPoint.y) + if distance < 4 { + activeDragPoints[activeDragPoints.count - 1] = point + return + } + } + + activeDragPoints.append(point) + } + + func endMatchDrag( + from leftIndex: Int, + finalPoint: CGPoint, + rightIndex: Int?, + snappedTargetPoint: CGPoint?, + persistence: PersistenceService + ) { + guard activeDragSource == leftIndex else { return } + + updateMatchDrag(to: finalPoint) + let pathPoints = normalizedMatchPath(snappedTargetPoint: snappedTargetPoint) + resetActiveMatchDrag() + + guard let rightIndex else { return } + submitMatch(leftIndex: leftIndex, rightIndex: rightIndex, pathPoints: pathPoints, persistence: persistence) + } + + func submitMatch(leftIndex: Int, rightIndex: Int, pathPoints: [CGPoint], persistence: PersistenceService) { guard let exercise = currentExercise, let correctIndices = exercise.correctMatchIndices else { return } + guard !isAwaitingAdvance else { return } + + let stroke = MatchStroke(leftIndex: leftIndex, rightIndex: rightIndex, points: pathPoints) if correctIndices[leftIndex] == rightIndex { - completedMatches.append((leftIndex, rightIndex)) + completedMatches.append(stroke) HapticManager.correctAnswer() if completedMatches.count == correctIndices.count { @@ -172,21 +222,29 @@ class ChapterViewModel { secondCorrect: nil, attempts: 1 ) - chapter.exerciseResults.append(result) - persistence.saveChapterProgress(chapter) + recordCompletedExercise(result, persistence: persistence) advanceAfterDelay() } } else { - wrongMatchPair = (leftIndex, rightIndex) + wrongMatchStroke = stroke HapticManager.wrongAnswer() Task { try? await Task.sleep(for: .seconds(0.6)) - wrongMatchPair = nil + if wrongMatchStroke == stroke { + wrongMatchStroke = nil + } } } } + private func recordCompletedExercise(_ result: ExerciseResult, persistence: PersistenceService) { + chapter.exerciseResults.append(result) + chapter.completedExerciseCount = max(chapter.completedExerciseCount, currentIndex + 1) + persistence.saveChapterProgress(chapter) + } + private func advanceAfterDelay() { + isAwaitingAdvance = true Task { try? await Task.sleep(for: .seconds(1.5)) showFeedback = false @@ -196,10 +254,30 @@ class ChapterViewModel { attempts = 0 evenOddStep = 0 completedMatches = [] + resetActiveMatchDrag() + wrongMatchStroke = nil currentIndex += 1 + isAwaitingAdvance = false } } + private func normalizedMatchPath(snappedTargetPoint: CGPoint?) -> [CGPoint] { + var points = activeDragPoints + if let snappedTargetPoint { + if let lastPoint = points.last, hypot(lastPoint.x - snappedTargetPoint.x, lastPoint.y - snappedTargetPoint.y) < 2 { + points[points.count - 1] = snappedTargetPoint + } else { + points.append(snappedTargetPoint) + } + } + return points + } + + private func resetActiveMatchDrag() { + activeDragSource = nil + activeDragPoints = [] + } + private func startTimer() { timerTask = Task { while timeRemaining > 0 && !Task.isCancelled { diff --git a/ios/Geni/ViewModels/ReadingViewModel.swift b/ios/Geni/ViewModels/ReadingViewModel.swift index 44a0940..f006723 100644 --- a/ios/Geni/ViewModels/ReadingViewModel.swift +++ b/ios/Geni/ViewModels/ReadingViewModel.swift @@ -3,6 +3,12 @@ import SwiftUI @Observable @MainActor class ReadingViewModel { + enum FeedbackTone { + case success + case guidance + case error + } + let profile: ChildProfile let readingText: ReadingText var mode: ReadingMode @@ -21,6 +27,7 @@ class ReadingViewModel { var matchedWordCount: Int = 0 var feedbackMessage: String = "" var showFeedback: Bool = false + var feedbackTone: FeedbackTone = .success private var timerTask: Task? private var highlightTask: Task? @@ -49,7 +56,8 @@ class ReadingViewModel { var progress: Double { guard !words.isEmpty else { return 0 } - return Double(currentWordIndex) / Double(words.count) + let completedWords = mode == .listenToMeRead ? matchedWordCount : currentWordIndex + return Double(min(completedWords, words.count)) / Double(words.count) } var timeProgress: Double { @@ -78,7 +86,7 @@ class ReadingViewModel { if mode == .listenToMeRead { let isMisread = recognitionService?.misreadIndices.contains(i) == true if i < matchedWordCount && isMisread { - wordStr.foregroundColor = Color(GeniColor.pink) + wordStr.foregroundColor = Color(GeniColor.orange) wordStr.underlineStyle = .single } else if i < matchedWordCount { wordStr.foregroundColor = Color(GeniColor.green) @@ -126,7 +134,7 @@ class ReadingViewModel { if mode == .readToMe { speechService?.pause() } else if mode == .listenToMeRead { - recognitionService?.stopListening() + recognitionService?.stopListening(clearTranscript: false) } } @@ -151,6 +159,10 @@ class ReadingViewModel { elapsedSeconds = 0 matchedWordCount = 0 isCompleted = false + feedbackMessage = "" + showFeedback = false + feedbackTone = .success + recognitionService?.resetTranscript(clearMisreads: true) start() } @@ -160,7 +172,7 @@ class ReadingViewModel { timerTask?.cancel() highlightTask?.cancel() speechService?.stop() - recognitionService?.stopListening() + recognitionService?.stopListening(clearTranscript: true) } func completeReading() { @@ -239,11 +251,14 @@ class ReadingViewModel { private func startListenMode() { guard let recognitionService else { return } + recognitionService.resetTranscript(clearMisreads: false) + currentWordIndex = matchedWordCount Task { let granted = await recognitionService.requestPermissions() guard granted else { feedbackMessage = L.s(.micPermissionNeeded) + feedbackTone = .error showFeedback = true return } @@ -251,39 +266,66 @@ class ReadingViewModel { highlightTask = Task { let expectedWords = words.map { $0.text } - var attemptsWhileStuck = 0 - var wasRecognizingWords = false - while !Task.isCancelled && matchedWordCount < words.count { - let wordsNonEmpty = !recognitionService.recognizedWords.isEmpty - // Detect session end: went non-empty → empty means child spoke and session closed - if wasRecognizingWords && !wordsNonEmpty { - attemptsWhileStuck += 1 - } - wasRecognizingWords = wordsNonEmpty + var lastProgressAt = Date() + var lastFeedbackAt = Date.distantPast + var lastTranscriptVersion = recognitionService.transcriptVersion + var lastTranscriptUpdateAt = recognitionService.lastTranscriptUpdateAt ?? Date() + while !Task.isCancelled && matchedWordCount < words.count { let count = recognitionService.matchedWordCount(expected: expectedWords, startFrom: matchedWordCount) if count > matchedWordCount { matchedWordCount = count - attemptsWhileStuck = 0 - wasRecognizingWords = false + currentWordIndex = count + lastProgressAt = Date() + lastTranscriptVersion = recognitionService.transcriptVersion + lastTranscriptUpdateAt = recognitionService.lastTranscriptUpdateAt ?? lastProgressAt + if feedbackTone != .error { + showFeedback = false + feedbackMessage = "" + } HapticManager.impact(.light) } - // After 2 failed attempts on the same word, mark it red and advance - if attemptsWhileStuck >= 2 { - recognitionService.misreadIndices.insert(matchedWordCount) - matchedWordCount += 1 - attemptsWhileStuck = 0 - wasRecognizingWords = false - HapticManager.impact(.light) + if recognitionService.transcriptVersion != lastTranscriptVersion { + lastTranscriptVersion = recognitionService.transcriptVersion + lastTranscriptUpdateAt = recognitionService.lastTranscriptUpdateAt ?? Date() + } + + let now = Date() + let stalledFor = now.timeIntervalSince(lastProgressAt) + let transcriptQuietFor = now.timeIntervalSince(lastTranscriptUpdateAt) + + if let error = recognitionService.error, !error.isEmpty { + feedbackMessage = error + feedbackTone = .error + showFeedback = true + } else if stalledFor >= listenWarningSeconds, + transcriptQuietFor >= 1.0, + now.timeIntervalSince(lastFeedbackAt) >= 2.0 { + feedbackMessage = L.s(.letsKeepGoing) + feedbackTone = .guidance + showFeedback = true + lastFeedbackAt = now + } + + if stalledFor >= listenAutoAdvanceSeconds && transcriptQuietFor >= 1.5 { + skipCurrentListenWord() + lastProgressAt = now + lastFeedbackAt = now + lastTranscriptUpdateAt = now + recognitionService.resetTranscript(clearMisreads: false) + if !recognitionService.isListening { + recognitionService.startListening() + } } - try? await Task.sleep(for: .milliseconds(200)) + try? await Task.sleep(for: .milliseconds(150)) } guard !Task.isCancelled else { return } if matchedWordCount >= words.count { feedbackMessage = L.s(.amazingReading) + feedbackTone = .success showFeedback = true HapticManager.notification(.success) try? await Task.sleep(for: .seconds(1.5)) @@ -293,4 +335,28 @@ class ReadingViewModel { } } } + + func skipCurrentListenWord() { + guard mode == .listenToMeRead, matchedWordCount < words.count else { return } + + recognitionService?.misreadIndices.insert(matchedWordCount) + matchedWordCount += 1 + currentWordIndex = matchedWordCount + feedbackMessage = L.s(.letsKeepGoing) + feedbackTone = .guidance + showFeedback = true + HapticManager.impact(.light) + } + + private var listenWarningSeconds: TimeInterval { + switch profile.ageGroup { + case .young: return 2.5 + case .middle: return 3.0 + case .older: return 3.5 + } + } + + private var listenAutoAdvanceSeconds: TimeInterval { + listenWarningSeconds + 2.0 + } } diff --git a/ios/Geni/Views/ChapterCompleteView.swift b/ios/Geni/Views/ChapterCompleteView.swift index 7fd924d..669df82 100644 --- a/ios/Geni/Views/ChapterCompleteView.swift +++ b/ios/Geni/Views/ChapterCompleteView.swift @@ -47,7 +47,7 @@ struct ChapterCompleteView: View { icon: "✅", color: GeniColor.green, label: L.s(.correct), - value: "\(chapter.correctCount)/\(chapter.exerciseResults.count)" + value: "\(chapter.correctCount)/\(chapter.completedExerciseCount)" ) RewardRow( diff --git a/ios/Geni/Views/ExerciseView.swift b/ios/Geni/Views/ExerciseView.swift index 82bff5c..696d32a 100644 --- a/ios/Geni/Views/ExerciseView.swift +++ b/ios/Geni/Views/ExerciseView.swift @@ -86,7 +86,7 @@ struct ExerciseView: View { .animation(.spring(response: 0.4), value: chapterVM.showFeedback) } .onChange(of: chapterVM.currentIndex) { _, newValue in - if newValue >= 20 { + if newValue >= chapterVM.totalExercises { chapterVM.stopTimer() onComplete(chapterVM.chapter) } @@ -533,120 +533,199 @@ struct ExerciseView: View { // MARK: - Match Connect (Draw a Line) private func matchConnectContent(_ exercise: Exercise) -> some View { - Text(L.s(.matchConnectInstruction)) - .font(.system(size: iPadScale.value(18), weight: .semibold, design: .rounded)) - .foregroundStyle(GeniColor.border) - .multilineTextAlignment(.leading) - - Text(L.s(.matchThePairs)) - .font(.system(size: iPadScale.value(28), weight: .bold, design: .rounded)) - .foregroundStyle(GeniColor.border) - let leftLabels = exercise.matchLeftLabels ?? [] let rightLabels = exercise.matchRightLabels ?? [] - let correctIndices = exercise.correctMatchIndices ?? [] let pairCount = leftLabels.count - return GeometryReader { geo in - ZStack { - // Draw completed match lines - Canvas { context, size in - for (leftIdx, rightIdx) in chapterVM.completedMatches { - let leftY = matchItemY(index: leftIdx, count: pairCount, height: size.height) - let rightY = matchItemY(index: rightIdx, count: pairCount, height: size.height) - var path = Path() - path.move(to: CGPoint(x: size.width * 0.38, y: leftY)) - path.addLine(to: CGPoint(x: size.width * 0.62, y: rightY)) - context.stroke(path, with: .color(GeniColor.green), lineWidth: 4) - } + return VStack(spacing: 16) { + Text(L.s(.matchConnectInstruction)) + .font(.system(size: iPadScale.value(18), weight: .semibold, design: .rounded)) + .foregroundStyle(GeniColor.border) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) - // Active drag line - if let sourceIdx = chapterVM.activeDragSource, let pos = chapterVM.activeDragPosition { - let leftY = matchItemY(index: sourceIdx, count: pairCount, height: size.height) - var path = Path() - path.move(to: CGPoint(x: size.width * 0.38, y: leftY)) - path.addLine(to: pos) - context.stroke(path, with: .color(GeniColor.blue), lineWidth: 3) - } + Text(L.s(.matchThePairs)) + .font(.system(size: iPadScale.value(28), weight: .bold, design: .rounded)) + .foregroundStyle(GeniColor.border) + .frame(maxWidth: .infinity, alignment: .leading) - // Wrong match flash - if let wrong = chapterVM.wrongMatchPair { - let leftY = matchItemY(index: wrong.0, count: pairCount, height: size.height) - let rightY = matchItemY(index: wrong.1, count: pairCount, height: size.height) - var path = Path() - path.move(to: CGPoint(x: size.width * 0.38, y: leftY)) - path.addLine(to: CGPoint(x: size.width * 0.62, y: rightY)) - context.stroke(path, with: .color(GeniColor.pink), lineWidth: 4) - } + GeometryReader { geo in + let leftAnchorX = matchLeftAnchorX(width: geo.size.width) + let rightAnchorX = matchRightAnchorX(width: geo.size.width) + let hoveredTargetIndex = chapterVM.activeDragPoints.last.flatMap { + matchDropTarget( + near: $0, + count: pairCount, + size: geo.size + ) } - HStack(spacing: 0) { - // Left column - VStack(spacing: 12) { - ForEach(0.. Path { + var path = Path() + guard let firstPoint = points.first else { return path } + + path.move(to: firstPoint) + for point in points.dropFirst() { + path.addLine(to: point) + } + return path + } + + private func matchLeftAnchorX(width: CGFloat) -> CGFloat { + width * 0.38 + } + + private func matchRightAnchorX(width: CGFloat) -> CGFloat { + width * 0.62 + } + + private func matchDropTarget(near point: CGPoint, count: Int, size: CGSize) -> Int? { + let rightX = matchRightAnchorX(width: size.width) + let horizontalTolerance = iPadScale.value(88) + let verticalTolerance = iPadScale.value(44) + + var bestMatch: (index: Int, distance: CGFloat)? + + for index in 0.. CGFloat { diff --git a/ios/Geni/Views/GuidedReadingView.swift b/ios/Geni/Views/GuidedReadingView.swift index 5a8d337..fe1706d 100644 --- a/ios/Geni/Views/GuidedReadingView.swift +++ b/ios/Geni/Views/GuidedReadingView.swift @@ -200,6 +200,22 @@ struct GuidedReadingView: View { .background(Rectangle().fill(GeniColor.border).offset(x: 4, y: 4)) } + if readingVM.mode == .listenToMeRead { + Button { + HapticManager.selection() + readingVM.skipCurrentListenWord() + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 18, weight: .black)) + .foregroundStyle(.white) + .frame(width: 52, height: 52) + .background(GeniColor.orange) + .overlay(Rectangle().stroke(GeniColor.border, lineWidth: 3)) + .background(Rectangle().fill(GeniColor.border).offset(x: 3, y: 3)) + } + .accessibilityLabel(L.s(.next)) + } + if readingVM.mode != .listenToMeRead && readingVM.currentWordIndex >= readingVM.words.count / 2 { Button { HapticManager.impact(.heavy) @@ -229,8 +245,19 @@ struct GuidedReadingView: View { Spacer() } .padding(16) - .background(GeniColor.green) + .background(feedbackColor) .overlay(Rectangle().stroke(GeniColor.border, lineWidth: 3)) .background(Rectangle().fill(GeniColor.border).offset(x: 4, y: 4)) } + + private var feedbackColor: Color { + switch readingVM.feedbackTone { + case .success: + GeniColor.green + case .guidance: + GeniColor.orange + case .error: + GeniColor.pink + } + } } diff --git a/ios/Geni/Views/ParentDashboardView.swift b/ios/Geni/Views/ParentDashboardView.swift index fefaa4f..378f236 100644 --- a/ios/Geni/Views/ParentDashboardView.swift +++ b/ios/Geni/Views/ParentDashboardView.swift @@ -378,7 +378,7 @@ struct ParentDashboardView: View { let chapters = viewModel.persistence.loadAllChapters(for: profile.id) let completedChapters = chapters.filter { $0.status == .completed } let totalCorrect = completedChapters.reduce(0) { $0 + $1.correctCount } - let totalExercises = completedChapters.reduce(0) { $0 + $1.exerciseResults.count } + let totalExercises = completedChapters.reduce(0) { $0 + $1.completedExerciseCount } let accuracy = totalExercises > 0 ? Int(Double(totalCorrect) / Double(totalExercises) * 100) : 0 VStack(spacing: 12) { @@ -461,7 +461,7 @@ struct ParentDashboardView: View { } } - Text("\(ch.correctCount)/\(ch.exerciseResults.count)") + Text("\(ch.correctCount)/\(ch.completedExerciseCount)") .font(.system(.caption2, design: .monospaced, weight: .bold)) .foregroundStyle(.black) } diff --git a/ios/GeniTests/GeniTests.swift b/ios/GeniTests/GeniTests.swift index c3f0e33..c102db0 100644 --- a/ios/GeniTests/GeniTests.swift +++ b/ios/GeniTests/GeniTests.swift @@ -52,4 +52,189 @@ struct GeniTests { } } + @Test func comparisonExercisesAlwaysShowDifferentResults() async throws { + for operation in [MathOperation.addition, .subtraction, .multiplication, .division] { + for _ in 0..<100 { + let exercise = Exercise( + operand1: 10, + operand2: 2, + operation: operation, + difficulty: .normal, + format: .comparison + ) + + let left = try #require(exercise.comparisonLeft) + let right = try #require(exercise.comparisonRight) + + #expect(evaluate(left) != evaluate(right)) + } + } + } + + @Test func matchConnectExercisesAlwaysHaveValidUniquePairs() async throws { + let ageGroups: [AgeGroup] = [.young, .middle, .older] + let operationSets = MathOperation.allCases.map { [$0] } + [MathOperation.allCases] + let difficulties: [ExerciseDifficulty] = [.warmup, .normal, .harder, .challenge] + + for difficulty in difficulties { + for ageGroup in ageGroups { + for operations in operationSets { + for _ in 0..<100 { + let exercise = await MainActor.run { + ExerciseGenerator.generateMatchConnectExercise( + difficulty: difficulty, + ageGroup: ageGroup, + ops: operations + ) + } + + let leftLabels = try #require(exercise.matchLeftLabels) + let rightLabels = try #require(exercise.matchRightLabels) + let correctIndices = try #require(exercise.correctMatchIndices) + + #expect(leftLabels.count == rightLabels.count) + #expect(leftLabels.count == correctIndices.count) + #expect(Set(rightLabels).count == rightLabels.count) + + for leftIndex in leftLabels.indices { + let rightIndex = correctIndices[leftIndex] + #expect(rightLabels.indices.contains(rightIndex)) + + let expectedAnswer = try #require(Int(rightLabels[rightIndex])) + #expect(try evaluateMatchLabel(leftLabels[leftIndex]) == expectedAnswer) + } + } + } + } + } + } + + @Test func generatedDailyChaptersAlwaysContainTwentyExercises() async throws { + let profiles = [ + ChildProfile(nickname: "A", age: 6, avatarId: "lion"), + ChildProfile(nickname: "B", age: 7, avatarId: "lion"), + ChildProfile(nickname: "C", age: 9, avatarId: "lion"), + ] + + for profile in profiles { + for _ in 0..<100 { + let chapter = await MainActor.run { + ExerciseGenerator.generateChapter(profile: profile) + } + + #expect(chapter.count == 20) + } + } + } + + @Test func generatedTopicChaptersAlwaysContainTwentyExercises() async throws { + let profiles = [ + ChildProfile(nickname: "B", age: 7, avatarId: "lion"), + ChildProfile(nickname: "C", age: 9, avatarId: "lion"), + ] + let topics: [MathTopic] = [.strategies, .timeAndCalendar, .logicPatterns] + + for profile in profiles { + for topic in topics { + for _ in 0..<100 { + let chapter = await MainActor.run { + ExerciseGenerator.generateTopicChapter(profile: profile, topic: topic) + } + + #expect(chapter.count == 20) + } + } + } + } + + @Test func speechRecognitionContinuesWithinSameUtteranceAfterInitialProgress() async throws { + let count = await MainActor.run { + let service = SpeechRecognitionService() + service.recognizedWords = ["visste", "du", "at", "blekkspruter"] + + return service.matchedWordCount( + expected: ["visste", "du", "at", "blekkspruter", "er"], + startFrom: 3 + ) + } + + #expect(count == 4) + } + + @Test func speechRecognitionCanAdvanceAfterRecognizerRestart() async throws { + let count = await MainActor.run { + let service = SpeechRecognitionService() + service.recognizedWords = ["blekkspruter"] + + return service.matchedWordCount( + expected: ["visste", "du", "at", "blekkspruter", "er"], + startFrom: 3 + ) + } + + #expect(count == 4) + } + + @Test func listenModeProgressFollowsMatchedWords() async throws { + let progress = await MainActor.run { + let text = ReadingText( + id: "test", + titleEN: "Test", + titleNO: "Test", + titleES: "Test", + titlePT: "Test", + contentEN: "one two three four", + contentNO: "en to tre fire", + contentES: "uno dos tres cuatro", + contentPT: "um dois tres quatro", + ageGroup: .young + ) + let profile = ChildProfile(nickname: "A", age: 6, avatarId: "lion") + let vm = ReadingViewModel(profile: profile, readingText: text, mode: .listenToMeRead, date: "2026-04-18") + vm.matchedWordCount = 2 + + return vm.progress + } + + #expect(progress == 0.5) + } + + private func evaluate(_ expression: (Int, MathOperation, Int)) -> Int { + switch expression.1 { + case .addition: + expression.0 + expression.2 + case .subtraction: + expression.0 - expression.2 + case .multiplication: + expression.0 * expression.2 + case .division: + expression.0 / expression.2 + } + } + + private func evaluateMatchLabel(_ label: String) throws -> Int { + let parts = label.split(separator: " ") + #expect(parts.count == 3) + + let lhs = try #require(Int(String(parts[0]))) + let rhs = try #require(Int(String(parts[2]))) + + switch String(parts[1]) { + case "+": + return lhs + rhs + case "−": + return lhs - rhs + case "×": + return lhs * rhs + case "÷": + return lhs / rhs + default: + throw MatchLabelError.unsupportedOperator(String(parts[1])) + } + } + + private enum MatchLabelError: Error { + case unsupportedOperator(String) + } + } diff --git a/metadata/version/1.1.5/en-US.json b/metadata/version/1.1.5/en-US.json new file mode 100644 index 0000000..74923ca --- /dev/null +++ b/metadata/version/1.1.5/en-US.json @@ -0,0 +1 @@ +{"description":"Geni makes learning fun for kids ages 5-10! With daily missions combining math exercises and guided reading, children build essential skills while having a blast.\n\nFEATURES:\n• Daily learning missions — complete math and reading activities each day to earn rewards\n• Math exercises — 20 exercises per chapter covering addition, subtraction, multiplication, and division\n• Guided reading — three modes: read by myself, read to me, and hear me read with speech recognition\n• Smart difficulty — activities adapt to your child's age and skill level automatically\n• Fun rewards — earn stars, coins, XP, and unlock badges as you progress\n• Multiple profiles — support for siblings, each with their own progress tracking\n• Parent dashboard — track progress, manage profiles, and customize settings with PIN protection\n• Choose operations — pick which math operations to practice, with age-based recommendations\n• Cloud sync — sync progress across devices with a simple code\n• Multilingual — full support for English, Norwegian, Spanish, and Portuguese\n\nDesigned with a playful, kid-friendly interface that children love to use. No ads, no in-app purchases — just pure learning fun.","keywords":"kids,learning,math,reading,education,children,homework,addition,subtraction,multiplication,rewards","marketingUrl":"https://geni.kids","supportUrl":"https://geni.kids","whatsNew":"This update makes matching exercises much easier for kids and keeps reading practice moving more smoothly.\n\nImproved\n• Matching lines can now be drawn freely with a finger or Apple Pencil, so connecting answers feels much more natural\n• Result boxes are easier to hit during matching activities, with a more forgiving connection area\n• Hear Me Read continues to follow kids more naturally during guided reading\n\nFixed\n• Fixed a bug that could create impossible matching pairs in some math exercises\n• Fixed chapter progress edge cases so exercise counts stay aligned with the real session"} diff --git a/metadata/version/1.1.5/es-ES.json b/metadata/version/1.1.5/es-ES.json new file mode 100644 index 0000000..c00356e --- /dev/null +++ b/metadata/version/1.1.5/es-ES.json @@ -0,0 +1 @@ +{"description":"Geni hace que aprender sea divertido para niños de 5 a 10 años. Con misiones diarias que combinan ejercicios de matemáticas y lectura guiada, los peques desarrollan habilidades esenciales mientras se lo pasan bien.\n\nCARACTERÍSTICAS:\n• Misiones diarias de aprendizaje: completa actividades de mates y lectura cada día para conseguir recompensas\n• Ejercicios de matemáticas: 20 ejercicios por capítulo con suma, resta, multiplicación y división\n• Lectura guiada: tres modos, leer yo solo, léemelo y escúchame leer con reconocimiento de voz\n• Dificultad inteligente: las actividades se adaptan automáticamente a la edad y al nivel del niño\n• Recompensas divertidas: gana estrellas, monedas, XP y desbloquea insignias\n• Varios perfiles: ideal para hermanos, cada uno con su propio progreso\n• Panel para familias: sigue el progreso, gestiona perfiles y ajusta opciones con PIN\n• Elige operaciones: decide qué operaciones practicar con recomendaciones por edad\n• Sincronización en la nube: sincroniza el progreso entre dispositivos con un código sencillo\n• Multilingüe: compatibilidad completa con inglés, noruego, español y portugués\n\nDiseñada con una interfaz alegre y pensada para niños. Sin anuncios ni compras dentro de la app, solo aprendizaje divertido.","keywords":"niños,aprendizaje,matemáticas,lectura,educación,deberes,sumas,restas,multiplicación,recompensas","marketingUrl":"https://geni.kids","supportUrl":"https://geni.kids","whatsNew":"Esta actualización hace que los ejercicios de unir sean mucho más fáciles para los peques y que la lectura guiada siga fluyendo mejor.\n\nMejorado\n• Las líneas de unión ahora se pueden dibujar libremente con el dedo o con Apple Pencil, para que conectar respuestas resulte más natural\n• Las casillas de resultado son más fáciles de alcanzar en las actividades de unir, con una zona de conexión más tolerante\n• Escúchame leer sigue mejor el ritmo del niño durante la lectura guiada\n\nCorregido\n• Corregido un fallo que podía crear parejas imposibles en algunos ejercicios de matemáticas para unir\n• Corregidos casos límite del progreso del capítulo para que el número de ejercicios coincida con la sesión real"} diff --git a/metadata/version/1.1.5/no.json b/metadata/version/1.1.5/no.json new file mode 100644 index 0000000..6234cdb --- /dev/null +++ b/metadata/version/1.1.5/no.json @@ -0,0 +1 @@ +{"description":"Geni gjør læring gøy for barn i alderen 5-10 år! Med daglige oppdrag som kombinerer matteøvelser og veiledet lesing, bygger barna viktige ferdigheter mens de har det moro.\n\nFUNKSJONER:\n• Daglige læringsoppdrag — fullfør matte- og leseaktiviteter hver dag for å tjene belønninger\n• Matteøvelser — 20 oppgaver per kapittel med addisjon, subtraksjon, multiplikasjon og divisjon\n• Veiledet lesing — tre lesemoduser: les selv, les for meg, og hør meg lese med talegjenkjenning\n• Smart vanskelighetsgrad — aktivitetene tilpasser seg barnets alder og ferdighetsnivå\n• Morsomme belønninger — tjen stjerner, mynter, XP, og lås opp merker underveis\n• Flere profiler — støtte for søsken, hver med sin egen fremgang\n• Foreldrepanel — følg fremgang, administrer profiler og tilpass innstillinger med PIN-beskyttelse\n• Velg regnearter — velg hvilke matteoperasjoner å øve på, med aldersbaserte anbefalinger\n• Skysynkronisering — synk fremgang mellom enheter med en enkel kode\n• Flerspråklig — full støtte for engelsk, norsk, spansk og portugisisk\n\nDesignet med et lekent, barnevennlig grensesnitt som barn elsker å bruke. Ingen annonser, ingen kjøp i appen — bare ren læringsglede.","keywords":"barn,læring,matte,lesing,utdanning,skole,lekser,addisjon,subtraksjon,multiplikasjon,belønning","marketingUrl":"https://geni.kids","supportUrl":"https://geni.kids","whatsNew":"Denne oppdateringen gjør koblingsoppgaver mye enklere for barn og gjør lesetreningen mer flytende.\n\nForbedret\n• Linjer i koblingsoppgaver kan nå tegnes fritt med finger eller Apple Pencil, slik at det føles mer naturlig å koble svar\n• Svarboksene er lettere å treffe i koblingsaktivitetene, med en mer tilgivende treffflate\n• Hør meg lese følger barnets rytme bedre under veiledet lesing\n\nFikset\n• Fikset en feil som kunne lage umulige par i enkelte matteoppgaver med kobling\n• Fikset kanttilfeller i kapittelfremgangen slik at antall oppgaver stemmer med den faktiske økten"} diff --git a/metadata/version/1.1.5/pt-PT.json b/metadata/version/1.1.5/pt-PT.json new file mode 100644 index 0000000..5619bf4 --- /dev/null +++ b/metadata/version/1.1.5/pt-PT.json @@ -0,0 +1 @@ +{"description":"O Geni torna a aprendizagem divertida para crianças dos 5 aos 10 anos. Com missões diárias que combinam exercícios de matemática e leitura guiada, as crianças desenvolvem competências essenciais enquanto se divertem.\n\nFUNCIONALIDADES:\n• Missões diárias de aprendizagem: conclua atividades de matemática e leitura todos os dias para ganhar recompensas\n• Exercícios de matemática: 20 exercícios por capítulo com adição, subtração, multiplicação e divisão\n• Leitura guiada: três modos, ler sozinho, ler para mim e ouvir-me ler com reconhecimento de voz\n• Dificuldade inteligente: as atividades adaptam-se automaticamente à idade e ao nível da criança\n• Recompensas divertidas: ganhe estrelas, moedas, XP e desbloqueie medalhas\n• Vários perfis: ideal para irmãos, cada um com o seu próprio progresso\n• Painel para pais: acompanhe o progresso, gira perfis e personalize definições com PIN\n• Escolha operações: decida quais as operações a praticar com recomendações por idade\n• Sincronização na nuvem: sincronize o progresso entre dispositivos com um código simples\n• Multilingue: suporte completo para inglês, norueguês, espanhol e português\n\nConcebida com uma interface divertida e pensada para crianças. Sem anúncios nem compras integradas, apenas aprendizagem divertida.","keywords":"crianças,aprendizagem,matemática,leitura,educação,estudo,adição,subtração,recompensas","marketingUrl":"https://geni.kids","supportUrl":"https://geni.kids","whatsNew":"Esta atualização torna os exercícios de ligação muito mais fáceis para as crianças e mantém a leitura guiada mais fluida.\n\nMelhorado\n• As linhas de ligação podem agora ser desenhadas livremente com o dedo ou com Apple Pencil, para que ligar respostas seja mais natural\n• As caixas de resultado são mais fáceis de acertar nas atividades de ligação, com uma área de ligação mais tolerante\n• O modo Ouvir-me ler acompanha melhor o ritmo da criança durante a leitura guiada\n\nCorrigido\n• Corrigido um erro que podia criar pares impossíveis em alguns exercícios de matemática de ligação\n• Corrigidos casos limite do progresso do capítulo para que o número de exercícios fique alinhado com a sessão real"} diff --git a/videos/geni_celebration.MP4 b/videos/geni_celebration.MP4 new file mode 100644 index 0000000..a6c3390 Binary files /dev/null and b/videos/geni_celebration.MP4 differ diff --git a/videos/geni_hello-first-frame.png b/videos/geni_hello-first-frame.png new file mode 100644 index 0000000..8c0c0cf Binary files /dev/null and b/videos/geni_hello-first-frame.png differ diff --git a/videos/geni_hello-poster.png b/videos/geni_hello-poster.png new file mode 100644 index 0000000..8c0c0cf Binary files /dev/null and b/videos/geni_hello-poster.png differ diff --git a/videos/geni_hello.MP4 b/videos/geni_hello.MP4 new file mode 100644 index 0000000..b85d754 Binary files /dev/null and b/videos/geni_hello.MP4 differ diff --git a/videos/geni_hello.optimized.mp4 b/videos/geni_hello.optimized.mp4 new file mode 100644 index 0000000..bafe078 Binary files /dev/null and b/videos/geni_hello.optimized.mp4 differ