diff --git a/docs/README.md b/docs/README.md index 6a41a805..884e37bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,8 @@ ### Design +- [`design/IOS_NATIVE_MOTION_REFINEMENT.md`](design/IOS_NATIVE_MOTION_REFINEMENT.md):EasyUI 概念映射、原生动效的局部采用范围与本次验证证据;共享约定仍由 Kitchen Design Language 的 Motion 节负责。 + - [`design/UI_TASTE.md`](design/UI_TASTE.md):iOS 实质性视觉 refinement 的轻量判断层与 Taste Pass;不定义新 Design System。 ### Product diff --git a/docs/design/IOS_NATIVE_MOTION_REFINEMENT.md b/docs/design/IOS_NATIVE_MOTION_REFINEMENT.md new file mode 100644 index 00000000..4007661e --- /dev/null +++ b/docs/design/IOS_NATIVE_MOTION_REFINEMENT.md @@ -0,0 +1,88 @@ +# Native motion refinement — 2026-10-22 + +Scope: native iOS, based on `45b108d`. The owner's request authorizes implementation and local verification. No integration, hosted writes or dependency changes are included. + +## Audit and design mapping + +Reviewed EasyUI's [philosophy](https://easyui.site/docs/introduction), [Story Cards](https://easyui.site/components/story-card), [Morphing Button](https://easyui.site/components/morphing-button), [Skeleton](https://easyui.site/components/skeleton) and [Animated Tabs](https://easyui.site/components/animated-tabs). These pages describe concepts; no source was copied. The static web review does not establish the timing or accessibility quality of its interactive demos. + +| Reference | Existing native equivalent | Bounded choice | +|---|---|---| +| Story Cards: continuous discovery, consistent objects | D-048 view-aligned horizontal shelf, next-card peek, five stable identities, narrower regeneration control; Accessibility sizes stack vertically | Preserve geometry, snapping, actions and ordering. Make secondary recommendation press feedback use the existing Home opt-in, including Reduce Motion. Give its utility label the same explicit 44pt content target as HomeActionPair. Strengthen actual swipe/detail/back coverage. | +| Morphing Button: stable geometry through idle/loading/result | Link-import save already owns `isSaving`, `isSaved`, throwing persistence and an error alert | Reusable presentation-only label reserves every state's footprint and crossfades real state. Preserve native button style, disable rules, persistence, haptic and immediate success navigation. | +| Skeleton: sweep and reveal | Home retains existing recommendations while generating; empty loading has an honest activity label | Skip shimmer, blur, artificial progress and replacement placeholders. They would hide useful content or imply new information. | +| Animated Tabs: moving selection indicator | Native TabView, Picker and navigation own these transitions | Leave unchanged. No custom tab bar or parallel selection state. | + +D-043 semantic restraint and D-048/D-049 hierarchy remain in force. Do not restore the removed discovery gate, Home planning rows, entrance effects, looping cards, autoplay, parallax, scroll scaling or competing drag gestures. A separate read-only reviewer recommended the minimal Home correction over additional shelf effects; there was no rendered evidence justifying new scroll choreography. + +## Acceptance contract and implementation plan + +Goal: coherent native feedback, without changing the user's task or its duration. + +Impact: `KitchenMotion`, Home recommendation button opt-ins and link-import save presentation. Existing token values and non-adopting consumers remain compatible. Do not touch inventory undo, stores, schema, provider routing, navigation ownership, flags, signing or external environments. + +- [x] Add focused geometry/state regression coverage; the missing-label API failed the test build. A later controlled removal of state-size reservation also failed the geometry assertions, proving sensitivity to the actual regression. +- [x] Extend the existing motion vocabulary with Morph and Feedback aliases; keep Gentle=`standard`, Snappy=`quick`. Add a label that owns presentation only, with stable intrinsic size at every supported text size. +- [x] Adopt the label in `ImportRecipeView`, deriving its phase from existing state. No timers, minimum loading dwell, fake success, delayed navigation or new persistence owner. +- [x] Opt secondary Home recommendation controls into existing tactile/Reduce Motion handling; preserve card structure and stable identity. +- [x] Build with Xcode; run focused unit/UI tests serially on Codex's reserved simulator. Capture Light/Dark × normal/AXXXL, failure/retry/success, swipe/detail/back and Reduce Motion evidence. Distinguish component test fixtures from normal navigation and actual system settings. +- [x] Inspect exported screenshots, final diff, untouched boundaries and temporary-path inventory. Report any VoiceOver listening or motion-timing checks not actually obtained. + +Stop for an Active Decision conflict, unavailable assigned simulator, unapproved environment/credential/capability change, or repeated same-cause failures without new evidence. Local `keepAlways` attachments satisfy review evidence here; no CI run is dispatched because push/PR is not authorized. + +## Validation + +Local Xcode MCP validation used `KitchenManager` on the reserved Codex simulator `18FAEFC5-3087-48A4-8131-5B97F48D62C6`, iOS 27.2. A cooperating Codex flow lease covered the complete MCP flow; the shared scheme has no parallelizable testables. No CLI simulator test bypassed the required wrapper. Other agent workspaces/devices were not used. + +- Final build-for-testing: success, **0 errors**; the full log contains **3 compiler warning lines**, each also present unchanged in the pre-change build log. The earlier “0 diagnostics” report was incorrect. This is a warning-signature comparison, not a complete-build warning-count comparison. Build completed 2026-10-22 12:33 Toronto. Build log: `/var/folders/q6/xkjcyzf9655_2gcmqk471rrm0000gn/T/ActionArtifacts/default/BuildProject/BuildProject-Log-20261022-123309.txt`. +- **60 current unit tests** passed across `KitchenActionLabelTests` (2), `RecipePersistenceTests`, `HomeRecommendationStoreTests` and `HomePrimaryTaskTests`. +- **10 current UI methods** passed across `NativeMotionUITests` (5), three focused Home hierarchy/accessibility methods and two existing Clipboard import-save/navigation methods. These are distinct tests across focused runs, not a full-suite result or one 70-test invocation. +- The main Reduce Motion run was **67/67**, 0 failed/skipped/notRun; it includes a subsequently replaced component snapshot method. Restored-motion final UI run: **4/4**. Final label plus runtime gallery run: **3/3**. `TEST_RESULTS.json` records exact scopes, counts, failures and result paths. +- Actual simulator Reduce Motion preference was enabled. The fixture required both UIKit and SwiftUI to observe it, then exercised real save failure/retry/success. Home browsing, hierarchy and execution controls also ran while that system setting was enabled. The original preference `0` was restored before final normal-motion validation. +- Real save state comes from the existing operation. One isolated fixture injects a single failing SwiftData persistence write, then forwards retry to the actual in-memory persistence. Its existing `onSaved` callback retains success for inspection. Separate normal-navigation tests prove saving still returns immediately to the recipe library. No artificial delay, success timer or production lifecycle change was added. +- A controlled negative check removed the hidden state-size reservation: the geometry test failed on width and height, then passed after restoration. A separate pixel-difference test catches removal of the visible label overlay. +- **51 canonical PNGs** are linked under `/tmp/km-native-motion-20261022/review/`. The runtime phase gallery is explicitly labelled as component preview, not a save operation. Representative Home and save captures were visually inspected across Light/Dark and normal/AXXXL. Filename audits confirm requested save, Home execution and all-phase gallery coverage. Earlier ImageRenderer/hosting snapshot exports were excluded from the review set because they could not faithfully demonstrate the spinner; runtime gallery screenshots show the native spinner and complete large text. +- Node source-contract guard: `node --test test/ios-native-core-alignment.test.mjs`, **13/13**. Final `git diff --check`: clean. + +Taste Pass: Home remains task → attention → optional discovery. Native snapping, peek, vertical Accessibility layout and subdued execution actions remain intact. The useful changes are consistent feedback, an explicit 44pt secondary-detail target (including a tested top-edge tap), and a stable, readable save state. No extra visual surface or navigation step was justified. + +Independent source review found no production correctness issue. Its visible-content coverage finding was addressed by pixel checks and runtime gallery evidence; execution-mode controls gained direct coverage. **Remaining limits:** the AXXXL regeneration control was checked for reachability and 44pt geometry, but its press animation was not separately isolated, to avoid an uncontrolled provider request. No frame-by-frame timing comparison, VoiceOver speech/focus-order listening, physical-device test, full native suite, Release build or fresh cloud CI is claimed. Accessibility evidence is the actual button role/current labels, disabled-state semantics, adaptive layout and target/navigation assertions. External recommendation generation completion was not exercised in this pass. + +## Delivery and retained evidence + +Implementation was prepared on `codex/km-native-motion`, based on fetched `origin/main` at `45b108d`. The owner subsequently authorized final acceptance and one scoped local commit of these 11 files. The original checkout's inherited governance and ScreenshotTour changes remain untouched. No push, merge, PR, deployment, schema, lockfile, secret, flag or inventory-undo change is part of this delivery. + +Changed files: + +- `ios-native/Kitchen Manager/KitchenManager/KitchenTheme.swift` +- `ios-native/Kitchen Manager/KitchenManager/KitchenActionLabel.swift` (new) +- `ios-native/Kitchen Manager/KitchenManager/HomeView.swift` +- `ios-native/Kitchen Manager/KitchenManager/AddRecipeViews.swift` +- `ios-native/Kitchen Manager/KitchenManager/ContentView.swift` (DEBUG routing only) +- `ios-native/Kitchen Manager/KitchenManager/KitchenMotionImportFixture.swift` (new, DEBUG only) +- `ios-native/Kitchen Manager/KitchenManagerTests/KitchenActionLabelTests.swift` (new) +- `ios-native/Kitchen Manager/KitchenManagerUITests/NativeMotionUITests.swift` (new) +- `docs/design/KITCHEN_DESIGN_LANGUAGE.md` +- `docs/design/IOS_NATIVE_MOTION_REFINEMENT.md` (this file) +- `docs/README.md` + +Source evidence manifest: `/tmp/km-native-motion-20261022/SOURCE_MANIFEST.json`, digest `100d6a1d29fde6a4192e4b7ee0da20e1ad750c9dfa8144e8d0774c03ae7122df`. It covers the eight affected Swift source/test files. Final acceptance rechecked every hash; the local commit identity is recorded in the acceptance result and delivery response. + +Temporary-output cleanup removed **895.5 MiB** of this task's reproducible build/index/dependency/symbol output after closing only its Xcode workspace and confirming no active build. The owned simulator lease was released; both reserved simulators remain intact. Required screenshots, logs, negative-proof and xcresults remain: exact paths, allocated sizes, Codex/Kitchen Manager ownership, purpose and deletion conditions are in `/tmp/km-native-motion-20261022/INVENTORY.md` and `RETAINED_PATHS.json`. The review worktree is a 20 MiB deliverable and remains attached. Canonical main/vault anchors remain unchanged; the local branch acceptance is recorded below. + + +## Final acceptance review — 2026-10-22 + +**Verdict: PASS for this bounded native motion change; ready for integration through the normal review/CI gate.** No confirmed blocking regression was found. All 11 approved changed files were inspected. No Swift source or test change was needed during final acceptance; the only acceptance correction is the build-diagnostic wording above and this review record. + +- **Home:** the diff changes only the existing secondary/utility/Accessibility regeneration style opt-ins and the utility label's explicit 44pt frame/content shape. Shelf geometry, view alignment, stable recommendation identities, vertical Accessibility composition, add/detail callbacks, disable rules, hierarchy and navigation destinations remain unchanged. Existing actual swipe/detail/back, target-edge and normal/AXXXL evidence remains applicable. +- **Save states and edges:** Processing takes precedence while the operation is active, Success only follows the existing successful persistence call, and Failure comes from the existing error. Dismissing the native alert clears that error and restores Idle for retry. The entire `saveRecipe()` implementation and the `.onDisappear` import-cancellation handler are byte-for-byte unchanged from the baseline. The label owns no task, timer, callback or stored result. It adds no delay to rapid saves, no new cancellation behavior and no post-navigation work. The pre-existing unstructured local-save task/cancellation semantics were not redesigned or newly runtime-tested in this presentation change. +- **Accessibility and announcements:** both reviewers independently checked the source. Hidden size reservations and the animated overlay are excluded from accessibility; one labelled element remains inside the existing native Button. Structural Home order remains add → detail, stacking vertically at Accessibility sizes; no sort priorities or focus overrides were added. Current label/value and disabled state are available to accessibility. A label update or success haptic does **not** prove an automatic speech announcement. The existing native error alert remains the error surface. **VoiceOver speech delivery, actual focus traversal/order, focus after alert dismissal or success navigation, and rapid-save success announcements remain UNVERIFIED.** This acceptance does not claim a duplicate-free traversal based only on screenshots or source. +- **Motion and compatibility:** existing quick/standard/emphasis presets are unchanged, Morph and Feedback are aliases, and the changed consumers choose token versus nil from the real Reduce Motion environment. No new framework, dependency, global style behavior, navigation replacement or deployment-target requirement was introduced. +- **Scope/security:** the six tracked modifications plus five new files match the approved 11-file inventory. Package/project/signing/configuration, persistent models/stores, sync, provider routing, inventory undo and transactional operations are outside the diff. New fixture routing and injected persistence are DEBUG-only; normal/Release composition is unchanged. +- **Evidence reuse:** the eight Swift hashes still match `SOURCE_MANIFEST.json`. Final acceptance parsed the original xcresults independently with `xcresulttool`: Reduce Motion **67/67**, restored-motion UI **4/4**, final label/gallery **3/3**, each with zero failures/skips. This confirms recorded runs rather than rerunning those suites. The current distinct-test accounting remains 60 unit tests plus 10 UI methods across focused runs. Representative retained Light/Dark and normal/AXXXL screenshots were re-inspected. No additional simulator build/test, device session, provider request or new screenshot bundle was necessary for a specific unresolved defect. +- **Non-blocking limits:** the AXXXL regeneration press animation is not separately isolated; its target/reachability and shared style implementation are checked. External generation completion, timing capture, Release/full-suite/cloud CI and physical-device validation remain outside this local acceptance. These limits do not authorize a broader pass. + +Evidence rechecks and protected-original-checkout manifests are retained under `/tmp/km-native-motion-20261022/acceptance/`. Durable original xcresults also remain under `/Users/lianghongjing/Library/Developer/Xcode/DerivedData/Kitchen_Manager-goeroulqnvyqgyedjwxfbhywdsjv/Logs/Test/`; the original implementation logs and review exports are preserved. Nothing was deleted during final acceptance, and no screenshot/result bundle is included in the commit. + +Safe to remove after review acceptance and confirmation that durable originals retain the required attachments: redundant exported intermediates `matrix-first/` and `normal/`, earlier renderer-only phase exports, and duplicate temporary MCP result copies. Keep `review/`, the source/test manifests, acceptance records and durable original xcresults while integration still needs this evidence. Final acceptance creates only small JSON/Markdown records; it creates no new DerivedData, package checkout, dependency tree or simulator state. diff --git a/docs/design/KITCHEN_DESIGN_LANGUAGE.md b/docs/design/KITCHEN_DESIGN_LANGUAGE.md index 6bfb6034..42a5ef27 100644 --- a/docs/design/KITCHEN_DESIGN_LANGUAGE.md +++ b/docs/design/KITCHEN_DESIGN_LANGUAGE.md @@ -33,11 +33,13 @@ The prior v1 text below is historical wherever it conflicts with this revision. Motion is restrained and semantic, never decorative. Prefer SwiftUI system spring presets over custom easing curves. `KitchenMotion` lives beside `KitchenTheme`: -| Token | Preset | Purpose | +| Intent / token | Preset | Purpose | |---|---|---| -| quick | `snappy(duration: 0.2)` | Direct selections and small controls. | -| standard | `smooth(duration: 0.28)` | Content or local layout state transitions. | -| emphasis | `smooth(duration: 0.32, extraBounce: 0.05)` | Rare emphasis with a very light spring; unused in this spike. | +| Gentle / `standard` | `smooth(duration: 0.28)` | Content expansion and local layout transitions. | +| Snappy / `quick` | `snappy(duration: 0.2)` | Direct selections and small controls. | +| Morph / `morph` | Alias of `standard` | A continuous object's geometry change, only where needed. | +| Feedback / `feedback` | Alias of `quick` | Short, local loading/result label crossfades. | +| `emphasis` (reserved) | `smooth(duration: 0.32, extraBounce: 0.05)` | Rare emphasis; no new adoption in this refinement. | Preserve system-native NavigationStack, sheet, tab, picker, toggle and other platform transitions. Avoid broad implicit animations over large hierarchies; @@ -47,15 +49,43 @@ when enabled. Preserve existing accessibility labels, traits and feedback. State changes and actions remain immediate and interruptible; never wait for an animation to finish before allowing interaction or completing a task. -This spike animates only the Accessibility-size Inventory Menu's current-filter -text with quick (the segmented Picker stays native), and Home's existing -remaining-dishes disclosure with standard. AI result appearance gets no extra -animation: the inspected recommendation flow already uses a native pager, and -initial Home content loading is not an entrance-animation opportunity. +The original D-043 adoption remains the Accessibility-size Inventory Menu's +current-filter text with quick (the segmented Picker stays native), and Home's +remaining-dishes disclosure with standard. D-048 subsequently replaced the +recommendation pager with the native view-aligned inline shelf. Initial loading +and result arrival remain free of entrance choreography. + +The 2026-10-22 bounded refinement opts Home's secondary recommendation controls +into existing `homeTactile` feedback: the same quick press response as the lead +action, with scale and custom animation suppressed under Reduce Motion. It does +not change default shared-button behavior elsewhere, shelf geometry, snapping, +card identity, vertical Accessibility layout or regeneration ownership. + +`KitchenActionLabel` is a presentation-only label inside an existing Button. +It reserves all phase labels' intrinsic footprint, adapts to Dynamic Type, +and uses `contentTransition(.opacity)` with Feedback for actual +Idle → Processing → Success / Failure changes. The caller owns real state, +disable rules, persistence, errors and navigation. Link-import save is the +first consumer. Its native error alert and existing success haptic remain; +do not add duplicate sensory feedback. Never add fake progress, timers, +minimum loading dwell or a success hold. Fast local saves may navigate before +the success label is perceptible; preserve that immediacy. + +Use `matchedGeometryEffect` with Morph only for the same content object within +a stable namespace, when preserving its spatial identity helps the task. +No current shelf or button needs a new geometry animation. Preserve native +navigation/tab transitions. With Reduce Motion, disable custom transforms and +animated geometry; keep static labels, native progress, alerts and valid actions. +Hidden label-size reservations must be excluded from accessibility so VoiceOver +encounters one real control with its current text. Never communicate a result +through animation, color, sound or haptics alone. Dark appearance uses existing +semantic materials; no new palette or fixed-size text is introduced. No whole-screen animation, appearance-only animation, staggered entrance, exaggerated bounce, decorative scale or Dribbble-style motion. Existing motion -outside these two interactions is not globally migrated by this spike. +outside these explicitly adopted interactions is not globally migrated. + +Audit, acceptance scope and current verification: [Native motion refinement](IOS_NATIVE_MOTION_REFINEMENT.md). ## Prior v1 specification — historical where superseded above diff --git a/ios-native/Kitchen Manager/KitchenManager/AddRecipeViews.swift b/ios-native/Kitchen Manager/KitchenManager/AddRecipeViews.swift index 5f2d77ba..621bcbe3 100644 --- a/ios-native/Kitchen Manager/KitchenManager/AddRecipeViews.swift +++ b/ios-native/Kitchen Manager/KitchenManager/AddRecipeViews.swift @@ -639,15 +639,12 @@ struct ImportRecipeView: View { } label: { HStack { Spacer() - if isSaving { - ProgressView() - .tint(AppTheme.onManagementAction) - } else { - Label( - isSaved ? "已保存" : "保存到菜谱库", - systemImage: isSaved ? "checkmark.circle.fill" : "square.and.arrow.down" - ) - } + KitchenActionLabel( + phase: savePhase, + idleTitle: "保存到菜谱库", + idleSymbol: "square.and.arrow.down" + ) + .tint(AppTheme.onManagementAction) Spacer() } } @@ -718,6 +715,13 @@ struct ImportRecipeView: View { ) } + private var savePhase: KitchenActionPhase { + if isSaving { return .processing } + if isSaved { return .success } + if saveErrorMessage != nil { return .failure } + return .idle + } + private var canSave: Bool { guard let editableDraft else { return false } return editableDraft.isSaveEligible diff --git a/ios-native/Kitchen Manager/KitchenManager/ContentView.swift b/ios-native/Kitchen Manager/KitchenManager/ContentView.swift index b5dd8a5b..bd8542d2 100644 --- a/ios-native/Kitchen Manager/KitchenManager/ContentView.swift +++ b/ios-native/Kitchen Manager/KitchenManager/ContentView.swift @@ -41,6 +41,7 @@ struct KitchenManagerApp: App { || ProcessInfo.processInfo.arguments.contains("UITEST_AI_CONVERSATION_FAKE") || AIConversationAcceptanceFixture.scenario != nil let isolatedFixture = RecipeRegressionFixture.isEnabled || ShoppingRegressionFixture.isEnabled + || KitchenMotionImportFixture.isEnabled || PlannerRegressionFixture.isEnabled || PlanPersistenceFailureFixture.failingWriteIndex != nil || isConversationUITest var persistence = isolatedFixture ? KitchenPersistenceFactory.isolatedInMemory() : KitchenPersistenceFactory.application() @@ -534,7 +535,9 @@ struct KitchenManagerApp: App { WindowGroup { Group { #if DEBUG - if PlannerRegressionFixture.isEnabled { + if KitchenMotionImportFixture.isEnabled { + KitchenMotionImportFixture() + } else if PlannerRegressionFixture.isEnabled { PlannerRegressionHost() } else if AIRecipeRegenerationHost.isEnabled { AIRecipeRegenerationHost() diff --git a/ios-native/Kitchen Manager/KitchenManager/HomeView.swift b/ios-native/Kitchen Manager/KitchenManager/HomeView.swift index 4f42d4bc..6ae2a764 100644 --- a/ios-native/Kitchen Manager/KitchenManager/HomeView.swift +++ b/ios-native/Kitchen Manager/KitchenManager/HomeView.swift @@ -1552,7 +1552,7 @@ private struct HomeRecommendationSection: View { Label(added ? "已加入" : "加入今天", systemImage: added ? "checkmark" : "plus") .frame(maxWidth: .infinity) } - .buttonStyle(KitchenButtonStyle(role: .secondary)) + .buttonStyle(KitchenButtonStyle(role: .secondary, homeTactile: true)) .disabled(added) .accessibilityIdentifier("home.recommendation.\(recommendation.id).addToday") @@ -1560,9 +1560,10 @@ private struct HomeRecommendationSection: View { onViewRecipe(recipe) } label: { Text("查看菜谱") - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: KitchenTheme.controlHeight) + .contentShape(Rectangle()) } - .buttonStyle(KitchenButtonStyle(role: .utility, tint: KitchenTheme.cookingGreen)) + .buttonStyle(KitchenButtonStyle(role: .utility, tint: KitchenTheme.cookingGreen, homeTactile: true)) .accessibilityIdentifier("home.recommendation.\(recommendation.id).viewRecipe") } @@ -1597,7 +1598,7 @@ private struct HomeRecommendationSection: View { Label(isGenerating ? "正在换一批…" : "换一批", systemImage: KitchenAISymbol.emblem) .frame(maxWidth: .infinity, minHeight: AppTheme.minimumHitTarget) } - .buttonStyle(KitchenButtonStyle(role: .secondary)) + .buttonStyle(KitchenButtonStyle(role: .secondary, homeTactile: true)) .disabled(isGenerating) .accessibilityIdentifier("home.recommendation.regenerate") } diff --git a/ios-native/Kitchen Manager/KitchenManager/KitchenActionLabel.swift b/ios-native/Kitchen Manager/KitchenManager/KitchenActionLabel.swift new file mode 100644 index 00000000..ff3cfc3c --- /dev/null +++ b/ios-native/Kitchen Manager/KitchenManager/KitchenActionLabel.swift @@ -0,0 +1,66 @@ +import SwiftUI + +enum KitchenActionPhase: CaseIterable { + case idle, processing, success, failure +} + +/// Presentation only. The caller owns the operation, disable rules, errors and +/// navigation. Reserve all four labels' footprint without imposing a fixed +/// height, so Dynamic Type and state changes cannot move the button's edges. +struct KitchenActionLabel: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let phase: KitchenActionPhase + let idleTitle: String + let idleSymbol: String + var processingTitle = "正在保存" + var successTitle = "已保存" + var failureTitle = "重试保存" + + var body: some View { + ZStack { + ForEach(KitchenActionPhase.allCases, id: \.self) { candidate in + Label(title(for: candidate), systemImage: symbol(for: candidate)) + .hidden() + .accessibilityHidden(true) + } + } + .overlay { + Label { + Text(title(for: phase)) + .contentTransition(.opacity) + } icon: { + if phase == .processing { + ProgressView() + .controlSize(.small) + .transition(.opacity) + } else { + Image(systemName: symbol(for: phase)) + .contentTransition(.opacity) + } + } + .animation(reduceMotion ? nil : KitchenMotion.feedback, value: phase) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(title(for: phase))) + .accessibilityValue(phase == .failure ? Text("保存失败") : Text("")) + } + + private func title(for phase: KitchenActionPhase) -> String { + switch phase { + case .idle: idleTitle + case .processing: processingTitle + case .success: successTitle + case .failure: failureTitle + } + } + + private func symbol(for phase: KitchenActionPhase) -> String { + switch phase { + case .idle: idleSymbol + case .processing: "arrow.trianglehead.2.clockwise" + case .success: "checkmark.circle.fill" + case .failure: "exclamationmark.circle" + } + } +} diff --git a/ios-native/Kitchen Manager/KitchenManager/KitchenMotionImportFixture.swift b/ios-native/Kitchen Manager/KitchenManager/KitchenMotionImportFixture.swift new file mode 100644 index 00000000..5b0e3dfd --- /dev/null +++ b/ios-native/Kitchen Manager/KitchenManager/KitchenMotionImportFixture.swift @@ -0,0 +1,92 @@ +#if DEBUG +import SwiftUI + +/// Isolated UI-test host. It exercises the real import save operation and +/// keeps its result visible using the view's existing onSaved callback. +/// Normal launches never enter this host or replace application persistence. +@MainActor +struct KitchenMotionImportFixture: View { + static var isEnabled: Bool { + ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_IMPORT") + || ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_LABELS") + } + + @StateObject private var store: RecipeStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init() { + let bundle = KitchenPersistenceFactory.isolatedInMemory() + let persistence = FailOneWrite(wrapping: bundle.userRecipes) + let suite = "ui-motion-import-\(ProcessInfo.processInfo.processIdentifier)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let store = RecipeStore(userDefaults: defaults, + userRecipePersistence: persistence, + recipePreferencePersistence: bundle.recipePreferences) + // Arm after initialization, so migration/loads remain ordinary reads. + persistence.failNextWrite = ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_FAIL_FIRST_SAVE") + defaults.removePersistentDomain(forName: suite) + _store = StateObject(wrappedValue: store) + } + + var body: some View { + NavigationStack { + if ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_LABELS") { + phaseGallery + } else { + ImportRecipeView(onSaved: {}) + } + } + .environmentObject(store) + .overlay(alignment: .topLeading) { + Color.clear.frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityLabel("Motion environment") + .accessibilityValue(reduceMotion && UIAccessibility.isReduceMotionEnabled ? "reduced" : "standard") + .accessibilityIdentifier("motion.environment") + } + } + + private var phaseGallery: some View { + ScrollView { + VStack(spacing: 20) { + Text("组件状态预览(非保存操作)").font(.caption) + ForEach(KitchenActionPhase.allCases, id: \.self) { phase in + Button {} label: { + HStack { + Spacer() + KitchenActionLabel(phase: phase, idleTitle: "保存到菜谱库", idleSymbol: "square.and.arrow.down") + .tint(AppTheme.onManagementAction) + Spacer() + } + } + .buttonStyle(.borderedProminent) + .tint(AppTheme.managementActionFill) + .foregroundStyle(AppTheme.onManagementAction) + .accessibilityIdentifier("motion.preview.\(phase)") + } + } + .padding(20) + } + .background(KitchenTheme.canvas) + .navigationTitle("组件状态预览") + .navigationBarTitleDisplayMode(.inline) + } + + private final class FailOneWrite: UserRecipePersistenceProtocol { + let wrapped: any UserRecipePersistenceProtocol + var failNextWrite = false + init(wrapping wrapped: any UserRecipePersistenceProtocol) { self.wrapped = wrapped } + func loadRecipes() throws -> [Recipe] { try wrapped.loadRecipes() } + func storedRecordCount() throws -> Int { try wrapped.storedRecordCount() } + func deleteAll() throws { try wrapped.deleteAll() } + func replaceRecipes(with recipes: [Recipe]) throws { + if failNextWrite { + failNextWrite = false + throw CocoaError(.fileWriteUnknown) + } + try wrapped.replaceRecipes(with: recipes) + } + } +} +#endif diff --git a/ios-native/Kitchen Manager/KitchenManager/KitchenTheme.swift b/ios-native/Kitchen Manager/KitchenManager/KitchenTheme.swift index 50eb6dd9..5ec46ad2 100644 --- a/ios-native/Kitchen Manager/KitchenManager/KitchenTheme.swift +++ b/ios-native/Kitchen Manager/KitchenManager/KitchenTheme.swift @@ -5,6 +5,9 @@ enum KitchenMotion { static let quick: Animation = .snappy(duration: 0.2) static let standard: Animation = .smooth(duration: 0.28) static let emphasis: Animation = .smooth(duration: 0.32, extraBounce: 0.05) + // Semantic aliases reuse the existing curves; no second motion system. + static let morph = standard + static let feedback = quick } /// The approved Kitchen Manager visual language for Home and Inventory: diff --git a/ios-native/Kitchen Manager/KitchenManagerTests/KitchenActionLabelTests.swift b/ios-native/Kitchen Manager/KitchenManagerTests/KitchenActionLabelTests.swift new file mode 100644 index 00000000..868e2dfd --- /dev/null +++ b/ios-native/Kitchen Manager/KitchenManagerTests/KitchenActionLabelTests.swift @@ -0,0 +1,58 @@ +import SwiftUI +import XCTest +@testable import KitchenManager + +@MainActor +final class KitchenActionLabelTests: XCTestCase { + func testSaveLabelKeepsItsFootprintAcrossEveryPhaseAndTextSize() { + for size in [DynamicTypeSize.large, .accessibility5] { + let dimensions = KitchenActionPhase.allCases.map { phase in + let host = UIHostingController(rootView: + KitchenActionLabel(phase: phase, idleTitle: "保存到菜谱库", idleSymbol: "square.and.arrow.down") + .environment(\.dynamicTypeSize, size) + ) + return host.sizeThatFits(in: CGSize(width: 300, height: 1_000)) + } + for dimension in dimensions { + XCTAssertEqual(dimension.width, dimensions[0].width, accuracy: 0.5) + XCTAssertEqual(dimension.height, dimensions[0].height, accuracy: 0.5) + XCTAssertGreaterThan(dimension.height, 0) + } + } + } + + func testEveryPhaseRendersDistinctVisibleContent() throws { + var images: [Data] = [] + for phase in KitchenActionPhase.allCases { + let image = try snapshot( + KitchenActionLabel(phase: phase, idleTitle: "保存到菜谱库", idleSymbol: "square.and.arrow.down") + .foregroundStyle(.white) + .padding(16) + .background(.blue) + .environment(\.colorScheme, .light) + ) + images.append(try XCTUnwrap(image.pngData())) + } + // Geometry-only and accessibility-label tests would stay green if the + // visible overlay were accidentally deleted. Pixel output must differ. + XCTAssertEqual(Set(images).count, KitchenActionPhase.allCases.count) + } + + private func snapshot(_ content: Content) throws -> UIImage { + let host = UIHostingController(rootView: content) + let size = host.sizeThatFits(in: CGSize(width: 390, height: 2_000)) + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(origin: .zero, size: size) + window.rootViewController = host + window.isHidden = false + defer { window.isHidden = true } + host.view.frame = window.bounds + host.view.layoutIfNeeded() + // SwiftUI ImageRenderer substitutes an unavailable glyph for the native + // ProgressView. Render the hosted hierarchy to include the real spinner. + return UIGraphicsImageRenderer(size: size).image { _ in + host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true) + } + } +} diff --git a/ios-native/Kitchen Manager/KitchenManagerUITests/NativeMotionUITests.swift b/ios-native/Kitchen Manager/KitchenManagerUITests/NativeMotionUITests.swift new file mode 100644 index 00000000..d0558b03 --- /dev/null +++ b/ios-native/Kitchen Manager/KitchenManagerUITests/NativeMotionUITests.swift @@ -0,0 +1,212 @@ +import XCTest + +/// Uses isolated recipe persistence. These tests exercise ImportRecipeView's +/// real save/error/retry path; the fixture keeps the result screen open solely +/// so its genuine success state can be inspected without delaying production navigation. +final class NativeMotionUITests: XCTestCase { + private func capture(_ app: XCUIApplication, _ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + func testImportSaveFailureRetryAndStableResultInAppearanceAndTypeMatrix() { + for dark in [false, true] { + for large in [false, true] { + let app = XCUIApplication() + app.launchArguments = [ + "UITEST_MOTION_IMPORT", "UITEST_MOTION_FAIL_FIRST_SAVE", "UITEST_SEED_IMPORT_RESULT", + dark ? "UITEST_FORCE_DARK_APPEARANCE" : "UITEST_FORCE_LIGHT_APPEARANCE", + "-AppleLanguages", "(zh-Hans)", "-AppleLocale", "zh_CN", + "-UIPreferredContentSizeCategoryName", + large ? "UICTContentSizeCategoryAccessibilityXXXL" : "UICTContentSizeCategoryLarge" + ] + app.launch() + XCTAssertTrue(app.staticTexts["基本信息"].waitForExistence(timeout: 8)) + let save = app.buttons["import.result.save"] + for _ in 0..<12 where !save.isHittable { app.swipeUp() } + XCTAssertTrue(save.isHittable) + XCTAssertEqual(save.label, "保存到菜谱库") + let before = save.frame + XCTAssertGreaterThanOrEqual(before.height, 43.5) + let variant = "\(dark ? "dark" : "light")-\(large ? "axxxl" : "normal")" + capture(app, "motion-save-idle-\(variant)") + save.tap() + let alert = app.alerts["无法保存菜谱"] + XCTAssertTrue(alert.waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["已保存"].exists) + capture(app, "motion-save-failure-\(variant)") + alert.buttons["好"].tap() + XCTAssertTrue(save.isEnabled) + XCTAssertEqual(save.label, "保存到菜谱库") + save.tap() + expectation(for: NSPredicate(format: "label == %@", "已保存"), evaluatedWith: save) + waitForExpectations(timeout: 5) + XCTAssertFalse(save.isEnabled) + XCTAssertFalse(app.alerts.firstMatch.exists) + XCTAssertEqual(save.frame.width, before.width, accuracy: 0.5) + XCTAssertEqual(save.frame.height, before.height, accuracy: 0.5) + capture(app, "motion-save-success-\(variant)") + app.terminate() + } + } + } + + func testHomeBrowsingDetailAndBackInAppearanceAndTypeMatrix() { + for dark in [false, true] { + for large in [false, true] { + let app = XCUIApplication() + app.launchArguments = [ + "UITEST_SEED_EMPTY_HOME", + dark ? "UITEST_FORCE_DARK_APPEARANCE" : "UITEST_FORCE_LIGHT_APPEARANCE", + "-AppleLanguages", "(zh-Hans)", "-AppleLocale", "zh_CN", + "-UIPreferredContentSizeCategoryName", + large ? "UICTContentSizeCategoryAccessibilityXXXL" : "UICTContentSizeCategoryLarge" + ] + app.launch() + XCTAssertTrue(app.staticTexts["home.recommendation.title"].waitForExistence(timeout: 8)) + XCTAssertFalse(app.buttons["home.recommendation.more"].exists) + let variant = "\(dark ? "dark" : "light")-\(large ? "axxxl" : "normal")" + capture(app, "motion-home-first-\(variant)") + let shelf = app.descendants(matching: .any)["home.recommendation.shelf"] + if large { + XCTAssertFalse(shelf.exists, "Accessibility text must keep vertical discovery") + } else { + XCTAssertTrue(shelf.exists) + shelf.swipeLeft() + } + let nextActions = app.buttons.matching(NSPredicate( + format: "identifier BEGINSWITH %@ AND identifier ENDSWITH %@ AND identifier != %@", + "home.recommendation.", ".viewRecipe", "home.recommendation.viewRecipe" + )) + for _ in 0..<8 where !nextActions.allElementsBoundByIndex.contains(where: \.isHittable) { + if large { app.swipeUp() } else { shelf.swipeLeft() } + } + guard let action = nextActions.allElementsBoundByIndex.first(where: \.isHittable) else { + XCTFail("Another recipe's detail action must be reachable") + app.terminate() + continue + } + XCTAssertGreaterThanOrEqual(action.frame.height, 43.5) + let titleID = String(action.identifier.dropLast("viewRecipe".count)) + "title" + let recipeTitle = app.staticTexts[titleID].label + XCTAssertFalse(recipeTitle.isEmpty) + capture(app, "motion-home-next-\(variant)") + action.tap() + XCTAssertTrue(app.navigationBars["菜谱详情"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts[recipeTitle].exists) + app.navigationBars.buttons.element(boundBy: 0).tap() + XCTAssertTrue(app.navigationBars["今天"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["home.recommendation.more"].exists) + capture(app, "motion-home-return-\(variant)") + app.terminate() + } + } + } + + /// Orchestration enables the actual simulator accessibility preference and + /// restores its original value afterward. There is no SwiftUI override. + func testSystemReduceMotionSaveFailureRetryAndSuccess() throws { + let app = XCUIApplication() + app.launchArguments = [ + "UITEST_MOTION_IMPORT", "UITEST_MOTION_FAIL_FIRST_SAVE", "UITEST_SEED_IMPORT_RESULT", + "UITEST_FORCE_DARK_APPEARANCE", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL" + ] + app.launch() + let environment = app.descendants(matching: .any)["motion.environment"] + XCTAssertTrue(environment.waitForExistence(timeout: 5)) + guard environment.value as? String == "reduced" else { + app.terminate() + throw XCTSkip("Requires actual simulator Reduce Motion enabled by the validation flow") + } + XCTAssertEqual(environment.value as? String, "reduced", "UIKit and SwiftUI must both observe the system setting") + let save = app.buttons["import.result.save"] + for _ in 0..<12 where !save.isHittable { app.swipeUp() } + XCTAssertTrue(save.isHittable) + let before = save.frame + capture(app, "motion-reduced-idle-dark-axxxl") + save.tap() + let alert = app.alerts["无法保存菜谱"] + XCTAssertTrue(alert.waitForExistence(timeout: 5)) + capture(app, "motion-reduced-failure-dark-axxxl") + alert.buttons["好"].tap() + XCTAssertEqual(save.label, "保存到菜谱库") + save.tap() + expectation(for: NSPredicate(format: "label == %@", "已保存"), evaluatedWith: save) + waitForExpectations(timeout: 5) + XCTAssertFalse(save.isEnabled) + XCTAssertEqual(save.frame.size, before.size) + capture(app, "motion-reduced-success-dark-axxxl") + app.terminate() + } + + func testExecutionRecommendationControlsAndAccessibilityRegeneration() { + for (dark, large) in [(false, false), (false, true), (true, false), (true, true)] { + let app = XCUIApplication() + app.launchArguments = [ + "UITEST_SEED_HOME_DASHBOARD", + dark ? "UITEST_FORCE_DARK_APPEARANCE" : "UITEST_FORCE_LIGHT_APPEARANCE", + "-AppleLanguages", "(zh-Hans)", "-AppleLocale", "zh_CN", + "-UIPreferredContentSizeCategoryName", + large ? "UICTContentSizeCategoryAccessibilityXXXL" : "UICTContentSizeCategoryLarge" + ] + app.launch() + XCTAssertTrue(app.buttons["home.today.plan.start"].waitForExistence(timeout: 5)) + let actions = app.buttons.matching(NSPredicate( + format: "identifier BEGINSWITH %@ AND identifier ENDSWITH %@", + "home.recommendation.", ".viewRecipe" + )) + for _ in 0..<12 where !actions.allElementsBoundByIndex.contains(where: \.isHittable) { app.swipeUp() } + guard let view = actions.allElementsBoundByIndex.first(where: \.isHittable) else { + XCTFail("Secondary recommendation action is unreachable") + app.terminate() + continue + } + XCTAssertGreaterThanOrEqual(view.frame.height, 43.5) + let addID = String(view.identifier.dropLast("viewRecipe".count)) + "addToday" + let add = app.buttons[addID] + XCTAssertTrue(add.exists) + XCTAssertGreaterThanOrEqual(add.frame.height, 43.5) + if large { XCTAssertGreaterThanOrEqual(view.frame.minY, add.frame.maxY) } + capture(app, "motion-home-execution-\(dark ? "dark" : "light")-\(large ? "axxxl" : "normal")") + // Exercise the top edge of the effective target, not just the text. + view.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + XCTAssertTrue(app.navigationBars["菜谱详情"].waitForExistence(timeout: 5)) + app.navigationBars.buttons.element(boundBy: 0).tap() + XCTAssertTrue(app.navigationBars["今天"].waitForExistence(timeout: 5)) + if large { + let regenerate = app.buttons["home.recommendation.regenerate"] + for _ in 0..<14 where !regenerate.isHittable { app.swipeUp() } + XCTAssertTrue(regenerate.isHittable) + XCTAssertTrue(regenerate.isEnabled) + XCTAssertGreaterThanOrEqual(regenerate.frame.height, 43.5) + capture(app, "motion-home-execution-terminal-\(dark ? "dark" : "light")-axxxl") + } + app.terminate() + } + } + + func testComponentPhaseGalleryInAppearanceAndTypeMatrix() { + for (dark, large) in [(false, false), (false, true), (true, false), (true, true)] { + let app = XCUIApplication() + app.launchArguments = [ + "UITEST_MOTION_LABELS", + dark ? "UITEST_FORCE_DARK_APPEARANCE" : "UITEST_FORCE_LIGHT_APPEARANCE", + "-UIPreferredContentSizeCategoryName", + large ? "UICTContentSizeCategoryAccessibilityXXXL" : "UICTContentSizeCategoryLarge" + ] + app.launch() + XCTAssertTrue(app.navigationBars["组件状态预览"].waitForExistence(timeout: 5)) + for (phase, title) in [("idle", "保存到菜谱库"), ("processing", "正在保存"), ("success", "已保存"), ("failure", "重试保存")] { + let button = app.buttons["motion.preview.\(phase)"] + for _ in 0..<8 where !button.isHittable { app.swipeUp() } + XCTAssertTrue(button.isHittable) + XCTAssertEqual(button.label, title) + capture(app, "motion-gallery-\(dark ? "dark" : "light")-\(large ? "axxxl" : "normal")-\(phase)") + } + app.terminate() + } + } +}