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 开发签名证书。
+
+## 界面预览
+
+
+
+
+
+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: