diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12c6843..c4c48ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,32 @@ jobs: - uses: actions/checkout@v4 - name: Build and test SwiftUI app run: xcodebuild -project WriteBench.xcodeproj -scheme WriteBench -configuration Debug -derivedDataPath build -destination 'platform=macOS' CODE_SIGN_IDENTITY=- test + ipad: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Select an available iPad simulator + run: | + xcrun simctl list devices available -j > /tmp/simulators.json + python3 - <<'PY' >> "$GITHUB_ENV" + import json + devices = json.load(open('/tmp/simulators.json'))['devices'] + ipad = next(d for group in devices.values() for d in group if d['name'].startswith('iPad')) + print('IPAD_SIMULATOR=' + ipad['udid']) + PY + - name: Boot iPad simulator + run: | + xcrun simctl boot "$IPAD_SIMULATOR" + xcrun simctl bootstatus "$IPAD_SIMULATOR" -b + - name: Build and test native iPad app + run: xcodebuild -project WriteBench.xcodeproj -scheme WriteBenchPad -destination "platform=iOS Simulator,id=$IPAD_SIMULATOR" -parallel-testing-enabled NO -derivedDataPath build-ipad CODE_SIGN_IDENTITY=- test + - name: Upload iPad test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ipad-verification + path: build-ipad/Logs/Test/*.xcresult windows: runs-on: windows-latest steps: diff --git a/.gitignore b/.gitignore index 87a8caa..6f2f1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ Documentation/codex-live-check.json *.iml .codex-build/ + +.codex-ipad/ + +.codex-ipad-device/ diff --git a/README.md b/README.md index 62b7e15..c2b6206 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,8 @@ macOS 自动化测试覆盖 87 个案例,Android 有 5 个领域测试与 2

WriteBench · 一次练习,一份原稿,一次认真重写。

考研翻译报告支持“意群精讲”:词汇、结构与翻译要点、分组译文及完整译文组合;英一只讲解作答划线句,英二按全文句子展开。点击交卷即暂停计时,提交受阻时可点击继续作答恢复。 + + +### iPadOS 开发版 + +已新增 iPad 专用原生 target `WriteBenchPad`(iPadOS 18+),支持横竖屏答题、PencilKit 手写与 OCR 校对、内置六级题库、DeepSeek 评阅及与 macOS 兼容的备份。详见 [iPad 开发和安装说明](platforms/ipad/README.md)。实体 iPad 安装需要自己的 Apple 开发签名,当前尚无公开可安装的 IPA。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index c9480e6..6d1fa03 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -9,12 +9,17 @@ /* Begin PBXBuildFile section */ 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */; }; 042D9A8FAAC22D71BDF6F7E4 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16DC3843F83A5135DD60057 /* ReviewCard.swift */; }; + 0611D0C9511C7414C1165A1F /* TranslationLesson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6211317C38F78901403EE347 /* TranslationLesson.swift */; }; + 0AB189E45DA34198003C4ACF /* ielts_task1.md in Resources */ = {isa = PBXBuildFile; fileRef = 405A85F2A07E72B5AD277D96 /* ielts_task1.md */; }; 0B2FE86F272731005E6A17BF /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3647AF5325DFD1CE43543B /* Theme.swift */; }; + 0ED522D3B22EAEB2CDF79443 /* kaoyan_english2_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 015B10F8076FB519F8FDAACD /* kaoyan_english2_small.md */; }; + 0F800C08810BB466CBE5CF50 /* cet6_writing.md in Resources */ = {isa = PBXBuildFile; fileRef = 650B4D62F506EDDC71D6396A /* cet6_writing.md */; }; 143462C249149F8D7065D80D /* AppLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018639A9F1452A7617A5A0A2 /* AppLifecycle.swift */; }; 14D92D7829215C6748E48951 /* StreamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306B86B1159828782A062144 /* StreamingTests.swift */; }; 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */; }; 1E1C317A4096B9830C2A3786 /* ExamTaskBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78510A02F0F827272F84695 /* ExamTaskBadge.swift */; }; 21B1D6631C778F17583209A5 /* ielts_task2.md in Resources */ = {isa = PBXBuildFile; fileRef = AA7BA8561FCE7DD2841C904B /* ielts_task2.md */; }; + 242298DFFDBE665961BCD774 /* PadEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C2AC6FE9A57B96996AC706 /* PadEditor.swift */; }; 27C8DB78E1B529A54B818ABD /* Exam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEB65E44D0601AEC6053865 /* Exam.swift */; }; 291B90A1A7DDA40A4591BE8A /* QuestionBank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E6EE384D62168057B908A3 /* QuestionBank.swift */; }; 2B8AA947E573B4AD052F6F95 /* OCRService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F892E2F452C5999A8C08043 /* OCRService.swift */; }; @@ -23,10 +28,17 @@ 337FAAE005C9D52F7539091A /* CETQuestionBank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1689A9A92BD0E930F5FB82F9 /* CETQuestionBank.swift */; }; 361B2B881C01F86CC3A784DC /* cet6_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 6CED406B3EAB7FA3947795C5 /* cet6_translation.md */; }; 380960FEBBC3C2CDC16C3147 /* BackgroundGradingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A07798AF0255AB8EAFF76A /* BackgroundGradingTests.swift */; }; + 3860A78F309E21B3B68AA88E /* kaoyan_english2_large.md in Resources */ = {isa = PBXBuildFile; fileRef = 0506352B36FD805B5E9053D8 /* kaoyan_english2_large.md */; }; + 3AF9A2452A97E843CA28DBD3 /* kaoyan_english2_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 56792E2C1501DBCE7B80739B /* kaoyan_english2_translation.md */; }; 3D7E00CED5955E5E709BAF28 /* RevisionViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E5B6AA16B1E69446387608 /* RevisionViews.swift */; }; + 3FAD16D146887EE128D0907B /* PadSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3877BD86B363D93BD41E2B /* PadSettings.swift */; }; 3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */; }; + 41EF54312B6D1CE976DD7BB1 /* cet6_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 6CED406B3EAB7FA3947795C5 /* cet6_translation.md */; }; + 4241BA18661160643D61DE99 /* kaoyan_english1_large.md in Resources */ = {isa = PBXBuildFile; fileRef = ACA34C8DE60D85ABFA45116F /* kaoyan_english1_large.md */; }; 448090E99953CC5C5F9C2F40 /* DeepSeekStreaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */; }; + 44EC8DBB916264EFCE0C8D90 /* PadHandwriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AEE0BB5A40311EB0DF71A8 /* PadHandwriting.swift */; }; 473B2ECB957707EDB2BF41AA /* BackgroundGradingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */; }; + 4A24CA800AC78EA32367B092 /* QuestionBank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E6EE384D62168057B908A3 /* QuestionBank.swift */; }; 4B7B1CDF7FADBE73B96E26DA /* kaoyan_english1_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = AD3CEB6EC11C595CA1EA79D6 /* kaoyan_english1_translation.md */; }; 51899521D61CB025A5559EC5 /* LocalProcessRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 100F5B726342F502CA8C4207 /* LocalProcessRunner.swift */; }; 5317F309ED9004BD4665C0DC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 37444757CFEC8F67705A2D6C /* Assets.xcassets */; }; @@ -34,7 +46,11 @@ 55C7907B2F577F01BC57A08B /* ReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9DB3748FBE72E213C6EB37 /* ReviewView.swift */; }; 55E01FD08D907D18E2AFCBFC /* WritingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00C1A68BA4227431C3B8884 /* WritingStore.swift */; }; 57B1584FB0F0DC411801C7D5 /* WritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3788E5EB782131E544DDEDC /* WritingView.swift */; }; + 5802389F014B6F342E5152C7 /* DeepSeekStreaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */; }; + 5F1536C7F3ABF096C23F2B41 /* CETQuestionBank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1689A9A92BD0E930F5FB82F9 /* CETQuestionBank.swift */; }; 611D1CBBEE2017C06DCD6D8D /* GradingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634F608AF0DF72D3DE51E438 /* GradingService.swift */; }; + 629089D675B6EC701836CB53 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B6B0E9758B9FE559058FEBBE /* Assets.xcassets */; }; + 632FDD2AB3BEDB2E641E1090 /* kaoyan_english1_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = AD3CEB6EC11C595CA1EA79D6 /* kaoyan_english1_translation.md */; }; 6A2D61FFBE7F968DBCFB1164 /* cet6-2022-2026.json in Resources */ = {isa = PBXBuildFile; fileRef = 445C1E2A61051C152970E26E /* cet6-2022-2026.json */; }; 6C1F7BEC5994E87DFA1CEF27 /* DeepSeekClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A399A503863E6199B340A50C /* DeepSeekClient.swift */; }; 6C3E278AD86905C7C0A63BCE /* QuestionText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0DD31CC0B614898B36ED76 /* QuestionText.swift */; }; @@ -44,41 +60,66 @@ 7A7FFA1F4A7D393FDC0F3AA0 /* EssayHistoryGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */; }; 7ADD51F051853BB04DC26A4D /* WindowImmersionBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */; }; 7E4B43903D948A9E26E11E8D /* BrandArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA993471F8CFF92BD9B2956 /* BrandArt.swift */; }; + 865F02CEF67F20E89E96A4E9 /* PadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524AD2D04A2A9004F1783E8A /* PadTests.swift */; }; 86CD1B5AEC144D4283491FC5 /* PlainTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6C23EF9D33F68C7816C642 /* PlainTextEditor.swift */; }; 892F119876541FA3E8699E90 /* BackgroundGradingJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DECD6B9F0B3A54C27F4535 /* BackgroundGradingJob.swift */; }; 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF0EC53CAA982107C3AA320 /* SettingsView.swift */; }; 8A386C67F3728D7CC51BC97A /* CETQuestionBankTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAD6385C4872B3E80D4B00C /* CETQuestionBankTests.swift */; }; + 8F5898E5109076BF0EFF1F88 /* EssaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8874F23C092B83269A6E56 /* EssaySession.swift */; }; + 90C3DE5400C92979D32AC1AD /* PadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9A3D1D6ED82A1482C350CCB /* PadUITests.swift */; }; 938D3916C068089FACA81D61 /* MistakesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */; }; + 96416E493449971277BD75FC /* CorrectionMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1020C698B4DA8C1C47852419 /* CorrectionMatcher.swift */; }; 96EB80336D8E7D70E3839817 /* kaoyan_english1_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */; }; + 9850669404F8D67333E6C3D7 /* OCRService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F892E2F452C5999A8C08043 /* OCRService.swift */; }; + 9B429BF8E4F61CDC600CE7D9 /* PadWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C85E80F277C5F00ACFD3C /* PadWorkspace.swift */; }; + 9B536D6CE7FE2E019121A5D3 /* ocr-sample-1.png in Resources */ = {isa = PBXBuildFile; fileRef = A35C864C69C265F8C846C881 /* ocr-sample-1.png */; }; 9EA1454709EC8802D43C7CA4 /* HistoryFolderEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419DE9ABE9E26F14128B9B1E /* HistoryFolderEditor.swift */; }; A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */; }; + A4D399A6F4E03F23FE75BD10 /* ielts_task2.md in Resources */ = {isa = PBXBuildFile; fileRef = AA7BA8561FCE7DD2841C904B /* ielts_task2.md */; }; A54BFAD371DEB925FCA1963B /* PracticeLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E568498AF07D6BF8C1335 /* PracticeLoopTests.swift */; }; + A6458710E8DAF4A3CDF760CD /* Exam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEB65E44D0601AEC6053865 /* Exam.swift */; }; A64FFC28FE1B55A2B44B2E4B /* ReportSynthesis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99093337348356622254C44E /* ReportSynthesis.swift */; }; A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A81F1C48B2FAE8914576B /* ReviewTextExporter.swift */; }; + A88B15150ABE8B3ECDEB51CB /* PadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6585246941091E1966ACFE51 /* PadStore.swift */; }; A97D07FFDB81393349741F89 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1B7E5BB4C4D7689B346DE /* PracticeView.swift */; }; + ACAB0542046596D38C566B67 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3647AF5325DFD1CE43543B /* Theme.swift */; }; + BA2F77C94675557CB5CE6F5D /* PadLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DD6C821E6AAACD390D0D60 /* PadLibrary.swift */; }; + BCD28EBF26B2DBEF3E99CCDD /* cet6-2022-2026.json in Resources */ = {isa = PBXBuildFile; fileRef = 445C1E2A61051C152970E26E /* cet6-2022-2026.json */; }; C118D4E53ACEC303DE46EA50 /* CorrectionMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1020C698B4DA8C1C47852419 /* CorrectionMatcher.swift */; }; + C2DD60867C21D42E48479564 /* ReportSynthesis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99093337348356622254C44E /* ReportSynthesis.swift */; }; C66E0F5339DADB4AE603BD5D /* GradingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */; }; C7DF6658F1F355665EEC175A /* GradingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD296324E8F63CD0B525458 /* GradingProvider.swift */; }; + CA861DEBE1C347F7936FFB80 /* WriteBenchPadApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC9D080A01177C3E30321D3 /* WriteBenchPadApp.swift */; }; CB760C24CAE63253B9400E19 /* WriteBenchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679F1D25813DBB4821B089EF /* WriteBenchTests.swift */; }; CCB636BAB11DE1B7DE6C1CA2 /* ImmersiveWritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B600C2CBBB1B477ABB65C42 /* ImmersiveWritingView.swift */; }; + CCCE53E8DC22C561B10A1F5E /* UsageCost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B358A2E1043646F5A65FA4 /* UsageCost.swift */; }; + CDFCD6C8002893F0FE940D50 /* GradingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */; }; CF9CA154E89BD40306ABC7D2 /* EssayFolderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9C06884EE52989CB4491F /* EssayFolderMetadata.swift */; }; D03424B806D55BF2BB9F11DC /* kaoyan_english2_large.md in Resources */ = {isa = PBXBuildFile; fileRef = 0506352B36FD805B5E9053D8 /* kaoyan_english2_large.md */; }; D3F29CFA674B891722D5B739 /* cet6_writing.md in Resources */ = {isa = PBXBuildFile; fileRef = 650B4D62F506EDDC71D6396A /* cet6_writing.md */; }; D4A1396AF9FFF2FF6C40C24D /* kaoyan_english2_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 56792E2C1501DBCE7B80739B /* kaoyan_english2_translation.md */; }; + DA1225EBA78250B9BBF1BE37 /* BackupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888D43292E376FCF9480159D /* BackupService.swift */; }; DB3DEEA68D82D9F8BECF108E /* ReviewOutline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B9132C06548AC9B8B44070 /* ReviewOutline.swift */; }; DB6452447E988C02C326B0BE /* WriteBenchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */; }; DB8D1A55F0E264D96E7E1D8D /* TextQuestionImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */; }; DCC333A3FAF19BC2ECC79BA0 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */; }; DEBA962949C4AADDFCB2C5D0 /* ielts_task1.md in Resources */ = {isa = PBXBuildFile; fileRef = 405A85F2A07E72B5AD277D96 /* ielts_task1.md */; }; DEF094BE47996A62C68AB985 /* CodexJudgeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */; }; + E0073DF9787252034D15C5FC /* GradingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634F608AF0DF72D3DE51E438 /* GradingService.swift */; }; E041B0138CF95145600B02C6 /* kaoyan_english2_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 015B10F8076FB519F8FDAACD /* kaoyan_english2_small.md */; }; E0495C7F8744F99633CD2EF5 /* UsageCost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B358A2E1043646F5A65FA4 /* UsageCost.swift */; }; E32387900468FFCA09760D27 /* kaoyan_english1_large.md in Resources */ = {isa = PBXBuildFile; fileRef = ACA34C8DE60D85ABFA45116F /* kaoyan_english1_large.md */; }; + E6E5B98B8CAC1DCC944E885A /* QuestionText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0DD31CC0B614898B36ED76 /* QuestionText.swift */; }; E7928726F27B1B2EB5341AEB /* EssaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8874F23C092B83269A6E56 /* EssaySession.swift */; }; + EBAE3F8E1B56E6AEBD98A371 /* EssayFolderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9C06884EE52989CB4491F /* EssayFolderMetadata.swift */; }; ECFAF47EFB1BFE1187B43C7D /* PracticeRail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876B3B648AA84D3EBCAD18D8 /* PracticeRail.swift */; }; + F09E424B1B72E44052E8B365 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */; }; F2DBAF31C0FA351AB0D126DB /* HistoryReviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879136039903990A8065C3BD /* HistoryReviewTests.swift */; }; + F3588953C5283BF0EFF36061 /* DeepSeekClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A399A503863E6199B340A50C /* DeepSeekClient.swift */; }; F59451A7E3C70F80EF599140 /* DeepSeekCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */; }; + F7C987F988EE4BA49468C249 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16DC3843F83A5135DD60057 /* ReviewCard.swift */; }; FAEA4C1F539996D40D1AD105 /* BackupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888D43292E376FCF9480159D /* BackupService.swift */; }; + FB1610A4D5B992AA3477C943 /* kaoyan_english1_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */; }; FBEB290B54B4DA0E050F77E1 /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 781A9E69AB1A93F99198A60E /* StatisticsView.swift */; }; /* End PBXBuildFile section */ @@ -90,6 +131,20 @@ remoteGlobalIDString = 42685113F6CE8DBF0DF8E5C6; remoteInfo = WriteBench; }; + 27A4741527532F7BC63449EC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29C958723C914B73391AB82A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2CE8CC0563717B5A4D293DA4; + remoteInfo = WriteBenchPad; + }; + E1B472EF8E8182D7A74DD385 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29C958723C914B73391AB82A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2CE8CC0563717B5A4D293DA4; + remoteInfo = WriteBenchPad; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -103,14 +158,17 @@ 1020C698B4DA8C1C47852419 /* CorrectionMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionMatcher.swift; sourceTree = ""; }; 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EssayHistoryGroup.swift; sourceTree = ""; }; 161DE1C1158C7D408EAD0C20 /* WriteBenchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WriteBenchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 1658A3CDE48F857354323CD7 /* WriteBenchPadTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WriteBenchPadTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1689A9A92BD0E930F5FB82F9 /* CETQuestionBank.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETQuestionBank.swift; sourceTree = ""; }; 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextQuestionImportView.swift; sourceTree = ""; }; 2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteBenchApp.swift; sourceTree = ""; }; 26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionLibraryView.swift; sourceTree = ""; }; 27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexJudgeService.swift; sourceTree = ""; }; + 28AEE0BB5A40311EB0DF71A8 /* PadHandwriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadHandwriting.swift; sourceTree = ""; }; 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionImportTests.swift; sourceTree = ""; }; 296A6193086826B2B071503F /* MockGradingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGradingService.swift; sourceTree = ""; }; + 2BC9D080A01177C3E30321D3 /* WriteBenchPadApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteBenchPadApp.swift; sourceTree = ""; }; 306B86B1159828782A062144 /* StreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingTests.swift; sourceTree = ""; }; 31DECD6B9F0B3A54C27F4535 /* BackgroundGradingJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingJob.swift; sourceTree = ""; }; 34A07798AF0255AB8EAFF76A /* BackgroundGradingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingTests.swift; sourceTree = ""; }; @@ -118,21 +176,25 @@ 3A3647AF5325DFD1CE43543B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 405A85F2A07E72B5AD277D96 /* ielts_task1.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ielts_task1.md; sourceTree = ""; }; 419DE9ABE9E26F14128B9B1E /* HistoryFolderEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFolderEditor.swift; sourceTree = ""; }; + 427C85E80F277C5F00ACFD3C /* PadWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadWorkspace.swift; sourceTree = ""; }; 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MistakesView.swift; sourceTree = ""; }; 445C1E2A61051C152970E26E /* cet6-2022-2026.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cet6-2022-2026.json"; sourceTree = ""; }; 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekStreaming.swift; sourceTree = ""; }; 4FAD6385C4872B3E80D4B00C /* CETQuestionBankTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETQuestionBankTests.swift; sourceTree = ""; }; 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowImmersionBridge.swift; sourceTree = ""; }; + 524AD2D04A2A9004F1783E8A /* PadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadTests.swift; sourceTree = ""; }; 56792E2C1501DBCE7B80739B /* kaoyan_english2_translation.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english2_translation.md; sourceTree = ""; }; 5AEB65E44D0601AEC6053865 /* Exam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exam.swift; sourceTree = ""; }; 6211317C38F78901403EE347 /* TranslationLesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationLesson.swift; sourceTree = ""; }; 634F608AF0DF72D3DE51E438 /* GradingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingService.swift; sourceTree = ""; }; 650B4D62F506EDDC71D6396A /* cet6_writing.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = cet6_writing.md; sourceTree = ""; }; + 6585246941091E1966ACFE51 /* PadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadStore.swift; sourceTree = ""; }; 679F1D25813DBB4821B089EF /* WriteBenchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteBenchTests.swift; sourceTree = ""; }; 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english1_small.md; sourceTree = ""; }; 6CED406B3EAB7FA3947795C5 /* cet6_translation.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = cet6_translation.md; sourceTree = ""; }; 70A5D3F3824C11B7B262C308 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 71E6EE384D62168057B908A3 /* QuestionBank.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionBank.swift; sourceTree = ""; }; + 755D2250B6AED145B82C8E1B /* WriteBenchPad.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = WriteBenchPad.app; sourceTree = BUILT_PRODUCTS_DIR; }; 781A9E69AB1A93F99198A60E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = ""; }; 84F1B7E5BB4C4D7689B346DE /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = ""; }; 876B3B648AA84D3EBCAD18D8 /* PracticeRail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeRail.swift; sourceTree = ""; }; @@ -145,12 +207,15 @@ 9AA993471F8CFF92BD9B2956 /* BrandArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandArt.swift; sourceTree = ""; }; 9B600C2CBBB1B477ABB65C42 /* ImmersiveWritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveWritingView.swift; sourceTree = ""; }; 9F5E568498AF07D6BF8C1335 /* PracticeLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeLoopTests.swift; sourceTree = ""; }; + A35C864C69C265F8C846C881 /* ocr-sample-1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "ocr-sample-1.png"; sourceTree = ""; }; A399A503863E6199B340A50C /* DeepSeekClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekClient.swift; sourceTree = ""; }; + AA3877BD86B363D93BD41E2B /* PadSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadSettings.swift; sourceTree = ""; }; AA7BA8561FCE7DD2841C904B /* ielts_task2.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ielts_task2.md; sourceTree = ""; }; ACA34C8DE60D85ABFA45116F /* kaoyan_english1_large.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english1_large.md; sourceTree = ""; }; AD3CEB6EC11C595CA1EA79D6 /* kaoyan_english1_translation.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english1_translation.md; sourceTree = ""; }; AEF0EC53CAA982107C3AA320 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B16DC3843F83A5135DD60057 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = ""; }; + B6B0E9758B9FE559058FEBBE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderTests.swift; sourceTree = ""; }; C00C1A68BA4227431C3B8884 /* WritingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingStore.swift; sourceTree = ""; }; C6B92D96525F9799F42C2EDE /* WriteBench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WriteBench.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -162,13 +227,17 @@ DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = ""; }; DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingView.swift; sourceTree = ""; }; DD0DD31CC0B614898B36ED76 /* QuestionText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionText.swift; sourceTree = ""; }; + E5C2AC6FE9A57B96996AC706 /* PadEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadEditor.swift; sourceTree = ""; }; E6B358A2E1043646F5A65FA4 /* UsageCost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCost.swift; sourceTree = ""; }; E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WriteBench.entitlements; sourceTree = ""; }; + E9DD6C821E6AAACD390D0D60 /* PadLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadLibrary.swift; sourceTree = ""; }; EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionTextReader.swift; sourceTree = ""; }; F11CD6DC48ADA2A2066B58A3 /* TranslationLessonCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationLessonCard.swift; sourceTree = ""; }; F3788E5EB782131E544DDEDC /* WritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingView.swift; sourceTree = ""; }; F54C82E0AE1ACA592616CDDB /* OCRConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRConfirmationView.swift; sourceTree = ""; }; + F60CDC36C4B907FF32441E0C /* WriteBenchPadUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WriteBenchPadUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F7B9132C06548AC9B8B44070 /* ReviewOutline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewOutline.swift; sourceTree = ""; }; + F9A3D1D6ED82A1482C350CCB /* PadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadUITests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -180,6 +249,31 @@ path = OCR; sourceTree = ""; }; + 1B79BA391979FBB2B22B1F4B /* WriteBenchPad */ = { + isa = PBXGroup; + children = ( + B6B0E9758B9FE559058FEBBE /* Assets.xcassets */, + E5C2AC6FE9A57B96996AC706 /* PadEditor.swift */, + 28AEE0BB5A40311EB0DF71A8 /* PadHandwriting.swift */, + E9DD6C821E6AAACD390D0D60 /* PadLibrary.swift */, + AA3877BD86B363D93BD41E2B /* PadSettings.swift */, + 6585246941091E1966ACFE51 /* PadStore.swift */, + 427C85E80F277C5F00ACFD3C /* PadWorkspace.swift */, + 2BC9D080A01177C3E30321D3 /* WriteBenchPadApp.swift */, + ); + name = WriteBenchPad; + path = platforms/ipad/WriteBenchPad; + sourceTree = ""; + }; + 1D6F3A87B962A76C59CB35C8 /* WriteBenchPadUITests */ = { + isa = PBXGroup; + children = ( + F9A3D1D6ED82A1482C350CCB /* PadUITests.swift */, + ); + name = WriteBenchPadUITests; + path = platforms/ipad/WriteBenchPadUITests; + sourceTree = ""; + }; 1E299B61878E0D3292AC4569 /* Review */ = { isa = PBXGroup; children = ( @@ -244,10 +338,22 @@ path = Models; sourceTree = ""; }; + 5F7DA285C20E5D45609396D7 /* TestImages */ = { + isa = PBXGroup; + children = ( + A35C864C69C265F8C846C881 /* ocr-sample-1.png */, + ); + name = TestImages; + path = Documentation/TestImages; + sourceTree = ""; + }; 6C0EBEADE4784897205C53CF /* Products */ = { isa = PBXGroup; children = ( C6B92D96525F9799F42C2EDE /* WriteBench.app */, + 755D2250B6AED145B82C8E1B /* WriteBenchPad.app */, + 1658A3CDE48F857354323CD7 /* WriteBenchPadTests.xctest */, + F60CDC36C4B907FF32441E0C /* WriteBenchPadUITests.xctest */, 161DE1C1158C7D408EAD0C20 /* WriteBenchTests.xctest */, ); name = Products; @@ -354,11 +460,24 @@ path = History; sourceTree = ""; }; + AF2B269A7710935879110FE6 /* WriteBenchPadTests */ = { + isa = PBXGroup; + children = ( + 524AD2D04A2A9004F1783E8A /* PadTests.swift */, + ); + name = WriteBenchPadTests; + path = platforms/ipad/WriteBenchPadTests; + sourceTree = ""; + }; B32DAE4CDBDFC97EC7BE4B5A = { isa = PBXGroup; children = ( 85C04166008095CC4B9B2FA4 /* QuestionBank */, + 5F7DA285C20E5D45609396D7 /* TestImages */, CC65E74040E23A5ED1894671 /* WriteBench */, + 1B79BA391979FBB2B22B1F4B /* WriteBenchPad */, + AF2B269A7710935879110FE6 /* WriteBenchPadTests */, + 1D6F3A87B962A76C59CB35C8 /* WriteBenchPadUITests */, 8E4D664CF8F8C451767E12E6 /* WriteBenchTests */, 6C0EBEADE4784897205C53CF /* Products */, ); @@ -476,6 +595,24 @@ productReference = 161DE1C1158C7D408EAD0C20 /* WriteBenchTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 2CE8CC0563717B5A4D293DA4 /* WriteBenchPad */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6F9EEFA295C71511C7B62D78 /* Build configuration list for PBXNativeTarget "WriteBenchPad" */; + buildPhases = ( + 8F8AEADB279D2985A71097A6 /* Sources */, + E36BF57CDAEF42663F20055C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WriteBenchPad; + packageProductDependencies = ( + ); + productName = WriteBenchPad; + productReference = 755D2250B6AED145B82C8E1B /* WriteBenchPad.app */; + productType = "com.apple.product-type.application"; + }; 42685113F6CE8DBF0DF8E5C6 /* WriteBench */ = { isa = PBXNativeTarget; buildConfigurationList = A95368A01FC6E68C8FBCD4FF /* Build configuration list for PBXNativeTarget "WriteBench" */; @@ -494,6 +631,43 @@ productReference = C6B92D96525F9799F42C2EDE /* WriteBench.app */; productType = "com.apple.product-type.application"; }; + 4D319C0D85B3B2D52BB9F38B /* WriteBenchPadTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F36EC6B07186189AA6AB61AF /* Build configuration list for PBXNativeTarget "WriteBenchPadTests" */; + buildPhases = ( + 7051537DAE21242AD61D8E50 /* Sources */, + 5558A3DC85775B1382A4F307 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5D1E68316177777BADD3862F /* PBXTargetDependency */, + ); + name = WriteBenchPadTests; + packageProductDependencies = ( + ); + productName = WriteBenchPadTests; + productReference = 1658A3CDE48F857354323CD7 /* WriteBenchPadTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 9A12594761FB8665EB47620A /* WriteBenchPadUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C1B3410118B038770315BB3A /* Build configuration list for PBXNativeTarget "WriteBenchPadUITests" */; + buildPhases = ( + FB29ADC38F2DBD78134FC2E0 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + EE717C6CCCBB2C5D6A7CC02D /* PBXTargetDependency */, + ); + name = WriteBenchPadUITests; + packageProductDependencies = ( + ); + productName = WriteBenchPadUITests; + productReference = F60CDC36C4B907FF32441E0C /* WriteBenchPadUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -506,9 +680,19 @@ 2544962C2680945644A01417 = { ProvisioningStyle = Automatic; }; + 2CE8CC0563717B5A4D293DA4 = { + ProvisioningStyle = Automatic; + }; 42685113F6CE8DBF0DF8E5C6 = { ProvisioningStyle = Automatic; }; + 4D319C0D85B3B2D52BB9F38B = { + ProvisioningStyle = Automatic; + }; + 9A12594761FB8665EB47620A = { + ProvisioningStyle = Automatic; + TestTargetID = 2CE8CC0563717B5A4D293DA4; + }; }; }; buildConfigurationList = EF77D57F624459FCA134BB20 /* Build configuration list for PBXProject "WriteBench" */; @@ -527,11 +711,22 @@ targets = ( 42685113F6CE8DBF0DF8E5C6 /* WriteBench */, 2544962C2680945644A01417 /* WriteBenchTests */, + 2CE8CC0563717B5A4D293DA4 /* WriteBenchPad */, + 4D319C0D85B3B2D52BB9F38B /* WriteBenchPadTests */, + 9A12594761FB8665EB47620A /* WriteBenchPadUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 5558A3DC85775B1382A4F307 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9B536D6CE7FE2E019121A5D3 /* ocr-sample-1.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9D8C3A595D9330A9226535FD /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -551,6 +746,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E36BF57CDAEF42663F20055C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 629089D675B6EC701836CB53 /* Assets.xcassets in Resources */, + BCD28EBF26B2DBEF3E99CCDD /* cet6-2022-2026.json in Resources */, + 41EF54312B6D1CE976DD7BB1 /* cet6_translation.md in Resources */, + 0F800C08810BB466CBE5CF50 /* cet6_writing.md in Resources */, + 0AB189E45DA34198003C4ACF /* ielts_task1.md in Resources */, + A4D399A6F4E03F23FE75BD10 /* ielts_task2.md in Resources */, + 4241BA18661160643D61DE99 /* kaoyan_english1_large.md in Resources */, + FB1610A4D5B992AA3477C943 /* kaoyan_english1_small.md in Resources */, + 632FDD2AB3BEDB2E641E1090 /* kaoyan_english1_translation.md in Resources */, + 3860A78F309E21B3B68AA88E /* kaoyan_english2_large.md in Resources */, + 0ED522D3B22EAEB2CDF79443 /* kaoyan_english2_small.md in Resources */, + 3AF9A2452A97E843CA28DBD3 /* kaoyan_english2_translation.md in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -629,17 +843,94 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7051537DAE21242AD61D8E50 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 865F02CEF67F20E89E96A4E9 /* PadTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8F8AEADB279D2985A71097A6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA1225EBA78250B9BBF1BE37 /* BackupService.swift in Sources */, + 5F1536C7F3ABF096C23F2B41 /* CETQuestionBank.swift in Sources */, + 96416E493449971277BD75FC /* CorrectionMatcher.swift in Sources */, + F3588953C5283BF0EFF36061 /* DeepSeekClient.swift in Sources */, + 5802389F014B6F342E5152C7 /* DeepSeekStreaming.swift in Sources */, + EBAE3F8E1B56E6AEBD98A371 /* EssayFolderMetadata.swift in Sources */, + 8F5898E5109076BF0EFF1F88 /* EssaySession.swift in Sources */, + A6458710E8DAF4A3CDF760CD /* Exam.swift in Sources */, + CDFCD6C8002893F0FE940D50 /* GradingModels.swift in Sources */, + E0073DF9787252034D15C5FC /* GradingService.swift in Sources */, + F09E424B1B72E44052E8B365 /* KeychainService.swift in Sources */, + 9850669404F8D67333E6C3D7 /* OCRService.swift in Sources */, + 242298DFFDBE665961BCD774 /* PadEditor.swift in Sources */, + 44EC8DBB916264EFCE0C8D90 /* PadHandwriting.swift in Sources */, + BA2F77C94675557CB5CE6F5D /* PadLibrary.swift in Sources */, + 3FAD16D146887EE128D0907B /* PadSettings.swift in Sources */, + A88B15150ABE8B3ECDEB51CB /* PadStore.swift in Sources */, + 9B429BF8E4F61CDC600CE7D9 /* PadWorkspace.swift in Sources */, + 4A24CA800AC78EA32367B092 /* QuestionBank.swift in Sources */, + E6E5B98B8CAC1DCC944E885A /* QuestionText.swift in Sources */, + C2DD60867C21D42E48479564 /* ReportSynthesis.swift in Sources */, + F7C987F988EE4BA49468C249 /* ReviewCard.swift in Sources */, + ACAB0542046596D38C566B67 /* Theme.swift in Sources */, + 0611D0C9511C7414C1165A1F /* TranslationLesson.swift in Sources */, + CCCE53E8DC22C561B10A1F5E /* UsageCost.swift in Sources */, + CA861DEBE1C347F7936FFB80 /* WriteBenchPadApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FB29ADC38F2DBD78134FC2E0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 90C3DE5400C92979D32AC1AD /* PadUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 5D1E68316177777BADD3862F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2CE8CC0563717B5A4D293DA4 /* WriteBenchPad */; + targetProxy = E1B472EF8E8182D7A74DD385 /* PBXContainerItemProxy */; + }; 6267C51647F1331E22F68E23 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 42685113F6CE8DBF0DF8E5C6 /* WriteBench */; targetProxy = 05E28766A5A1D3E6A0617685 /* PBXContainerItemProxy */; }; + EE717C6CCCBB2C5D6A7CC02D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2CE8CC0563717B5A4D293DA4 /* WriteBenchPad */; + targetProxy = 27A4741527532F7BC63449EC /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 0774EAF5920BA355785F491F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_ENTITLEMENTS = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chen.WriteBenchPadUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 2; + TEST_TARGET_NAME = WriteBenchPad; + }; + name = Debug; + }; 0D4182C73077754150582570 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -656,6 +947,24 @@ }; name = Release; }; + 2F229AEB87C17144C272914A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_ENTITLEMENTS = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chen.WriteBenchPadTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 2; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WriteBenchPad.app/WriteBenchPad"; + }; + name = Release; + }; 770C895008D3624B0ADFB0CE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -711,13 +1020,34 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; }; name = Release; }; + 82E2FD9948C210020B9C1D7A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_IDENTITY = "Apple Development"; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = platforms/ipad/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WriteBench; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chen.WriteBench.ipad; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 2; + }; + name = Debug; + }; 99460C1F2BDFC0094106F5F2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -736,6 +1066,64 @@ }; name = Debug; }; + A44940CC0FDB9C8B38D9C643 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_ENTITLEMENTS = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chen.WriteBenchPadUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 2; + TEST_TARGET_NAME = WriteBenchPad; + }; + name = Release; + }; + A87D63488E47D987322B4927 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_IDENTITY = "Apple Development"; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = platforms/ipad/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WriteBench; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chen.WriteBench.ipad; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 2; + }; + name = Release; + }; + B17B8D7A317AE3C2A37ADF6C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_ENTITLEMENTS = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.chen.WriteBenchPadTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 2; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WriteBenchPad.app/WriteBenchPad"; + }; + name = Debug; + }; B6F36944794557B5D49E23DE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -814,7 +1202,6 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -851,6 +1238,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 6F9EEFA295C71511C7B62D78 /* Build configuration list for PBXNativeTarget "WriteBenchPad" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 82E2FD9948C210020B9C1D7A /* Debug */, + A87D63488E47D987322B4927 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; A95368A01FC6E68C8FBCD4FF /* Build configuration list for PBXNativeTarget "WriteBench" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -860,6 +1256,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + C1B3410118B038770315BB3A /* Build configuration list for PBXNativeTarget "WriteBenchPadUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0774EAF5920BA355785F491F /* Debug */, + A44940CC0FDB9C8B38D9C643 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; EF77D57F624459FCA134BB20 /* Build configuration list for PBXProject "WriteBench" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -869,6 +1274,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + F36EC6B07186189AA6AB61AF /* Build configuration list for PBXNativeTarget "WriteBenchPadTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B17B8D7A317AE3C2A37ADF6C /* Debug */, + 2F229AEB87C17144C272914A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 29C958723C914B73391AB82A /* Project object */; diff --git a/WriteBench.xcodeproj/xcshareddata/xcschemes/WriteBenchPad.xcscheme b/WriteBench.xcodeproj/xcshareddata/xcschemes/WriteBenchPad.xcscheme new file mode 100644 index 0000000..a6840f8 --- /dev/null +++ b/WriteBench.xcodeproj/xcshareddata/xcschemes/WriteBenchPad.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WriteBench/Persistence/EssaySession.swift b/WriteBench/Persistence/EssaySession.swift index 1531309..eeb2b8c 100644 --- a/WriteBench/Persistence/EssaySession.swift +++ b/WriteBench/Persistence/EssaySession.swift @@ -42,6 +42,8 @@ import SwiftData } @Model final class WritingDraft { + @Attribute(.externalStorage) var richText: Data? = nil + @Attribute(.externalStorage) var pencilDrawing: Data? = nil @Attribute(.unique) var subtype: String var question: String var questionLabel: String diff --git a/WriteBench/Services/Backup/BackupService.swift b/WriteBench/Services/Backup/BackupService.swift index 7f11e1d..ececc95 100644 --- a/WriteBench/Services/Backup/BackupService.swift +++ b/WriteBench/Services/Backup/BackupService.swift @@ -21,6 +21,8 @@ struct BackupArchive: Codable { var parentSessionID: UUID?, questionImage: Data?, sourceImages: Data? } struct DraftRecord: Codable { + var richText: Data? = nil + var pencilDrawing: Data? = nil var subtype: String, question: String, questionLabel: String, essay: String, elapsed: Double, inputMode: String, updatedAt: Date var rewriteSessionID: UUID?, questionImage: Data?, sourceImages: Data? } @@ -59,7 +61,7 @@ struct BackupSummary: Equatable { questionImage: $0.questionImage, sourceImages: $0.sourceImages) }, drafts: try context.fetch(FetchDescriptor()).map { - .init(subtype: $0.subtype, question: $0.question, questionLabel: $0.questionLabel, essay: $0.essay, elapsed: $0.elapsed, + .init(richText: $0.richText, pencilDrawing: $0.pencilDrawing, subtype: $0.subtype, question: $0.question, questionLabel: $0.questionLabel, essay: $0.essay, elapsed: $0.elapsed, inputMode: $0.inputMode, updatedAt: $0.updatedAt, rewriteSessionID: $0.rewriteSessionID, questionImage: $0.questionImage, sourceImages: $0.sourceImages) }, questions: try context.fetch(FetchDescriptor()).map { @@ -111,7 +113,7 @@ struct BackupSummary: Equatable { if drafts[record.subtype] == nil { context.insert(draft) } draft.question = record.question; draft.questionLabel = record.questionLabel; draft.essay = record.essay; draft.elapsed = record.elapsed draft.inputMode = record.inputMode; draft.updatedAt = record.updatedAt; draft.rewriteSessionID = record.rewriteSessionID - draft.questionImage = record.questionImage; draft.sourceImages = record.sourceImages + draft.questionImage = record.questionImage; draft.sourceImages = record.sourceImages; draft.richText = record.richText; draft.pencilDrawing = record.pencilDrawing summary.added += 1 } let questionIDs = Set(try context.fetch(FetchDescriptor()).map(\.id)) diff --git a/WriteBench/Services/OCR/OCRService.swift b/WriteBench/Services/OCR/OCRService.swift index 34a9b21..8754eee 100644 --- a/WriteBench/Services/OCR/OCRService.swift +++ b/WriteBench/Services/OCR/OCRService.swift @@ -2,7 +2,9 @@ import Foundation import Vision import ImageIO import UniformTypeIdentifiers +#if os(macOS) import AppKit +#endif struct OCRPage: Identifiable, Sendable { var id = UUID() @@ -66,6 +68,7 @@ struct VisionOCRService: Sendable { } } +#if os(macOS) @MainActor enum ImageImporter { static func selectImages() async -> [URL] { let panel = NSOpenPanel() @@ -78,3 +81,5 @@ struct VisionOCRService: Sendable { return await panel.begin() == .OK ? panel.urls : [] } } + +#endif diff --git a/platforms/ipad/Info.plist b/platforms/ipad/Info.plist new file mode 100644 index 0000000..461eec9 --- /dev/null +++ b/platforms/ipad/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/platforms/ipad/README.md b/platforms/ipad/README.md new file mode 100644 index 0000000..627f2f4 --- /dev/null +++ b/platforms/ipad/README.md @@ -0,0 +1,38 @@ +# WriteBench for iPad + +原生 SwiftUI / UIKit / PencilKit 应用,要求 **iPadOS 18 或更新版本**。`TARGETED_DEVICE_FAMILY = 2`,只面向 iPad,不包含 iPhone 版,也不启用 Mac Catalyst。 + +## 功能 + +- 考研英语一/二、六级、IELTS 的写作与翻译练习;66 道近五年六级完整正文随应用打包。 +- 宽窗口题目与答题卡并排;窄窗口上下排列。横竖屏、窗口大小变化均可使用;窄屏切到手写时自动滚到答题卡。 +- 原生键盘编辑:保留连续空格,Tab 插入四个空格,当前/选中段落左对齐、居中、右对齐。草稿和备份保留段落格式。 +- PencilKit 手写纸、手指/Apple Pencil 输入、橡皮擦和清空确认;笔迹随草稿和备份保存。手写识别需要人工校对,再转换成评阅所需文字。 +- 文件 App 图片导入、本机 Vision 中英文 OCR、原图预览、强制校对确认;UTF-8 文本、带文字层 PDF 导入。 +- 交卷立即暂停计时;页面切换和退到后台暂停计时并保存草稿。离开前台会取消未完成的评阅,保留提交内容,不生成部分总分。 +- DeepSeek 快速/三评审评阅,共用桌面的 rubric、JSON 校验、聚合规则、修改建议与翻译教学。 +- 评阅历史、分享复习文本、错题/表达的间隔复习、按题型统计。 +- Keychain 保存 API Key;无额外账号或云同步。可导入/导出与 macOS 兼容的 JSON 备份,备份不含凭据。自定义六级题库 JSON 需单独导入到题库。 + +桌面版的 Codex CLI 无法在 iPadOS 执行,因此 iPad 版目前只提供 DeepSeek。付费真实评阅与实体 Apple Pencil 尚需实机验证。 + +## Xcode 开发与测试 + +在仓库根目录: + +```sh +xcodegen generate +xcodebuild -project WriteBench.xcodeproj -scheme WriteBenchPad \ + -destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M5)' \ + -derivedDataPath build-ipad CODE_SIGN_IDENTITY=- test +``` + +使用本机实际可用的 iPad 模拟器名称(`xcrun simctl list devices available`)。模拟器构建使用临时签名;不要设置 `CODE_SIGNING_ALLOWED=NO` 来运行应用,否则 Keychain 会返回缺少 entitlement。 + +CI 会自动选取可用 iPad 模拟器,执行8 项单元测试与 6 项 UI 测试并上传 xcresult。测试使用内存数据库;OCR 测试用样本图片,不调用付费评阅。 + +## 安装到实体 iPad / TestFlight + +打开 `WriteBench.xcodeproj`,选择 **WriteBenchPad** target,在 Signing & Capabilities 选择自己的 Apple 开发 Team,连接 iPad 并运行。按设备要求开启 Developer Mode。 + +TestFlight / App Store 需要 Apple Developer Program、有效的分发签名和 App Store Connect 配置。模拟器 `.app` 与未签名设备构建都不能直接作为可安装 IPA 分发。当前没有发布 iPad 安装包,不会把模拟器包伪装成 IPA。 diff --git a/platforms/ipad/Screenshots/settings-portrait.png b/platforms/ipad/Screenshots/settings-portrait.png new file mode 100644 index 0000000..1cc33ae Binary files /dev/null and b/platforms/ipad/Screenshots/settings-portrait.png differ diff --git a/platforms/ipad/Screenshots/writing-landscape.png b/platforms/ipad/Screenshots/writing-landscape.png new file mode 100644 index 0000000..6f75b53 Binary files /dev/null and b/platforms/ipad/Screenshots/writing-landscape.png differ diff --git a/platforms/ipad/VALIDATION.md b/platforms/ipad/VALIDATION.md new file mode 100644 index 0000000..726fac8 --- /dev/null +++ b/platforms/ipad/VALIDATION.md @@ -0,0 +1,25 @@ +# iPad 验证记录 + +验证日期:2026-10-03(本机时区)。Xcode 27.0,iPadOS 26.4 模拟器。 + +| 检查 | 结果 | +| --- | --- | +| iPad Pro 11-inch (M5) | 8 项单元测试、6 项 UI 测试全部通过 | +| iPad mini (A17 Pro) | 同一套 14 项测试全部通过 | +| iOS arm64 设备构建 | 通过,未签名,不作为可安装 IPA | +| macOS 回归 | 87 项原有测试全部通过 | +| 设备族配置 | UIDeviceFamily = [2],只面向 iPad | + +单元测试实际读取内置六级题库(33 写作 + 33 翻译)与全部 rubric,执行真实 Vision OCR,并核对草稿的连续空格、排版和备份数据。题型切换保持独立草稿,间隔复习推进卡片。 + +UI 测试实际点击题库正文与选题、键盘输入 Tab/空格、交卷后暂停计时、横竖屏导航、PencilKit 笔迹绘制与清空确认、OCR 的必选校对开关,以及切到后台后的计时和草稿状态。OCR 校对 UI 使用调试样本;真实图像 OCR 另由单元测试执行。 + +未验证实体 iPad、实体 Apple Pencil、多任务窗口拖拽、付费 DeepSeek 评阅或 TestFlight 分发。模拟器运行包采用临时签名;本机没有有效 Apple 开发签名证书。 + +## 界面预览 + +![横屏答题](Screenshots/writing-landscape.png) + +![竖屏设置](Screenshots/settings-portrait.png) + +CI 的 iPad job 会执行测试并上传 xcresult。真机安装需要配置 Apple 开发 Team,详见 [安装说明](README.md)。 diff --git a/platforms/ipad/WriteBenchPad/Assets.xcassets/AppIcon.appiconset/Contents.json b/platforms/ipad/WriteBenchPad/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..c59a08a --- /dev/null +++ b/platforms/ipad/WriteBenchPad/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images": [{"filename": "icon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"}], "info": {"author": "xcode", "version": 1}} \ No newline at end of file diff --git a/platforms/ipad/WriteBenchPad/Assets.xcassets/AppIcon.appiconset/icon.png b/platforms/ipad/WriteBenchPad/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 0000000..dcdc057 Binary files /dev/null and b/platforms/ipad/WriteBenchPad/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/platforms/ipad/WriteBenchPad/PadEditor.swift b/platforms/ipad/WriteBenchPad/PadEditor.swift new file mode 100644 index 0000000..b3eade4 --- /dev/null +++ b/platforms/ipad/WriteBenchPad/PadEditor.swift @@ -0,0 +1,69 @@ +import SwiftUI +import UIKit + +struct PadEditor: UIViewRepresentable { + @Binding var text: String + @Binding var richText: Data? + var identifier: String + var editable = true + var command: EditorCommand? + enum EditorCommand: Equatable { case spaces(UUID), alignment(NSTextAlignment, UUID) } + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.delegate = context.coordinator + view.accessibilityIdentifier = identifier + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 24, right: 16) + view.autocorrectionType = .no; view.spellCheckingType = .no + view.smartQuotesType = .no; view.smartDashesType = .no + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = 12 + return view + } + func updateUIView(_ view: UITextView, context: Context) { + context.coordinator.parent = self + if view.text != text { let range = view.selectedRange; view.text = text; if range.location <= (text as NSString).length { view.selectedRange = range } } + if richText != context.coordinator.lastRichText { + if let richText, let attributed = try? NSAttributedString(data: richText, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil), attributed.string == text { + let selection = view.selectedRange; view.attributedText = attributed; view.selectedRange = selection + } + context.coordinator.lastRichText = richText + } + view.isEditable = editable + if let command, command != context.coordinator.lastCommand { + context.coordinator.lastCommand = command + let coordinator = context.coordinator + DispatchQueue.main.async { [weak view] in + guard let view else { return } + switch command { + case .spaces: view.becomeFirstResponder(); view.insertText(" ") + case .alignment(let alignment, _): + // Apply only to selected/current paragraphs, preserving whitespace and undo. + let ns = view.text as NSString + let selection = view.selectedRange + let range = ns.paragraphRange(for: selection) + let style = NSMutableParagraphStyle(); style.alignment = alignment + view.textStorage.addAttribute(.paragraphStyle, value: style, range: range) + view.typingAttributes[.paragraphStyle] = style + coordinator.textViewDidChange(view) + } + } + } + } + final class Coordinator: NSObject, UITextViewDelegate { + var parent: PadEditor + var lastCommand: EditorCommand? + var lastRichText: Data? + init(_ parent: PadEditor) { self.parent = parent } + func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + let data = try? textView.attributedText.data(from: NSRange(location: 0, length: textView.attributedText.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]) + lastRichText = data; parent.richText = data + } + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if text == "\t" { textView.insertText(" "); return false }; return true + } + } +} diff --git a/platforms/ipad/WriteBenchPad/PadHandwriting.swift b/platforms/ipad/WriteBenchPad/PadHandwriting.swift new file mode 100644 index 0000000..84ac3b7 --- /dev/null +++ b/platforms/ipad/WriteBenchPad/PadHandwriting.swift @@ -0,0 +1,37 @@ +import SwiftUI +import PencilKit + +struct PadHandwriting: UIViewRepresentable { + @Binding var data: Data? + var erasing: Bool + var editable: Bool + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeUIView(context: Context) -> PKCanvasView { + let view = PKCanvasView() + view.delegate = context.coordinator + view.drawingPolicy = .anyInput + view.backgroundColor = .white + view.layer.cornerRadius = 12 + view.accessibilityLabel = "手写答题纸" + view.accessibilityIdentifier = "handwriting-canvas" + return view + } + func updateUIView(_ view: PKCanvasView, context: Context) { + context.coordinator.parent = self + if data != context.coordinator.lastData { + view.drawing = data.flatMap { try? PKDrawing(data: $0) } ?? PKDrawing() + context.coordinator.lastData = data + } + view.isUserInteractionEnabled = editable + view.tool = erasing ? PKEraserTool(.bitmap) : PKInkingTool(.pen, color: .black, width: 2.5) + } + static func dismantleUIView(_ uiView: PKCanvasView, coordinator: Coordinator) { uiView.delegate = nil } + final class Coordinator: NSObject, PKCanvasViewDelegate { + var parent: PadHandwriting + var lastData: Data? + init(_ parent: PadHandwriting) { self.parent = parent } + func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { + let data: Data? = canvasView.drawing.bounds.isEmpty ? nil : canvasView.drawing.dataRepresentation(); lastData = data; parent.data = data + } + } +} diff --git a/platforms/ipad/WriteBenchPad/PadLibrary.swift b/platforms/ipad/WriteBenchPad/PadLibrary.swift new file mode 100644 index 0000000..afe701e --- /dev/null +++ b/platforms/ipad/WriteBenchPad/PadLibrary.swift @@ -0,0 +1,144 @@ +import SwiftUI +import SwiftData +import UniformTypeIdentifiers + +struct PadBank: View { + @Bindable var store: PadStore + let chosen: () -> Void + @Query private var saved: [SavedQuestion] + @State private var task: WritingTask = .cet6Writing + @State private var search = "" + @State private var importing = false + @State private var revision = 0 + private var questions: [BankQuestion] { + _ = revision + return (QuestionBank.questions(for: task) + saved.filter { $0.subtype == task.rawValue }.map { BankQuestion(id: $0.id.uuidString, task: task, title: $0.title, prompt: $0.prompt) }).filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) || $0.prompt.localizedCaseInsensitiveContains(search) } + } + var body: some View { + List { + Section { + Picker("题型", selection: $task) { ForEach(WritingTask.allCases) { Text($0.fullTitle).tag($0) } }.accessibilityIdentifier("bank-task") + } + Section("\(questions.count) 道题 · 点击查看正文") { + ForEach(questions) { item in + NavigationLink { ScrollView { VStack(alignment: .leading, spacing: 24) { + Text(item.title).font(.title2.bold()) + Text(QuestionText.attributed(item.prompt)).font(.body).textSelection(.enabled) + Button("用这道题练习") { store.choose(item); chosen() }.buttonStyle(.borderedProminent).disabled(store.isGrading).accessibilityIdentifier("use-question") + }.padding(24) }.navigationTitle("题目正文") } label: { VStack(alignment: .leading, spacing: 8) { Text(item.title).font(.headline); Text(QuestionText.plain(item.prompt)).lineLimit(2).foregroundStyle(.secondary) } }.accessibilityIdentifier("bank-question") + } + } + }.navigationTitle("题库").searchable(text: $search, prompt: "搜索题目或正文") + .toolbar { Button("导入六级题库 JSON") { importing = true } } + .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in + do { + let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } } + _ = try CETQuestionBank.importData(Data(contentsOf: url)); revision += 1 + } catch { store.error = error.localizedDescription } + } + } +} +struct PadHistory: View { + @Query(sort: \EssaySession.date, order: .reverse) private var sessions: [EssaySession] + var body: some View { + List { + if sessions.isEmpty { ContentUnavailableView("还没有评阅记录", systemImage: "clock", description: Text("完成一次评阅后,可以在这里回看原稿、修改建议和参考版本。")) } + ForEach(sessions) { session in + NavigationLink { PadReport(session: session) } label: { + HStack { + VStack(alignment: .leading, spacing: 8) { Text(session.task.fullTitle).font(.headline); Text(session.date, style: .date).foregroundStyle(.secondary) } + Spacer(); Text("\(session.finalScore.scoreText) / \(session.task.maxScore.scoreText)").font(.title3.monospacedDigit()) + } + } + } + }.navigationTitle("历史记录") + } +} +struct PadReport: View { + let session: EssaySession + @Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .firstTextBaseline) { + Text(session.finalScore.scoreText).font(.system(size: 52, weight: .semibold, design: .rounded)) + Text("/ \(session.task.maxScore.scoreText)").font(.title2).foregroundStyle(.secondary) + Spacer(); Text(session.task.fullTitle).font(.headline) + } + if let report = session.report { + Text(report.reviewers.count == 1 ? "快速评阅 · 1 位评审" : "完整评阅 · 3 位评审").foregroundStyle(.secondary) + Text(report.synthesis?.summary ?? report.reviewers.first?.response.summary ?? "").font(.body) + ForEach(report.corrections) { correction in + VStack(alignment: .leading, spacing: 8) { + Text(correction.original).strikethrough().foregroundStyle(.secondary) + Text(correction.corrected).foregroundStyle(WB.green) + Text(correction.explanation).font(.callout) + }.padding().frame(maxWidth: .infinity, alignment: .leading).background(WB.tint, in: RoundedRectangle(cornerRadius: 12)) + } + ForEach(report.translationLessons, id: \.number) { lesson in + DisclosureGroup("句子 \(lesson.number):\(lesson.source)") { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(lesson.groups.enumerated()), id: \.offset) { _, group in + Text(group.source).bold(); Text(group.translation) + ForEach(Array(group.vocabulary.enumerated()), id: \.offset) { _, word in Text("\(word.word) · \(word.partOfSpeech) · \(word.contextualMeaning)").font(.callout) } + Text(group.techniques.joined(separator: "\n")).foregroundStyle(.secondary) + } + Text(lesson.referenceTranslation).bold(); Text(lesson.assemblyNotes.joined(separator: "\n")); Text(lesson.studentAdvice) + }.padding(.vertical) + } + } + DisclosureGroup("各评审意见") { ForEach(report.reviewers, id: \.judge) { reviewer in VStack(alignment: .leading, spacing: 8) { Text("\(reviewer.judge.title) · \(reviewer.response.score.scoreText)").bold(); Text(reviewer.response.summary) }.padding(.vertical) } } + } + DisclosureGroup("题目与提交原稿") { VStack(alignment: .leading, spacing: 20) { Text(QuestionText.attributed(session.question)) + if let data = session.questionImage, let image = UIImage(data: data) { Image(uiImage: image).resizable().scaledToFit().frame(maxHeight: 360) } + Text(session.originalEssay) }.padding(.vertical).textSelection(.enabled) } + Text("参考版本").font(.title2.bold()); Text(session.correctedEssay).textSelection(.enabled) + ShareLink(item: "\(session.task.fullTitle)\n\(session.finalScore.scoreText)/\(session.task.maxScore.scoreText)\n\n\(session.originalEssay)\n\n参考版本\n\(session.correctedEssay)", label: { Label("分享复习文本", systemImage: "square.and.arrow.up") }) + }.padding(24).frame(maxWidth: 980) + }.navigationTitle("评阅报告").toolbar { Button("完成") { dismiss() } } + } + } +} +struct PadPractice: View { + @Environment(\.modelContext) private var context + @Query(sort: \ReviewCard.dueDate) private var cards: [ReviewCard] + @State private var revealed = Set() + @State private var error: String? + var body: some View { + List { + let due = cards.filter(\.isDue) + if due.isEmpty { ContentUnavailableView("当前没有待复习卡片", systemImage: "checkmark.circle", description: Text("评阅中的错误与表达会自动生成复习卡片。")) } + ForEach(due, id: \.key) { card in + VStack(alignment: .leading, spacing: 12) { + Text(card.prompt).font(.headline) + if revealed.contains(card.key) { + Text(card.answer).foregroundStyle(WB.green); Text(card.explanation); Text(card.context).font(.callout).foregroundStyle(.secondary) + HStack { Button("还不会") { review(card, .again) }; Spacer(); Button("记住了") { review(card, .good) } }.buttonStyle(.bordered) + } else { Button("显示答案") { revealed.insert(card.key) } } + }.padding(.vertical, 8) + } + }.navigationTitle("错题复习").task { do { try ReviewCardSync.sync(context) } catch { self.error = error.localizedDescription } } + .alert("保存失败", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("知道了") {} } message: { Text(error ?? "") } + } + private func review(_ card: ReviewCard, _ grade: ReviewGrade) { + SpacedRepetition.apply(grade, to: card) + do { try context.save() } catch { self.error = error.localizedDescription } + } +} +struct PadStatistics: View { + @Query private var sessions: [EssaySession] + var body: some View { + List { + Section("练习积累") { + LabeledContent("完成评阅", value: "\(sessions.count) 次") + LabeledContent("累计作答", value: "\(sessions.reduce(0) { $0 + $1.wordCount }) words") + LabeledContent("累计用时", value: "\(Int(sessions.reduce(0) { $0 + $1.writingDuration }) / 60) 分钟") + } + ForEach(WritingTask.allCases) { task in + let values = sessions.filter { $0.subtype == task.rawValue } + if !values.isEmpty { Section(task.fullTitle) { LabeledContent("练习次数", value: "\(values.count)"); LabeledContent("平均得分", value: "\((values.reduce(0) { $0 + $1.finalScore } / Double(values.count)).scoreText) / \(task.maxScore.scoreText)") } } + } + }.navigationTitle("统计") + } +} diff --git a/platforms/ipad/WriteBenchPad/PadSettings.swift b/platforms/ipad/WriteBenchPad/PadSettings.swift new file mode 100644 index 0000000..e9b9ce5 --- /dev/null +++ b/platforms/ipad/WriteBenchPad/PadSettings.swift @@ -0,0 +1,52 @@ +import SwiftUI +import SwiftData +import UniformTypeIdentifiers + +struct PadBackupDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json] } + var data: Data + init(data: Data) { self.data = data } + init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } +} +struct PadSettings: View { + @Bindable var store: PadStore + @Environment(\.modelContext) private var context + @AppStorage("deepSeekModel") private var model = DeepSeekClient.defaultModel + @State private var key = "" + @State private var remembered = DeepSeekCredentials.hasSessionKey + @State private var message: String? + @State private var document = PadBackupDocument(data: Data()) + @State private var exporting = false + @State private var importing = false + @State private var confirmImport = false + var body: some View { + Form { + Section("DeepSeek 评阅") { + TextField("模型 ID", text: $model).textInputAutocapitalization(.never).autocorrectionDisabled() + SecureField("API Key", text: $key).textInputAutocapitalization(.never).autocorrectionDisabled().accessibilityIdentifier("api-key") + Button("保存 API Key") { do { try DeepSeekCredentials.save(key); key = ""; remembered = true; message = "API Key 已安全保存。" } catch { store.error = error.localizedDescription } }.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if remembered { Label("已保存到本机 Keychain", systemImage: "lock.shield"); Button("删除保存的 API Key", role: .destructive) { do { try DeepSeekCredentials.forget(); remembered = false } catch { store.error = error.localizedDescription } } } + Text("快速评阅使用 1 位评审;完整评阅使用 3 位评审并汇总。请求使用你自己的 DeepSeek API 余额。iPad 版支持 DeepSeek,桌面的 Codex CLI 登录无法在 iPad 上运行。").font(.footnote).foregroundStyle(.secondary) + } + Section("资料备份") { + Button("导出备份到文件") { store.pause(); do { document = PadBackupDocument(data: try BackupService.export(from: context)); exporting = true } catch { store.error = error.localizedDescription } } + Button("导入桌面或 iPad 备份") { confirmImport = true }.disabled(store.isGrading) + Text("备份包含草稿、题目、历史评阅和复习卡片,不包含 API Key。可以通过文件 App 或 AirDrop 传到另一台设备。").font(.footnote).foregroundStyle(.secondary) + } + Section("关于") { LabeledContent("平台", value: "iPadOS 18+"); Text("题库默认包含近五年六级写作、翻译完整正文。资料保存在本机;目前不提供自动云同步。") } + }.navigationTitle("设置") + .confirmationDialog("导入备份", isPresented: $confirmImport, titleVisibility: .visible) { Button("选择备份文件") { importing = true } } message: { Text("补充本机缺少的记录,不覆盖已有作答。") } + .fileExporter(isPresented: $exporting, document: document, contentType: .json, defaultFilename: BackupService.suggestedFileName) { result in + if case .failure(let error) = result { store.error = error.localizedDescription } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in + do { + store.pause() + let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } } + message = try BackupService.restore(Data(contentsOf: url), into: context).text; store.restore() + } catch { store.error = error.localizedDescription } + } + .alert("已完成", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") {} } message: { Text(message ?? "") } + } +} diff --git a/platforms/ipad/WriteBenchPad/PadStore.swift b/platforms/ipad/WriteBenchPad/PadStore.swift new file mode 100644 index 0000000..9591176 --- /dev/null +++ b/platforms/ipad/WriteBenchPad/PadStore.swift @@ -0,0 +1,110 @@ +import SwiftUI +import SwiftData + +// iPad uses the same report format as desktop; CLI providers are not available on iPadOS. +enum GradingProvider: String, Codable, CaseIterable, Identifiable, Sendable { + case deepSeek, codex + var id: String { rawValue } + var title: String { self == .deepSeek ? "DeepSeek" : "ChatGPT · via Codex" } +} +enum GradingProgressEvent: Sendable { + case started(Judge), preview(Judge, String), completed(ReviewerResult), failed(Judge, String), summarizing +} +@MainActor enum DeepSeekCredentials { + private static var key: String? + static var hasSessionKey: Bool { key != nil } + static func load() throws -> String { guard let key else { throw GradingError.missingKey }; return key } + static func restore() throws { + do { key = try KeychainService.load() } catch GradingError.missingKey { key = nil } + } + static func save(_ value: String) throws { try KeychainService.save(value); key = value.trimmingCharacters(in: .whitespacesAndNewlines) } + static func forget() throws { try KeychainService.remove(); key = nil } +} + +@MainActor @Observable final class PadStore { + var task: WritingTask = .cet6Writing + var question = WritingTask.cet6Writing.sampleQuestion + var label = "原创练习" + var essay = "" + var richText: Data? + var pencilDrawing: Data? + var questionImage: Data? + var sourceImages: [Data] = [] + var inputMode: InputMode = .typed + var elapsed: Double = 0 + var startedAt: Date? + var isGrading = false + var status = "" + var previews: [Judge: String] = [:] + var error: String? + var result: EssaySession? + var savedAt: Date? + private var context: ModelContext? + private var grading: Task? + var running: Bool { startedAt != nil } + func time(at date: Date = Date()) -> Double { elapsed + (startedAt.map { max(0, date.timeIntervalSince($0)) } ?? 0) } + func attach(_ context: ModelContext) { guard self.context == nil else { return }; self.context = context; restore() } + func restore() { + guard let context else { return } + do { + let subtype = task.rawValue + if let draft = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.subtype == subtype })).first { + question = draft.question; label = draft.questionLabel; essay = draft.essay; elapsed = draft.elapsed; richText = draft.richText; pencilDrawing = draft.pencilDrawing; questionImage = draft.questionImage; sourceImages = draft.sourceImages.flatMap { try? JSONDecoder().decode([Data].self, from: $0) } ?? []; inputMode = InputMode(rawValue: draft.inputMode) ?? .typed + } else { question = task.sampleQuestion; label = "原创练习"; essay = ""; elapsed = 0; richText = nil; pencilDrawing = nil; questionImage = nil; sourceImages = []; inputMode = .typed } + startedAt = nil + } catch { self.error = error.localizedDescription } + } + @discardableResult func save() -> Bool { + guard let context else { return false } + do { + let subtype = task.rawValue + let draft: WritingDraft + if let found = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.subtype == subtype })).first { draft = found } + else { draft = WritingDraft(task: task); context.insert(draft) } + draft.question = question; draft.questionLabel = label; draft.essay = essay; draft.richText = richText; draft.pencilDrawing = pencilDrawing; draft.questionImage = questionImage; draft.sourceImages = sourceImages.isEmpty ? nil : try JSONEncoder().encode(sourceImages); draft.inputMode = inputMode.rawValue; draft.elapsed = time(); draft.updatedAt = Date() + try context.save(); savedAt = Date(); return true + } catch { self.error = "草稿保存失败:\(error.localizedDescription)"; return false } + } + func pause() { elapsed = time(); startedAt = nil; _ = save() } + func start() { guard !isGrading else { return }; if startedAt == nil { startedAt = Date() }; _ = save() } + @discardableResult func select(_ task: WritingTask) -> Bool { guard !isGrading else { return false }; pause(); guard save() else { return false }; self.task = task; restore(); return true } + func choose(_ item: BankQuestion) { guard select(item.task) else { return }; question = item.prompt; label = item.title; questionImage = nil; _ = save() } + func randomQuestion() { if let item = QuestionBank.questions(for: task).randomElement() { choose(item) } } + func cancel() { grading?.cancel() } + func submit(quick: Bool) { + guard !isGrading else { return } + pause() + guard !question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !essay.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = "请先填写题目与作答内容。"; return } + guard save(), let context else { return } + do { + let key = try DeepSeekCredentials.load() + let client = DeepSeekClient(apiKey: key, model: UserDefaults.standard.string(forKey: "deepSeekModel") ?? DeepSeekClient.defaultModel) + let input = GradingInput(task: task, question: question, essay: essay, rubric: try RubricLoader.load(task)) + let duration = elapsed + let capturedImage = questionImage, capturedSources = sourceImages, capturedMode = inputMode + isGrading = true; result = nil; previews = [:]; status = "正在评阅" + grading = Task { + defer { isGrading = false; grading = nil } + do { + let report = try await GradingCoordinator(service: client, synthesizer: quick ? nil : client).grade(input, isDemo: false, judges: quick ? [.b] : Judge.allCases) { event in + await MainActor.run { + switch event { + case .preview(let judge, let text): self.previews[judge] = text + case .completed(let reviewer): self.previews[reviewer.judge] = reviewer.response.summary + case .summarizing: self.status = "正在汇总评阅" + default: break + } + } + } + try Task.checkCancellation() + let session = try EssaySession(task: input.task, question: input.question, essay: input.essay, duration: duration, inputMode: capturedMode, report: report, questionImage: capturedImage, sourceImages: capturedSources) + context.insert(session) + do { try context.save() } catch { context.delete(session); throw error } + result = session; status = "评阅完成" + _ = try? ReviewCardSync.sync(context) + } catch is CancellationError { status = "评阅已停止,草稿已保留" } + catch { self.error = error.localizedDescription; status = "评阅未完成,草稿已保留" } + } + } catch { self.error = error.localizedDescription } + } +} diff --git a/platforms/ipad/WriteBenchPad/PadWorkspace.swift b/platforms/ipad/WriteBenchPad/PadWorkspace.swift new file mode 100644 index 0000000..8d30ef8 --- /dev/null +++ b/platforms/ipad/WriteBenchPad/PadWorkspace.swift @@ -0,0 +1,268 @@ +import SwiftUI +import SwiftData +import UniformTypeIdentifiers +import PDFKit +import PencilKit + +private enum PadPage: String, CaseIterable, Identifiable { + case write = "答题", bank = "题库", history = "历史记录", practice = "错题复习", statistics = "统计", settings = "设置" + var id: String { rawValue } + var symbol: String { switch self { case .write: "square.and.pencil"; case .bank: "books.vertical"; case .history: "clock"; case .practice: "rectangle.on.rectangle"; case .statistics: "chart.bar"; case .settings: "gearshape" } } +} +struct PadWorkspace: View { + @Bindable var store: PadStore + @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase + @State private var page: PadPage? = .write + var body: some View { + NavigationSplitView { + List(PadPage.allCases, selection: $page) { item in Label(item.rawValue, systemImage: item.symbol).tag(item) } + .navigationTitle("WriteBench") + .safeAreaInset(edge: .bottom) { Text("iPad · 本地保存").font(.caption).foregroundStyle(.secondary).padding() } + } detail: { + NavigationStack { + Group { + switch page ?? .write { + case .write: PadWriting(store: store) + case .bank: PadBank(store: store) { page = .write } + case .history: PadHistory() + case .practice: PadPractice() + case .statistics: PadStatistics() + case .settings: PadSettings(store: store) + } + }.background(Color(uiColor: .systemGroupedBackground)) + } + } + .task { store.attach(context); do { try DeepSeekCredentials.restore() } catch { store.error = error.localizedDescription } } + .onChange(of: page) { _, _ in store.pause() } + .onChange(of: scenePhase) { _, phase in + if phase != .active { store.pause(); store.cancel() } + } + .alert("请检查", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) { Button("知道了") { store.error = nil } } message: { Text(store.error ?? "") } + .sheet(item: $store.result) { PadReport(session: $0) } + } +} +struct PadWriting: View { + @Environment(\.modelContext) private var context + @Bindable var store: PadStore + @State private var handwriting = false + @State private var erasing = false + @State private var confirmClear = false + @State private var canvasID = UUID() + @State private var command: PadEditor.EditorCommand? + @State private var importImages = false + @State private var importText = false + @State private var scanning = false + @State private var ocr: OCRImport? + @State private var submitDialog = false + @State private var ocrDestination = OCRPurpose.question + private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @State private var now = Date() + var body: some View { + GeometryReader { geometry in + VStack(spacing: 16) { + controls + if store.isGrading { + HStack { ProgressView(); Text(store.status); Spacer(); Button("停止评阅") { store.cancel() } }.padding().background(WB.tint, in: RoundedRectangle(cornerRadius: 12)) + } + if geometry.size.width >= 850 { + HStack(alignment: .top, spacing: 20) { questionPanel.frame(width: geometry.size.width * 0.35); answerPanel } + } else { + ScrollViewReader { proxy in + ScrollView { VStack(spacing: 20) { questionPanel.frame(minHeight: 270); answerPanel.frame(height: max(430, geometry.size.height * 0.65)).id("answer-card") } } + .onChange(of: handwriting) { _, active in + if active { withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("answer-card", anchor: .top) } } + } + } + } + }.padding(20) + } + .navigationTitle(store.task.fullTitle).navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } label: { Image(systemName: "keyboard.chevron.compact.down") }.accessibilityLabel("收起键盘") }; ToolbarItem(placement: .topBarTrailing) { Button("交卷") { store.pause(); if handwriting && store.pencilDrawing != nil { store.error = "请先识别并校对手写作答,再交卷。" } else { submitDialog = true } }.disabled(store.isGrading).accessibilityIdentifier("submit") } } + .confirmationDialog("选择评阅方式", isPresented: $submitDialog, titleVisibility: .visible) { + Button("快速评阅 · 1 位评审") { store.submit(quick: true) } + Button("完整评阅 · 3 位评审与汇总") { store.submit(quick: false) } + } message: { Text("评阅会发送题目和作答至 DeepSeek,并使用你的 API 余额。交卷后计时暂停。") } + .task { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ui-testing"), ProcessInfo.processInfo.arguments.contains("--ocr-review-testing") { + ocr = OCRImport(pages: [OCRPage(name: "OCR 校对测试", imageData: Data(), text: "Dear Alex,\nPlease attend the reading programme.", warning: nil)], purpose: .question) + } + #endif + } + .onReceive(clock) { now = $0 } + .task(id: store.essay) { do { try await Task.sleep(for: .milliseconds(400)); _ = store.save() } catch {} } + .task(id: store.pencilDrawing) { do { try await Task.sleep(for: .milliseconds(400)); _ = store.save() } catch {} } + .task(id: store.richText) { do { try await Task.sleep(for: .milliseconds(400)); _ = store.save() } catch {} } + .task(id: store.question) { do { try await Task.sleep(for: .milliseconds(400)); _ = store.save() } catch {} } + .fileImporter(isPresented: $importImages, allowedContentTypes: [.image], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): + scanning = true + Task { + defer { scanning = false } + do { ocr = OCRImport(pages: try await VisionOCRService().recognize(urls: urls), purpose: ocrDestination) } + catch { store.error = error.localizedDescription } + } + case .failure(let error): store.error = error.localizedDescription + } + } + .fileImporter(isPresented: $importText, allowedContentTypes: [.plainText, .pdf]) { result in + do { + let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } } + guard (try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0) <= 10 * 1024 * 1024 else { throw CETBankError.invalid("文件超过 10 MB") } + let text: String + if url.pathExtension.lowercased() == "pdf" { + guard let pdf = PDFDocument(url: url), pdf.pageCount <= 50 else { throw CETBankError.invalid("无法读取 PDF,或页数超过 50 页") } + text = (0..()).contains { $0.subtype == store.task.rawValue && QuestionBank.key($0.prompt) == prompt } + if !exists { + let item = SavedQuestion(title: store.label == "原创练习" ? "自定义题目" : store.label, task: store.task, prompt: store.question, image: store.questionImage) + context.insert(item) + do { try context.save() } catch { context.delete(item); throw error } + } + } catch { store.error = error.localizedDescription } + }.disabled(store.isGrading || store.question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("导入文本 / PDF") { importText = true }.disabled(store.isGrading) + Text(store.task.wordGuidance).font(.caption).foregroundStyle(.secondary) + }.padding(16).background(.background, in: RoundedRectangle(cornerRadius: 16)) + } + private var answerPanel: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("答题卡").font(.headline) + Spacer() + Text(store.task.targetLanguage == "Simplified Chinese" ? "\(store.essay.count) 字符" : "\(WordCounter.count(store.essay)) words").foregroundStyle(.secondary).accessibilityIdentifier("word-count") + } + Picker("作答方式", selection: $handwriting) { Text("键盘输入").tag(false); Text("手写纸").tag(true) }.pickerStyle(.segmented).disabled(store.isGrading) + if handwriting { + HStack { + Toggle("橡皮擦", isOn: $erasing).toggleStyle(.button) + Button("识别并校对手写") { recognizeDrawing() }.disabled(scanning || store.isGrading || store.pencilDrawing == nil) + Spacer() + Button("清空", role: .destructive) { confirmClear = true }.disabled(store.isGrading) + }.buttonStyle(.bordered) + PadHandwriting(data: $store.pencilDrawing, erasing: erasing, editable: !store.isGrading).id(canvasID) + Text("手写后先识别并校对,再交卷。笔迹会随草稿保存。").font(.caption).foregroundStyle(.secondary) + } else { + HStack(spacing: 16) { + Button("四个空格") { command = .spaces(UUID()) } + ForEach([NSTextAlignment.left, .center, .right], id: \.rawValue) { alignment in + Button { command = .alignment(alignment, UUID()) } label: { Image(systemName: alignment == .left ? "text.alignleft" : alignment == .center ? "text.aligncenter" : "text.alignright") } + .accessibilityLabel(alignment == .left ? "左对齐" : alignment == .center ? "居中" : "右对齐") + } + }.disabled(store.isGrading).buttonStyle(.bordered) + PadEditor(text: $store.essay, richText: $store.richText, identifier: "answer-editor", editable: !store.isGrading, command: command) + } + HStack { + Image(systemName: "checkmark.circle").foregroundStyle(WB.green) + Text(store.savedAt == nil ? "自动保存草稿" : "草稿已保存").font(.caption).foregroundStyle(.secondary) + Spacer() + Text("Tab 插入四个空格").font(.caption).foregroundStyle(.secondary) + } + }.padding(16).background(.background, in: RoundedRectangle(cornerRadius: 16)) + .alert("清空手写笔迹?", isPresented: $confirmClear) { Button("清空笔迹", role: .destructive) { store.pencilDrawing = nil; canvasID = UUID(); _ = store.save() }; Button("取消", role: .cancel) {} } + } + private func recognizeDrawing() { + guard let data = store.pencilDrawing, let drawing = try? PKDrawing(data: data), !drawing.bounds.isEmpty else { store.error = "请先在手写纸上作答。"; return } + let rect = drawing.bounds.insetBy(dx: -24, dy: -24) + guard rect.width <= 8000, rect.height <= 8000 else { store.error = "手写范围过大,请分段识别。"; return } + let image = drawing.image(from: rect, scale: min(2, 3200 / max(rect.width, rect.height))) + guard let cg = image.cgImage else { return } + let size = CGSize(width: cg.width, height: cg.height) + let format = UIGraphicsImageRendererFormat(); format.scale = 1; format.opaque = true + let flattened = UIGraphicsImageRenderer(size: size, format: format).image { renderer in + UIColor.white.setFill(); renderer.fill(CGRect(origin: .zero, size: size)); image.draw(in: CGRect(origin: .zero, size: size)) + } + guard let png = flattened.pngData() else { return } + scanning = true + Task { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png") + defer { scanning = false; try? FileManager.default.removeItem(at: file) } + do { + try png.write(to: file) + ocr = OCRImport(pages: try await VisionOCRService().recognize(urls: [file]), purpose: .essay) + handwriting = false + } catch { store.error = error.localizedDescription } + } + } +} +struct PadOCRReview: View { + let imported: OCRImport + let confirm: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var text = "" + @State private var verified = false + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + Text("对照原图校对,确认后填入\(imported.purpose.rawValue)。").foregroundStyle(.secondary) + ScrollView(.horizontal) { + HStack { + ForEach(imported.pages) { page in + VStack { + if let image = UIImage(data: page.imageData) { Image(uiImage: image).resizable().scaledToFit().frame(width: 260, height: 200) } + Text(page.name).font(.caption) + if let warning = page.warning { Text(warning).font(.caption).foregroundStyle(.orange).frame(width: 260) } + } + } + } + } + TextEditor(text: $text).accessibilityIdentifier("ocr-review-editor") + Toggle("我已对照原图核对所有页面", isOn: $verified) + }.padding().navigationTitle("校对识别结果") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("确认填入") { confirm(text); dismiss() }.disabled(!verified || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + } + }.onAppear { text = imported.pages.map(\.text).joined(separator: "\n\n") } + } +} diff --git a/platforms/ipad/WriteBenchPad/WriteBenchPadApp.swift b/platforms/ipad/WriteBenchPad/WriteBenchPadApp.swift new file mode 100644 index 0000000..1dcfdc1 --- /dev/null +++ b/platforms/ipad/WriteBenchPad/WriteBenchPadApp.swift @@ -0,0 +1,21 @@ +import SwiftUI +import SwiftData + +@main struct WriteBenchPadApp: App { + @State private var store = PadStore() + private let container: ModelContainer? + private let failure: String? + init() { + do { + let test = ProcessInfo.processInfo.arguments.contains("--ui-testing") || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, ReviewCard.self, configurations: ModelConfiguration(isStoredInMemoryOnly: test)) + failure = nil + } catch { container = nil; failure = error.localizedDescription } + } + var body: some Scene { + WindowGroup { + if let container { PadWorkspace(store: store).modelContainer(container).tint(WB.blue).preferredColorScheme(.light) } + else { ContentUnavailableView("无法打开本地资料", systemImage: "externaldrive.badge.exclamationmark", description: Text(failure ?? "请重新启动应用。已有资料未被重置。")) } + } + } +} diff --git a/platforms/ipad/WriteBenchPadTests/PadTests.swift b/platforms/ipad/WriteBenchPadTests/PadTests.swift new file mode 100644 index 0000000..c721b24 --- /dev/null +++ b/platforms/ipad/WriteBenchPadTests/PadTests.swift @@ -0,0 +1,68 @@ +import XCTest +import SwiftData +import UIKit +@testable import WriteBenchPad + +@MainActor final class PadTests: XCTestCase { + private func container() throws -> ModelContainer { + try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, ReviewCard.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + } + func testBundledCETBankAndRubrics() throws { + let entries = try CETQuestionBank.entries(storage: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) + XCTAssertEqual(entries.filter { $0.task == "cet6Writing" }.count, 33) + XCTAssertEqual(entries.filter { $0.task == "cet6Translation" }.count, 33) + XCTAssertTrue(entries.allSatisfy(\.hasPrompt)) + for task in WritingTask.allCases { XCTAssertFalse(try RubricLoader.load(task).isEmpty) } + } + func testDraftWhitespaceAndParagraphFormattingRestore() throws { + let container = try container(); let store = PadStore(); store.attach(container.mainContext) + store.essay = " Notice\n\nBody text\nUniversity Library" + let styled = NSMutableAttributedString(string: store.essay) + let style = NSMutableParagraphStyle(); style.alignment = .right + styled.addAttribute(.paragraphStyle, value: style, range: (store.essay as NSString).range(of: "University Library")) + store.richText = try styled.data(from: NSRange(location: 0, length: styled.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]) + XCTAssertTrue(store.save()) + let restored = PadStore(); restored.attach(container.mainContext) + XCTAssertEqual(restored.essay, store.essay); XCTAssertEqual(restored.richText, store.richText) + XCTAssertFalse(restored.running) + } + func testTimerPausesForInvalidSubmission() throws { + let container = try container(); let store = PadStore(); store.attach(container.mainContext) + store.start(); XCTAssertTrue(store.running) + store.submit(quick: true) + XCTAssertFalse(store.running); XCTAssertNotNil(store.error); XCTAssertFalse(store.isGrading) + let snapshot = store.elapsed; XCTAssertEqual(store.time(at: Date().addingTimeInterval(60)), snapshot) + } + func testTaskSwitchKeepsIndependentDrafts() throws { + let container = try container(); let store = PadStore(); store.attach(container.mainContext) + store.essay = "Writing answer"; store.start(); store.select(.cet6Translation) + XCTAssertFalse(store.running); XCTAssertEqual(store.essay, "") + store.essay = "Translation answer"; store.select(.cet6Writing) + XCTAssertEqual(store.essay, "Writing answer") + store.select(.cet6Translation); XCTAssertEqual(store.essay, "Translation answer") + } + func testPortableBackupPreservesFormattingWithoutCredentials() throws { + let source = try container(); let store = PadStore(); store.attach(source.mainContext) + store.essay = " Portable draft"; store.richText = Data("format fixture".utf8); store.pencilDrawing = Data("pencil fixture".utf8); XCTAssertTrue(store.save()) + let data = try BackupService.export(from: source.mainContext) + let destination = try container(); let summary = try BackupService.restore(data, into: destination.mainContext) + XCTAssertEqual(summary.added, 1) + let drafts = try destination.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(drafts.first?.essay, store.essay); XCTAssertEqual(drafts.first?.richText, store.richText); XCTAssertEqual(drafts.first?.pencilDrawing, store.pencilDrawing) + let text = String(decoding: data, as: UTF8.self); XCTAssertFalse(text.contains("apiKey")); XCTAssertFalse(text.contains("api-key")) + } + func testRealVisionOCR() async throws { + let fixture = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "ocr-sample-1", withExtension: "png")) + let pages = try await VisionOCRService().recognize(urls: [fixture]) + XCTAssertEqual(pages.count, 1); XCTAssertTrue(pages[0].text.contains("Alex")); XCTAssertFalse(pages[0].imageData.isEmpty) + } + func testDeviceFamilyExcludesIPhone() { + XCTAssertEqual(Bundle.main.object(forInfoDictionaryKey: "UIDeviceFamily") as? [Int], [2]) + } + func testSpacedRepetitionAdvancesCard() { + let card = ReviewCard(key: "fixture", kind: .mistake, sessionID: nil, subtype: WritingTask.cet6Writing.rawValue, prompt: "a information", answer: "information", explanation: "不可数", context: "") + let now = Date(); SpacedRepetition.apply(.good, to: card, now: now) + XCTAssertEqual(card.box, 1); XCTAssertGreaterThan(card.dueDate, now) + SpacedRepetition.apply(.again, to: card, now: now); XCTAssertEqual(card.box, 0) + } +} diff --git a/platforms/ipad/WriteBenchPadUITests/PadUITests.swift b/platforms/ipad/WriteBenchPadUITests/PadUITests.swift new file mode 100644 index 0000000..b703514 --- /dev/null +++ b/platforms/ipad/WriteBenchPadUITests/PadUITests.swift @@ -0,0 +1,80 @@ +import XCTest + +final class PadUITests: XCTestCase { + private var app: XCUIApplication! + override func setUpWithError() throws { + continueAfterFailure = false + XCUIDevice.shared.orientation = .landscapeLeft + app = XCUIApplication(); app.launchArguments = ["--ui-testing"]; app.launch() + } + private func openPage(_ title: String) { + if !app.staticTexts[title].firstMatch.isHittable { + let sidebar = app.buttons["Show Sidebar"] + if sidebar.exists { sidebar.tap() } + } + app.staticTexts[title].firstMatch.tap() + } + func testWhitespaceKeyboardAndSubmissionPause() { + let answer = app.textViews["answer-editor"] + XCTAssertTrue(answer.waitForExistence(timeout: 15)); answer.tap() + answer.typeText("Notice\n\tBody text\nUniversity Library") + XCTAssertTrue((answer.value as? String ?? "").contains("\n Body text")) + app.buttons["开始 / 继续"].tap() + XCTAssertTrue(app.buttons["暂停"].exists) + app.buttons["收起键盘"].tap() + app.buttons["submit"].tap(); app.buttons["快速评阅 · 1 位评审"].tap() + XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: 5)) + app.alerts.buttons["知道了"].tap() + XCTAssertTrue(app.buttons["开始 / 继续"].exists) + let first = app.staticTexts["elapsed"].label + sleep(2) + XCTAssertEqual(app.staticTexts["elapsed"].label, first) + let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); screenshot.name = "iPad-writing-landscape"; screenshot.lifetime = .keepAlways; add(screenshot) + } + func testLibrarySelectsFullPrompt() { + openPage("题库") + let row = app.descendants(matching: .any).matching(identifier: "bank-question").firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 5)); row.tap() + XCTAssertTrue(app.buttons["use-question"].waitForExistence(timeout: 5)) + app.buttons["use-question"].tap() + let question = app.textViews["question-editor"] + XCTAssertTrue(question.waitForExistence(timeout: 5)) + XCTAssertGreaterThan((question.value as? String ?? "").count, 100) + } + func testHandwritingCanvasAndClearConfirmation() { + app.segmentedControls.buttons["手写纸"].tap() + let canvas = app.descendants(matching: .any).matching(identifier: "handwriting-canvas").firstMatch + XCTAssertTrue(canvas.waitForExistence(timeout: 5)) + let first = canvas.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.25)) + let second = canvas.coordinate(withNormalizedOffset: CGVector(dx: 0.65, dy: 0.35)) + first.press(forDuration: 0.1, thenDragTo: second) + XCTAssertTrue(app.buttons["识别并校对手写"].isEnabled) + app.buttons["清空"].tap(); XCTAssertTrue(app.buttons["清空笔迹"].waitForExistence(timeout: 3)); app.buttons["清空笔迹"].tap() + XCTAssertFalse(app.buttons["识别并校对手写"].isEnabled) + } + func testOCRRequiresReviewBeforeFillingQuestion() { + app.terminate(); app.launchArguments = ["--ui-testing", "--ocr-review-testing"]; app.launch() + let confirm = app.buttons["确认填入"] + XCTAssertTrue(confirm.waitForExistence(timeout: 5)); XCTAssertFalse(confirm.isEnabled) + let toggle = app.switches["我已对照原图核对所有页面"] + toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() + XCTAssertEqual(toggle.value as? String, "1") + XCTAssertTrue(confirm.isEnabled); confirm.tap() + XCTAssertTrue((app.textViews["question-editor"].value as? String ?? "").contains("Dear Alex")) + } + func testBackgroundPausesTimerAndKeepsDraft() { + let editor = app.textViews["answer-editor"] + XCTAssertTrue(editor.waitForExistence(timeout: 5)); editor.tap(); editor.typeText("Draft survives background") + app.buttons["收起键盘"].tap(); app.buttons["开始 / 继续"].tap() + XCUIDevice.shared.press(.home); app.activate() + XCTAssertTrue(app.buttons["开始 / 继续"].waitForExistence(timeout: 5)) + XCTAssertTrue((editor.value as? String ?? "").contains("Draft survives background")) + } + func testPortraitNavigationAndSettings() { + XCUIDevice.shared.orientation = .portrait + openPage("设置") + XCTAssertTrue(app.secureTextFields["api-key"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["导出备份到文件"].exists) + let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); screenshot.name = "iPad-settings-portrait"; screenshot.lifetime = .keepAlways; add(screenshot) + } +} diff --git a/project.yml b/project.yml index 0d98dd5..f9b6b39 100644 --- a/project.yml +++ b/project.yml @@ -35,7 +35,91 @@ targets: sources: [WriteBenchTests] dependencies: - target: WriteBench + WriteBenchPad: + type: application + platform: iOS + deploymentTarget: "18.0" + sources: + - platforms/ipad/WriteBenchPad + - WriteBench/Models/Exam.swift + - WriteBench/Models/GradingModels.swift + - WriteBench/Models/TranslationLesson.swift + - WriteBench/Models/QuestionBank.swift + - WriteBench/Models/CETQuestionBank.swift + - WriteBench/Persistence + - WriteBench/Components/QuestionText.swift + - WriteBench/DesignSystem/Theme.swift + - WriteBench/Services/DeepSeek/DeepSeekClient.swift + - WriteBench/Services/DeepSeek/DeepSeekStreaming.swift + - WriteBench/Services/DeepSeek/KeychainService.swift + - WriteBench/Services/Grading/GradingService.swift + - WriteBench/Services/Grading/ReportSynthesis.swift + - WriteBench/Services/Grading/CorrectionMatcher.swift + - WriteBench/Services/Grading/UsageCost.swift + - WriteBench/Services/OCR/OCRService.swift + - WriteBench/Services/Backup/BackupService.swift + - path: WriteBench/Rubrics + buildPhase: resources + - path: Shared/QuestionBank/cet6-2022-2026.json + buildPhase: resources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.chen.WriteBench.ipad + CODE_SIGN_IDENTITY: "Apple Development" + TARGETED_DEVICE_FAMILY: "2" + SUPPORTS_MACCATALYST: NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + CODE_SIGN_ENTITLEMENTS: "" + ENABLE_HARDENED_RUNTIME: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + INFOPLIST_KEY_CFBundleDisplayName: WriteBench + info: + path: platforms/ipad/Info.plist + properties: + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + UILaunchScreen: {} + UIApplicationSupportsIndirectInputEvents: true + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UISceneConfigurations: {} + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + WriteBenchPadTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "18.0" + sources: + - platforms/ipad/WriteBenchPadTests + - path: Documentation/TestImages/ocr-sample-1.png + buildPhase: resources + dependencies: + - target: WriteBenchPad + settings: + base: + CODE_SIGN_ENTITLEMENTS: "" + TARGETED_DEVICE_FAMILY: "2" + WriteBenchPadUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "18.0" + sources: [platforms/ipad/WriteBenchPadUITests] + dependencies: + - target: WriteBenchPad + settings: + base: + CODE_SIGN_ENTITLEMENTS: "" + TARGETED_DEVICE_FAMILY: "2" schemes: + WriteBenchPad: + build: + targets: + WriteBenchPad: all + test: + targets: [WriteBenchPadTests, WriteBenchPadUITests] WriteBench: build: targets: