diff --git a/TODO.md b/TODO.md index 12e14f0..2147d4f 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ Codex 做新 UI 时,不要直接吃整份 TODO;应把 `docs/codex-goals/warm - [x] 本地 SQLite、默认分类、示例 Wiki、首页统计、物品目录、添加物品、Wiki 详情、库存详情、历史记录已接入。 - [x] 订单截图 VLM 识别入口、识别结果预览、批量入库、VLM 设置页已接入。 - [x] legacy SQLite -> Flutter JSON 导入通道已接入。 -- [ ] Android SDK、完整 Xcode/xcodebuild、Android/macOS 通知设备验收仍需补齐;Web 页面实现截图已补齐。 +- [ ] Android SDK/设备与 Android/macOS 通知运行时验收仍需补齐;macOS Xcode build/test/launch 已通过,Web 页面实现截图已补齐。 ## UI 工作流约定 @@ -85,7 +85,7 @@ Codex 做新 UI 时,不要直接吃整份 TODO;应把 `docs/codex-goals/warm - [x] UI 支持为单个物品设置提醒开关和提前天数。 - [x] 分类/Wiki 默认提醒规则:例如牛奶提前 2 天、药品提前 30 天。 - [ ] Android 本地通知:权限引导、定时调度、点击跳转详情页(代码已接入,待 Android SDK/设备验证)。 -- [ ] macOS 本地通知:权限引导、调度、点击行为(代码已接入,`flutter build macos --debug` 当前阻塞于缺少 `xcodebuild`)。 +- [ ] macOS 本地通知:权限引导、调度、点击行为(代码已接入,macOS build/native tests/launch 已通过;仍待系统授权后的投递和点击验证)。 - [x] 首页新增“今天要处理”聚合模块:今日到期、已过期、提醒到期。 - [x] 通知防重复:同一物品同一提醒类型一天只发一次。 - [x] 支持稍后提醒/忽略本次提醒。 @@ -223,7 +223,7 @@ Codex 做新 UI 时,不要直接吃整份 TODO;应把 `docs/codex-goals/warm ### 6.2 macOS - [x] 文件选择、图片读取权限确认。 -- [ ] 本地通知权限(代码已接入,待完整 Xcode/xcodebuild 构建验证)。 +- [ ] 本地通知权限(代码已接入,macOS build/native tests/launch 已通过;待系统授权后的投递和点击验证)。 - [x] macOS 打包与签名流程。 - [x] 菜单栏/窗口尺寸体验优化。 diff --git a/docs/todo-acceptance-2026-06-16.md b/docs/todo-acceptance-2026-06-16.md index 6b84421..7e4b547 100644 --- a/docs/todo-acceptance-2026-06-16.md +++ b/docs/todo-acceptance-2026-06-16.md @@ -16,6 +16,13 @@ Follow-up beta verification on 2026-06-17 expanded the Flutter suite to 45 tests, including local notification channel coverage, with `flutter test` still passing. +Follow-up beta verification through 2026-06-20 expanded the Flutter suite and +macOS native RunnerTests substantially. Current evidence is tracked in +`docs/ux-test-findings-2026-06-17.md`; macOS build, native tests, debug launch, +and Xcode first-launch checks now pass locally. Android SDK/device validation +and real delivered notification click-through remain unproven. Current full +Flutter regression evidence is 109 passing tests. + The first sandboxed `flutter test` run was blocked by local socket creation permissions. Re-running the same command with permission to bind a local test socket passed. @@ -36,20 +43,47 @@ All TODO items that can be proven in the current local environment are either checked in `TODO.md` or covered by the evidence above. Remaining unchecked items are intentionally limited to native platform -verification that cannot be proven on this machine: +verification that is still not fully proven on this machine: - Android SDK is missing. On 2026-06-17, `flutter build apk --debug` downloaded Flutter Android artifacts successfully after network approval, then stopped with `No Android SDK found`, so Android local notification permission, scheduling, and tap-through behavior still need Android SDK/device - verification. -- Full Xcode is missing. On 2026-06-17, `flutter build macos --debug` reached - Xcode dependency resolution, then stopped because `xcrun` could not find - `xcodebuild` while the active developer directory was - `/Library/Developer/CommandLineTools`, so macOS notification build/device - behavior still needs full Xcode and CocoaPods verification. - -`flutter doctor -v` confirms the same platform gaps: no Android SDK, incomplete -Xcode installation, no Google Chrome binary, and sandboxed network checks unable -to resolve external hosts. Web launch verification used the generated Web build -served locally instead. + verification. A 2026-06-19 recheck with `flutter devices` still detected only + macOS, and `flutter doctor -v` still reported that no Android SDK could be + located. A 2026-06-20 recheck still detects only macOS and still reports no + Android SDK; `flutter build apk --debug` still stops with no Android SDK, and + `flutter emulators` reports no emulator sources. A later 2026-06-20 recheck + after the full Flutter regression recheck produced the same Android blocker: + `flutter devices` detected only macOS, `flutter emulators` reported no + emulator sources, `flutter doctor -v` reported `Unable to locate Android + SDK`, and `flutter build apk --debug` stopped before Android compilation with + `[!] No Android SDK found`. +- macOS Xcode/CocoaPods setup is no longer the blocking item. On 2026-06-19, + `flutter build macos --debug`, `flutter run -d macos`, + `xcodebuild -checkFirstLaunchStatus`, and native RunnerTests passed locally. + A running-app VM Service smoke now also proves the live Flutter method + channel can read macOS notification permission state from the native bridge; + `tools/check_notification_status.mjs` now makes that smoke check reusable for + future `flutter run -d macos` sessions and can assert expected supported, + granted, and status values. `tools/run_macos_notification_smoke.mjs` wraps + the launch, VM Service parsing, assertion, and clean shutdown into one local + command. The smoke tools also have an explicit `--send-test` path for a + manual authorization pass, but the default command remains read-only. macOS + system notification permission approval, delivered reminder visibility, and + notification-click routing still need targeted runtime validation after the + test host/app is authorized for notifications. On 2026-06-20, + `xcodebuild -runFirstLaunch`, `xcodebuild -checkFirstLaunchStatus`, and + `xcodebuild -license check` returned success; `flutter build macos --debug` + and native RunnerTests also passed again. `flutter doctor -v` still reports + the Xcode additional-component warning because no Simulator runtimes/devices + are installed, but macOS desktop build/test validation remains usable. + +`flutter doctor -v` was rechecked on 2026-06-19 with the bundled Flutter SDK. +Android SDK is still absent, Chrome is not installed at Flutter's default path, +and sandboxed network resource checks still fail. `xcodebuild +-checkFirstLaunchStatus` returns success even though `flutter doctor` still +emits a stale first-launch warning. Rechecked on 2026-06-20: Android SDK is +still absent, only macOS is detected, elevated `xcrun simctl list` reports no +Simulator runtimes/devices, and macOS build/RunnerTests still pass. Web launch +verification used the generated Web build served locally instead. diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 347cd0c..6de2a2c 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -7,7 +7,7 @@ Viewport: mobile-sized browser viewport, 390 x 844. Focus: whether a user can complete the main app jobs: understand the dashboard, add inventory, inspect inventory detail, use the shopping loop, consume via -recipes, and run the built-in acceptance check. +recipes, and manage settings without developer-only diagnostics exposed. ## Summary @@ -16,66 +16,1241 @@ item, see it in the catalog, open expiring inventory detail, adjust quantity, add an item to the shopping list, mark it purchased, convert it back to inventory, open recipe suggestions, and deduct inventory after cooking. -The strongest remaining product gap is platform-specific notification -validation. Flutter Web cold-start text now has a native HTML loading screen to -cover the short CanvasKit font fallback window, and the latest mobile Web smoke -check confirmed it hands off cleanly to the rendered Home screen. +The strongest remaining product gap is platform-specific notification runtime +validation. Settings now has an immediate test-notification action to make +permission and delivery checks faster on Android/macOS, but scheduled reminder +delivery and click behavior from a real delivered Android/macOS notification +still require target-platform manual validation. The app-side notification tap +handoff, launch target handling, and macOS payload bridge are covered by current +automated tests. Flutter Web cold-start text now has a native HTML loading +screen to cover the short CanvasKit font fallback window, and the latest mobile +Web smoke check confirmed it hands off cleanly to the rendered Home screen. + +Update on 2026-06-19: current-worktree macOS debug build, native RunnerTests, +and a `flutter run -d macos` launch smoke all pass. `xcodebuild +-checkFirstLaunchStatus` now also returns success. This proves the macOS app +still builds, the native notification bridge remains wired at source/runtime +test level, and the debug app can start and expose a Dart VM Service. It still +does not prove system notification permission prompts, delivered notification +visibility, or click routing from a real delivered macOS notification. + +Additional macOS notification update on 2026-06-19: RunnerTests now also query +the real `UNUserNotificationCenter` from the Runner app/test host and confirm +that the bundle can read the system notification permission state. A pending +system-request smoke test is available and passed as a controlled skip on this +machine because notifications are not yet authorized for the test host; it will +validate that the system accepts a real pending request once macOS notification +permission has been granted. This still does not replace manual delivered-banner +and click-through validation. + +Runtime macOS update on 2026-06-19: launching the rebuilt debug app exposed a +startup `NSInvalidArgumentException` from calling +`super.applicationDidFinishLaunching(notification)` on Flutter's macOS app +delegate optional selector. The app now only installs the notification-center +delegate in `applicationDidFinishLaunching`; after rebuilding and launching the +debug app again, the process checked in as a foreground app, created a +`UNUserNotificationCenter`, and read notification settings without the previous +selector exception. RunnerTests now also prove the launched app delegate is +registered as `UNUserNotificationCenter.current().delegate`, so notification +responses have an app-side entry point before manual delivered-notification +validation. + +Running-app macOS update on 2026-06-19: a debug-only VM Service extension now +queries the real `LocalNotificationService.getPermissionStatus()` path from +inside the launched macOS Flutter app. A fresh `flutter run -d macos` launch +then returned `supported: true`, `granted: false`, `status: unknown`, and +`displayText: 未确认` from the extension. This proves the running app's Flutter +method channel can reach the native macOS notification bridge and read system +permission state. It still does not prove permission approval, delivered +notification visibility, or notification-click routing because this machine has +not authorized notifications for the app. + +Running-app macOS recheck on 2026-06-20: after the Web startup-bootstrap +change, `HOME=/private/tmp/vibe-fridge-flutter-home node +tools/run_macos_notification_smoke.mjs --expect-supported true --timeout-ms +180000` rebuilt and launched the macOS debug app, reached the VM Service at +`http://127.0.0.1:58130/...`, returned `supported: true`, `granted: false`, +`status: unknown`, and `displayText: 未确认`, then exited cleanly. This keeps +the running-app macOS notification-channel evidence current while still +avoiding permission prompts and diagnostic notification delivery. + +Running-app macOS smoke hardening on 2026-06-20: a later re-run showed the VM +Service URL can be emitted before the debug notification extension has finished +registering, which produced a transient `Method not found` failure. The smoke +helper now waits for extension registration before reading notification state. +After that change, the same read-only command rebuilt and launched the macOS +debug app, reached `http://127.0.0.1:57168/...`, returned `supported: true`, +`granted: false`, `status: unknown`, and `displayText: 未确认`, then exited +cleanly without requesting permission or sending a notification. + +Running-app macOS notification recheck on 2026-06-20: reran +`HOME=/private/tmp/vibe-fridge-flutter-home node +tools/run_macos_notification_smoke.mjs --expect-supported true --timeout-ms +180000` from the current worktree. The command rebuilt and launched the macOS +debug app, reached the VM Service at `http://127.0.0.1:62175/...`, returned +`supported: true`, `granted: false`, `status: unknown`, and +`displayText: 未确认`, then exited with `Application finished.` This confirms +the current build still exposes the macOS notification bridge and permission +status path. It remains a non-delivery check: no permission prompt was accepted, +no diagnostic notification was sent, and notification-click routing still needs +manual platform validation. + +macOS send-test smoke attempt on 2026-06-21: after the user explicitly +authorized the notification-send path, `HOME=/private/tmp/vibe-fridge-sendtest-home +node tools/run_macos_notification_smoke.mjs --send-test --expect-supported true +--timeout-ms 180000` rebuilt and launched the macOS debug app from a clean +temporary Flutter home, reached the VM Service at +`http://127.0.0.1:59927/...`, and called the debug notification test extension. +The extension returned `supported: true`, `granted: false`, `status: denied`, +`displayText: 未授权`, `sent: false`, and `skippedReason: permission`, then the +app exited with `Application finished.` This proves the send-test route reaches +the real macOS permission gate in the launched app. It still does not prove +delivered notification visibility or click-through behavior because the system +did not grant notification permission, so no diagnostic notification was sent. + +Reusable macOS smoke update on 2026-06-19: `tools/check_notification_status.mjs` +now wraps the VM Service extension call so future beta passes can reproduce the +running-app notification status check without ad hoc scripting. Against a fresh +`flutter run -d macos` session, the tool found the app isolate and returned the +same live native-channel status: `supported: true`, `granted: false`, +`status: unknown`, and `displayText: 未确认`. The tool now also supports +optional `--expect-supported`, `--expect-granted`, and `--expect-status` +assertions so the smoke can fail fast when the runtime status diverges from the +expected beta environment. + +Automated macOS smoke update on 2026-06-19: +`tools/run_macos_notification_smoke.mjs` now starts `flutter run -d macos`, +waits for the VM Service URL, runs the notification status helper with optional +expectations, and exits `flutter run` cleanly. This turns the live notification +permission-channel check into a single repeatable command while still avoiding +permission prompts or delivered-notification side effects by default. + +Optional notification-send smoke update on 2026-06-19: the debug VM Service now +also exposes an explicit notification test extension, and the smoke tools can +call it only when `--send-test` is passed. The default command remains read-only +and does not request notification permission or send a notification. The +optional send path is reserved for a manual authorization pass because it may +open the macOS notification permission prompt or send the diagnostic +notification if permission has already been granted. + +Debug smoke payload update on 2026-06-19: the VM Service notification status +and test-notification payload builders now live in a small tested module, so +the app-side JSON contract used by `tools/check_notification_status.mjs` is +covered without registering duplicate VM extensions or triggering notification +permission prompts during tests. + +macOS secure-storage runtime update on 2026-06-20: +`HOME=/private/tmp/vibe-fridge-flutter-home node +tools/run_macos_secure_storage_smoke.mjs --timeout-ms 180000` rebuilt and +launched the macOS debug app, reached the VM Service at +`http://127.0.0.1:61416/...`, wrote a temporary secure-storage key, read it +back, deleted it, and exited cleanly. This directly verifies the running app's +Keychain path used by order-recognition and WebDAV secrets no longer hits the +previous macOS `-34018` entitlement failure path. + +Environment recheck on 2026-06-20: Android SDK and Android devices are still +absent. `xcodebuild -runFirstLaunch`, `xcodebuild -checkFirstLaunchStatus`, +and `xcodebuild -license check` all return success, while `flutter doctor -v` +still reports Xcode additional-component setup because no Simulator runtimes or +Simulator devices are installed. The macOS desktop target itself remains +usable: `flutter build macos --debug` passed and native RunnerTests passed with +14 passed tests and 1 authorization-gated notification request test skipped. +The Android readiness check is now also reproducible through +`tools/check_android_environment.mjs`, which exits non-zero until Flutter has +an Android SDK plus an Android device or emulator. A current run returned +`ready: false`, `androidToolchainReady: false`, `androidDeviceReady: false`, +`emulatorAvailable: false`, `sdkPath: null`, and blockers for missing Android +SDK plus missing Android device/emulator. + +Remaining gate recheck on 2026-06-21: the current worktree remains clean on +`codex/beta-user-testing`. `node tools/check_android_environment.mjs` still +returned `ready: false`, no Android SDK path, no Android device/emulator, and +the same blockers for missing Android SDK plus missing Android device/emulator. +The repository still only exposes `.github/workflows/flutter.yml`, +`mobile/web/_headers`, and the generated `mobile/build/web/_headers`; no +Netlify, Vercel, Firebase, Cloudflare, Pages, or other final-host deployment +configuration was present to verify hosted update headers. A fresh running-app +macOS smoke with `HOME=/private/tmp/vibe-fridge-sendtest-home node +tools/run_macos_notification_smoke.mjs --expect-supported true --timeout-ms +180000` rebuilt and launched the debug app, reached the VM Service at +`http://127.0.0.1:60702/...`, and returned `supported: true`, +`granted: false`, `status: denied`, and `displayText: 未授权`. This confirms +the macOS notification bridge still works but the host permission state still +blocks delivered-notification and click-through validation. + +## Beta Readiness + +Verdict: ready for continued Web beta testing of the core inventory workflow, +but not yet ready to claim Android/macOS notification readiness. + +Proven enough for beta: + +- Home dashboard, today actions, reminder snooze/ignore, item catalog, item + profile, inventory detail, manual add, order-text import, shopping list, + recipes, local backup/export, local restore/import copy, WebDAV + cloud-backup configuration/upload/restore paths, settings, and the internal + app self-check service all have passing automated or browser-smoke evidence. +- Web deep links and browser Back/Forward now preserve hash-free query routes + across catalog search detail, inventory detail, and recipe detail paths. +- Web startup now paints a Flutter-owned loading state before local inventory + initialization starts, and falls back to a user-safe copy-details error page + if initialization stalls or fails instead of leaving users on the static + native loading screen. +- User-facing copy has been scrubbed across the main tested flows to avoid raw + database, migration, legacy implementation, and diagnostic details. + +Not proven yet: + +- Android/macOS scheduled local notification runtime behavior. Dart fallback + behavior, the Settings test-notification action, Android manifest wiring + tests, macOS bridge wiring source tests, repository notification payload + tests, Dart platform-schedule payload handoff tests, Flutter shell + notification-tap detail routing, and macOS native + scheduling-payload/content/trigger construction, permission-status mapping, + and tap payload handoff tests are covered, but real device/desktop scheduled + reminder delivery and system notification click behavior still need runtime + validation on the target platforms. +- Hosted Web update behavior on the final deployment origin. Local same-origin + stale service-worker and stale Flutter Cache Storage recovery is now proven + with browser smokes. A deployment host that ignores `_headers` still needs + equivalent HTTP cache headers before hosted update behavior is fully proven. + Current repository audit found only the Flutter CI workflow and + `mobile/web/_headers`; no Netlify, Vercel, Firebase, Cloudflare, or Pages + deployment configuration was present to identify or verify a final hosted + origin from the worktree alone. + +Recommended next beta gate: + +- Run one Android debug build on a machine with Android SDK configured, then + manually verify Android notification permission, scheduled reminder delivery, + and notification click opening the matching inventory detail. On macOS, build + plus native scheduling payload, permission-status mapping, and tap payload + handoff are now covered locally; the next gate is manual permission, + delivery, and system notification click validation. + +Platform notification checklist for that gate: + +- Build and launch Android debug on a device or emulator with notification + permission support; verify Settings shows a supported permission state. +- On Android, request notification permission from Settings, send a test + notification, sync reminders, wait for a due reminder, tap the delivered + reminder notification, and confirm the matching inventory detail opens. +- Restart the Android app or emulator after reminders are scheduled, then + confirm boot/package-replaced restoration still delivers the stored reminder. +- Build and launch macOS debug with full Xcode/CocoaPods tooling; verify + Settings shows a supported permission state. +- On macOS, request notification permission, send a test notification, sync + reminders, wait for a due reminder, tap the delivered reminder notification, + and confirm the matching inventory detail opens. +- Re-run the internal app self-check after platform notification testing to + confirm notification experiments did not leave invalid inventory, shopping, + recipe, reminder, or history state behind. ## Verification - `flutter test`: passed after the latest beta fixes including Android reminder - restoration, Web route cleanup, notification channel coverage, startup error - copy coverage, plus AI recipe and order-recognition error-copy coverage, - 48 tests. + restoration, Web route cleanup, the `MaterialApp.router` browser-history fix, + notification channel coverage, startup error copy coverage, AI recipe and + order-recognition error-copy coverage, plus the generic error snackbar + detail-copy coverage, notification tap controller handoff coverage, and + notification permission-to-sync controller coverage, 57 tests; passed again + after the macOS native notification request builder extraction. Passed again + after adding the custom Web bootstrap guard, 58 tests. Passed again after + adding Android notification wiring source tests, 60 tests. Passed again after + adding macOS notification wiring source tests, 62 tests. Passed again after + extending app self-check backup coverage, 64 tests. Passed again after adding + the Settings test-notification action and channel coverage, 67 tests. Passed + again after adding Settings widget coverage for the test-notification action, + 68 tests. Passed again after adding Web entry cache-busting coverage and + hardening loading-screen hiding, 69 tests. Passed again after adding recipe + cooking inventory-deduction coverage, 70 tests. Passed again after adding + Settings widget coverage for the app self-check 17/17 result, 71 tests. + Passed again after adding Web deployment header coverage, 72 tests. Passed + again after adding Settings backup reminder widget coverage, 73 tests. + Passed again after adding Settings order-recognition key privacy widget + coverage, 74 tests. Passed again after fixing order-recognition clear + persistence and adding clear-confirmation coverage, 76 tests. Passed again + after adding recipe-preference store and Settings save coverage, 80 tests. + Passed again after adding Settings demo-data reset confirmation coverage, 81 + tests. Passed again after adding order-import review widget coverage, 82 + tests. Passed again after adding shopping conversion confirmation coverage, + 83 tests. Passed again after adding notification sync payload handoff + coverage, 85 tests. Passed again after fixing macOS order-recognition secure + key storage, 86 tests. Passed again after removing the user-facing Settings + self-check controls while keeping the internal coverage, 86 tests. Passed + again after making macOS notification scheduling wait for native add + completion and report scheduling failures, 87 tests. Passed again after + adding WebDAV cloud-backup configuration/upload/restore coverage and + Web-facing access copy, 97 tests. Passed again after adding a local HTTP + WebDAV round-trip for MKCOL, PUT, PROPFIND, and GET restore flow, 100 tests. + Passed again after adding WebDAV restore-cancellation coverage, 101 tests. + Passed again after adding AppShell notification tap-to-detail route coverage, + 102 tests. Passed again after adding AppShell launch-notification target + routing coverage, 103 tests. Passed again after adding Android native + malformed-reminder-id guard coverage, 104 tests. Passed again after adding + launch notification target filtering coverage, 105 tests. Passed again after + fixing WebDAV password-field cleanup and stale error feedback during retry, + 106 tests. Passed again on the current worktree on 2026-06-19 with 106 tests; + the first sandboxed attempt was blocked by the pub.dev advisories DNS check, + and the same command passed after network access was allowed. Passed again + after the macOS AppDelegate launch fix, still with 106 tests; as before, the + sandboxed first attempt was blocked by the pub.dev advisories DNS check and + the same command passed after network access was allowed. Passed again after + adding the source-level user-facing copy guard, now with 107 tests. + Passed again after adding the debug-only running-app notification status + extension, still with 107 tests. Passed again on 2026-06-20 on the current + worktree with 109 tests after the WebDAV, debug smoke payload, and platform + validation record work. Passed again on 2026-06-20 after the macOS + notification smoke recheck, with 109 tests and `All tests passed!`. Passed + again on 2026-06-20 after adding the Android environment gate, with 112 tests + and `All tests passed!`. Passed again on 2026-06-20 after adding the macOS + secure-storage runtime smoke extension, with 113 tests and + `All tests passed!`. Passed again on 2026-06-21 from the current worktree + with `HOME=/private/tmp/vibe-fridge-sendtest-home flutter test --no-pub`; + the first sandboxed attempt was blocked by Flutter tester's local + server-socket bind, and the same command passed after allowing local port + binding, with 113 tests and `All tests passed!`. +- Current core workflow targeted recheck on 2026-06-21 with `flutter test + --no-pub test/inventory_repository_test.dart test/items_screen_test.dart + test/order_import_review_screen_test.dart test/recipes_screen_test.dart + test/recipe_suggestion_service_test.dart test/app_shell_widget_test.dart + test/app_error_snackbar_test.dart`: passed with 31 tests and `All tests + passed!`. The first sandboxed attempt was blocked by Flutter tester's local + server-socket bind; the same command passed after allowing local port + binding. +- `flutter test test/app_error_snackbar_test.dart`: passed after clarifying + the generic error snackbar copy action and covering that technical details + stay hidden from the visible message. +- `flutter test test/app_shell_widget_test.dart`: passed after adding widget + coverage that a local-notification tap callback is consumed by AppShell and + opens the matching inventory detail route with the expected quantity and + storage-location content. Passed again after adding coverage that AppShell + opens the matching inventory detail when a launch notification target is + already pending as the shell starts. +- `flutter test test/inventory_repository_test.dart`: passed after the + no-date order duplicate fix and backup-reminder copy cleanup, 19 tests. + Passed again after adding inventory-table export coverage for commas, + quotes, and newline characters in user-entered fields, 20 tests. Passed + again after adding backup round-trip coverage for inventory tags, reminder + logs, and shopping-list data, 21 tests. Passed again after extending the + internal app self-check to verify backup content includes inventory, tags, + reminder logs, and shopping-list data, 21 tests. Passed again after adding + recipe cooking deduction to app self-check, 21 tests. - `flutter analyze`: passed after the latest beta fixes including Web route - cleanup, direct Web detail URL hash cleanup, notification channel coverage, - and the edit-page Material fix. + cleanup, direct Web detail URL hash cleanup, the `MaterialApp.router` + browser-history fix, notification channel coverage, the edit-page Material + fix, and no-date order duplicate handling; passed again during final route + code review, after the final Settings copy cleanup, and after adding the + notification tap and permission-to-sync controller tests, after the macOS + native notification request builder extraction, and after the Web entry cache + and loading-screen fixes, and after adding recipe cooking deduction coverage, + after adding former Settings self-check result coverage, and after adding Web + deployment headers, after adding Settings backup reminder coverage, after + adding Settings order-recognition key privacy coverage, and after fixing + order-recognition clear persistence, and after adding recipe-preference + coverage, and after adding Settings demo-data reset confirmation coverage, + after adding order-import review widget coverage, and after adding shopping + conversion confirmation coverage, and after adding notification sync payload + handoff coverage, and after adding macOS inventory-reminder content/trigger + wiring coverage, and after fixing macOS order-recognition secure key storage, + with no issues. Passed again on the current worktree on 2026-06-19 after + allowing the pub.dev advisories network check. Passed again after adding the + debug-only running-app notification status extension. Passed again on + 2026-06-20 on the current worktree with `No issues found!`. Passed again on + 2026-06-20 after the macOS notification smoke recheck with + `No issues found! (ran in 1.5s)`. Passed again on 2026-06-21 from the + current worktree with `flutter analyze --no-pub` and `No issues found!`. - `flutter test test/local_notification_service_test.dart`: passed after the - Android reminder scheduler refactor, 6 tests. + Android reminder scheduler refactor, notification tap controller handoff + coverage, notification permission-to-sync controller coverage, and Settings + test-notification flow coverage, 12 tests. Passed again after adding + coverage that granted notification sync sends the real pending-reminder + payload to the platform and denied permission skips platform scheduling, 14 + tests. Passed again after adding coverage that a native scheduling + `PlatformException` reports `同步失败,请稍后重试` instead of a successful sync, + 15 tests. Passed again after adding coverage that launch notification targets + return only non-empty platform item ids, 17 tests. Passed again after + normalizing notification tap and launch-target item ids by trimming + surrounding whitespace and ignoring blank payloads, 17 tests. +- `flutter test test/debug_service_extensions_test.dart + test/local_notification_service_test.dart`: passed after extracting the + debug VM Service notification payload builders and covering the exact status + and test-notification JSON shapes consumed by the smoke helper, 19 tests. + The first sandboxed attempt was blocked by the pub.dev advisories DNS check; + the same command passed after network access was allowed. Passed again after + adding the secure-storage smoke payload and temporary-key cleanup coverage. +- `flutter test test/settings_screen_test.dart`: passed after adding widget + coverage that renders the Settings test-notification action and verifies the + button calls the notification service path. Historical coverage previously + tapped `运行自检` and verified the user-visible `应用自检通过:17/17` result; + this was later replaced when the developer-only entry was hidden. Passed + again after adding widget coverage for + the pending backup reminder card and its user-facing copy. Passed again after + adding widget coverage that verifies a stored order-recognition key is shown + as configured without exposing the saved secret. Passed again after adding + widget coverage for the order-recognition clear confirmation flow. Passed + again after adding widget coverage for saving recipe preferences from user + inputs and reloading the normalized values into the fields. Passed again + after adding widget coverage that confirms demo-data reset cancellation does + not run the reset and confirmation reports the cleared-row count. Passed + again after replacing the Settings self-check action coverage with a + regression check that developer-only self-check controls are not rendered. + Passed again after adding coverage that cancelling a WebDAV restore leaves + the current inventory unchanged. Passed again after covering that a failed + WebDAV connection retry clears the stale authentication error once the retry + succeeds, 12 Settings tests. Current targeted recheck on 2026-06-21 with + `flutter test --no-pub test/settings_screen_test.dart + test/user_facing_copy_test.dart test/webdav_backup_store_test.dart + test/webdav_backup_service_test.dart` passed with 21 tests. The first + sandboxed attempt was blocked by the SQLite native asset download DNS check; + the same command passed after allowing network access. +- `flutter test test/order_import_review_screen_test.dart`: passed after + adding widget coverage that low-confidence rows remain excluded until marked + confirmed, the primary import count updates, the batch-confirmation copy is + shown, and the controller receives the confirmed items. +- `flutter test test/items_screen_test.dart`: passed after adding widget + coverage that the shopping view asks for confirmation before converting + checked items, the cancel path does not call conversion, and confirmation + reports the `已入库 1 项` success feedback. +- `flutter test test/recipe_preferences_store_test.dart`: passed after adding + coverage for default first-run preferences, trimmed text persistence, bounded + time/serving values, and damaged persisted numeric values. +- `flutter test test/vlm_settings_store_test.dart`: passed after verifying + fresh settings still load the default endpoint/model while the explicit + clear action persists blank endpoint/model values and removes the secure API + key. Passed again after verifying VLM API key storage disables the macOS + Data Protection Keychain path that caused local debug saves to fail with + security result `-34018`, 5 tests. +- `flutter test test/android_notification_wiring_test.dart`: passed after + adding Android source-level checks for notification manifest permissions, + receivers, method-channel names, payload keys, scheduling persistence, + boot/package-update restoration, and click handoff wiring. Passed again after + adding source-level coverage that Android skips blank reminder item ids and + non-positive scheduled times before scheduling or showing a notification. + Passed again after adding scheduled-receiver permission-revocation guards and + `SecurityException` handling, 3 tests. Passed again after covering Android + launch intent target normalization before forwarding taps to Flutter, 3 + tests. Passed again on 2026-06-20 after adding the reusable Android + environment checker, still with 3 tests. Rechecked from the current worktree + with `flutter test --no-pub test/android_notification_wiring_test.dart`: + passed with the same 3 source-level Android notification wiring tests. +- `flutter test test/macos_notification_wiring_test.dart`: passed after adding + macOS source-level checks for method-channel names, native bridge methods, + notification payload parsing, delegate presentation behavior, and system tap + handoff into the launch target. Passed again after moving test-notification + request construction into the native request factory. Passed again after + verifying scheduled inventory reminders use the native content and trigger + helpers. Passed again after updating the macOS bridge contract coverage for + asynchronous native scheduling completion and `schedule_failed` propagation. + Passed again after covering macOS bridge item-id trimming before invoking + Flutter. Passed again after adding coverage that the AppDelegate no longer + calls FlutterAppDelegate's unimplemented optional + `applicationDidFinishLaunching` selector. +- `flutter test test/web_bootstrap_test.dart`: passed after adding the custom + Web bootstrap guard that clears stale service workers without registering a + replacement Flutter service worker; passed again after extending the guard to + delete stale Flutter Cache Storage entries; passed again after adding + no-cache entry hints, a cache-busted bootstrap script request, inline + loading-screen hide styles, and HTTP header policy coverage. Rechecked from + the current worktree on 2026-06-21 with + `flutter test --no-pub test/web_bootstrap_test.dart`: passed with 3 tests + after downloading the SQLite native test asset. +- `flutter test test/bootstrap_error_app_test.dart + test/app_database_web_wiring_test.dart test/web_bootstrap_test.dart`: passed + on 2026-06-20 after moving Flutter Web startup to a Flutter-owned bootstrap + app. The test coverage now proves the loading UI is rendered before + inventory initialization finishes, failed initialization routes to a + user-safe copy-details page without exposing paths or SQLite details, and + the Web database factory avoids both the shared-worker path and the + main-thread no-worker path. +- `flutter build web --debug --no-pub --no-wasm-dry-run`: passed on + 2026-06-20 after the bootstrap app change. A local static server on + `http://127.0.0.1:54424/` served the rebuilt `main.dart.js`; direct HTTP + checks confirmed that the served runtime contains + `VibeFridgeBootstrapLoadingApp` and the Settings WebDAV copy. A full + Playwright visual smoke could not be completed in this environment because + the bundled Playwright browser was not installed and system Microsoft Edge + aborted in headless automation, so this entry proves build and served + runtime content, not a fresh visual browser pass. +- `flutter test test/webdav_backup_service_test.dart`: passed after adding a + `dart:io` local WebDAV server that requires Basic Auth and exercises the + real service through MKCOL directory creation, PUT backup upload, PROPFIND + listing, and GET restore download against the same local endpoint. +- Browser WebDAV smoke on `http://127.0.0.1:54391/?route=settings`: passed + against a temporary CORS-enabled local WebDAV endpoint on + `http://127.0.0.1:54392/`. The browser flow saved WebDAV configuration, + verified that the password input DOM value is cleared after save, confirmed + a successful connection and upload do not retain the earlier authentication + error copy, and the smoke endpoint recorded MKCOL, PROPFIND, and PUT + requests plus two uploaded backup JSON files. Rechecked on 2026-06-19 after + the earlier local smoke endpoint had stopped: Settings first showed the + user-actionable network error copy with no console warnings/errors; after + starting a fresh local WebDAV endpoint, re-saving `codex/codex` + credentials, and verifying the password field no longer exposed the typed + value, `测试连接` showed `WebDAV 连接可用` and `上传备份` showed + `备份已上传到 WebDAV` with the latest uploaded file name. +- Browser WebDAV restore smoke on + `http://127.0.0.1:54393/?route=settings`: passed against a temporary + CORS-enabled local WebDAV endpoint on `http://127.0.0.1:54394/`. The browser + flow saved WebDAV configuration, uploaded a backup, opened the restore + confirmation dialog naming the latest cloud backup, confirmed restore, + showed `已从 WebDAV 恢复:...`, then verified the configuration still worked + by running a successful connection test. The smoke endpoint recorded PUT, + Depth 1 PROPFIND, GET download, and the post-restore Depth 0 PROPFIND. +- Browser WebDAV clear-configuration smoke on + `http://127.0.0.1:54395/?route=settings`: passed after saving a WebDAV + configuration with a password. The clear action showed a confirmation dialog + explaining that the service address, account, and saved password would be + removed; after confirming, Settings returned to `云端备份 未配置`, removed the + saved-password status row, cleared all WebDAV input values, and showed + `WebDAV 配置已清空`. +- Current mobile Web route smoke on `http://127.0.0.1:54396/`: passed on + 2026-06-20 at 390 x 844. Home rendered the dashboard with today actions, + expiring inventory, and category distribution. Direct routes for + `?route=items`, `?route=recipes`, and `?route=settings` initially showed the + Flutter-owned loading state, then settled to the item catalog, recipe + suggestions, and Settings content within the smoke wait. Browser logs showed + no warnings or errors on the checked routes. Settings scrolling confirmed + WebDAV backup, local notification fallback, recipe preferences, and order + recognition settings remain visible while `应用自检` / `运行自检` controls did + not reappear. +- Current mobile Web add/delete smoke on `http://127.0.0.1:54396/?route=add`: + passed on 2026-06-20 at 390 x 844. The Add flow accepted a unique item name + and unit, saved the item, navigated back to the item catalog, and showed the + new item with quantity `1` and unit copy. Opening the saved item showed the + item profile plus one active inventory batch. Attempting to delete the + profile before deleting its active batch correctly showed a user-facing + blocker message. Deleting the inventory batch required confirmation, returned + to the profile with `暂无库存`, then deleting the empty profile required + confirmation and returned to the catalog with the test item removed. Browser + logs showed no warnings or errors throughout the flow. +- Current mobile Web shopping-list smoke on + `http://127.0.0.1:54396/?route=items&view=shopping`: passed on 2026-06-20 at + 390 x 844. The shopping view loaded with the `view=shopping` route parameter + and showed replenishment suggestions plus empty pending/purchased sections. + A unique manual shopping item was added with unit `袋`, checked into the + purchased section, and converted through the `采购项入库` confirmation. The app + showed `已入库 1 项`, the item appeared in the catalog search as inventory with + quantity `1`, unit `袋`, and one active batch, then the created batch and empty + item profile were deleted. Browser logs showed no warnings or errors + throughout the flow. +- Current mobile Web order-recognition settings smoke on + `http://127.0.0.1:54397/?route=settings`: passed on 2026-06-20 at 390 x 844. + The Settings page loaded the `订单识别 AI` card with status `未配置` and key + status `本机安全保存`. Entering a fake local API secret and saving showed + `订单识别配置已保存`, updated the status to `已配置`, and kept the secret field + blank with the helper text that saved secrets are not shown. Reloading + Settings preserved `已配置` without exposing the secret. The clear action + showed a confirmation dialog explaining that service address, model name, and + saved API secret would be removed; after confirming, the status returned to + `未配置`, all order-recognition fields were blank, and a second reload kept + them blank. Browser logs showed no warnings or errors throughout the flow. +- Current mobile Web demo-data reset smoke on + `http://127.0.0.1:54404/?route=settings`: passed on 2026-06-20 at 390 x 844. + A unique user-created inventory item was added first, then the built-in + no-inventory sample profile `牙膏` was deleted through its confirmation + dialog and verified absent from catalog search. Settings then opened the + `重置示例数据` confirmation dialog with copy explaining that only built-in + sample profiles/inventory are rebuilt and user-created data is not deleted. + After confirming, the app showed `示例数据已重置,清理 8 条旧示例数据`, catalog + search showed `牙膏` restored, and the user-created test item was still + present with quantity `1` and one active batch. The test item and its empty + profile were then deleted through the UI. Browser logs showed no warnings or + errors throughout the flow. +- Current mobile Web inventory-edit smoke on + `http://127.0.0.1:54405/?route=add`: passed on 2026-06-20 at 390 x 844. A + unique inventory item was created with an initial description and unit, then + opened through item profile and inventory detail. The edit screen preserved + the existing quantity, unit, location, and description values. Updating the + quantity to `3`, unit to `盒`, storage location to `冷藏`, and description to + `编辑后描述` saved successfully and returned to inventory detail. The detail + summary showed `3盒`, the storage suggestion updated to `冷藏`, and the facts + card showed quantity `3盒`, location `冷藏`, and description `编辑后描述`. The + edited batch and empty item profile were then deleted through the UI. Browser + logs showed no warnings or errors throughout the flow. +- Current mobile Web order-text import smoke on + `http://127.0.0.1:54406/?route=add`: passed on 2026-06-20 at 390 x 844. The + Add screen opened the `粘贴订单文本` dialog, accepted a unique pasted order + containing two inventory lines and a standalone order id, then showed the + review screen with `2/2 已选` and `2 可入库`. The first parsed item showed + quantity `4` and unit `个`; the second showed quantity `2` and unit `盒`. + Confirming `添加 2 个物品` opened the batch confirmation with `新增 2`, + `重复跳过 0`, and `仍需确认 0`, then the result dialog showed `导入完成`, + `新增 2`, `跳过 0`, and `需要手动处理 0`. Catalog search verified the imported + apple item as quantity `4` with one active batch and the yogurt item as + quantity `2` with one active batch. Both imported batches and their empty + item profiles were then deleted through the UI. Browser logs showed no + warnings or errors throughout the flow. +- Current mobile Web today-action reminder smoke on + `http://127.0.0.1:54407/`: passed on 2026-06-20 at 390 x 844. Home loaded + with `今天要处理 2 件` and `提醒到期 2`; tapping the `提醒到期 2` summary opened + `?route=items&focus=reminderDue` with `面包` and `鲜牛奶` in the focused list. + Returning to Home and using `稍后` on `面包` changed the list from 2 items to + 1 and showed `今天稍后再提醒:面包`. Using `忽略` on `鲜牛奶` changed the section + to `0 项`, showed `今天没有待处理`, and the summary card updated to `0 件`, + `已过期 0`, `今日到期 0`, and `提醒到期 0`, with feedback + `今天不再提醒:鲜牛奶`. Settings `重置示例数据` then restored the sample state, + and Home returned to `今天要处理 2 件` / `提醒到期 2`. Browser logs showed no + warnings or errors throughout the flow. +- Current mobile Web recipe-cooking smoke on + `http://127.0.0.1:54408/?route=recipes`: passed on 2026-06-20 at 390 x 844. + The Recipes page initially listed priority consumables `面包 1袋`, + `鲜牛奶 2盒`, and `鸡蛋 12个`, and `快手蛋奶早餐` showed `消耗 3 项`. + Opening the recipe detail preserved the hash-free route + `?route=recipes%2Fquick-breakfast`, showed the exact inventory deductions + `鸡蛋 1个`, `鲜牛奶 1盒`, and `面包 1袋`, plus possible missing seasonings. + Using `做这道菜并扣减库存` returned to `?route=recipes`, showed the rule list + with priority consumables reduced to `鲜牛奶 1盒` and `鸡蛋 11个`, and the + recipe card changed to `消耗 2 项`. Catalog search for `面包` then showed the + profile with `0` active batches. Settings `重置示例数据` restored the sample + state, and the Recipes page returned to `面包 1袋`, `鲜牛奶 2盒`, `鸡蛋 12个`, + and `消耗 3 项`. Browser logs showed no warnings or errors throughout the + flow. +- Current mobile Web AI-recipe fallback smoke on + `http://127.0.0.1:54409/?route=recipes`: passed on 2026-06-20 at 390 x 844. + With no AI service configured, the Recipes page showed the `AI 食谱` card with + the primary `生成食谱` action and the rule-based recipe list still visible. + Tapping `生成食谱` changed the card status to `规则兜底`, showed the + user-facing message `AI 食谱未配置,已使用规则建议`, kept the same rule suggestions + available below the card, and exposed the reset icon action. Using the reset + action returned the card to the initial `生成食谱` state without leaving the + Recipes route. Browser logs showed no warnings or errors throughout the flow. +- Current mobile Web WebDAV backup smoke on + `http://127.0.0.1:54410/?route=settings`: passed on 2026-06-20 at 390 x 844 + against a temporary CORS-enabled local WebDAV endpoint on + `http://127.0.0.1:54411/dav`. Settings saved `codex/codex` credentials, + changed `云端备份` to `已配置`, showed `密码状态 本机安全保存`, and cleared the + active password field with helper copy saying the saved password is not shown. + `测试连接` showed `WebDAV 连接可用`; `上传备份` showed + `备份已上传到 WebDAV` plus latest-upload filename + `vibe-fridge-backup-20260620-154621.json`. The smoke endpoint recorded MKCOL + for `/dav/vibe-fridge/` and `/dav/vibe-fridge/backups/`, Depth 0 PROPFIND for + the backup directory, and PUT for the uploaded backup file. The clear action + showed `清空 WebDAV 配置` confirmation copy for service address, account, and + saved password, then returned Settings to `云端备份 未配置` with blank service + address, account, and password fields. Browser logs showed no warnings or + errors throughout the flow. +- Current WebDAV restore smoke on + `http://127.0.0.1:54412/?route=settings`: passed on 2026-06-20 against a + temporary CORS-enabled local WebDAV endpoint on + `http://127.0.0.1:54413/dav`. The in-app browser rendered this pass at its + default wide viewport after reconnect, so this validates the current build's + cloud-restore behavior rather than a mobile breakpoint. Settings saved + `codex/codex` credentials with remote directory `codex`, showed + `WebDAV 连接可用`, uploaded + `vibe-fridge-backup-20260620-155516.json`, opened a restore confirmation + dialog naming that backup and explaining that current data would be replaced + after keeping a current backup, then restored successfully with + `已从 WebDAV 恢复:vibe-fridge-backup-20260620-155516.json` and + `最近恢复:vibe-fridge-backup-20260620-155516.json`. A post-restore + `测试连接` still showed `WebDAV 连接可用`, proving the saved configuration and + password remained usable after restore. The smoke endpoint recorded MKCOL, + Depth 0 and Depth 1 PROPFIND, PUT upload, GET restore download, and a + post-restore Depth 0 PROPFIND; the uploaded backup file was 7755 bytes. + Browser logs showed no warnings or errors throughout the flow. +- Current mobile Web local-export smoke on + `http://127.0.0.1:54414/?route=settings`: passed on 2026-06-20 at 390 x 844. + Settings loaded after the startup inventory screen, showed the Data and + Backup card, and triggered both local export actions from the first screen. + `导出备份` showed `备份已导出`, and `导出库存表格` showed `库存表格已导出`; browser + logs showed no warnings or errors. The in-app browser did not emit a + Playwright `download` event for either programmatic save within the 8-second + observation window, so this pass verifies the current UI flow and success + feedback. The implementation path still reaches `XFile.saveTo` before + showing either success snackbar, which keeps the smoke aligned with the + actual save operation rather than a purely cosmetic button tap. +- Current mobile Web item-profile edit smoke on + `http://127.0.0.1:54415/?route=items%2Fwiki%2Fwiki-milk`: passed on + 2026-06-20 at 390 x 844. The direct `鲜牛奶` item-profile route loaded with + category, default unit, suggested shelf life, default reminder, storage + location, active-batch count, and its active batch. Using the edit icon + opened `编辑物品资料` with editable name, category, icon, description, default + unit, shelf-life, reminder, storage-location, and notes fields. Adding + `内测资料编辑1605` to the description and `内测备注1605` to notes, then using + `保存资料`, returned to the item-profile detail without leaving the route; + the header immediately showed the updated description and the facts card + showed the new notes row. Settings `重置示例数据` then showed clear + confirmation copy, reset 9 sample rows, and a return to the direct + `鲜牛奶` profile proved the default description was restored and the notes + row disappeared. Browser logs showed no warnings or errors throughout the + flow. +- Current mobile Web recipe-preference smoke on + `http://127.0.0.1:54416/?route=settings`: passed on 2026-06-20 at 390 x 844. + Settings scrolled to `食谱偏好` with default blank preference fields plus + `30` minutes and `2` people. Entering `清淡内测1608`, `不吃辣1608`, + `电饭煲1608`, `28` minutes, and `4` people, then using `保存食谱偏好`, showed + `食谱偏好已保存`. Reloading the Settings route and scrolling back to the same + card showed all five values persisted from local storage. The smoke then + cleared the three text fields, restored `30` minutes and `2` people, saved + again, and saw the same success feedback so later tests start from the + default preference state. Browser logs showed no warnings or errors + throughout the flow. +- Current mobile Web legacy-import preview smoke on + `http://127.0.0.1:54417/?route=settings`: passed on 2026-06-20 at 390 x 844. + Settings opened with `库存批次 4`, `物品资料 5`, and a visible + `导入旧版库存` action. Tapping it opened `预览旧版库存导入` without a Flutter Web + asset error. The bundled empty legacy payload produced readable zero-count + rows for `待导入`, `将新增`, `将更新`, and `将跳过`, showed the + `导入前清空示例资料/库存` checkbox with copy that only built-in examples would + be cleared while categories and user data are retained, and kept + `确认导入` disabled because there were no importable rows. Using `取消` + returned to Settings with the same inventory/profile counts and no import + result summary or snackbar. Browser logs showed no warnings or errors + throughout the flow. +- Current mobile Web shopping-management smoke on + `http://127.0.0.1:54418/?route=items&view=shopping`: passed on 2026-06-20 + at 390 x 844. The Shopping view loaded with replenishment suggestions and + empty `待采购` / `已买到` sections. The top `添加` action opened the + `添加采购项` sheet; saving `购物管理1609番茄` with quantity `2`, unit `袋`, and + note `初始备注1609` created one pending item with the expected quantity and + note summary. The row overflow menu opened `编辑采购项`; changing the item to + `购物管理1609番茄编辑`, quantity `5`, unit `盒`, and note `编辑备注1609` saved back + to the pending list and refreshed the visible row. Checking the item moved it + from `待采购 1` to `已买到 1` and revealed the `入库` action; unchecking it + moved it back to `待采购 1` and returned `已买到` to `0`. The row menu's + destructive `删除采购项` confirmation named the edited item; confirming delete + returned both shopping sections to `0`. Browser logs showed no warnings or + errors throughout the flow. +- Current mobile Web consume/restore smoke on `http://127.0.0.1:54419/`: + passed on 2026-06-20 at 390 x 844 against a freshly rebuilt Web debug build. + Directly opening `?route=items%2Fitem%2Fitem-bread-1` showed the `面包` + inventory detail as `使用中`, quantity `1袋`, and quantity controls enabled. + The bottom `标记已消耗` action showed a confirmation dialog with clear + destructive copy; confirming returned to the catalog where `面包` had + `0` batches. Opening the `历史` tab showed `面包` as `已消耗`; opening that + history record showed disabled quantity controls and the consumed state. + Using `恢复为使用中` restored the detail state to `使用中`, re-enabled quantity + controls, returned History to `暂无历史记录`, and returned the catalog to + `面包` with `1` batch. Browser logs showed no warnings or errors throughout + the flow. +- Current mobile Web full-field manual-add smoke on + `http://127.0.0.1:54420/?route=add`: passed on 2026-06-20 at 390 x 844 + against a freshly rebuilt Web debug build. Saving with a blank name kept the + user on the Add page and highlighted `物品名称` with `请输入物品名称`. Filling a + unique item `完整添加1620燕麦`, selecting tags `临期优先` and `囤货`, changing + category to `日用品`, storage location to `冷藏`, description to + `完整字段验证描述1620`, quantity to `3`, and unit to `袋` saved successfully and + returned to the catalog. The catalog row showed quantity `3`, category + `日用品`, location `冷藏`, unit `袋`, and one active batch. The item-profile + detail showed description, default unit, category, default reminder, storage + location, and `使用中批次 1`; the inventory row showed `3袋` and tag summary. + The inventory detail facts card showed quantity `3袋`, category `日用品`, + purchase date `2026-06-20`, unset expiry/reminder date, enabled reminder, + storage location `冷藏`, and the saved description; the tags card showed + `囤货` and `临期优先`. The created batch was deleted through its named + destructive confirmation, the empty item profile was deleted through its + named destructive confirmation, and catalog search for the unique name then + showed `没有匹配物品`. Browser logs showed no warnings or errors throughout the + flow. +- Current mobile Web item-profile batch-delete smoke on + `http://127.0.0.1:54421/`: passed on 2026-06-20 at 390 x 844 against a + freshly rebuilt Web debug build. Adding `批量删除1621麦片` twice created a + single item profile with total quantity `3`, unit `袋`, and `2` active + batches. Opening the item profile showed `使用中批次 2` and two inventory rows + (`1` and `2袋`). Entering `批量` mode showed `已选择 0 项`, `全选`, `消耗`, + `改分类`, `改位置`, and `删除`; using `全选` changed the header to + `已选择 2 项` and visually selected both rows. `删除` opened a + `批量删除库存` confirmation that named the selected count; confirming returned + to the profile with `使用中批次 0`, the `暂无库存` empty state, and feedback + `已删除 2 条库存记录`. Deleting the now-empty item profile showed a named + destructive confirmation, returned to the catalog, and searching the unique + name showed `没有匹配物品`. Browser logs showed no warnings or errors + throughout the flow. +- Current mobile Web shopping-note conversion smoke on + `http://127.0.0.1:54422/?route=items&view=shopping`: passed on 2026-06-20 at + 390 x 844 against a freshly rebuilt Web debug build. The Shopping view loaded + with empty `待采购` and `已买到` sections. Adding `采购转库存1622酸奶` with + quantity `4`, unit `盒`, and note `采购备注应保留1622` created a pending row + showing `4盒` plus the note summary. Checking the row moved it to `已买到 1` + and revealed `入库`; the `采购项入库` confirmation clearly said one purchased + item would be converted into an inventory record. Confirming conversion + returned both shopping sections to `0` and showed `已入库 1 项`. Catalog + search found the converted inventory as quantity `4`, unit `盒`, `未分类`, + and one active batch. The item-profile detail remained `暂无描述`, proving the + shopping note did not pollute the profile description; the inventory detail + facts card showed `描述 采购备注应保留1622`, proving the shopping note was + preserved on the inventory batch. The converted batch and empty item profile + were then deleted through their named destructive confirmations, and searching + the unique name showed `没有匹配物品`. Browser logs showed no warnings or errors + throughout the flow. +- `flutter test test/recipes_screen_test.dart`: passed after adding coverage + that applies a recipe's inventory uses through `InventoryController` and + verifies the real inventory quantities are deducted. - `flutter build web --debug --no-wasm-dry-run`: passed after the latest beta fixes including Web route cleanup, direct Web detail URL hash cleanup, - startup error copy coverage, and the edit-page Material fix. + startup error copy coverage, the edit-page Material fix, and no-date order + duplicate handling; passed again after extending the native Web loading + screen delay to cover desktop CanvasKit font settling, again before the + latest Settings smoke check, and again as a final current-worktree Web build + gate after the browser-history route fix. Passed again after pushing the + beta notification and routing validation commit, then served on port 54390 + for post-push smoke testing. Passed again after adding the custom Web + bootstrap; generated `flutter_bootstrap.js` now calls `_flutter.loader.load()` + without `serviceWorkerSettings` and contains the stale-registration cleanup. + Passed again after adding no-cache entry hints and a cache-busted bootstrap + request to `index.html`. Passed again after adding recipe cooking deduction + to the app self-check. Passed again after adding `_headers`; the generated + `build/web/_headers` contains no-cache policy for `index.html`, + `flutter_bootstrap.js`, `flutter.js`, `main.dart.js`, and `sqflite_sw.js`. + Passed again after removing the user-facing Settings self-check controls. + Passed again after adding no-cache policy for Flutter's generated + `flutter_service_worker.js`. Passed again before the browser WebDAV smoke + that verified password cleanup, retry feedback, and upload. Passed again on + the current worktree on 2026-06-19 after allowing the pub.dev advisories + network check. Passed again after adding the user-facing copy guard; the + generated `build/web/_headers` still contains no-cache/no-store policy for + entry scripts and service-worker files, and the generated Web entry still + uses a cache-busted bootstrap request plus stale service-worker/cache cleanup. + Passed again on 2026-06-20 with the bundled Flutter SDK; `build/web` was + generated and `build/web/_headers` still includes no-cache/no-store policy + for `index.html`, `flutter_bootstrap.js`, `flutter.js`, + `flutter_service_worker.js`, `main.dart.js`, and `sqflite_sw.js`. Passed + again on 2026-06-21 with `flutter build web --debug --no-pub + --no-wasm-dry-run`; generated `build/web/_headers` still includes + no-cache/no-store policy for the same entry scripts and service-worker files, + and `build/web/index.html` still loads `flutter_bootstrap.js` with a + timestamp cache buster. +- `flutter build macos --debug`: passed after the user completed the local + Xcode installation and license flow. The build produced + `build/macos/Build/Products/Debug/vibe-fridge.app`; Flutter also generated + Swift Package Manager integration for the macOS project and warned that + `flutter_secure_storage_macos` still uses CocoaPods. Passed again after + extracting and testing the macOS notification request builder and permission + status mapper, again after adding the native tap payload handoff helper, + again after the Web entry cache and loading-screen fixes, and again after + extracting inventory-reminder content/trigger construction into tested + helpers. Passed again after fixing macOS order-recognition secure key + storage without requiring a development-certificate keychain entitlement. + Passed again after routing macOS inventory reminder scheduling through an + async scheduler that waits for native add completion and propagates failures. + Passed again on the current worktree on 2026-06-19; the build produced + `build/macos/Build/Products/Debug/vibe-fridge.app`. Flutter still warns that + `flutter_secure_storage_macos` has not adopted Swift Package Manager support, + and Xcode emitted stale DerivedData path warnings, but neither blocked the + app build. Passed again on 2026-06-20 with the bundled Flutter SDK, producing + `build/macos/Build/Products/Debug/vibe-fridge.app`; the same + `flutter_secure_storage_macos` Swift Package Manager adoption warning remains + informational for this build. +- `xcodebuild -checkFirstLaunchStatus`: returned success on 2026-06-19, + confirming the local Xcode first-launch setup is no longer blocking macOS + build/test validation. +- `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration + Debug -destination 'platform=macOS' -derivedDataPath + /private/tmp/vibe-fridge-xcode-derived-empty-launch + -clonedSourcePackagesDirPath + /private/tmp/vibe-fridge-xcode-spm-empty-launch`: + passed after fixing the RunnerTests `TEST_HOST` product path and importing + the app module as `vibe_fridge`. RunnerTests now covers macOS notification + request construction, malformed payload skipping, the 60-second minimum + notification trigger delay, immediate test-notification request construction, + native permission-status channel mapping, and native tap payload handoff into + the launch target plus Flutter event path. Passed again with 7 tests after + extracting the diagnostic test-notification request factory. Passed again + with 8 tests after adding direct inventory-reminder content construction and + future trigger coverage. Passed again with 9 tests after adding a native + Keychain write/read/delete smoke for VLM API secret storage without the Data + Protection Keychain entitlement path. Passed again with 11 tests after adding + native scheduler coverage for both successful pending-request handoff and + system add failure reporting. + Passed again on the current worktree on 2026-06-19 with 12 RunnerTests: + diagnostic notification request construction, Keychain secret smoke, + inventory-reminder request parsing, malformed payload skipping, scheduler + success/failure reporting, tap payload storage/event handoff, empty stored + launch-target cleanup, trigger timing, and permission-status channel mapping. + Passed again after adding real `UNUserNotificationCenter` status coverage, + with 13 passed RunnerTests and 1 skipped pending-request smoke because the + current test host is not authorized for notifications. Passed again after + adding native tap-handler coverage for whitespace-trimmed and blank + launch-target ids, again with 13 passed and 1 skipped. Passed again after + the AppDelegate launch fix, with 13 passed and 1 skipped. Passed again after + adding runtime notification-center delegate registration coverage, with 14 + passed and 1 skipped. Passed again on 2026-06-20 with 14 passed RunnerTests + and 1 skipped pending-request smoke because notification authorization has + not been granted for the test host. Passed again on 2026-06-20 after the + Web startup-bootstrap change using `xcodebuild test -workspace + Runner.xcworkspace -scheme Runner -configuration Debug -destination + 'platform=macOS' -derivedDataPath /private/tmp/vibe-fridge-macos-derived`: + `TEST SUCCEEDED`, 14 RunnerTests passed, and the same authorization-gated + pending-request smoke was skipped. +- `xcodebuild test -workspace Runner.xcworkspace -scheme Runner + -configuration Debug -destination 'platform=macOS' -derivedDataPath + /private/tmp/vibe-fridge-macos-derived`: passed on 2026-06-20 after adding + direct AppDelegate notification-response payload routing coverage: + `TEST SUCCEEDED`, 15 RunnerTests passed, and the same authorization-gated + pending-request smoke was skipped. +- `flutter test --no-pub test/local_notification_service_test.dart + test/app_shell_widget_test.dart test/macos_notification_wiring_test.dart`: + passed on 2026-06-20 after the AppDelegate payload-routing change, proving + the Dart-side notification channel, AppShell notification tap-to-detail route, + launch notification target route, and macOS static wiring tests still agree. + Rechecked on 2026-06-20 from the current worktree: `flutter test --no-pub + test/local_notification_service_test.dart test/app_shell_widget_test.dart` + passed with 20 tests after downloading the SQLite native test asset, and + `flutter test --no-pub test/macos_notification_wiring_test.dart` passed with + 2 tests. This keeps the automated notification tap/launch/payload-routing + evidence current while still not replacing real delivered-notification manual + validation. +- `flutter run -d macos`: launched the current debug macOS target on + 2026-06-19, built `vibe-fridge.app`, started the app process, and exposed a + Dart VM Service. The shell still reported `Failed to foreground app; open + returned 1`, so this remains a launch smoke rather than a manual UI or + delivered-notification validation. The run session was then terminated and + the app exited with `Application finished.` Passed again after adding the + debug-only notification-status VM Service extension; calling + `ext.vibe_fridge.notificationStatus` against the running app returned + `supported: true`, `granted: false`, `status: unknown`, and + `displayText: 未确认`, proving the live Flutter-to-macOS notification + permission channel is wired while confirming system notification permission + remains unapproved on this machine. +- `node tools/check_notification_status.mjs + http://127.0.0.1:57383/rRIjQRZY31c=/`: passed against the live macOS + `flutter run` VM Service. The tool found the main app isolate and returned + the same native-channel status payload: `supported: true`, `granted: false`, + `status: unknown`, `displayText: 未确认`. +- `node tools/check_notification_status.mjs --expect-supported true + --expect-granted false --expect-status unknown + http://127.0.0.1:57872/m5FH7TIIYx8=/`: passed against a fresh live macOS + `flutter run` VM Service, proving the reusable smoke helper can assert the + current notification permission state rather than only printing it. +- `node --check tools/check_notification_status.mjs`: passed after adding the + reusable VM Service smoke helper and again after adding expectation options. + Passed again after adding `--wait-extension-ms` support so the helper can + tolerate the app isolate appearing before debug extensions are registered. +- `HOME=/private/tmp/vibe-fridge-flutter-home node + tools/run_macos_notification_smoke.mjs --expect-supported true + --expect-granted false --expect-status unknown`: passed. The runner built and + launched the macOS debug app, parsed the VM Service URL, verified the live + notification status payload, then exited `flutter run` with `Application + finished.` Passed again after adding the optional `--send-test` path, + confirming the default runner remains read-only and still returns + `supported: true`, `granted: false`, `status: unknown`, `displayText: 未确认`. + Passed again after extracting and testing the debug notification payload + builders, proving the real launched app still registers the VM Service + extensions and the default smoke path still avoids permission prompts or + notification delivery. Rechecked on 2026-06-20 against the current + `codex/beta-user-testing` worktree with `node + tools/run_macos_notification_smoke.mjs --expect-supported true + --timeout-ms 180000`: the app built, launched, exposed + `http://127.0.0.1:64161/...`, returned `supported: true`, `granted: false`, + `status: unknown`, and `displayText: 未确认`, then exited cleanly with + `Application finished.`. Rechecked again after the Web startup-bootstrap + change with the same read-only command: the app exposed + `http://127.0.0.1:58130/...`, returned the same status payload, and exited + cleanly. Rechecked again after adding extension-registration waiting: the + app exposed `http://127.0.0.1:57168/...`, returned `supported: true`, + `granted: false`, `status: unknown`, and `displayText: 未确认`, then exited + cleanly without sending a notification. +- `node --check tools/run_macos_notification_smoke.mjs`: passed after adding + the automated macOS smoke runner and again after wiring its default + extension-registration wait into `tools/check_notification_status.mjs`. +- `node --check tools/check_secure_storage_smoke.mjs + tools/run_macos_secure_storage_smoke.mjs`: passed after adding the reusable + secure-storage VM Service helpers. +- `HOME=/private/tmp/vibe-fridge-flutter-home node + tools/run_macos_secure_storage_smoke.mjs --timeout-ms 180000`: passed on + 2026-06-20. The runner built and launched the macOS debug app, parsed the VM + Service URL, called `ext.vibe_fridge.secureStorageSmoke`, and got + `wrote: true`, `readBackMatches: true`, and `deleted: true` for a temporary + `debug.secure_storage_smoke` key, then exited `flutter run` cleanly. +- Tool argument guards: `node tools/check_notification_status.mjs + --expect-sent false ...` and `node tools/run_macos_notification_smoke.mjs + --expect-sent false` both fail before any runtime call unless `--send-test` + is present, protecting the default smoke from accidental permission prompts. +- `flutter build macos --debug`: passed again after removing the invalid + AppDelegate `super.applicationDidFinishLaunching(notification)` call and + produced `build/macos/Build/Products/Debug/vibe-fridge.app`. +- `open -n build/macos/Build/Products/Debug/vibe-fridge.app`: launched the + rebuilt debug app as process `vibe-fridge`; recent system logs showed the + app checked in as foreground, created a user notification center, and read + notification settings. The previous `applicationDidFinishLaunching` / + `NSInvalidArgumentException` log entries were not present in the repaired + launch window. +- `flutter build apk --debug`: attempted after the latest route and platform + checks. Dependency resolution completed, but the local environment could not + continue because no Android SDK was found; this did not produce an app compile + error, but leaves Android native runtime validation unproven in this + environment. Rechecked on 2026-06-20 with the bundled Flutter SDK after + dependency resolution completed: Flutter still stops with `[!] No Android SDK + found. Try setting the ANDROID_HOME environment variable.` `flutter emulators` + also reports no emulator sources, and `android/local.properties` only points + at the bundled Flutter SDK. Rechecked again on 2026-06-20 after the full + Flutter regression recheck: `flutter devices` still detected only macOS, + `flutter emulators` still reported no emulator sources, `flutter doctor -v` + still reported `Unable to locate Android SDK`, and `flutter build apk + --debug` again stopped before Android compilation with `[!] No Android SDK + found. Try setting the ANDROID_HOME environment variable.` Network resources + were available during this pass. Rechecked again from the current worktree + with `HOME=/private/tmp/vibe-fridge-flutter-home + ../.tools/flutter/bin/flutter build apk --debug --no-pub`: Flutter still + stopped immediately with `[!] No Android SDK found. Try setting the + ANDROID_HOME environment variable.`, so Android compile/runtime validation + remains blocked by host setup rather than a new app compile error. +- `node --check tools/check_android_environment.mjs`: passed after adding the + reusable Android runtime-gate helper. +- `node tools/check_android_environment.mjs`: currently exits non-zero, as + expected for this host, and returns a JSON report with + `ready: false`, `androidToolchainReady: false`, + `androidDeviceReady: false`, `emulatorAvailable: false`, `sdkPath: null`, + and blockers `Android SDK is not configured for Flutter` plus + `No Android device or emulator is available`. Rechecked from the current + worktree on 2026-06-20 with the same result; the report saw only the macOS + desktop device, no emulator sources, and `flutter doctor -v` still reported + `Unable to locate Android SDK`. This makes the Android runtime validation + blocker explicit and reproducible without attempting to install or launch + anything. +- Native notification bridge static review: Android and macOS implementations + use the same `vibe_fridge/local_notifications` method channel as Dart, + preserve `itemId` in scheduled notification payloads, expose launch/tap + callbacks back to Flutter, and include boot or app-start recovery paths where + the platform supports them. Android manifest/channel/payload-key wiring and + macOS bridge/delegate wiring are now covered by + `android_notification_wiring_test.dart` and + `macos_notification_wiring_test.dart`. No obvious channel-name, payload-key, + or click callback mismatch was found, but this does not replace runtime + validation on real Android/macOS environments. +- Final targeted user-facing copy grep: scanned Flutter screens, widgets, and + bootstrap UI for database, SQLite, migration, legacy, Wiki, JSON/id, and + related implementation wording. Remaining matches were internal identifiers, + file extensions, prompts, hidden diagnostics, or already-user-facing product + language. The only visible wording tightened in this pass was the order + recognition key copy, rephrased from storage terminology to + `密钥状态` / `本机安全保存` / `本机安全区域`. - Web build output now includes the native HTML loading screen used to cover Flutter Web's cold-start font fallback window. -- App self-check from Settings: passed, 15/15. +- Web bootstrap output now omits Flutter service-worker registration settings, + unregisters stale same-origin service workers, and deletes stale Flutter + Cache Storage entries before loading the app. A same-origin Settings smoke + check on port 54390 loaded successfully with no active controller, no + registrations, hidden loading screen, and no browser warnings or errors in + this environment. After adding Cache Storage cleanup, the rebuilt Web output + contained the cache-deletion calls, and a follow-up Settings smoke check on + port 54390 loaded `vibe-fridge`, hid the loading screen, captured a non-empty + page screenshot, and reported no browser warnings or errors. +- Existing-origin Web update simulation on port 54390: seeded a stale + same-origin service worker plus a `flutter-*` Cache Storage entry, then + navigated to the current Settings build. The app recovered to the current + Flutter view, the stale registration list was empty, the stale Flutter cache + was gone, the loading screen was hidden, and browser warning/error logs were + empty. +- Existing-origin Web update simulation recheck on port 54397: seeded + `registrations=1` and `caches=flutter-stale-smoke-cache` from a same-origin + page, then opened the current Settings build. The Settings page rendered + successfully, browser warning/error logs were empty, and a follow-up + same-origin check page reported `registrations=0;caches=`. +- Web entry HTML now carries no-cache meta hints and loads + `flutter_bootstrap.js` through a cache-busted dynamic script request, reducing + the chance that a current index page reuses stale bootstrap code. +- In-app browser retesting on port 54390 showed the Flutter view was mounted + but the loading layer still captured center hit testing after only adding the + `is-hidden` class. The hide handler now also applies inline opacity, + visibility, and pointer-events styles; after rebuilding and reloading, center + hit testing lands on `FLUTTER-VIEW` instead of the loading layer. +- Historical app self-check from Settings: passed, 15/15; passed again on the + restarted Web target on port 54390 in about 354ms. After adding the backup + content check and rebuilding Web, the then-current Settings self-check passed + 16/16 on port 54390 in about 372ms with no browser warning or error logs. + The repository-level self-check test now covers 17 checks including recipe + cooking inventory deduction. - Fresh Web smoke check on a new local port: no new console warnings or errors for the latest build. - Mobile Web cold-start visual smoke check on port 54331: the native loading screen handed off to the rendered Home screen at 390 x 844 with no blank viewport and no browser warning or error logs. +- Mobile Web core-tab smoke check on port 54410 against the current + `build/web` on 2026-06-20: opened Home at 390 x 844, then used the bottom + navigation to open Items, Add, Recipes, and Settings. Each tab rendered the + expected first-viewport content and updated the query route + (`?route=items`, `?route=add`, `?route=recipes`, `?route=settings`) without + browser warning or error logs. Scrolling Settings confirmed WebDAV backup + controls and Web-local-notification unavailable/disabled states were visible + while the developer-only app self-check remained absent. +- Desktop Web main-surface smoke check on port 54409 at 1280 x 720: opened + Home, Items, Add, Recipes, and Settings. Home, Items, Add, and Settings used + the centered content width and wider card layouts without overlap or console + warnings/errors. The first desktop Recipes direct-load pass exposed that + Flutter content with square Chinese glyphs could become visible before fonts + settled; after extending the native loading screen's first-frame delay, port + 54411 kept the native loading screen visible during that window and then + handed off to correctly rendered Chinese text with no browser warning or + error logs. - Mobile Web detail URL smoke check on port 54331: tapping the Home priority row for `面包` opened the inventory detail page and kept the address bar at `?route=items%2Fitem%2Fitem-bread-1` with no hash fragment and no browser warning or error logs. +- Mobile Web today-action reminder smoke check on port 54403: opened Home, + tapped the `提醒到期 2` summary to verify it linked to the focused + reminder-due inventory list at `?route=items&focus=reminderDue`, then returned + to Home and used `稍后` on `面包` plus `忽略` on `鲜牛奶`. The Home list updated + from `2项` to `1项` to `0项`, showed `今天没有待处理`, the top summary updated + to `0件` and `提醒到期 0`, and the snackbars said + `今天稍后再提醒:面包` and `今天不再提醒:鲜牛奶`, with no browser warning or + error logs. - Mobile Web recipe consumption smoke check on port 54332: opened the Recipes tab, opened `快手蛋奶早餐`, used `做这道菜并扣减库存`, returned to `?route=recipes`, saw `库存已扣减`, and verified priority counts dropped from `鸡蛋 12个` / `鲜牛奶 2盒` to `鸡蛋 11个` / `鲜牛奶 1盒` with no browser warning or error logs. +- Mobile Web direct recipe URL smoke check on port 54386: loaded + `?route=recipes%2Fquick-breakfast` directly and verified it opened the + `快手蛋奶早餐` detail page with inventory use, missing ingredients, and steps; + tapping back returned to `?route=recipes`, and both URLs stayed hash-free + with no browser warning or error logs. +- Desktop Web recipe browser-history smoke check on fresh port 54390 after the + `MaterialApp.router` fix: opened `?route=recipes`, tapped + `快手蛋奶早餐` to reach `?route=recipes%2Fquick-breakfast`, used browser Back + to return to the recipe list, then browser Forward to reopen the same recipe + detail. Both transitions preserved hash-free query routes and produced no + browser warning or error logs. +- Post-push desktop Web target smoke check on restarted port 54390: opened + Settings, switched to Recipes, verified the URL became `?route=recipes`, + opened `快手蛋奶早餐` at `?route=recipes%2Fquick-breakfast`, and used browser + Back to return to `?route=recipes` with the recipe list restored. +- Desktop Web direct recipe URL regression check on port 54381 after switching + the root app shell to `MaterialApp.router`: loaded + `?route=recipes%2Fquick-breakfast` directly and verified it opened + `快手蛋奶早餐` with inventory use, missing ingredients, and steps. The URL + stayed hash-free and no browser warning or error logs appeared. +- Mobile Web unknown recipe URL smoke check on port 54387: loaded an + unrecoverable generated-style route, + `?route=recipes%2Fai-recipe-old-generated-0`, and verified the app replaced + it with `?route=recipes` while rendering the recipe list, with no hash + fragment and no browser warning or error logs. - Mobile Web AI-recipe fallback smoke check on port 54364: opened Recipes with no AI service configured, tapped `生成食谱`, saw the AI card switch to `规则兜底` with `AI 食谱未配置,已使用规则建议`, verified rule suggestions stayed visible, then used the reset button to return to the initial `生成食谱` state with no browser warning or error logs. +- Mobile Web recipe favorite/recent smoke check on port 54388: favorited + `快手蛋奶早餐` from the Recipes list without navigating away, verified + `收藏` appeared with `1 个常用方案`, opened the favorited recipe detail with + the heart state preserved, unfavorited it from detail, returned to the list, + and verified `收藏` disappeared while `最近生成` listed the viewed recipes, + with no browser warning or error logs. +- Mobile Web AI-recipe success smoke check on ports 54383 and 54385: configured + a localhost-only fake OpenAI-compatible endpoint, generated one AI recipe + named `内测牛奶快手杯`, opened its detail, and verified inventory use, + missing ingredients, steps, and no browser warning or error logs. The first + pass exposed that a Chinese AI recipe id left the address bar as a mixed + query/hash route; after canonicalizing Web route cleanup, the rebuilt detail + URL stayed at `?route=recipes%2Fai-recipe-...` with no hash fragment. - Mobile Web shopping-loop smoke check on port 54333: opened the shopping tab, added the `感冒药` replenishment suggestion to the list, checked it as purchased, confirmed `采购项入库`, saw `已入库 1 项`, and verified the catalog count for `感冒药` increased from 1 to 2 with no browser warning or error logs. -- Mobile Web Settings self-check smoke check on port 54334: opened Settings, - ran `运行自验收`, saw the card switch to `全部通过` with `15/15`, verified - the detailed check list rendered readable rows for inventory, reminders, - recipes, shopping, batch edits, and history, with no browser warning or error - logs. +- Mobile Web manual shopping edit/convert smoke check on port 54395: added + `shopping-edit-test` from the shopping tab with quantity `3`, unit `包`, and + a note, edited it to quantity `5` with a new note, checked it as purchased, + and converted it into inventory. The first pass exposed that the shopping + note was lost after conversion; after preserving it as the inventory batch + description without updating the item-profile description, the rebuilt Web + app converted `shopping-note-test` and showed `描述 采购备注应保留` on the + inventory detail facts card, while the item-profile header stayed + `暂无描述`, with no browser warning or error logs. The temporary test + inventory and profiles were cleaned up through the UI. +- Mobile Web uncategorized shopping conversion smoke check on port 54396: + added `category-copy-test` from the shopping tab without selecting a + category, converted it into inventory, and verified the shopping list, + catalog card, item-profile facts card, and inventory-detail facts card all + consistently used `未分类` instead of switching the converted inventory to + `其他`, with no browser warning or error logs. The temporary test inventory + and profile were cleaned up through the UI. +- Mobile Web catalog-to-shopping smoke check on port 54391: opened the item + catalog, tapped the cart action on the `感冒药` row, saw + `已加入采购清单:感冒药` without leaving the catalog, opened the Shopping view, + verified `待采购 1` contained `感冒药` with quantity `1` and source-note copy, + then deleted the pending item and verified `待采购 0` while `感冒药` returned + to replenishment suggestions, with no browser warning or error logs. +- Mobile Web focused-inventory shopping smoke check on port 54392: opened Home, + tapped the `提醒到期 2` tile to reach `?route=items&focus=reminderDue`, + added `面包` to the shopping list from the focused inventory card without + leaving that list, opened the Shopping view, verified `待采购 1` contained + `面包` with quantity `1袋` and source-note copy, then deleted it and verified + `待采购 0` while `面包` returned to replenishment suggestions, with no browser + warning or error logs. +- Historical Mobile Web Settings self-check smoke check on port 54334, before + the entry was hidden: opened Settings, ran the app self-check, saw the card + switch to `全部通过` with `15/15`, verified the detailed check list rendered + readable rows for inventory, reminders, recipes, shopping, batch edits, and + history, with no browser warning or error logs. +- Historical Desktop Web Settings self-check smoke check on fresh port 54372 + after a rebuild, before the entry was hidden: opened Settings, verified the + then-current `应用自检` / `运行自检` copy, ran the app self-check, saw + `全部通过` with `15/15` in about 397ms, and saw no browser warning or error + logs for the fresh origin. A same-port reload on + port 54371 still showed older self-check copy after rebuilding, indicating + older same-origin cache or previous registration state can keep stale Web + assets during validation. The Web bootstrap now avoids registering a + replacement Flutter service worker and clears stale registrations once the + current index is loaded. +- Historical desktop and mobile Web Settings regression smoke check on fresh + port 54384, before the self-check entry was hidden: opened Settings on that + build, verified backup/notification copy, scrolled to recipe preferences and + app self-check, ran self-check, and saw `全部通过` with `15/15`. Then switched + to 390 x 844, reloaded Settings, + verified the mobile layout stayed readable, opened Recipes from the bottom + navigation, and opened `快手蛋奶早餐` at + `?route=recipes%2Fquick-breakfast` with no browser warning or error logs. - Mobile Web recipe-preference settings smoke check on port 54363: opened Settings, scrolled to `食谱偏好`, entered `清淡内测`, `不吃辣`, `电饭煲`, changed time to `25` minutes and servings to `3`, saved, saw `食谱偏好已保存`, then reloaded Settings and verified all preference fields persisted with no browser warning or error logs. +- Mobile Web order-recognition settings smoke check on port 54366: opened + Settings, scrolled to `订单识别 AI`, entered a fake local API key, saved, + saw `订单识别配置已保存` and `已配置`, then reloaded Settings and verified the + saved-key state persisted without showing the key in clear text. The pass + then cleared the configuration through the confirmation dialog and verified + `未配置` returned, with no browser warning or error logs. The `测试配置` + action was intentionally not used, so the pass did not send an external + request. +- Mobile Web order-recognition test-configuration smoke check on port 54382: + ran Settings against a localhost-only fake OpenAI-compatible endpoint on + port 54381, saved `订单识别 AI` configuration, verified the API key field + cleared to the saved-key state without exposing the secret, tapped + `测试配置`, saw `配置可用`, confirmed the fake service received one local POST, + and then cleared the configuration back to `未配置` with no browser warning + or error logs. +- Mobile Web demo-data reset smoke check on port 54367: added a user-created + item named `reset-test-nori`, verified the Settings counts increased to + `库存批次 5` and `物品资料 6`, then used `重置示例数据`. The confirmation copy + clearly promised only built-in sample data would be rebuilt, the app showed + `示例数据已重置,清理 9 条旧示例数据`, and the catalog still showed + `reset-test-nori` afterward with no browser warning or error logs. - Mobile Web manual-add smoke check on port 54335: opened Add, entered `内测手动橙子` with the default quantity and purchase date, saved it, returned to the catalog, saw the new item with `1` batch, then opened its item-profile detail at `?route=items%2Fwiki%2F...` with no browser warning or error logs. +- Mobile Web full-field manual-add smoke check on port 54368: opened Add, + selected category `日用品`, selected storage location `冷藏`, entered + `field-test-cleanser`, set unit `瓶`, chose expiry date `2026-06-25` from the + date picker, and saved. The catalog immediately showed `日用品 · 冷藏 · 单位 瓶` + with `7 天后到期`, the item-profile detail showed category, default unit, + storage location, and the inventory batch dates, and the inventory detail + showed expiry `2026-06-25`, reminder date `2026-06-22`, lead time `3 天`, + and storage `冷藏`, with no browser warning or error logs. +- Mobile Web tagged manual-add smoke check on port 54369: opened Add, selected + the tags `临期优先`, `常用`, and `易浪费`, entered `tag-test-cereal` with unit + `袋`, and saved. The item-profile inventory row showed the first two tags + (`临期优先 · 常用`) in its compact summary, and the inventory detail `标签` + card showed all three saved tags with no browser warning or error logs. +- Mobile Web manual-add validation smoke check on port 54400: tried saving a + blank manual item and verified the form stayed on the Add page with + `请输入物品名称`; then entered `validation-test-item`, changed quantity to + `0`, and verified the form showed `请输入大于 0 的整数` without saving. After + correcting quantity to `2`, saving succeeded, the catalog showed the new + item with quantity `2`, and the temporary inventory/profile were cleaned up + through the UI with no browser warning or error logs. +- Mobile Web manual-add round-trip smoke check on port 54412 against the + current build/web on 2026-06-20: opened Add in a 390 px mobile viewport, + entered `beta-smoke-0620b` with quantity `1` and unit `盒`, saved the item, + verified the catalog showed the new profile with `1` active batch, opened + the item-profile detail, verified the facts and batch row, deleted the batch + through `批量` / `全选` / `批量删除库存`, verified `使用中批次 0` and + `暂无库存`, then deleted the empty profile and confirmed searching + `beta-smoke` showed `没有匹配物品`. The same pass also cleaned up the prior + leftover `beta-smoke-0620` test profile through the same user-visible delete + path. No browser warning or error logs appeared. - Mobile Web inventory-detail quantity smoke check on port 54336: opened the Home priority row for `鲜牛奶`, reached `?route=items%2Fitem%2Fitem-milk-1`, increased quantity from `2 盒` to `3 盒`, verified the fact row updated, then @@ -85,6 +1260,38 @@ check confirmed it hands off cleanly to the rendered Home screen. correct quantities and units, confirmed batch import, saw `导入完成` with `新增 2`, and verified the catalog showed `苹果 4` and `酸奶 2` with no browser warning or error logs. +- Mobile Web no-date duplicate order-text smoke check on port 54402: pasted + `DUP-SMOKE-NODATE-001` without a purchase date and imported two rows. The + first pass exposed that repeating the same order still showed `2 可入库` and + would add duplicates because duplicate checks skipped rows without purchase + dates. After allowing order-id/name duplicate checks without a purchase date, + the rebuilt Web app showed `0 可入库`, `2 疑似重复`, `添加 0 个物品`, and the + result dialog `没有新增物品` with `跳过 2`, with no browser warning or error + logs. +- Mobile Web order-text review-edit smoke check on port 54393: pasted an order + containing `内测复核苹果 4个`, a gift line, and `内测散装坚果` without a + quantity. The review page showed `3/3 已选`, `1 可入库`, and `2 需要确认`; + editing the apple quantity from `4` to `6` updated its summary pill, unselecting + the gift left it out of the import, filling the nut unit as `袋` and tapping + `标记已确认` changed the primary action to `添加 2 个物品`. Confirming import + showed `新增 2`, `跳过 1`, `需要手动处理 0`, and the catalog showed only + `内测复核苹果 6` and `内测散装坚果 1`, with no browser warning or error logs. +- Mobile Web bulk order-text backup reminder smoke check on ports 54397 and + 54398: pasted an order-like text containing a standalone reference + `BETA-BACKUP-001` plus 10 inventory lines. The first pass imported the 10 + valid rows and verified Settings showed the backup reminder card with a + cumulative unbacked-change note; it also exposed that the standalone + reference appeared as a low-confidence item needing confirmation. After + filtering standalone reference lines, the rebuilt Web app showed + `10/10 已选`, `10 可入库`, no `需要确认`, and the first review card was + `备份提醒米`, with no browser warning or error logs. The test used isolated + local ports. +- Mobile Web backup reminder export-clear smoke check on port 54399: pasted 10 + valid order-text inventory rows, confirmed batch import with `新增 10` and + `需要手动处理 0`, opened Settings, verified the `建议导出备份` card appeared + with a cumulative unbacked-change note, tapped the card's `导出` action, saw + `备份已导出`, and verified the reminder card disappeared with no browser + warning or error logs. The test used an isolated local port. - Mobile Web consume/history smoke check on port 54338: opened `面包` inventory detail, confirmed `标记已消耗`, returned to Home with the pending reminder count reduced from 2 to 1, then opened `?route=items&view=history` and saw @@ -97,13 +1304,34 @@ check confirmed it hands off cleanly to the rendered Home screen. the search restored the `面包` row with no browser warning or error logs. - Mobile Web inventory-table export smoke check on port 54339: opened Settings, used `导出库存表格`, and saw `库存表格已导出` feedback with no browser - warning or error logs. Codex In-app Browser does not support download events, - so this proves the live UI trigger and success feedback but not downloaded - file persistence. + warning or error logs. Rechecked on port 54396 with + `waitForEvent('download')`: no download event was emitted, but the page + created a blob link with download name + `vibe-fridge-inventory-20260619-212643.csv`. This proves the live UI trigger, + success path, and intended file name, but not downloaded file persistence. +- Mobile Web backup export smoke check on port 54365: opened Settings, used + `导出备份`, and saw `备份已导出` feedback with no browser warning or error + logs. Rechecked on port 54396 with `waitForEvent('download')`: no download + event was emitted, but the page created a blob link with download name + `vibe-fridge-backup-20260619-212616.json`. This proves the live UI trigger, + success path, and intended file name, but not downloaded file persistence. +- Mobile Web legacy-import preview smoke check on ports 54372 and 54373: + opening Settings and tapping `导入旧版库存` originally produced a Flutter Web + asset 404 warning while probing the optional ignored + `legacy_inventory.local.json`; after checking the asset manifest before + loading the override, the preview dialog opened on the empty bundled legacy + file with all counts at 0 and no current-port browser warning or error logs. - Mobile Web catalog search smoke check on port 54340: opened the Items tab, searched for `牛奶`, saw the catalog narrow to `鲜牛奶` while keeping the expiring mini-card visible, then opened the result to `?route=items%2Fwiki%2Fwiki-milk` with no browser warning or error logs. +- Mobile Web browser-history smoke check on port 54380 after switching the root + app shell to `MaterialApp.router`, then repeated on port 54382 after making + route parsing synchronous: loaded `?route=items&q=牛奶`, opened the `鲜牛奶` + item-profile detail at `?route=items%2Fwiki%2Fwiki-milk`, used browser Back + to return to the searched catalog with `q=牛奶`, then used browser Forward to + reopen `?route=items%2Fwiki%2Fwiki-milk`. The URL stayed hash-free, the + detail page rendered, and no browser warning or error logs appeared. - Mobile Web catalog category-filter smoke check on port 54361: opened the Items tab, selected the `日用品` category chip, verified the URL changed to `?route=items&category=cat-daily` and the catalog list narrowed to `牙膏`, @@ -115,12 +1343,32 @@ check confirmed it hands off cleanly to the rendered Home screen. its own transparent `Material`, the edit screen opened with no browser warning or error logs, and saving `存放位置` as `冷藏` was visible on the inventory detail facts after reload. +- Mobile Web inventory-edit tags/reminder smoke check on port 54370: opened + `鲜牛奶` inventory detail, edited the batch, selected `临期优先` and `常用`, + changed reminder lead time from `3` days to `1` day, and saved. The detail + screen updated reminder date from `2026-06-18` to `2026-06-20`, showed + `提前天数 1 天` in the facts card, and rendered the saved tags in the `标签` + card with no browser warning or error logs. +- Mobile Web inventory-reminder disable smoke check on port 54371: opened + `鲜牛奶` inventory detail, edited the batch, turned off `启用过期提醒`, and + saved. The detail reminder card changed its status pill from `已启用` to + `已关闭`, the facts card showed `提醒开关 关闭`, and the `提前天数` fact row + was hidden while the expiry date stayed intact, with no browser warning or + error logs. - Mobile Web item-profile edit smoke check on port 54349: editing `鲜牛奶` originally saved data but left the detail page stale and produced a Flutter debug assertion from an async-looking `setState` refresh callback; after reworking the detail refresh, saving a second description marker returned to the item-profile detail with the new description visible immediately and no new browser warning or error logs. +- Mobile Web item-profile default-field edit smoke check on port 54394: added + `profile-default-test`, opened its item-profile detail, edited default unit + to `盒`, suggested shelf life to `14` days, default reminder lead time to + `2` days, storage location to `冷藏`, and notes to `默认值内测备注`. The first + pass exposed that notes saved but were invisible on the detail page; after + showing non-empty notes in the profile facts card, the rebuilt Web app showed + default unit, shelf life, reminder lead time, storage location, and notes + immediately after save with no browser warning or error logs. - Mobile Web item-profile batch-location smoke check on port 54350: opened `鲜牛奶`, entered batch mode, selected the inventory batch, used `改位置`, chose `冷冻`, saw `已修改 1 条库存的位置`, verified the batch card showed @@ -140,6 +1388,13 @@ check confirmed it hands off cleanly to the rendered Home screen. error logs. The first pass exposed that the facts card said `库存批次 1` while the batch list showed both active and consumed rows, so the facts label was clarified to `使用中批次`. +- Mobile Web item-profile batch-delete smoke check on port 54390: manually + added `batch-delete-test` twice and verified the catalog merged them into + one item profile with `2` batches, opened the item-profile detail, entered + `批量` mode, used `全选`, confirmed `批量删除库存`, saw the detail page exit + selection mode with `使用中批次 0`, `暂无库存`, and `已删除 2 条库存记录`, + returned to the catalog and verified the profile count was `0` batches, then + deleted the empty test profile, with no browser warning or error logs. - Mobile Web consume-restore smoke check on port 54355: opened `面包` inventory detail, confirmed `标记已消耗`, verified it appeared in History as `已消耗`, opened the consumed detail, used `恢复为使用中`, then verified @@ -151,6 +1406,13 @@ check confirmed it hands off cleanly to the rendered Home screen. `感冒药` changed to `0` batches, then reopened the deleted direct detail URL and saw the friendly `库存记录不存在` empty state with no browser warning or error logs. +- Mobile Web item-profile delete smoke check on port 54389: added + `profile-delete-test`, opened its item-profile detail, verified deleting the + profile was blocked while `使用中批次` was `1`, deleted the inventory batch, + saw the profile update to `使用中批次 0` with the `暂无库存` empty state, + deleted the now-empty profile, verified catalog search showed + `没有匹配物品`, and reopened the stale profile URL to the friendly + `物品资料不存在` empty state with no browser warning or error logs. - Mobile Web shopping-item delete smoke check on port 54357: opened the shopping tab, added the `面包` replenishment suggestion to the pending list, opened the row menu, confirmed the destructive `删除采购项` dialog, and @@ -176,19 +1438,63 @@ check confirmed it hands off cleanly to the rendered Home screen. verified the address bar stayed on the query route without a Flutter hash fragment while rendering the inventory detail page with no browser warning or error logs. -- Targeted UI-copy grep for engineering terms found no new actionable +- Desktop Web direct inventory-detail URL regression check on port 54381 after + switching the root app shell to `MaterialApp.router`: loaded + `?route=items%2Fitem%2Fitem-bread-1` directly and verified it rendered the + `面包` inventory detail page. The URL stayed hash-free and no browser warning + or error logs appeared. +- Initial targeted UI-copy grep for engineering terms found no new actionable user-facing leaks. The remaining AI `JSON` wording is confined to prompts or internal exceptions and is wrapped by the user-friendly recipe fallback copy. -- `flutter build macos --debug`: blocked by local environment. Flutter reached - Xcode dependency resolution, then failed because the active developer - directory is Command Line Tools and `xcodebuild` is unavailable to `xcrun`. +- Follow-up UI-copy scan found inventory-detail import traces could display + internal source values such as `legacy` and import batch identifiers for + older imported rows. The detail page now maps that source to `旧版库存` and + hides internal import batch ids from the user-facing trace. +- The same copy pass found Settings restore/import feedback using + implementation-flavored terms like `恢复前快照`, `健康检查`, and `日志`. + Those labels now use user-facing backup/check/detail wording instead. +- A follow-up Settings copy pass found the then-visible built-in check still + used acceptance-style wording. That user-facing entry was later removed; the + internal check-data labels still use `应用自检` language. +- Inspecting the app self-check failure path found failed check details would + include raw Dart prefixes such as `Bad state:` before the useful message. + Internal self-check failures now strip those technical prefixes. +- `flutter build macos --debug`: initially blocked by local environment. + Flutter reached Xcode dependency resolution, then failed because the active + developer directory was Command Line Tools and `xcodebuild` was unavailable + to `xcrun`. After the user installed Xcode and accepted the license, the same + command passed and produced the debug macOS app bundle. +- Xcode environment setup attempt after user approval: installed Homebrew + `cocoapods`, `mas`, `xcodes`, and `aria2`. `xcodes install 26.5` could not + proceed without Apple ID credentials, and `mas get 497799835` failed while + looking up Xcode through the App Store API with a TLS error. The App Store + Xcode page was opened for manual sign-in/install. +- `flutter run -d macos`: initially blocked by missing full Xcode, then passed + after Xcode 26.5 was installed and selected. The app launched in debug mode + and exposed a Dart VM Service; the shell reported `Failed to foreground app` + after launch. - `flutter build apk --debug`: blocked by local environment after downloading Flutter Android artifacts; Flutter reported no Android SDK. - `xmllint --noout mobile/android/app/src/main/AndroidManifest.xml`: passed after adding the Android boot/package-replaced reminder receiver. -- `flutter doctor -v`: confirmed no Android SDK, incomplete Xcode, missing - CocoaPods, no Chrome binary, and sandboxed network checks failing without - elevated network access. +- `flutter doctor -v`: initially confirmed no Android SDK, incomplete Xcode, + missing CocoaPods, no Chrome binary, and sandboxed network checks failing + without elevated network access. After installing CocoaPods and rerunning with + elevated network access, it confirmed CocoaPods 1.16.2 and healthy network + resources. After Xcode 26.5 was installed and selected, doctor no longer + reported missing first-launch components; only Android SDK, Chrome, and + simulator runtime gaps remained relevant outside the macOS desktop target. + Rechecked on 2026-06-19 with the bundled Flutter SDK: Android SDK is still + absent, only the macOS desktop device is detected, sandboxed network resource + checks still fail, and `xcodebuild -checkFirstLaunchStatus` returns success + even though `flutter doctor` still emits a stale first-launch warning. + Rechecked on 2026-06-20 after `xcodebuild -runFirstLaunch`, + `xcodebuild -checkFirstLaunchStatus`, and `xcodebuild -license check` all + returned success: `flutter doctor` still reports the Xcode + additional-component warning, and elevated `xcrun simctl list runtimes` / + `xcrun simctl list devices` show no Simulator runtimes or devices. This + leaves iOS/Simulator validation unavailable, but does not block macOS desktop + build/test validation. - `python3 tools/perf_inventory_sqlite.py`: passed. Core inventory queries remained well under thresholds with 5,000 generated records: exact catalog search 0.683 ms, category filter 2.177 ms, today-action query 1.839 ms, @@ -204,6 +1510,10 @@ check confirmed it hands off cleanly to the rendered Home screen. instead of retaining Flutter hash fragments. - Web route cleanup now covers the remaining detail entry points from home priority rows, item-profile batch rows, and recipe cards. +- Web route cleanup now also handles generated non-ASCII recipe identifiers, + such as AI recipe titles in Chinese, without leaving Flutter hash fragments. +- Web route restoration now opens direct rule-recipe links such as + `?route=recipes%2Fquick-breakfast` instead of only selecting the Recipes tab. - Web cold-start loading screen compiles into `build/web/index.html` and uses system Chinese fonts until Flutter's first frame has settled. - Web app metadata now uses the product name, inventory-focused description, @@ -220,7 +1530,13 @@ check confirmed it hands off cleanly to the rendered Home screen. - Home dashboard renders summary, expiring priority inventory, and category distribution clearly after fonts load. +- Desktop Web renders the main Home, Items, Add, Recipes, and Settings surfaces + in centered wide layouts without overlap, and the native loading screen now + hides the desktop Chinese-font settling window before handing off to Flutter. - Home total action badge opens the cleanup-focused inventory list. +- Home reminder-due summary opens the focused reminder inventory list, and + today-action cards can be snoozed or ignored with immediate count/list + refresh and user feedback. - Home expiring-priority rows open inventory batch detail. - Catalog expiring mini cards open inventory batch detail. - Catalog search filters the live mobile Web list and search results still open @@ -229,20 +1545,39 @@ check confirmed it hands off cleanly to the rendered Home screen. the full catalog without stale route state. - Manual add flow saved a test item, returned to the catalog, and the new item-profile detail opened from the live mobile Web UI. +- Manual add also handles category, storage location, unit, and expiry-date + fields end to end, with catalog, item-profile detail, and inventory detail + staying consistent. +- Manual add saves processing-priority tags from the mobile Web UI, and tagged + inventory displays those tags in item-profile and inventory detail views. +- Manual add validation blocks blank names and non-positive quantities with + user-actionable inline messages, then allows saving once the user corrects + the fields. - Inventory detail quantity controls update visibly in both directions and the detail fact row stays in sync. - Inventory edit now opens without Flutter debug assertions in the live mobile Web UI, and edited storage location data persists into the detail facts. +- Inventory edit can update reminder lead time and processing-priority tags, + and the inventory detail view reflects both changes immediately after save. +- Inventory reminders can be disabled from the edit screen, and the detail + view reflects the disabled state without losing the expiry date. - Item-profile edit now saves from the live mobile Web UI and refreshes the detail header to the edited description without a manual browser reload. +- Item-profile edit now shows saved notes in the profile facts card, along + with edited default unit, shelf life, reminder lead time, and storage + location. - Item-profile batch mode can update selected inventory storage locations, and both the batch card and inventory detail fact row stay in sync. - Item-profile batch category changes update the profile-level category and the inventory detail fact row consistently. - Item-profile batch consume can split a multi-quantity batch into active and consumed rows while keeping the active-batch count understandable. +- Item-profile batch delete can select all visible inventory batches, remove + them together, clear selection mode, and keep profile/catalog counts in sync. - Direct Web detail URLs now clean up late Flutter hash fragments and keep the copyable address bar on the app's query-route format. +- Browser Back from a searched catalog detail returns to the searched catalog + state with the keyword preserved. - Marking an inventory batch consumed removes it from active priority handling and shows it in the history tab as `已消耗`. - History search keeps matching consumed records visible and shows a @@ -251,6 +1586,8 @@ check confirmed it hands off cleanly to the rendered Home screen. History and back into active catalog and expiring views. - Deleting a single inventory batch returns to the catalog, updates the item count, and leaves a friendly empty state for stale direct detail URLs. +- Empty item profiles can be deleted only after their inventory batches are + removed, and stale profile URLs resolve to a friendly empty state. - Shopping list checkbox moves a pending item into the purchased section. - Purchased shopping-list items can be unchecked back into the pending section after a mistaken tap. @@ -261,22 +1598,86 @@ check confirmed it hands off cleanly to the rendered Home screen. - Shopping-list row deletion removes the pending item and lets its replenishment suggestion reappear. - Purchased shopping items can be converted into inventory after confirmation, - and the catalog count updates in the live mobile Web UI. + the catalog count updates in the live mobile Web UI, and shopping notes are + retained as inventory batch descriptions without polluting the item-profile + description. +- Shopping conversion now has widget coverage for the confirmation dialog, + cancel path, controller handoff, and success feedback. +- Uncategorized shopping items remain labeled `未分类` after conversion into + catalog, item-profile, and inventory-detail surfaces. +- Catalog row cart actions add the selected item profile to the shopping list + without navigating away, and deleting that pending item restores the + replenishment suggestion state. +- Focused inventory cards reached from Home action tiles can add their exact + batch to the shopping list while preserving the focused list route. - Recipes page lists priority consumables and concrete recipe suggestions. - AI recipe generation falls back to rule suggestions when the AI service is not configured, with user-facing copy and a reset action. +- AI recipe generation succeeds against a reachable local OpenAI-compatible + endpoint, replaces the rule list with the AI result, and opens the generated + recipe detail without mixed query/hash routing. +- Direct Web recipe URLs restore rule-generated recipe detail pages and return + to the recipe list cleanly from the detail back action. +- Unrecoverable Web recipe detail URLs, such as stale AI-generated ids after a + refresh, now fall back to the recipe list instead of leaving a stale detail + route in the address bar. - Recipe detail shows consumed inventory, missing ingredients, steps, and the inventory deduction action. +- Recipe cooking now has controller-level coverage that applies the detail + action's inventory-use quantities and verifies the deducted inventory values + are persisted. +- Recipe favorites update from both the list and detail surfaces, and recently + viewed recipes appear in `最近生成` without stale favorite state. - Running a recipe deduction updates priority consumable counts in the live mobile Web UI and returns to the recipe list with user feedback. -- Settings self-check completed and cleaned up its temporary data, and the - mobile Web UI shows the 15/15 result plus readable per-check timings. +- Internal app self-check completed and cleaned up its temporary data. + Automated self-check coverage now verifies 17 checks including recipe + cooking deduction. +- Settings now has widget coverage verifying developer-only self-check controls + are not rendered for users. +- Rebuilt Web output on port 54390 and reloaded Settings in the in-app + browser. The old self-check position now flows directly from `食谱偏好` to + `订单识别 AI`, with no `应用自检` card and no browser warning/error logs. +- Settings backup reminder UI now has widget coverage for the pending reminder + card, reason copy, accumulated change count, and export action. +- Settings order-recognition key privacy now has widget coverage that verifies + a stored key keeps the API key field empty while showing configured and + locally saved status copy. - Settings recipe preferences save from the mobile Web UI and reload with the edited values still visible. +- Settings recipe preferences now have automated coverage for default values, + text trimming, time/serving bounds, damaged persisted numeric values, and the + Settings save action that reloads normalized field values. +- Settings order-recognition configuration saves from the mobile Web UI, + persists after reload without revealing the saved key, and can be cleared + through the confirmation dialog. +- Settings order-recognition clear now persists blank service address and model + values instead of restoring defaults on the next load, while fresh first-run + settings still start from the built-in defaults. +- Settings order-recognition `测试配置` can validate a reachable local + OpenAI-compatible endpoint and report `配置可用` without exposing the saved API + key in the UI. +- Settings demo-data reset rebuilds the built-in sample data from the mobile + Web UI while preserving user-created inventory. +- Settings demo-data reset now has widget coverage for the confirmation copy, + cancel path, reset call, and success message with cleared-row count. - Settings inventory-table export can be triggered from the live mobile Web UI, and the app shows success feedback without console warnings or errors. +- Settings backup export can be triggered from the live mobile Web UI, and the + app shows success feedback without console warnings or errors. +- Settings backup reminder appears in the live mobile Web UI after a bulk + local import reaches the dirty-change threshold, with user-facing copy that + explains a backup is recommended before more changes accumulate. +- Settings backup reminder can be cleared from the live mobile Web UI by using + the reminder card's export action, and the card disappears after the app + reports `备份已导出`. +- Settings legacy-import preview opens without probing missing optional local + assets, so the empty bundled import file no longer causes Web asset warnings. - Repository backup/restore tests cover pre-restore snapshots, replacement restore, post-restore health checks, and backup reminder clearing. +- Repository backup/restore tests now verify a backup can round-trip user + inventory tags, ignored-reminder records, and pending shopping-list data + across a replacement restore. - Repository backup/restore tests now also reject incomplete or damaged backup files before any current data is replaced or a restore snapshot is created. - Repository export tests now verify the user-facing inventory table uses @@ -284,19 +1685,52 @@ check confirmed it hands off cleanly to the rendered Home screen. plain calendar dates. - Repository export tests now verify the inventory table starts with a UTF-8 marker so spreadsheet apps can detect Chinese text more reliably. +- Repository export tests now verify inventory-table cells escape commas, + quotes, and newlines in user-entered item names, storage locations, tags, + and source labels. - Notification payload tests cover pending reminders, ignored reminders, title and body content, schedule time, and serialized timestamp fields. - Local notification sync result tests now cover supported, unauthorized, unsupported, missing implementation, and unknown failure copy. +- Local notification sync service tests now verify granted sync sends the real + pending-reminder payload to the platform channel and denied permission skips + platform scheduling. - Local notification permission channel tests now cover native status parsing and platform-exception fallback copy. - Local notification permission copy tests now cover the unsupported-platform hint shown in Settings when native scheduling actions are disabled. - Local notification channel tests now cover tap callbacks, malformed tap payloads, and safe launch-target fallback when the platform call fails. +- Local notification controller handoff tests now verify a notification tap + target is stored for the app shell and consumed exactly once. +- Local notification controller tests now verify requesting permission triggers + reminder sync only after authorization is granted, and skips scheduling when + permission is denied. +- macOS RunnerTests now verify the native notification request builder maps + Dart channel payloads to `inventory-` request identifiers, preserves + `itemId` in `userInfo`, skips malformed rows, and enforces the minimum + trigger delay used before adding `UNNotificationRequest`s. They also verify + the scheduled inventory reminder's native content title/body/sound/userInfo, + future trigger timing, the immediate Settings test notification request's + diagnostic identifier prefix, copy, and 1-second trigger; native notification + permission statuses map to the Flutter channel contract; and notification tap + payloads store/emit the selected inventory item id. - Android notification scheduling now persists pending reminder payloads and registers boot/package-replaced restoration points; runtime proof still needs an Android SDK/device environment. +- Android scheduled reminder delivery now checks `POST_NOTIFICATIONS` again in + the broadcast receiver and catches `SecurityException` before posting, so a + reminder firing after the user revokes notification permission should be + skipped instead of crashing the receiver. +- Notification tap and launch-target item ids are now normalized across Dart, + Android, and macOS bridges. Blank or whitespace-only platform payloads are + ignored, and item ids with accidental surrounding whitespace are trimmed + before routing to inventory detail. +- Added a long-term source-level user-facing copy guard for screens and shared + widgets. It scans Dart string literals while ignoring interpolation + expressions, and fails if Settings, app pages, dialogs, snackbars, or shared + UI copy reintroduce terms such as `应用自检`, `SQLite`, `Wiki`, `legacy`, + `ISO-8601`, or `payload`. - Bootstrap error page tests now verify startup diagnostics remain copyable without exposing technical details on screen by default. - AI recipe fallback tests now verify service failures still return rule-based @@ -305,6 +1739,19 @@ check confirmed it hands off cleanly to the rendered Home screen. response snippets stay out of user-facing configuration messages. - Pasted order-text import now has live mobile Web coverage from text parsing through review confirmation and catalog verification. +- Order-text review supports editing recognized quantities, filling missing + units, excluding selected rows such as gifts, and confirming low-confidence + items before batch import. +- Order-import review now has widget coverage for low-confidence confirmation, + primary import-count updates, batch confirmation copy, and the final + controller handoff for confirmed items. +- Pasted order-text parsing now ignores standalone order/reference id lines so + users do not have to manually exclude an obvious non-inventory row. +- Order-recognition parser tests now cover standalone reference lines in + pasted order text while keeping normal product rows intact. +- Repository shopping-list conversion tests now verify converted shopping + notes are retained on inventory batches and do not become item-profile + descriptions. ## Fixes Made During This Pass @@ -330,6 +1777,9 @@ check confirmed it hands off cleanly to the rendered Home screen. screenshot in app storage. - Expanded pasted order text unit parsing so common units like `枚` and `根` do not get stuck in the item name or default to quantity 1. +- Filtered standalone order/reference id lines out of pasted order-text import + so strings such as `BETA-BACKUP-001` do not appear as low-confidence + inventory candidates. - Disabled local notification action buttons on unsupported platforms so users do not have to click a dead-end action to learn that reminders cannot be scheduled there. @@ -340,6 +1790,12 @@ check confirmed it hands off cleanly to the rendered Home screen. - Simplified the backup restore success message so it does not expose an implementation-level restored row count as if it were a user-facing item count. +- Preserved shopping-list notes when checked items are converted into + inventory by saving the note on the inventory batch description without + syncing it into the item-profile description. +- Unified null-category fallback copy to `未分类` across catalog cards, + item-profile facts, and inventory-detail facts, while leaving the real + `其他` category unchanged. - Reworded local notification sync failures so platform/plugin error codes are not shown directly to users. - Added Android reminder restoration after device reboot or app update by @@ -357,19 +1813,44 @@ check confirmed it hands off cleanly to the rendered Home screen. - Fixed nullable SQL query arguments in shopping-list de-duplication and legacy duplicate detection so sqflite no longer logs a future-breaking null argument warning. +- Changed legacy import asset loading to consult the Flutter asset manifest + before loading the ignored `.local` override, avoiding a Web 404 warning when + only the bundled empty legacy file is present. - Cleaned up Web detail route syncing so copied detail URLs are no longer set up to keep both the app route query and a Flutter hash route after - navigation; this pass verified compilation, while browser address-bar - automation was unavailable locally. + navigation; later browser-history and direct-route checks verified the + address bar stays on hash-free query routes. - Extended the same route cleanup pattern to home priority rows, item-profile batch rows, and recipe detail navigation so those natural beta-user paths avoid mixed query/hash URLs too. - Strengthened Web route hash cleanup with delayed retries so direct detail URLs also remove late Flutter hash fragments after the Navigator settles. +- Canonicalized Web route comparisons so non-ASCII generated routes, including + Chinese AI recipe ids, are compared consistently with URL-encoded query + routes before clearing Flutter hash fragments. +- Added recipe-detail restoration for Web route startup and history changes, + so stable rule recipe ids can be opened from copied URLs or browser reloads. +- Added a recipe-route fallback that replaces unrecoverable detail ids with + `/recipes`, keeping stale generated links from leaving misleading URLs. - Added a lightweight Web loading screen that uses native system Chinese fonts, matches the app's warm visual style, honors reduced-motion preferences, and - hides shortly after Flutter's first frame to reduce the cold-start square-text - flash. + hides after Flutter's first frame to reduce the cold-start square-text flash. +- Extended the Web loading screen's first-frame delay after desktop direct-route + testing showed CanvasKit could briefly expose square Chinese glyphs before + fonts settled. +- Hardened the Web loading screen hide handler with inline opacity, + visibility, and pointer-events styles after browser retesting showed class + toggling alone could leave the layer intercepting clicks. +- Added a custom Web bootstrap that omits Flutter service-worker registration, + unregisters stale same-origin service workers, deletes stale Flutter Cache + Storage entries, and reloads once when the current page is still controlled + by an old worker. +- Added no-cache hints to the Web entry HTML and cache-busted the bootstrap + script request so a current index page is less likely to execute stale Web + startup code. +- Added Web `_headers` cache policy for common static hosts so entry and + startup scripts, including Flutter's generated `flutter_service_worker.js`, + are served with no-cache/no-store semantics after deployment. - Replaced Web/PWA template metadata so browser tabs and installed app surfaces show `vibe-fridge`, the app's actual inventory purpose, and product colors. - Reworded macOS camera and photo permission prompts to match the app's Chinese @@ -382,6 +1863,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - Reworked item-profile detail refresh after editing so saved profile changes reload from controller changes and remain visible without a manual browser refresh. +- Displayed non-empty item-profile notes in the profile detail facts card, so + notes entered on the edit screen are visible after saving. - Clarified item-profile batch category copy so the picker and success feedback describe the profile-level category being changed instead of implying only one inventory batch owns the category. @@ -392,11 +1875,134 @@ check confirmed it hands off cleanly to the rendered Home screen. updates refresh without returning a `Future` from `setState`. - Reworded the history-page filtered-empty state so searching within existing history no longer implies there are no historical records at all. +- Reworded inventory-detail import traces so old imports show `旧版库存` rather + than raw internal source values, and internal import batch ids are no longer + displayed as user-facing details. +- Switched the main app shell to `MaterialApp.router` with a root Navigator + that leaves browser history ownership to the app's query-route state, fixing + browser Forward from a searched catalog detail back into the same detail URL. +- Reworded Settings restore/import feedback from snapshot, health-check, and + log terminology to backup, check, detail, and record wording. +- Reworded Settings built-in check copy from acceptance wording to + app self-check wording. +- Removed the Settings app self-check card and run action so developer-only + diagnostics are no longer exposed to users. +- Cleaned app self-check failure details so internal reports show the + actionable reason without raw exception prefixes. +- Extended the internal app self-check so it also verifies backup content + includes inventory rows, tag links, reminder logs, and shopping-list data. +- Clarified the generic error snackbar action from `复制` to `复制详情`, keeping + technical diagnostics out of the visible message while making the hidden copy + action understandable. +- Reworded the backup reminder card from row/export wording to inventory-data + backup wording, so users see why they should back up after local changes + without spreadsheet-like implementation terms. +- Reworded data-health check messages from internal status/field names to + inventory and profile language, so import or restore issues explain what + needs attention without exposing raw state values. +- Reworded legacy-import and demo-reset summaries from data-row wording to + user-facing record wording, including preview counts, import results, and + example-data cleanup feedback. +- Reworded the order-recognition key copy from storage terminology to + `密钥状态` / `本机安全保存` / `本机安全区域`, so Settings describes the outcome + rather than the implementation. +- Fixed macOS order-recognition configuration saves failing with + `PlatformException(Unexpected security result code, Code: -34018)` by + storing the VLM API key through the regular macOS Keychain path instead of + the Data Protection Keychain path that requires an entitlement not present in + local debug builds. +- Fixed macOS reminder sync reporting success before `UNUserNotificationCenter` + finished adding pending requests. The native bridge now waits for all add + completions and returns `schedule_failed` if the system rejects a request, so + Settings shows a sync failure instead of `已同步` for a failed platform + schedule. +- Added WebDAV cloud backup in Settings, including configuration storage, + connection testing, backup upload, latest-backup restore confirmation, and + user-facing copy that warns Web users when the cloud service needs to allow + browser access. +- Fixed two WebDAV Settings issues found during browser smoke testing: after + saving a WebDAV password, the password field is now rebuilt so Flutter Web no + longer keeps the secret in the active text-editing DOM value; WebDAV actions + also clear stale snackbars before running so a previous authentication error + does not remain visible after a successful retry or upload. +- Rechecked the live Settings WebDAV path on the existing in-app browser tab: + a stopped local WebDAV endpoint produced actionable connection-failure copy, + then a fresh local endpoint accepted saved credentials, connection testing, + and backup upload without browser warning/error logs. +- Fixed a macOS runtime launch exception discovered during desktop validation: + AppDelegate no longer calls FlutterAppDelegate's unimplemented optional + launch selector, and a rebuilt debug app now starts far enough to initialize + and query the notification center without that exception. +- Added macOS native coverage that the launched app's + `UNUserNotificationCenter.current().delegate` is the app delegate, proving + system notification responses have a registered app-side entry point before + manual delivery and click validation. +- Added direct macOS AppDelegate payload-routing coverage so the delegate layer + now proves notification response userInfo is handed to the shared tap handler, + stores the launch target, and posts the in-process tap event without needing + a real delivered notification. +- Added a debug-only VM Service extension for beta smoke testing of the running + macOS app's notification permission channel. It is not visible in the UI and + is not release behavior, but it lets local validation prove the live Flutter + app can reach the native notification bridge. Added a second explicit + debug-only extension for sending a diagnostic test notification when a manual + authorization pass intentionally opts into `--send-test`. Extracted the + payload builders behind those extensions so the smoke-contract JSON shapes + have durable Flutter test coverage. +- Added `tools/check_notification_status.mjs`, a reusable local smoke helper + that calls the debug VM Service extension for a running Flutter app and + validates the notification status payload shape before printing the result. + It can optionally assert expected `supported`, `granted`, and `status` + values for repeatable beta validation, can wait for debug extension + registration with `--wait-extension-ms`, and can call the diagnostic + test-notification extension only when `--send-test` is supplied. +- Added `tools/run_macos_notification_smoke.mjs`, an automated local runner + that launches the macOS Flutter target, waits for the VM Service URL, runs + the notification status helper after a short extension-registration wait, and + exits the app. This makes the current macOS notification-channel smoke + reproducible without manual URL copying, and keeps the send-test path opt-in. +- Added `tools/check_android_environment.mjs`, a read-only Android runtime + gate that runs Flutter's devices, emulators, and doctor checks, prints a + compact JSON report, and fails until an Android SDK and Android runtime target + are available. +- Added `ext.vibe_fridge.secureStorageSmoke` plus + `tools/check_secure_storage_smoke.mjs` and + `tools/run_macos_secure_storage_smoke.mjs`, giving beta validation a + repeatable way to prove the running macOS app can write, read, and delete a + temporary secure-storage value without using real user secrets. ## Remaining Risks -- Android and macOS local notification behavior still needs device or desktop - runtime validation on a machine with Android SDK and full Xcode/CocoaPods. -- The native notification implementations still need runtime proof even though - the Dart service degrades cleanly when permission is missing, unsupported, or - the platform channel is absent. +- Android local notification behavior still needs runtime validation on a + machine with Android SDK configured. A current `flutter devices` check still + detects only macOS, and `flutter doctor -v` still reports no Android SDK. + A 2026-06-20 `flutter build apk --debug` recheck still stops before Android + compilation with no Android SDK, and `flutter emulators` reports no emulator + sources. A later 2026-06-20 recheck after the full Flutter regression recheck + produced the same result: only macOS was detected, no emulator sources were + available, `flutter doctor -v` reported `Unable to locate Android SDK`, and + `flutter build apk --debug` stopped with `[!] No Android SDK found`. + Android source-level manifest/channel/payload wiring and the immediate + test-notification channel are now covered by tests, but scheduled reminder + delivery, permission prompts, and notification-click routing still need a + device or emulator. +- macOS app build and repaired launch are now proven locally, but macOS + notification permission, due reminder delivery, and notification-click routing + still need targeted desktop runtime validation. The native bridge/delegate + source wiring, runtime delegate registration, live running-app permission + status channel, immediate test-notification channel, scheduling payload + builder, async scheduling completion/error handling, permission-status mapper, + and tap payload handoff are now covered by tests or smoke hooks, but system + notification behavior is not fully replaceable with unit tests. +- Web app updates can still be masked by older same-origin browser cache or + previously registered service-worker state. During Settings retesting, port + 54371 still showed older self-check wording after a rebuild, while fresh port + 54372 loaded the current build. The custom bootstrap now avoids registering a + replacement Flutter service worker, clears stale registrations, and deletes + stale Flutter Cache Storage entries once the current index is loaded, and the + current entry HTML discourages browser caching plus cache-busts the bootstrap + request. The generated Web build also includes `_headers` no-cache policy for + common static hosts, including `flutter_service_worker.js`, and a same-origin + simulation with a stale worker plus stale Flutter cache now recovers cleanly. + Deployment hosts that ignore `_headers` still need equivalent HTTP cache + headers before hosted update behavior is fully proven. diff --git a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalNotificationContract.kt b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalNotificationContract.kt index 3ad13dd..fbaeb80 100644 --- a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalNotificationContract.kt +++ b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalNotificationContract.kt @@ -6,6 +6,7 @@ object LocalNotificationContract { const val notificationChannelName = "库存提醒" const val notificationChannelDescription = "库存到期和处理提醒" const val permissionRequestCode = 4817 + const val testNotificationRequestCode = 4818 const val prefsName = "vibe_fridge_notifications" const val scheduledItemIdsKey = "scheduled_item_ids" const val scheduledNotificationsKey = "scheduled_notifications" diff --git a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalReminderScheduler.kt b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalReminderScheduler.kt index 2e8b693..9350bf4 100644 --- a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalReminderScheduler.kt +++ b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/LocalReminderScheduler.kt @@ -146,6 +146,9 @@ object LocalReminderScheduler { val scheduledAtMillis = (row["scheduledAtMillis"] as? Number)?.toLong() ?: return@mapNotNull null + if (itemId.isBlank() || scheduledAtMillis <= 0L) { + return@mapNotNull null + } ReminderRow( itemId = itemId, title = row["title"] as? String ?: "库存提醒", diff --git a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/MainActivity.kt index 89d1215..d0efdba 100644 --- a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/MainActivity.kt @@ -1,8 +1,10 @@ package com.vibefridge.vibe_fridge import android.Manifest +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -19,7 +21,7 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) ensureNotificationChannel() - launchItemId = intent?.getStringExtra(LocalNotificationContract.extraItemId) + launchItemId = normalizedNotificationItemId(intent) notificationChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, LocalNotificationContract.channelName, @@ -41,6 +43,7 @@ class MainActivity : FlutterActivity() { ) result.success(null) } + "sendTestNotification" -> sendTestNotification(result) "cancelAll" -> { LocalReminderScheduler.cancelScheduledReminders(this) result.success(null) @@ -54,8 +57,8 @@ class MainActivity : FlutterActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - val itemId = intent.getStringExtra(LocalNotificationContract.extraItemId) - if (!itemId.isNullOrEmpty()) { + val itemId = normalizedNotificationItemId(intent) + if (itemId != null) { launchItemId = itemId notificationChannel?.invokeMethod( "notificationTapped", @@ -64,6 +67,13 @@ class MainActivity : FlutterActivity() { } } + private fun normalizedNotificationItemId(intent: Intent?): String? { + return intent + ?.getStringExtra(LocalNotificationContract.extraItemId) + ?.trim() + ?.takeIf { itemId -> itemId.isNotEmpty() } + } + override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -120,4 +130,45 @@ class MainActivity : FlutterActivity() { notificationManager.createNotificationChannel(channel) } + private fun sendTestNotification(result: MethodChannel.Result) { + try { + ensureNotificationChannel() + val notificationManager = getSystemService( + Context.NOTIFICATION_SERVICE, + ) as NotificationManager + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + LocalNotificationContract.testNotificationRequestCode, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder( + this, + LocalNotificationContract.notificationChannelId, + ) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + val notification = builder + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("库存提醒测试") + .setContentText("看到这条通知说明本地通知可用") + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + notificationManager.notify( + LocalNotificationContract.testNotificationRequestCode, + notification, + ) + result.success(null) + } catch (error: SecurityException) { + result.error("permission", error.localizedMessage, null) + } + } + } diff --git a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/ReminderNotificationReceiver.kt b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/ReminderNotificationReceiver.kt index 7e9ba0e..c0b195e 100644 --- a/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/ReminderNotificationReceiver.kt +++ b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/ReminderNotificationReceiver.kt @@ -1,5 +1,6 @@ package com.vibefridge.vibe_fridge +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -7,16 +8,23 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Build class ReminderNotificationReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val itemId = intent.getStringExtra(LocalNotificationContract.extraItemId) ?: return + if (itemId.isBlank()) { + return + } val title = intent.getStringExtra(LocalNotificationContract.extraTitle) ?: "库存提醒" val body = intent.getStringExtra(LocalNotificationContract.extraBody) ?: "打开 vibe-fridge 查看详情" + if (!hasNotificationPermission(context)) { + return + } val notificationManager = context.getSystemService( Context.NOTIFICATION_SERVICE ) as NotificationManager @@ -48,10 +56,22 @@ class ReminderNotificationReceiver : BroadcastReceiver() { .setContentIntent(pendingIntent) .setAutoCancel(true) .build() - notificationManager.notify( - LocalNotificationContract.requestCodeFor(itemId), - notification, - ) + try { + notificationManager.notify( + LocalNotificationContract.requestCodeFor(itemId), + notification, + ) + } catch (ignored: SecurityException) { + return + } + } + + private fun hasNotificationPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return true + } + return context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED } private fun ensureChannel(notificationManager: NotificationManager) { diff --git a/mobile/lib/data/acceptance_test_service.dart b/mobile/lib/data/acceptance_test_service.dart index 35a4461..528d9ea 100644 --- a/mobile/lib/data/acceptance_test_service.dart +++ b/mobile/lib/data/acceptance_test_service.dart @@ -13,11 +13,15 @@ class AcceptanceTestService { final InventoryRepository repository; - Future runCoreInventoryChecks() async { + Future runCoreInventoryChecks() { + return repository.preserveBackupReminderState(_runCoreInventoryChecks); + } + + Future _runCoreInventoryChecks() async { final startedAt = DateTime.now(); final checks = []; - final testName = '自验收测试物品-${startedAt.microsecondsSinceEpoch}'; - const testNamePrefix = '自验收测试物品-'; + final testName = '应用自检测试物品-${startedAt.microsecondsSinceEpoch}'; + const testNamePrefix = '应用自检测试物品-'; String? wikiId; String? originalItemId; String? restoredItemId; @@ -40,7 +44,7 @@ class AcceptanceTestService { AcceptanceCheckResult.failed( name: name, duration: stopwatch.elapsed, - message: error.toString(), + message: selfCheckFailureMessage(error), ), ); } @@ -65,7 +69,7 @@ class AcceptanceTestService { await repository.createItem( name: testName, categoryId: categories.isEmpty ? null : categories.first.id, - description: 'app 自验收临时数据', + description: '应用自检临时数据', quantity: 2, unit: '份', purchaseDate: startedAt, @@ -87,7 +91,7 @@ class AcceptanceTestService { throw StateError('提醒日期或提醒开关未正确初始化'); } if (item.imagePath != '/tmp/vibe-fridge-acceptance/package.jpg') { - throw StateError('图片附件路径未正确保存'); + throw StateError('图片附件未正确保存'); } if (item.storageLocation != '冷藏') { throw StateError('存放位置未正确保存'); @@ -106,7 +110,7 @@ class AcceptanceTestService { }); await check('本地通知内容可基于提醒生成', () async { - final item = await _activeItem(_required(wikiId, 'wikiId')); + final item = await _activeItem(_required(wikiId, '物品资料')); final pending = await repository.getPendingReminderNotifications(); PendingReminderNotification? notification; for (final candidate in pending) { @@ -126,16 +130,16 @@ class AcceptanceTestService { }); await check('提醒日志防重复并支持忽略本次', () async { - final item = await _activeItem(_required(wikiId, 'wikiId')); + final item = await _activeItem(_required(wikiId, '物品资料')); final firstSent = await repository.recordReminderSentIfNeeded( itemId: item.id, reminderType: 'reminder_due', - message: '自验收提醒', + message: '应用自检提醒', ); final duplicateSent = await repository.recordReminderSentIfNeeded( itemId: item.id, reminderType: 'reminder_due', - message: '自验收重复提醒', + message: '应用自检重复提醒', ); if (!firstSent || duplicateSent) { throw StateError('同日同类型提醒没有正确去重'); @@ -179,6 +183,57 @@ class AcceptanceTestService { } }); + await check('食谱扣减会更新库存数量', () async { + await repository.createItem( + name: '$testName-食谱鸡蛋', + quantity: 3, + unit: '个', + expiryDate: startedAt.add(const Duration(days: 1)), + ); + await repository.createItem( + name: '$testName-食谱牛奶', + quantity: 2, + unit: '盒', + expiryDate: startedAt.add(const Duration(days: 1)), + ); + await repository.createItem( + name: '$testName-食谱面包', + quantity: 2, + unit: '片', + expiryDate: startedAt.add(const Duration(days: 2)), + ); + + final activeItems = await repository.getActiveItems(limit: 100); + final recipeItems = activeItems + .where((item) => item.name.startsWith('$testName-食谱')) + .toList(); + final suggestions = RecipeSuggestionService().generate(recipeItems); + final recipe = suggestions.firstWhere( + (suggestion) => suggestion.id == 'quick-breakfast', + orElse: () => throw StateError('没有生成可扣减的早餐食谱'), + ); + final recipeUses = recipe.inventoryUses + .where((use) => use.item.name.startsWith('$testName-食谱')) + .toList(); + if (recipeUses.length != 3) { + throw StateError('食谱没有使用预期的临时库存'); + } + + final beforeQuantities = { + for (final use in recipeUses) use.item.id: use.item.quantity, + }; + for (final use in recipeUses) { + await repository.updateItemQuantity(use.item.id, -use.quantity); + } + for (final use in recipeUses) { + final updated = await repository.getItem(use.item.id); + final expectedQuantity = beforeQuantities[use.item.id]! - use.quantity; + if (updated == null || updated.quantity != expectedQuantity) { + throw StateError('食谱扣减后库存数量不正确'); + } + } + }); + await check('采购清单可添加并转为库存', () async { final categories = await repository.getCategories(); final suggestionName = '$testName-建议'; @@ -199,8 +254,8 @@ class AcceptanceTestService { categoryId: categories.isEmpty ? null : categories.first.id, quantity: 1, unit: '份', - note: '自验收补货', - source: 'acceptance', + note: '应用自检补货', + source: '应用自检', ), ); @@ -226,8 +281,44 @@ class AcceptanceTestService { } }); + await check('备份内容包含关键数据', () async { + final item = await _activeItem(_required(wikiId, '物品资料')); + final categories = await repository.getCategories(); + final backupShoppingId = await repository.addShoppingListItem( + ShoppingListDraft( + name: '$testName-备份采购', + categoryId: categories.isEmpty ? null : categories.first.id, + quantity: 2, + unit: '份', + note: '应用自检备份验证', + source: '应用自检', + ), + ); + + final backup = await repository.exportBackup(); + final itemRows = _backupRows(backup, 'items'); + final itemTagRows = _backupRows(backup, 'item_tags'); + final reminderRows = _backupRows(backup, 'reminder_logs'); + final shoppingRows = _backupRows(backup, 'shopping_list_items'); + + final containsItem = itemRows.any((row) => row['id'] == item.id); + final containsTags = itemTagRows.any((row) => row['item_id'] == item.id); + final containsIgnoredReminder = reminderRows.any((row) { + return row['item_id'] == item.id && row['reminder_type'] == 'ignored'; + }); + final containsShoppingItem = shoppingRows.any((row) { + return row['id'] == backupShoppingId && row['note'] == '应用自检备份验证'; + }); + if (!containsItem || + !containsTags || + !containsIgnoredReminder || + !containsShoppingItem) { + throw StateError('备份内容缺少库存、标签、提醒或采购清单数据'); + } + }); + await check('更新库存数量', () async { - final item = await _activeItem(_required(wikiId, 'wikiId')); + final item = await _activeItem(_required(wikiId, '物品资料')); await repository.updateItemQuantity(item.id, 1); final updated = await repository.getItem(item.id); if (updated == null || updated.quantity != 3) { @@ -237,7 +328,7 @@ class AcceptanceTestService { }); await check('批量修改位置和分类', () async { - final item = await _activeItem(_required(wikiId, 'wikiId')); + final item = await _activeItem(_required(wikiId, '物品资料')); final categories = await repository.getCategories(); final targetCategory = categories.firstWhere( (category) => category.name == '日用品', @@ -256,7 +347,7 @@ class AcceptanceTestService { }); await check('标记消耗并写入历史', () async { - final itemId = _required(originalItemId, 'itemId'); + final itemId = _required(originalItemId, '库存记录'); await repository.markAsConsumed(itemId); final active = await repository.getItem(itemId); if (active == null || @@ -273,16 +364,16 @@ class AcceptanceTestService { }); await check('恢复已消耗记录', () async { - final itemId = _required(restoredItemId, 'restoredItemId'); + final itemId = _required(restoredItemId, '已消耗记录'); await repository.restoreItem(itemId); final restored = await repository.getItem(itemId); if (restored == null || restored.status != ItemStatus.active) { - throw StateError('恢复后记录未回到 active 状态'); + throw StateError('恢复后记录未回到使用中状态'); } }); await check('删除恢复后的库存记录', () async { - final itemId = _required(restoredItemId, 'restoredItemId'); + final itemId = _required(restoredItemId, '已恢复记录'); await repository.deleteItem(itemId); final deleted = await repository.getItem(itemId); if (deleted != null) { @@ -290,7 +381,7 @@ class AcceptanceTestService { } }); - await check('清理验收测试数据', () async { + await check('清理自检临时数据', () async { await _cleanupTemporaryData(testNamePrefix); final remaining = await repository.getRegisteredItems( keyword: testNamePrefix, @@ -300,7 +391,7 @@ class AcceptanceTestService { } }); - await check('数据健康检查通过', () async { + await check('资料一致性检查通过', () async { final health = await repository.checkDataHealth(); if (!health.passed) { throw StateError(health.summary); @@ -329,11 +420,11 @@ class AcceptanceTestService { .where( (item) => item.status == ItemStatus.active && - item.name.startsWith('自验收测试物品-'), + item.name.startsWith('应用自检测试物品-'), ) .toList(); if (activeItems.isEmpty) { - throw StateError('没有找到 active 测试库存记录'); + throw StateError('没有找到使用中的自检库存记录'); } return activeItems.first; } @@ -376,6 +467,46 @@ class AcceptanceTestService { } return value; } + + List> _backupRows( + Map backup, + String table, + ) { + final data = backup['data']; + if (data is! Map) { + throw StateError('备份内容缺少数据'); + } + final rows = data[table]; + if (rows is! List) { + throw StateError('备份内容缺少 $table'); + } + return rows.map((row) { + if (row is! Map) { + throw StateError('备份内容包含无效记录'); + } + return Map.from(row); + }).toList(); + } +} + +String selfCheckFailureMessage(Object error) { + var message = error.toString().trim(); + const prefixes = [ + 'Bad state: ', + 'Invalid argument(s): ', + 'Exception: ', + 'FormatException: ', + ]; + for (final prefix in prefixes) { + if (message.startsWith(prefix)) { + message = message.substring(prefix.length).trim(); + break; + } + } + if (message.isEmpty) { + return '检查没有完成,请稍后重试'; + } + return message; } class AcceptanceReport { diff --git a/mobile/lib/data/app_database.dart b/mobile/lib/data/app_database.dart index 9962bd2..670fc66 100644 --- a/mobile/lib/data/app_database.dart +++ b/mobile/lib/data/app_database.dart @@ -33,7 +33,7 @@ class AppDatabase { static DatabaseFactory get _databaseFactory { if (kIsWeb) { - return databaseFactoryFfiWeb; + return databaseFactoryFfiWebBasicWebWorker; } return databaseFactory; } diff --git a/mobile/lib/data/debug_service_extensions.dart b/mobile/lib/data/debug_service_extensions.dart new file mode 100644 index 0000000..7e4c778 --- /dev/null +++ b/mobile/lib/data/debug_service_extensions.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; + +import 'inventory_controller.dart'; +import 'local_notification_service.dart'; +import 'vlm_settings_store.dart'; + +@visibleForTesting +Map debugNotificationPermissionPayload( + LocalNotificationPermissionSnapshot permission, +) { + return { + 'supported': permission.supported, + 'granted': permission.granted, + 'status': permission.status, + 'displayText': permission.displayText, + }; +} + +@visibleForTesting +Map debugNotificationTestPayload( + LocalNotificationTestResult result, +) { + return { + 'sent': result.sent, + 'skippedReason': result.skippedReason, + 'displayText': result.displayText, + 'permission': debugNotificationPermissionPayload(result.permission), + }; +} + +@visibleForTesting +Future> runDebugSecureStorageSmoke({ + VlmSecretStore? secretStore, + DateTime? now, +}) async { + final store = secretStore ?? FlutterSecureSecretStore(); + final timestamp = (now ?? DateTime.now()).microsecondsSinceEpoch; + final key = 'debug.secure_storage_smoke.$timestamp'; + final value = 'vibe-fridge-smoke-$timestamp'; + var wrote = false; + var readBackMatches = false; + var deleted = false; + + try { + await store.write(key, value); + wrote = true; + readBackMatches = await store.read(key) == value; + } finally { + await store.delete(key); + final afterDelete = await store.read(key); + deleted = afterDelete == null || afterDelete.isEmpty; + } + return { + 'wrote': wrote, + 'readBackMatches': readBackMatches, + 'deleted': deleted, + 'keyPrefix': 'debug.secure_storage_smoke', + }; +} + +void registerDebugServiceExtensions(InventoryController controller) { + if (!kDebugMode) { + return; + } + developer.registerExtension( + 'ext.vibe_fridge.notificationStatus', + (method, parameters) async { + final permission = + await controller.notificationService.getPermissionStatus(); + return developer.ServiceExtensionResponse.result(jsonEncode( + debugNotificationPermissionPayload(permission), + )); + }, + ); + developer.registerExtension( + 'ext.vibe_fridge.notificationTest', + (method, parameters) async { + final result = + await controller.notificationService.sendTestNotification(); + return developer.ServiceExtensionResponse.result(jsonEncode( + debugNotificationTestPayload(result), + )); + }, + ); + developer.registerExtension( + 'ext.vibe_fridge.secureStorageSmoke', + (method, parameters) async { + return developer.ServiceExtensionResponse.result(jsonEncode( + await runDebugSecureStorageSmoke(), + )); + }, + ); +} diff --git a/mobile/lib/data/inventory_controller.dart b/mobile/lib/data/inventory_controller.dart index cd9d654..43f5af2 100644 --- a/mobile/lib/data/inventory_controller.dart +++ b/mobile/lib/data/inventory_controller.dart @@ -18,12 +18,15 @@ class InventoryController extends ChangeNotifier { InventoryController( this.repository, { LocalNotificationService? notificationService, - }) : notificationService = notificationService ?? LocalNotificationService() { + AssetBundle? assetBundle, + }) : notificationService = notificationService ?? LocalNotificationService(), + assetBundle = assetBundle ?? rootBundle { this.notificationService.setOnNotificationTap(_handleNotificationTap); } final InventoryRepository repository; final LocalNotificationService notificationService; + final AssetBundle assetBundle; bool isLoading = true; String? errorMessage; @@ -145,7 +148,7 @@ class InventoryController extends ChangeNotifier { for (var index = 0; index < items.length; index += 1) { final item = items[index]; final purchaseDate = item.purchaseDate ?? result.purchaseDate; - if (item.name.trim().isEmpty || purchaseDate == null) { + if (item.name.trim().isEmpty) { continue; } final count = await repository.countOrderImportDuplicates( @@ -181,9 +184,7 @@ class InventoryController extends ChangeNotifier { final sourceOrderId = result.orderId; for (final item in items) { final purchaseDate = item.purchaseDate ?? result.purchaseDate; - if (sourceOrderId != null && - sourceOrderId.trim().isNotEmpty && - purchaseDate != null) { + if (sourceOrderId != null && sourceOrderId.trim().isNotEmpty) { final duplicates = await repository.countOrderImportDuplicates( sourceOrderId: sourceOrderId, name: item.name, @@ -306,9 +307,7 @@ class InventoryController extends ChangeNotifier { sourceItemId: item.id, quantity: 1, unit: item.unit, - note: item.status == ItemStatus.consumed - ? '上次已消耗' - : '从库存记录加入', + note: item.status == ItemStatus.consumed ? '上次已消耗' : '从库存记录加入', ), ); } @@ -398,6 +397,13 @@ class InventoryController extends ChangeNotifier { return result; } + Future sendTestNotification() async { + final result = await notificationService.sendTestNotification(); + notificationPermission = result.permission; + notifyListeners(); + return result; + } + String? consumeNotificationTappedItemId() { final itemId = _notificationTappedItemId; _notificationTappedItemId = null; @@ -524,12 +530,13 @@ class InventoryController extends ChangeNotifier { } Future _loadLegacyImportAsset() async { - try { - return await rootBundle - .loadString('assets/import/legacy_inventory.local.json'); - } catch (_) { - return rootBundle.loadString('assets/import/legacy_inventory.json'); + const localAsset = 'assets/import/legacy_inventory.local.json'; + const defaultAsset = 'assets/import/legacy_inventory.json'; + final manifest = await AssetManifest.loadFromAssetBundle(assetBundle); + if (manifest.listAssets().contains(localAsset)) { + return assetBundle.loadString(localAsset); } + return assetBundle.loadString(defaultAsset); } String? _categoryIdForName(String? categoryName) { @@ -562,7 +569,7 @@ class OrderImportDuplicate { final int index; final String name; - final DateTime purchaseDate; + final DateTime? purchaseDate; final int existingCount; } diff --git a/mobile/lib/data/inventory_repository.dart b/mobile/lib/data/inventory_repository.dart index e793a98..c0a9a01 100644 --- a/mobile/lib/data/inventory_repository.dart +++ b/mobile/lib/data/inventory_repository.dart @@ -411,25 +411,29 @@ class InventoryRepository { Future countOrderImportDuplicates({ required String sourceOrderId, required String name, - required DateTime purchaseDate, + DateTime? purchaseDate, }) async { final normalizedOrderId = _blankToNull(sourceOrderId); final normalizedName = _blankToNull(name); if (normalizedOrderId == null || normalizedName == null) { return 0; } - return Sqflite.firstIntValue( - await _db.rawQuery(''' + final where = StringBuffer(''' SELECT COUNT(*) FROM items WHERE source_order_id = ? AND lower(name) = lower(?) - AND purchase_date = ? - ''', [ - normalizedOrderId, - normalizedName, - _dateText(purchaseDate), - ]), + '''); + final args = [ + normalizedOrderId, + normalizedName, + ]; + if (purchaseDate != null) { + where.write(' AND purchase_date = ?'); + args.add(_dateText(purchaseDate)); + } + return Sqflite.firstIntValue( + await _db.rawQuery(where.toString(), args), ) ?? 0; } @@ -537,6 +541,7 @@ class InventoryRepository { required String name, String? categoryId, String? description, + bool syncDescriptionToWiki = true, int quantity = 1, String? unit, DateTime? purchaseDate, @@ -584,7 +589,7 @@ class InventoryRepository { 'id': wikiId, 'name': normalizedName, 'icon': null, - 'description': description, + 'description': syncDescriptionToWiki ? description : null, 'category_id': categoryId, 'default_unit': unit, 'suggested_expiry_days': null, @@ -599,13 +604,17 @@ class InventoryRepository { wikiId = existing.first['id'] as String; effectiveReminderDays = reminderDaysBefore ?? ((existing.first['default_reminder_days'] as int?) ?? 3); - if (categoryId != null || unit != null || description != null) { + if (categoryId != null || + unit != null || + (syncDescriptionToWiki && description != null)) { await txn.update( 'item_wikis', { if (categoryId != null) 'category_id': categoryId, if (unit != null && unit.trim().isNotEmpty) 'default_unit': unit, - if (description != null && description.trim().isNotEmpty) + if (syncDescriptionToWiki && + description != null && + description.trim().isNotEmpty) 'description': description, if (storageLocation != null && storageLocation.trim().isNotEmpty) 'storage_location': storageLocation.trim(), @@ -809,6 +818,8 @@ class InventoryRepository { await createItem( name: item.name, categoryId: item.categoryId, + description: item.note, + syncDescriptionToWiki: false, quantity: item.quantity, unit: item.unit, purchaseDate: boughtAt, @@ -1613,7 +1624,7 @@ class InventoryRepository { LegacyImportLogEntry.updated( table: 'demo', name: '示例数据', - reason: '导入前清理 $clearedDemoRows 行示例资料/库存', + reason: '导入前清理 $clearedDemoRows 条示例资料/库存', ), ); } @@ -2322,13 +2333,7 @@ class InventoryRepository { } Future getBackupReminderState() async { - final metadata = await _getAppMetadata([ - _metadataBackupReminderPending, - _metadataBackupReminderReason, - _metadataBackupDirtyCount, - _metadataBackupReminderUpdatedAt, - _metadataLastBackupExportedAt, - ]); + final metadata = await _getAppMetadata(_backupReminderMetadataKeys); final pending = metadata[_metadataBackupReminderPending] == '1'; final dirtyCount = int.tryParse(metadata[_metadataBackupDirtyCount] ?? '0') ?? 0; @@ -2345,6 +2350,27 @@ class InventoryRepository { ); } + Future preserveBackupReminderState( + Future Function() action, + ) async { + final snapshot = await _getAppMetadata(_backupReminderMetadataKeys); + try { + return await action(); + } finally { + await _setAppMetadata({ + _metadataBackupReminderPending: + snapshot[_metadataBackupReminderPending] ?? '0', + _metadataBackupReminderReason: + snapshot[_metadataBackupReminderReason] ?? '', + _metadataBackupDirtyCount: snapshot[_metadataBackupDirtyCount] ?? '0', + _metadataBackupReminderUpdatedAt: + snapshot[_metadataBackupReminderUpdatedAt] ?? '', + _metadataLastBackupExportedAt: + snapshot[_metadataLastBackupExportedAt] ?? '', + }); + } + } + Future markBackupExported({DateTime? exportedAt}) async { final now = exportedAt ?? DateTime.now(); await _setAppMetadata({ @@ -2389,7 +2415,7 @@ class InventoryRepository { final health = await _checkDataHealth(txn); if (!health.passed) { - throw StateError('恢复后的数据健康检查失败:${health.summary}'); + throw StateError('恢复后的资料检查未通过:${health.summary}'); } }); @@ -2441,7 +2467,7 @@ class InventoryRepository { executor, issues, code: 'invalid_status', - message: '库存状态不在允许集合内', + message: '库存状态需要修正', query: ''' SELECT COUNT(*) FROM items @@ -2486,7 +2512,7 @@ class InventoryRepository { executor, issues, code: 'active_with_consumed_at', - message: 'active 库存不应带 consumed_at', + message: '使用中库存不应带有消耗时间', query: ''' SELECT COUNT(*) FROM items @@ -2497,7 +2523,7 @@ class InventoryRepository { executor, issues, code: 'consumed_without_consumed_at', - message: 'consumed 库存应记录 consumed_at', + message: '已消耗库存缺少消耗时间', query: ''' SELECT COUNT(*) FROM items @@ -2554,7 +2580,7 @@ class InventoryRepository { issues.add( DataHealthIssue( code: 'foreign_key_violation', - message: '数据关联检查失败', + message: '资料关联需要修正', count: foreignKeyRows.length, ), ); @@ -2706,6 +2732,13 @@ const _metadataBackupReminderReason = 'backup_reminder_reason'; const _metadataBackupDirtyCount = 'backup_dirty_count'; const _metadataBackupReminderUpdatedAt = 'backup_reminder_updated_at'; const _metadataLastBackupExportedAt = 'last_backup_exported_at'; +const _backupReminderMetadataKeys = [ + _metadataBackupReminderPending, + _metadataBackupReminderReason, + _metadataBackupDirtyCount, + _metadataBackupReminderUpdatedAt, + _metadataLastBackupExportedAt, +]; const _demoWikiIds = [ 'wiki-milk', @@ -2916,8 +2949,10 @@ class BackupReminderState { if (!isPending) { return '当前没有待处理的备份提醒'; } - final prefix = reason == null || reason!.isEmpty ? '数据已变更' : reason!; - return '$prefix 后建议导出一份备份'; + if (reason == null || reason!.isEmpty) { + return '库存资料有更新,建议备份一次'; + } + return '因为$reason,建议备份一次'; } } @@ -2995,7 +3030,7 @@ class DataHealthReport { String get summary { if (passed) { - return '数据健康'; + return '资料检查正常'; } return issues.map((issue) => '${issue.message} ${issue.count} 处').join(';'); } diff --git a/mobile/lib/data/local_notification_service.dart b/mobile/lib/data/local_notification_service.dart index e9988ea..ad6344f 100644 --- a/mobile/lib/data/local_notification_service.dart +++ b/mobile/lib/data/local_notification_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'inventory_repository.dart'; @@ -7,20 +8,29 @@ class LocalNotificationService { MethodChannel channel = const MethodChannel( 'vibe_fridge/local_notifications', ), - }) : _channel = channel; + bool? supportsPlatformNotifications, + }) : _channel = channel, + _supportsPlatformNotifications = + supportsPlatformNotifications ?? !kIsWeb; final MethodChannel _channel; + final bool _supportsPlatformNotifications; void Function(String itemId)? _onNotificationTap; void setOnNotificationTap(void Function(String itemId)? handler) { _onNotificationTap = handler; + if (!_supportsPlatformNotifications) { + return; + } _channel.setMethodCallHandler((call) async { if (call.method != 'notificationTapped') { return null; } final arguments = call.arguments; - final itemId = arguments is Map ? arguments['itemId'] as String? : null; - if (itemId != null && itemId.isNotEmpty) { + final itemId = _normalizeItemId( + arguments is Map ? arguments['itemId'] : null, + ); + if (itemId != null) { _onNotificationTap?.call(itemId); } return null; @@ -28,21 +38,64 @@ class LocalNotificationService { } Future initialize() async { + if (!_supportsPlatformNotifications) { + return LocalNotificationPermissionSnapshot.unsupported; + } return _permissionCall('initialize'); } Future getPermissionStatus() async { + if (!_supportsPlatformNotifications) { + return LocalNotificationPermissionSnapshot.unsupported; + } return _permissionCall('getPermissionStatus'); } Future requestPermission() async { + if (!_supportsPlatformNotifications) { + return LocalNotificationPermissionSnapshot.unsupported; + } return _permissionCall('requestPermission'); } + Future sendTestNotification() async { + final permission = await requestPermission(); + if (!permission.supported || !permission.granted) { + return LocalNotificationTestResult( + permission: permission, + sent: false, + skippedReason: permission.supported ? 'permission' : 'unsupported', + ); + } + + try { + await _channel.invokeMethod('sendTestNotification'); + return LocalNotificationTestResult( + permission: permission, + sent: true, + ); + } on MissingPluginException { + return const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot.unsupported, + sent: false, + skippedReason: 'missing_plugin', + ); + } on PlatformException catch (error) { + return LocalNotificationTestResult( + permission: permission, + sent: false, + skippedReason: error.code, + ); + } + } + Future getLaunchItemId() async { + if (!_supportsPlatformNotifications) { + return null; + } try { final itemId = await _channel.invokeMethod('getLaunchItemId'); - return itemId == null || itemId.isEmpty ? null : itemId; + return _normalizeItemId(itemId); } on MissingPluginException { return null; } on PlatformException { @@ -115,6 +168,14 @@ class LocalNotificationService { ); } } + + String? _normalizeItemId(Object? rawItemId) { + if (rawItemId is! String) { + return null; + } + final itemId = rawItemId.trim(); + return itemId.isEmpty ? null : itemId; + } } class LocalNotificationPermissionSnapshot { @@ -202,3 +263,29 @@ class LocalNotificationSyncResult { return '同步失败,请稍后重试'; } } + +class LocalNotificationTestResult { + const LocalNotificationTestResult({ + required this.permission, + required this.sent, + this.skippedReason, + }); + + final LocalNotificationPermissionSnapshot permission; + final bool sent; + final String? skippedReason; + + String get displayText { + if (sent) { + return '已发送测试通知'; + } + final reason = skippedReason; + if (reason == 'permission') { + return '通知未授权'; + } + if (reason == 'unsupported' || reason == 'missing_plugin') { + return '当前平台不可用'; + } + return '测试通知发送失败,请稍后重试'; + } +} diff --git a/mobile/lib/data/order_text_import_parser.dart b/mobile/lib/data/order_text_import_parser.dart index 5e1e85e..bff9adf 100644 --- a/mobile/lib/data/order_text_import_parser.dart +++ b/mobile/lib/data/order_text_import_parser.dart @@ -92,6 +92,9 @@ _ParsedTextItem? _parseItemLine(String line) { bool _shouldSkipLine(String line) { final lower = line.toLowerCase(); + if (_looksLikeStandaloneReference(line)) { + return true; + } final nonInventoryPattern = RegExp( r'退款|退货|已退|取消|运费|配送费|包装费|' r'服务费|优惠|红包|实付|合计|总计', @@ -111,6 +114,16 @@ bool _shouldSkipLine(String line) { lower.startsWith('merchant:'); } +bool _looksLikeStandaloneReference(String line) { + final text = line.trim(); + if (text.length < 5 || + text.contains(RegExp(r'\s')) || + text.contains(RegExp(r'[\u4e00-\u9fa5]'))) { + return false; + } + return RegExp(r'^[A-Z]{2,}[A-Z0-9_-]*[-_#]\d[A-Z0-9_-]*$').hasMatch(text); +} + bool _looksLikeMetadata(String text) { if (_datePattern.hasMatch(text) || _chineseDatePattern.hasMatch(text)) { return true; @@ -164,9 +177,9 @@ String? _categoryForName(String name) { String? _merchant(List lines) { for (final line in lines) { - final match = RegExp(r'(?:商家|门店|merchant)\s*[::]\s*(.+)', - caseSensitive: false) - .firstMatch(line); + final match = + RegExp(r'(?:商家|门店|merchant)\s*[::]\s*(.+)', caseSensitive: false) + .firstMatch(line); if (match != null) { return match.group(1)?.trim(); } diff --git a/mobile/lib/data/vlm_settings_store.dart b/mobile/lib/data/vlm_settings_store.dart index f0fa56f..efa559a 100644 --- a/mobile/lib/data/vlm_settings_store.dart +++ b/mobile/lib/data/vlm_settings_store.dart @@ -42,29 +42,38 @@ abstract class VlmSecretStore { Future delete(String key); } -class FlutterSecureVlmSecretStore implements VlmSecretStore { - FlutterSecureVlmSecretStore({ +class FlutterSecureSecretStore implements VlmSecretStore { + FlutterSecureSecretStore({ FlutterSecureStorage storage = const FlutterSecureStorage(), }) : _storage = storage; final FlutterSecureStorage _storage; + static const _macOsOptions = MacOsOptions( + useDataProtectionKeyChain: false, + ); @override Future read(String key) { - return _storage.read(key: key); + return _storage.read(key: key, mOptions: _macOsOptions); } @override Future write(String key, String value) { - return _storage.write(key: key, value: value); + return _storage.write(key: key, value: value, mOptions: _macOsOptions); } @override Future delete(String key) { - return _storage.delete(key: key); + return _storage.delete(key: key, mOptions: _macOsOptions); } } +class FlutterSecureVlmSecretStore extends FlutterSecureSecretStore { + FlutterSecureVlmSecretStore({ + super.storage, + }); +} + class VlmSettingsStore { VlmSettingsStore({VlmSecretStore? secretStore}) : _secretStore = secretStore ?? FlutterSecureVlmSecretStore(); @@ -116,8 +125,8 @@ class VlmSettingsStore { Future clear() async { final preferences = await SharedPreferences.getInstance(); await Future.wait([ - preferences.remove(_endpointKey), - preferences.remove(_modelKey), + preferences.setString(_endpointKey, ''), + preferences.setString(_modelKey, ''), preferences.remove(_legacyApiKeyKey), _secretStore.delete(_secureApiKeyKey), ]); diff --git a/mobile/lib/data/webdav_backup_service.dart b/mobile/lib/data/webdav_backup_service.dart new file mode 100644 index 0000000..88e7a1c --- /dev/null +++ b/mobile/lib/data/webdav_backup_service.dart @@ -0,0 +1,387 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'webdav_backup_store.dart'; + +class WebDavBackupService { + WebDavBackupService({http.Client? client}) + : _client = client ?? http.Client(); + + final http.Client _client; + + void close() { + _client.close(); + } + + Future validateConfiguration(WebDavBackupSettings settings) async { + final directoryUri = await _ensureDirectory(settings); + final response = await _send( + 'PROPFIND', + directoryUri, + settings, + headers: const {'Depth': '0'}, + body: _propfindBody, + ); + _throwForHttpFailure(response); + } + + Future uploadBackup({ + required WebDavBackupSettings settings, + required String fileName, + required String backupJson, + }) async { + _validateBackupFileName(fileName); + final directoryUri = await _ensureDirectory(settings); + final fileUri = _appendPathSegment(directoryUri, fileName); + final response = await _send( + 'PUT', + fileUri, + settings, + headers: const {'Content-Type': 'application/json; charset=utf-8'}, + body: backupJson, + ); + _throwForHttpFailure(response, successCodes: const {200, 201, 204}); + return WebDavBackupUploadResult(fileName: fileName, uri: fileUri); + } + + Future downloadLatestBackup( + WebDavBackupSettings settings, + ) async { + final directoryUri = await _ensureDirectory(settings); + final listResponse = await _send( + 'PROPFIND', + directoryUri, + settings, + headers: const {'Depth': '1'}, + body: _propfindBody, + ); + _throwForHttpFailure(listResponse); + + final entries = _parseBackupEntries( + utf8.decode(listResponse.bodyBytes), + directoryUri, + ); + if (entries.isEmpty) { + throw const WebDavBackupException( + '没有找到由本应用上传的备份文件', + type: WebDavBackupErrorType.notFound, + ); + } + entries.sort((left, right) => right.fileName.compareTo(left.fileName)); + final latest = entries.first; + final downloadResponse = await _send('GET', latest.uri, settings); + _throwForHttpFailure(downloadResponse); + return WebDavBackupDownloadResult( + fileName: latest.fileName, + uri: latest.uri, + backupJson: utf8.decode(downloadResponse.bodyBytes), + ); + } + + Future _ensureDirectory(WebDavBackupSettings settings) async { + _validateSettings(settings); + var current = _baseUri(settings.serverUrl); + for (final segment in _remoteDirectorySegments(settings.remoteDirectory)) { + current = _appendPathSegment(current, segment, trailingSlash: true); + final response = await _send('MKCOL', current, settings); + if (response.statusCode == 201 || + response.statusCode == 200 || + response.statusCode == 204 || + response.statusCode == 405) { + continue; + } + _throwForHttpFailure(response, successCodes: const {201, 405}); + } + return _ensureTrailingSlash(current); + } + + Future _send( + String method, + Uri uri, + WebDavBackupSettings settings, { + Map headers = const {}, + String? body, + }) async { + final request = http.Request(method, uri); + request.headers.addAll(_authHeaders(settings)); + request.headers.addAll(headers); + if (body != null) { + request.bodyBytes = utf8.encode(body); + } + try { + final streamed = await _client.send(request); + return http.Response.fromStream(streamed); + } on http.ClientException catch (error) { + throw WebDavBackupException( + '网络错误:${error.message}', + type: WebDavBackupErrorType.network, + ); + } on Exception catch (error) { + throw WebDavBackupException( + '网络错误:$error', + type: WebDavBackupErrorType.network, + ); + } + } + + Map _authHeaders(WebDavBackupSettings settings) { + final username = settings.username.trim(); + final password = settings.password.trim(); + if (username.isEmpty && password.isEmpty) { + return const {}; + } + final token = base64Encode(utf8.encode('$username:$password')); + return {'Authorization': 'Basic $token'}; + } + + Uri _baseUri(String rawUrl) { + final uri = Uri.tryParse(rawUrl.trim()); + if (uri == null || + !uri.hasScheme || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty) { + throw const WebDavBackupException( + '请填写完整的 http 或 https WebDAV 服务地址', + type: WebDavBackupErrorType.configuration, + ); + } + if (uri.hasQuery || uri.hasFragment) { + throw const WebDavBackupException( + 'WebDAV 服务地址不能包含查询参数或锚点', + type: WebDavBackupErrorType.configuration, + ); + } + return _ensureTrailingSlash(uri); + } + + Uri _appendPathSegment( + Uri uri, + String segment, { + bool trailingSlash = false, + }) { + final baseSegments = uri.pathSegments + .where((part) => part.isNotEmpty) + .toList(growable: true); + baseSegments.add(segment); + final encodedPath = '/${baseSegments.map(Uri.encodeComponent).join('/')}' + '${trailingSlash ? '/' : ''}'; + return uri.replace(path: encodedPath, query: null, fragment: null); + } + + Uri _ensureTrailingSlash(Uri uri) { + if (uri.path.endsWith('/')) { + return uri.replace(query: null, fragment: null); + } + return uri.replace(path: '${uri.path}/', query: null, fragment: null); + } + + List _remoteDirectorySegments(String remoteDirectory) { + final normalized = remoteDirectory.trim().replaceAll('\\', '/'); + final segments = normalized + .split('/') + .map((segment) => segment.trim()) + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + for (final segment in segments) { + if (segment == '.' || segment == '..') { + throw const WebDavBackupException( + '备份目录不能包含 . 或 ..', + type: WebDavBackupErrorType.configuration, + ); + } + } + return segments; + } + + void _validateSettings(WebDavBackupSettings settings) { + if (settings.serverUrl.trim().isEmpty) { + throw const WebDavBackupException( + '请先填写 WebDAV 服务地址', + type: WebDavBackupErrorType.configuration, + ); + } + _remoteDirectorySegments(settings.remoteDirectory); + } + + void _validateBackupFileName(String fileName) { + if (!fileName.startsWith('vibe-fridge-backup-') || + !fileName.endsWith('.json') || + fileName.contains('/') || + fileName.contains('\\')) { + throw const WebDavBackupException( + '备份文件名不正确', + type: WebDavBackupErrorType.configuration, + ); + } + } + + void _throwForHttpFailure( + http.Response response, { + Set successCodes = const {200, 207}, + }) { + if (successCodes.contains(response.statusCode)) { + return; + } + if (response.statusCode == 401 || response.statusCode == 403) { + throw WebDavBackupException( + 'HTTP ${response.statusCode}', + type: WebDavBackupErrorType.authentication, + ); + } + if (response.statusCode == 404) { + throw const WebDavBackupException( + 'HTTP 404', + type: WebDavBackupErrorType.notFound, + ); + } + if (response.statusCode >= 500) { + throw WebDavBackupException( + 'HTTP ${response.statusCode}', + type: WebDavBackupErrorType.server, + ); + } + throw WebDavBackupException( + 'HTTP ${response.statusCode}', + type: WebDavBackupErrorType.server, + ); + } + + List<_WebDavBackupEntry> _parseBackupEntries(String xml, Uri directoryUri) { + final entries = <_WebDavBackupEntry>[]; + final responseExp = RegExp( + r'<(?:\w+:)?response\b[^>]*>([\s\S]*?)', + caseSensitive: false, + ); + final hrefExp = RegExp( + r'<(?:\w+:)?href\b[^>]*>([\s\S]*?)', + caseSensitive: false, + ); + final responseMatches = responseExp.allMatches(xml).toList(); + final blocks = responseMatches.isEmpty + ? [xml] + : responseMatches.map((match) => match.group(1) ?? '').toList(); + for (final block in blocks) { + final hrefMatch = hrefExp.firstMatch(block); + if (hrefMatch == null) { + continue; + } + final href = _decodeXmlText(hrefMatch.group(1) ?? '').trim(); + if (href.isEmpty) { + continue; + } + final uri = _resolveHref(href, directoryUri); + final pathSegments = uri.pathSegments + .where((segment) => segment.isNotEmpty) + .map(Uri.decodeComponent) + .toList(growable: false); + if (pathSegments.isEmpty) { + continue; + } + final fileName = pathSegments.last; + if (!fileName.startsWith('vibe-fridge-backup-') || + !fileName.endsWith('.json')) { + continue; + } + entries.add(_WebDavBackupEntry(fileName: fileName, uri: uri)); + } + return entries; + } + + Uri _resolveHref(String href, Uri directoryUri) { + final parsed = Uri.parse(href); + if (parsed.hasScheme) { + return parsed; + } + if (href.startsWith('/')) { + return directoryUri.replace( + path: parsed.path, + query: parsed.query.isEmpty ? null : parsed.query, + fragment: null, + ); + } + return directoryUri.resolve(href); + } + + String _decodeXmlText(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); + } +} + +class WebDavBackupUploadResult { + const WebDavBackupUploadResult({ + required this.fileName, + required this.uri, + }); + + final String fileName; + final Uri uri; +} + +class WebDavBackupDownloadResult { + const WebDavBackupDownloadResult({ + required this.fileName, + required this.uri, + required this.backupJson, + }); + + final String fileName; + final Uri uri; + final String backupJson; +} + +enum WebDavBackupErrorType { + configuration, + network, + authentication, + server, + notFound, +} + +class WebDavBackupException implements Exception { + const WebDavBackupException( + this.message, { + this.type = WebDavBackupErrorType.server, + }); + + final String message; + final WebDavBackupErrorType type; + + String get userMessage { + return switch (type) { + WebDavBackupErrorType.configuration => 'WebDAV 配置错误:$message', + WebDavBackupErrorType.network => '无法连接 WebDAV:请检查地址、网络或云盘的网页访问设置', + WebDavBackupErrorType.authentication => 'WebDAV 鉴权失败:请检查用户名和密码', + WebDavBackupErrorType.notFound => '没有找到可恢复的 WebDAV 备份', + WebDavBackupErrorType.server => 'WebDAV 服务返回错误:请检查目录权限或稍后重试', + }; + } + + @override + String toString() => message; +} + +class _WebDavBackupEntry { + const _WebDavBackupEntry({ + required this.fileName, + required this.uri, + }); + + final String fileName; + final Uri uri; +} + +const _propfindBody = ''' + + + + + + + +'''; diff --git a/mobile/lib/data/webdav_backup_store.dart b/mobile/lib/data/webdav_backup_store.dart new file mode 100644 index 0000000..2f85814 --- /dev/null +++ b/mobile/lib/data/webdav_backup_store.dart @@ -0,0 +1,97 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'vlm_settings_store.dart'; + +class WebDavBackupSettings { + const WebDavBackupSettings({ + this.serverUrl = '', + this.remoteDirectory = WebDavBackupSettingsStore.defaultRemoteDirectory, + this.username = '', + this.password = '', + this.hasStoredPassword = false, + }); + + final String serverUrl; + final String remoteDirectory; + final String username; + final String password; + final bool hasStoredPassword; + + bool get hasPassword => password.trim().isNotEmpty || hasStoredPassword; + + bool get isConfigured => serverUrl.trim().isNotEmpty; +} + +class WebDavBackupSettingsStore { + WebDavBackupSettingsStore({VlmSecretStore? secretStore}) + : _secretStore = secretStore ?? FlutterSecureSecretStore(); + + static const defaultRemoteDirectory = 'vibe-fridge/backups'; + + static const _serverUrlKey = 'webdav.server_url'; + static const _remoteDirectoryKey = 'webdav.remote_directory'; + static const _usernameKey = 'webdav.username'; + static const _securePasswordKey = 'webdav.secure_password'; + + final VlmSecretStore _secretStore; + + Future load({bool revealPassword = true}) async { + final preferences = await SharedPreferences.getInstance(); + final password = (await _secretStore.read(_securePasswordKey)) ?? ''; + return WebDavBackupSettings( + serverUrl: preferences.getString(_serverUrlKey) ?? '', + remoteDirectory: + preferences.getString(_remoteDirectoryKey) ?? defaultRemoteDirectory, + username: preferences.getString(_usernameKey) ?? '', + password: revealPassword ? password : '', + hasStoredPassword: password.trim().isNotEmpty, + ); + } + + Future save( + WebDavBackupSettings settings, { + bool preserveExistingPasswordIfBlank = true, + }) async { + final preferences = await SharedPreferences.getInstance(); + final trimmedPassword = settings.password.trim(); + await Future.wait([ + preferences.setString(_serverUrlKey, settings.serverUrl.trim()), + preferences.setString( + _remoteDirectoryKey, + _normalizedRemoteDirectory(settings.remoteDirectory), + ), + preferences.setString(_usernameKey, settings.username.trim()), + ]); + + if (trimmedPassword.isNotEmpty) { + await _secretStore.write(_securePasswordKey, trimmedPassword); + return; + } + if (!preserveExistingPasswordIfBlank) { + await _secretStore.delete(_securePasswordKey); + } + } + + Future clear() async { + final preferences = await SharedPreferences.getInstance(); + await Future.wait([ + preferences.remove(_serverUrlKey), + preferences.remove(_remoteDirectoryKey), + preferences.remove(_usernameKey), + _secretStore.delete(_securePasswordKey), + ]); + } + + String _normalizedRemoteDirectory(String value) { + final trimmed = value.trim().replaceAll('\\', '/'); + final parts = trimmed + .split('/') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) { + return defaultRemoteDirectory; + } + return parts.join('/'); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0516731..81d5032 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,32 +1,115 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/services.dart'; import 'data/app_database.dart'; +import 'data/debug_service_extensions.dart'; import 'data/inventory_controller.dart'; import 'data/inventory_repository.dart'; import 'screens/app_shell.dart'; import 'theme/app_theme.dart'; +const bootstrapInitializationTimeout = Duration(seconds: 12); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); - try { - final database = await AppDatabase.open(); - final repository = InventoryRepository(database); - final controller = InventoryController(repository); - await controller.initialize(); - - runApp(VibeFridgeApp(controller: controller)); - } catch (error, stackTrace) { - runApp( - VibeFridgeBootstrapErrorApp( + runApp(const VibeFridgeBootstrapApp()); +} + +@visibleForTesting +Future loadInventoryControllerForBootstrap() async { + final database = await withBootstrapTimeout( + AppDatabase.open(), + operation: '打开本地资料', + ); + final repository = InventoryRepository(database); + final controller = InventoryController(repository); + await withBootstrapTimeout( + controller.initialize(), + operation: '加载库存资料', + ); + registerDebugServiceExtensions(controller); + return controller; +} + +typedef BootstrapControllerLoader = Future Function(); + +class VibeFridgeBootstrapApp extends StatefulWidget { + const VibeFridgeBootstrapApp({ + super.key, + this.controllerLoader = loadInventoryControllerForBootstrap, + }); + + final BootstrapControllerLoader controllerLoader; + + @override + State createState() => _VibeFridgeBootstrapAppState(); +} + +class _VibeFridgeBootstrapAppState extends State { + InventoryController? _controller; + Object? _error; + StackTrace? _stackTrace; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadController()); + } + + Future _loadController() async { + try { + final controller = await widget.controllerLoader(); + if (!mounted) { + return; + } + setState(() => _controller = controller); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + setState(() { + _error = error; + _stackTrace = stackTrace; + }); + } + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + if (controller != null) { + return VibeFridgeApp(controller: controller); + } + final error = _error; + final stackTrace = _stackTrace; + if (error != null && stackTrace != null) { + return VibeFridgeBootstrapErrorApp( error: error, stackTrace: stackTrace, - ), - ); + ); + } + return const VibeFridgeBootstrapLoadingApp(); } } +@visibleForTesting +Future withBootstrapTimeout( + Future future, { + required String operation, + Duration timeout = bootstrapInitializationTimeout, +}) { + return future.timeout( + timeout, + onTimeout: () { + throw TimeoutException('启动初始化超时:$operation', timeout); + }, + ); +} + class VibeFridgeApp extends StatelessWidget { const VibeFridgeApp({super.key, required this.controller}); @@ -34,7 +117,7 @@ class VibeFridgeApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return MaterialApp.router( debugShowCheckedModeBanner: false, title: 'vibe-fridge', theme: AppTheme.light(), @@ -49,7 +132,122 @@ class VibeFridgeApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - home: AppShell(controller: controller), + routeInformationParser: const _VibeFridgeRouteParser(), + routerDelegate: _VibeFridgeRouterDelegate(controller), + ); + } +} + +class VibeFridgeBootstrapLoadingApp extends StatelessWidget { + const VibeFridgeBootstrapLoadingApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'vibe-fridge', + theme: AppTheme.light(), + themeMode: ThemeMode.light, + home: Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxWidth, + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.pageHorizontal), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: AppSizes.emptyIconContainer, + height: AppSizes.emptyIconContainer, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(AppRadii.large), + ), + child: const Icon( + Icons.kitchen_outlined, + color: AppColors.primary, + ), + ), + const SizedBox(height: AppSpacing.fieldGap), + Text( + '正在整理你的库存', + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: AppSpacing.cardGap), + Text( + '这通常只需要几秒。', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.sectionGap), + const LinearProgressIndicator(minHeight: 4), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _VibeFridgeRouteParser extends RouteInformationParser { + const _VibeFridgeRouteParser(); + + @override + Future parseRouteInformation( + RouteInformation routeInformation, + ) { + return SynchronousFuture(routeInformation.uri.toString()); + } + + @override + RouteInformation restoreRouteInformation(String configuration) { + return RouteInformation(uri: Uri.parse(configuration)); + } +} + +class _VibeFridgeRouterDelegate extends RouterDelegate + with ChangeNotifier, PopNavigatorRouterDelegateMixin { + _VibeFridgeRouterDelegate(this.controller); + + final InventoryController controller; + + @override + final GlobalKey navigatorKey = GlobalKey(); + + String _configuration = '/'; + + @override + String get currentConfiguration => _configuration; + + @override + Future setNewRoutePath(String configuration) async { + _configuration = configuration; + notifyListeners(); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + pages: [ + MaterialPage( + child: AppShell(controller: controller), + ), + ], + onDidRemovePage: (_) {}, ); } } diff --git a/mobile/lib/screens/app_shell.dart b/mobile/lib/screens/app_shell.dart index fa06ede..05f4c3d 100644 --- a/mobile/lib/screens/app_shell.dart +++ b/mobile/lib/screens/app_shell.dart @@ -27,6 +27,7 @@ class _AppShellState extends State { int _index = 0; final List _screens = List.filled(_tabCount, null); final _itemsScreenKey = GlobalKey(); + final _recipesScreenKey = GlobalKey(); StreamSubscription? _webRouteSubscription; @override @@ -109,18 +110,39 @@ class _AppShellState extends State { } _index = index; }); - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) { return; } Navigator.of(context).popUntil((route) => route.isFirst); if (index == 1) { _itemsScreenKey.currentState?.applyRequest(_itemsRequestFromUri(uri)); - _openItemsDetailForRoute(uri); + if (_isItemsDetailRoute(uri)) { + await _waitForWebRouteSettle(route); + if (!mounted || !isCurrentWebRoute(route)) { + return; + } + } + await _openItemsDetailForRoute(uri); + } else if (index == 3) { + if (_isRecipeDetailRoute(uri)) { + await _waitForWebRouteSettle(route); + if (!mounted || !isCurrentWebRoute(route)) { + return; + } + } + _openRecipeDetailForRoute(uri); } }); } + Future _waitForWebRouteSettle(String route) async { + if (!supportsWebRouteState || !isCurrentWebRoute(route)) { + return; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + int _indexForWebRoute(Uri uri) { final segment = uri.pathSegments.isEmpty ? 'home' : uri.pathSegments.first; return switch (segment) { @@ -182,7 +204,9 @@ class _AppShellState extends State { ); setWebRouteState(route, replace: true); await detail; - setWebRouteState('/items', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/items', replace: true); + } return; } if (kind == 'wiki') { @@ -198,8 +222,32 @@ class _AppShellState extends State { ); setWebRouteState(route, replace: true); await detail; - setWebRouteState('/items', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/items', replace: true); + } + } + } + + bool _isItemsDetailRoute(Uri uri) { + return uri.pathSegments.length >= 3 && uri.pathSegments.first == 'items'; + } + + void _openRecipeDetailForRoute(Uri uri) { + if (uri.pathSegments.length < 2 || uri.pathSegments.first != 'recipes') { + return; + } + final id = uri.pathSegments[1]; + if (id.isEmpty) { + return; } + final opened = _recipesScreenKey.currentState?.openRecipeById(id) ?? false; + if (!opened) { + setWebRouteState('/recipes', replace: true); + } + } + + bool _isRecipeDetailRoute(Uri uri) { + return uri.pathSegments.length >= 2 && uri.pathSegments.first == 'recipes'; } String _routeForIndex(int index) { @@ -280,7 +328,9 @@ class _AppShellState extends State { ), ) .then((_) { - setWebRouteState('/items', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/items', replace: true); + } }); setWebRouteState(route, replace: true); }); @@ -307,7 +357,10 @@ class _AppShellState extends State { controller: widget.controller, onItemSaved: () => _select(1), ), - 3 => RecipesScreen(controller: widget.controller), + 3 => RecipesScreen( + key: _recipesScreenKey, + controller: widget.controller, + ), 4 => SettingsScreen(controller: widget.controller), _ => const SizedBox.shrink(), }; diff --git a/mobile/lib/screens/home_screen.dart b/mobile/lib/screens/home_screen.dart index e42b20a..7d0512a 100644 --- a/mobile/lib/screens/home_screen.dart +++ b/mobile/lib/screens/home_screen.dart @@ -405,7 +405,9 @@ class _ExpiringPriorityCard extends StatelessWidget { ); setWebRouteState(route, replace: true); await detail; - setWebRouteState('/home', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/home', replace: true); + } await controller.refresh(); } } @@ -747,7 +749,9 @@ class _InventoryTile extends StatelessWidget { ); setWebRouteState(route, replace: true); await detail; - setWebRouteState('/home', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/home', replace: true); + } await controller.refresh(); }, child: Column( diff --git a/mobile/lib/screens/item_detail_screen.dart b/mobile/lib/screens/item_detail_screen.dart index 7067e23..341f4fd 100644 --- a/mobile/lib/screens/item_detail_screen.dart +++ b/mobile/lib/screens/item_detail_screen.dart @@ -10,6 +10,7 @@ import '../models/inventory_item.dart'; import '../models/item_status.dart'; import '../theme/app_theme.dart'; import '../utils/date_formatters.dart'; +import '../utils/import_trace_display.dart'; import '../widgets/app_cards.dart'; import '../widgets/image_attachment_card.dart'; import '../widgets/icon_mapper.dart'; @@ -417,10 +418,7 @@ class _ItemDetailScreenState extends State { } bool _hasImportTrace(InventoryItem item) { - return item.sourceApp != null || - item.sourceOrderId != null || - item.importBatchId != null || - item.recognitionConfidence != null; + return hasUserVisibleImportTrace(item); } class _Header extends StatelessWidget { @@ -818,7 +816,7 @@ class _Facts extends StatelessWidget { child: Column( children: [ _FactRow(label: '数量', value: '${item.quantity}${item.unit ?? ''}'), - _FactRow(label: '分类', value: item.categoryName ?? '其他'), + _FactRow(label: '分类', value: item.categoryName ?? '未分类'), _FactRow(label: '购买日期', value: formatDate(item.purchaseDate)), _FactRow(label: '过期日期', value: formatDate(item.expiryDate)), _FactRow(label: '提醒日期', value: formatDate(item.reminderDate)), @@ -951,6 +949,7 @@ class _ImportTrace extends StatelessWidget { @override Widget build(BuildContext context) { + final source = importSourceLabel(item.sourceApp); return SectionCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -963,12 +962,9 @@ class _ImportTrace extends StatelessWidget { ), ), const SizedBox(height: 10), - if (item.sourceApp != null) - _TraceRow(label: '来源', value: item.sourceApp!), + if (source != null) _TraceRow(label: '来源', value: source), if (item.sourceOrderId != null) _TraceRow(label: '订单号', value: item.sourceOrderId!), - if (item.importBatchId != null) - _TraceRow(label: '导入批次', value: item.importBatchId!), if (item.recognitionConfidence != null) _TraceRow( label: '识别置信度', diff --git a/mobile/lib/screens/item_wiki_detail_screen.dart b/mobile/lib/screens/item_wiki_detail_screen.dart index ea06f71..2aaa348 100644 --- a/mobile/lib/screens/item_wiki_detail_screen.dart +++ b/mobile/lib/screens/item_wiki_detail_screen.dart @@ -302,7 +302,9 @@ class _ItemWikiDetailScreenState extends State { ); setWebRouteState(route, replace: true); await detail; - setWebRouteState('/items/wiki/${widget.wikiId}', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/items/wiki/${widget.wikiId}', replace: true); + } if (mounted) { setState(() { _future = _load(); @@ -623,7 +625,7 @@ class _WikiFacts extends StatelessWidget { return SectionCard( child: Column( children: [ - _FactRow(label: '分类', value: wiki.categoryName ?? '其他'), + _FactRow(label: '分类', value: wiki.categoryName ?? '未分类'), _FactRow(label: '默认单位', value: wiki.defaultUnit ?? '未设置'), _FactRow( label: '建议保质期', @@ -636,6 +638,8 @@ class _WikiFacts extends StatelessWidget { value: '提前 ${wiki.defaultReminderDays} 天', ), _FactRow(label: '存放位置', value: wiki.storageLocation ?? '未设置'), + if (wiki.notes != null && wiki.notes!.trim().isNotEmpty) + _FactRow(label: '备注', value: wiki.notes!.trim()), _FactRow(label: '使用中批次', value: '${wiki.inventoryCount}'), ], ), diff --git a/mobile/lib/screens/items_screen.dart b/mobile/lib/screens/items_screen.dart index 8cbc548..91c9ac2 100644 --- a/mobile/lib/screens/items_screen.dart +++ b/mobile/lib/screens/items_screen.dart @@ -302,7 +302,10 @@ class ItemsScreenState extends State { ); setWebRouteState(route, replace: true); await detail; - _syncItemsRoute(replace: true); + if (!supportsWebRouteState || + isCurrentWebRoute(route)) { + _syncItemsRoute(replace: true); + } if (mounted) { await widget.controller.refresh(); } @@ -380,7 +383,9 @@ class ItemsScreenState extends State { ); setWebRouteState(route, replace: true); await detail; - _syncItemsRoute(replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + _syncItemsRoute(replace: true); + } if (mounted) { await widget.controller.refresh(); } @@ -1422,7 +1427,7 @@ class _RegisteredItemTile extends StatelessWidget { const SizedBox(height: 4), Text( [ - item.categoryName ?? '其他', + item.categoryName ?? '未分类', if (item.storageLocation != null) item.storageLocation!, if (item.defaultUnit != null) '单位 ${item.defaultUnit}', ].join(' · '), diff --git a/mobile/lib/screens/recipes_screen.dart b/mobile/lib/screens/recipes_screen.dart index 6d9504f..7ba113b 100644 --- a/mobile/lib/screens/recipes_screen.dart +++ b/mobile/lib/screens/recipes_screen.dart @@ -18,10 +18,10 @@ class RecipesScreen extends StatefulWidget { final InventoryController controller; @override - State createState() => _RecipesScreenState(); + State createState() => RecipesScreenState(); } -class _RecipesScreenState extends State { +class RecipesScreenState extends State { final _service = RecipeSuggestionService(); final _aiService = AiRecipeService(); final _preferencesStore = RecipePreferencesStore(); @@ -214,6 +214,19 @@ class _RecipesScreenState extends State { return null; } + bool openRecipeById(String id) { + final suggestions = [ + if (_aiSuggestions != null) ..._aiSuggestions!, + ..._service.generate(widget.controller.activeItems), + ]; + final suggestion = _suggestionById(suggestions, id); + if (suggestion == null) { + return false; + } + _openRecipe(suggestion); + return true; + } + Future _openRecipe(RecipeSuggestion suggestion) async { setState(() { _recentIds @@ -238,7 +251,9 @@ class _RecipesScreenState extends State { ); setWebRouteState(route, replace: true); await detail; - setWebRouteState('/recipes', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/recipes', replace: true); + } if (mounted) { setState(() {}); } diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 76db451..39af88b 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -4,26 +4,39 @@ import 'package:file_selector/file_selector.dart' as file_selector; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../data/acceptance_test_service.dart'; import '../data/inventory_controller.dart'; import '../data/inventory_repository.dart'; import '../data/recipe_preferences_store.dart'; import '../data/vlm_order_service.dart'; import '../data/vlm_settings_store.dart'; +import '../data/webdav_backup_service.dart'; +import '../data/webdav_backup_store.dart'; import '../theme/app_theme.dart'; import '../widgets/app_cards.dart'; class SettingsScreen extends StatefulWidget { - const SettingsScreen({super.key, required this.controller}); + const SettingsScreen({ + super.key, + required this.controller, + this.vlmSettingsStore, + this.recipePreferencesStore, + this.vlmOrderService, + this.webDavBackupSettingsStore, + this.webDavBackupService, + }); final InventoryController controller; + final VlmSettingsStore? vlmSettingsStore; + final RecipePreferencesStore? recipePreferencesStore; + final VlmOrderService? vlmOrderService; + final WebDavBackupSettingsStore? webDavBackupSettingsStore; + final WebDavBackupService? webDavBackupService; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { - final _vlmSettingsStore = VlmSettingsStore(); final _vlmEndpointController = TextEditingController(); final _vlmModelController = TextEditingController(); final _vlmApiKeyController = TextEditingController(); @@ -32,24 +45,41 @@ class _SettingsScreenState extends State { final _recipeToolsController = TextEditingController(); final _recipeMinutesController = TextEditingController(); final _recipeServingsController = TextEditingController(); - final _vlmOrderService = VlmOrderService(); - final _recipePreferencesStore = RecipePreferencesStore(); + final _webDavServerUrlController = TextEditingController(); + final _webDavRemoteDirectoryController = TextEditingController(); + final _webDavUsernameController = TextEditingController(); + final _webDavPasswordController = TextEditingController(); + late final _vlmSettingsStore = widget.vlmSettingsStore ?? VlmSettingsStore(); + late final _vlmOrderService = widget.vlmOrderService ?? VlmOrderService(); + late final _recipePreferencesStore = + widget.recipePreferencesStore ?? RecipePreferencesStore(); + late final _webDavBackupSettingsStore = + widget.webDavBackupSettingsStore ?? WebDavBackupSettingsStore(); + late final _webDavBackupService = + widget.webDavBackupService ?? WebDavBackupService(); bool _importing = false; bool _exportingBackup = false; bool _restoringBackup = false; bool _exportingCsv = false; bool _loadingVlmSettings = true; bool _loadingRecipePreferences = true; + bool _loadingWebDavSettings = true; bool _savingRecipePreferences = false; bool _savingVlmSettings = false; bool _testingVlmSettings = false; - bool _runningAcceptance = false; + bool _savingWebDavSettings = false; + bool _testingWebDavConnection = false; + bool _uploadingWebDavBackup = false; + bool _restoringWebDavBackup = false; + int _webDavPasswordFieldRevision = 0; bool _resettingDemoData = false; bool _syncingNotifications = false; bool _hasStoredVlmApiKey = false; + bool _hasStoredWebDavPassword = false; String? _vlmTestMessage; bool? _vlmTestPassed; - AcceptanceReport? _lastAcceptanceReport; + String? _webDavStatusMessage; + bool? _webDavStatusPassed; LegacyImportResult? _lastLegacyImportResult; @override @@ -57,6 +87,7 @@ class _SettingsScreenState extends State { super.initState(); _loadVlmSettings(); _loadRecipePreferences(); + _loadWebDavSettings(); } @override @@ -69,7 +100,12 @@ class _SettingsScreenState extends State { _recipeToolsController.dispose(); _recipeMinutesController.dispose(); _recipeServingsController.dispose(); + _webDavServerUrlController.dispose(); + _webDavRemoteDirectoryController.dispose(); + _webDavUsernameController.dispose(); + _webDavPasswordController.dispose(); _vlmOrderService.close(); + _webDavBackupService.close(); super.dispose(); } @@ -158,6 +194,29 @@ class _SettingsScreenState extends State { onRestoreBackup: _restoreBackupJson, onExportCsv: _exportInventoryCsv, ), + const SizedBox(height: AppSpacing.cardGap), + _WebDavBackupPanel( + loading: _loadingWebDavSettings, + serverUrlController: _webDavServerUrlController, + remoteDirectoryController: + _webDavRemoteDirectoryController, + usernameController: _webDavUsernameController, + passwordController: _webDavPasswordController, + hasStoredPassword: _hasStoredWebDavPassword, + passwordFieldRevision: _webDavPasswordFieldRevision, + statusMessage: _webDavStatusMessage, + statusPassed: _webDavStatusPassed, + saving: _savingWebDavSettings, + testing: _testingWebDavConnection, + uploading: _uploadingWebDavBackup, + restoring: _restoringWebDavBackup, + onSave: _saveWebDavSettings, + onTest: _testWebDavConnection, + onUpload: _uploadBackupToWebDav, + onRestore: _restoreBackupFromWebDav, + onClear: _clearWebDavSettings, + onPasswordChanged: () => setState(() {}), + ), if (kDebugMode) ...[ const SizedBox(height: AppSpacing.cardGap), SizedBox( @@ -204,9 +263,10 @@ class _SettingsScreenState extends State { '尚未同步', ), const SizedBox(height: AppSpacing.cardGap), - Row( + Column( children: [ - Expanded( + SizedBox( + width: double.infinity, child: OutlinedButton.icon( onPressed: _syncingNotifications || !notificationsSupported @@ -220,8 +280,9 @@ class _SettingsScreenState extends State { label: const Text('请求权限'), ), ), - const SizedBox(width: AppSpacing.cardGap), - Expanded( + const SizedBox(height: AppSpacing.compactPadding), + SizedBox( + width: double.infinity, child: FilledButton.icon( onPressed: _syncingNotifications || !notificationsSupported @@ -233,6 +294,22 @@ class _SettingsScreenState extends State { label: const Text('同步提醒'), ), ), + const SizedBox(height: AppSpacing.compactPadding), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _syncingNotifications || + !notificationsSupported + ? null + : _sendTestNotification, + icon: _syncingNotifications + ? const _TinyProgress() + : const Icon( + Icons.notification_important_outlined, + ), + label: const Text('测试通知'), + ), + ), ], ), if (widget.controller.notificationPermission @@ -352,85 +429,6 @@ class _SettingsScreenState extends State { ), ), const SizedBox(height: AppSpacing.fieldGap), - SectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - '自验收测试', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - ), - if (_lastAcceptanceReport != null) - StatusPill( - label: _lastAcceptanceReport!.passed - ? '全部通过' - : '存在失败', - icon: _lastAcceptanceReport!.passed - ? Icons.check_circle_outline - : Icons.error_outline, - color: _lastAcceptanceReport!.passed - ? AppColors.success - : AppColors.error, - backgroundColor: _lastAcceptanceReport!.passed - ? AppColors.successContainer - : AppColors.errorContainer, - ), - ], - ), - const SizedBox(height: AppSpacing.cardGap), - _SettingRow( - icon: Icons.fact_check_outlined, - label: '核心闭环', - value: _lastAcceptanceReport == null - ? '未运行' - : '${_lastAcceptanceReport!.passedCount}/' - '${_lastAcceptanceReport!.checks.length}', - ), - if (_lastAcceptanceReport != null) ...[ - _SettingRow( - icon: Icons.timer_outlined, - label: '耗时', - value: _formatDuration( - _lastAcceptanceReport!.duration, - ), - ), - const SizedBox(height: AppSpacing.compactPadding), - _AcceptanceResults(report: _lastAcceptanceReport!), - const SizedBox(height: AppSpacing.cardGap), - ] else - const SizedBox(height: AppSpacing.cardGap), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: - _runningAcceptance ? null : _runAcceptanceChecks, - icon: _runningAcceptance - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.play_arrow_outlined), - label: Text( - _runningAcceptance ? '运行中' : '运行自验收', - ), - ), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.fieldGap), SectionCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -453,8 +451,8 @@ class _SettingsScreenState extends State { ), const _SettingRow( icon: Icons.security_outlined, - label: '密钥存储', - value: '系统安全存储', + label: '密钥状态', + value: '本机安全保存', ), const SizedBox(height: AppSpacing.cardGap), TextField( @@ -483,7 +481,7 @@ class _SettingsScreenState extends State { labelText: 'API 密钥', hintText: _hasStoredVlmApiKey ? '已安全保存,留空保持不变' - : '只保存在系统安全存储', + : '只保存在本机安全区域', helperText: _hasStoredVlmApiKey ? '已保存的密钥不会明文显示' : '仅保存在本机安全区域', @@ -492,7 +490,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: AppSpacing.cardGap), if (_vlmTestMessage != null) ...[ - _VlmTestResult( + _InlineStatusMessage( message: _vlmTestMessage!, passed: _vlmTestPassed == true, ), @@ -548,6 +546,23 @@ class _SettingsScreenState extends State { setState(() => _loadingRecipePreferences = false); } + Future _loadWebDavSettings() async { + final settings = await _webDavBackupSettingsStore.load( + revealPassword: false, + ); + if (!mounted) { + return; + } + _webDavServerUrlController.text = settings.serverUrl; + _webDavRemoteDirectoryController.text = settings.remoteDirectory; + _webDavUsernameController.text = settings.username; + _webDavPasswordController.clear(); + setState(() { + _hasStoredWebDavPassword = settings.hasStoredPassword; + _loadingWebDavSettings = false; + }); + } + Future _requestNotificationPermission() async { setState(() => _syncingNotifications = true); try { @@ -603,6 +618,33 @@ class _SettingsScreenState extends State { } } + Future _sendTestNotification() async { + setState(() => _syncingNotifications = true); + try { + final result = await widget.controller.sendTestNotification(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.displayText)), + ); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + showAppErrorSnackBar( + context, + message: '测试通知发送失败', + error: error, + stackTrace: stackTrace, + ); + } finally { + if (mounted) { + setState(() => _syncingNotifications = false); + } + } + } + Future _saveRecipePreferences() async { setState(() => _savingRecipePreferences = true); try { @@ -876,7 +918,7 @@ class _SettingsScreenState extends State { builder: (context) => AlertDialog( title: const Text('恢复备份'), content: const Text( - '会先创建恢复前快照,然后用备份替换当前库存数据。', + '会先自动保留一份恢复前备份,然后用所选备份替换当前库存数据。', ), actions: [ TextButton( @@ -926,6 +968,309 @@ class _SettingsScreenState extends State { } } + Future _currentWebDavSettings() async { + final saved = await _webDavBackupSettingsStore.load(); + final password = _webDavPasswordController.text.trim().isEmpty + ? saved.password + : _webDavPasswordController.text.trim(); + return WebDavBackupSettings( + serverUrl: _webDavServerUrlController.text, + remoteDirectory: _webDavRemoteDirectoryController.text, + username: _webDavUsernameController.text, + password: password, + hasStoredPassword: password.isNotEmpty, + ); + } + + Future _saveWebDavSettings() async { + ScaffoldMessenger.of(context).clearSnackBars(); + setState(() => _savingWebDavSettings = true); + final password = _webDavPasswordController.text; + FocusManager.instance.primaryFocus?.unfocus(); + try { + await _webDavBackupSettingsStore.save( + WebDavBackupSettings( + serverUrl: _webDavServerUrlController.text, + remoteDirectory: _webDavRemoteDirectoryController.text, + username: _webDavUsernameController.text, + password: password, + hasStoredPassword: _hasStoredWebDavPassword, + ), + preserveExistingPasswordIfBlank: true, + ); + if (!mounted) { + return; + } + final saved = await _webDavBackupSettingsStore.load( + revealPassword: false, + ); + if (!mounted) { + return; + } + _webDavServerUrlController.text = saved.serverUrl; + _webDavRemoteDirectoryController.text = saved.remoteDirectory; + _webDavUsernameController.text = saved.username; + _webDavPasswordController.clear(); + FocusManager.instance.primaryFocus?.unfocus(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('WebDAV 配置已保存')), + ); + setState(() { + _hasStoredWebDavPassword = saved.hasStoredPassword; + _webDavPasswordFieldRevision += 1; + _webDavStatusMessage = null; + _webDavStatusPassed = null; + }); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + showAppErrorSnackBar( + context, + message: _webDavErrorMessage(error, fallback: '保存 WebDAV 配置失败'), + error: error, + stackTrace: stackTrace, + ); + } finally { + if (mounted) { + setState(() => _savingWebDavSettings = false); + } + } + } + + Future _testWebDavConnection() async { + ScaffoldMessenger.of(context).clearSnackBars(); + setState(() { + _testingWebDavConnection = true; + _webDavStatusMessage = null; + _webDavStatusPassed = null; + }); + try { + await _webDavBackupService.validateConfiguration( + await _currentWebDavSettings(), + ); + if (!mounted) { + return; + } + setState(() { + _webDavStatusPassed = true; + _webDavStatusMessage = 'WebDAV 连接可用'; + }); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + final message = _webDavErrorMessage(error, fallback: 'WebDAV 连接失败'); + setState(() { + _webDavStatusPassed = false; + _webDavStatusMessage = message; + }); + showAppErrorSnackBar( + context, + message: message, + error: error, + stackTrace: stackTrace, + ); + } finally { + if (mounted) { + setState(() => _testingWebDavConnection = false); + } + } + } + + Future _uploadBackupToWebDav() async { + ScaffoldMessenger.of(context).clearSnackBars(); + setState(() { + _uploadingWebDavBackup = true; + _webDavStatusMessage = null; + _webDavStatusPassed = null; + }); + try { + final fileName = 'vibe-fridge-backup-${_fileTimestamp()}.json'; + final backup = await widget.controller.exportBackup(); + final text = const JsonEncoder.withIndent(' ').convert(backup); + final result = await _webDavBackupService.uploadBackup( + settings: await _currentWebDavSettings(), + fileName: fileName, + backupJson: text, + ); + await widget.controller.markBackupExported(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('备份已上传到 WebDAV')), + ); + setState(() { + _webDavStatusPassed = true; + _webDavStatusMessage = '最近上传:${result.fileName}'; + }); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + final message = _webDavErrorMessage(error, fallback: '上传 WebDAV 备份失败'); + setState(() { + _webDavStatusPassed = false; + _webDavStatusMessage = message; + }); + showAppErrorSnackBar( + context, + message: message, + error: error, + stackTrace: stackTrace, + ); + } finally { + if (mounted) { + setState(() => _uploadingWebDavBackup = false); + } + } + } + + Future _restoreBackupFromWebDav() async { + ScaffoldMessenger.of(context).clearSnackBars(); + setState(() { + _restoringWebDavBackup = true; + _webDavStatusMessage = null; + _webDavStatusPassed = null; + }); + WebDavBackupDownloadResult downloaded; + try { + downloaded = await _webDavBackupService.downloadLatestBackup( + await _currentWebDavSettings(), + ); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + final message = _webDavErrorMessage(error, fallback: '下载 WebDAV 备份失败'); + setState(() { + _restoringWebDavBackup = false; + _webDavStatusPassed = false; + _webDavStatusMessage = message; + }); + showAppErrorSnackBar( + context, + message: message, + error: error, + stackTrace: stackTrace, + ); + return; + } + + if (!mounted) { + return; + } + setState(() => _restoringWebDavBackup = false); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('恢复云端备份'), + content: Text( + '将用云端备份「${downloaded.fileName}」替换当前库存数据。' + '恢复前会自动保留一份当前备份。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('恢复'), + ), + ], + ), + ); + if (confirmed != true || !mounted) { + return; + } + + setState(() => _restoringWebDavBackup = true); + try { + final decoded = jsonDecode(downloaded.backupJson); + if (decoded is! Map) { + throw const FormatException('备份文件格式不正确'); + } + await widget.controller.restoreBackup( + Map.from(decoded), + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已从 WebDAV 恢复:${downloaded.fileName}')), + ); + setState(() { + _webDavStatusPassed = true; + _webDavStatusMessage = '最近恢复:${downloaded.fileName}'; + }); + } catch (error, stackTrace) { + if (!mounted) { + return; + } + showAppErrorSnackBar( + context, + message: '恢复 WebDAV 备份失败', + error: error, + stackTrace: stackTrace, + ); + } finally { + if (mounted) { + setState(() => _restoringWebDavBackup = false); + } + } + } + + Future _clearWebDavSettings() async { + ScaffoldMessenger.of(context).clearSnackBars(); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('清空 WebDAV 配置'), + content: const Text('服务地址、账号和已保存的密码都会被清空。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('清空'), + ), + ], + ), + ); + if (confirmed != true) { + return; + } + + setState(() => _savingWebDavSettings = true); + try { + await _webDavBackupSettingsStore.clear(); + if (!mounted) { + return; + } + await _loadWebDavSettings(); + if (!mounted) { + return; + } + setState(() { + _hasStoredWebDavPassword = false; + _webDavPasswordFieldRevision += 1; + _webDavStatusMessage = null; + _webDavStatusPassed = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('WebDAV 配置已清空')), + ); + } finally { + if (mounted) { + setState(() => _savingWebDavSettings = false); + } + } + } + Future _exportInventoryCsv() async { setState(() => _exportingCsv = true); try { @@ -1010,7 +1355,7 @@ class _SettingsScreenState extends State { ? '没有可导入的数据' : '导入完成:${result.items} 条库存,' '${result.wikis} 个物品资料,${result.tags} 个标签,' - '健康检查${result.healthPassed ? '通过' : '未通过'}', + '资料检查${result.healthPassed ? '通过' : '未通过'}', ), ), ); @@ -1059,7 +1404,7 @@ class _SettingsScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '示例数据已重置,清理 $clearedRows 行旧示例数据', + '示例数据已重置,清理 $clearedRows 条旧示例数据', ), ), ); @@ -1079,44 +1424,10 @@ class _SettingsScreenState extends State { } } } - - Future _runAcceptanceChecks() async { - setState(() => _runningAcceptance = true); - try { - final report = await widget.controller.runAcceptanceChecks(); - if (!mounted) { - return; - } - setState(() => _lastAcceptanceReport = report); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - report.passed - ? '自验收通过:${report.passedCount}/${report.checks.length}' - : '自验收失败:${report.passedCount}/${report.checks.length}', - ), - ), - ); - } catch (error, stackTrace) { - if (!mounted) { - return; - } - showAppErrorSnackBar( - context, - message: '自验收无法运行', - error: error, - stackTrace: stackTrace, - ); - } finally { - if (mounted) { - setState(() => _runningAcceptance = false); - } - } - } } -class _VlmTestResult extends StatelessWidget { - const _VlmTestResult({ +class _InlineStatusMessage extends StatelessWidget { + const _InlineStatusMessage({ required this.message, required this.passed, }); @@ -1230,6 +1541,243 @@ class _VlmSettingsActions extends StatelessWidget { } } +class _WebDavBackupPanel extends StatelessWidget { + const _WebDavBackupPanel({ + required this.loading, + required this.serverUrlController, + required this.remoteDirectoryController, + required this.usernameController, + required this.passwordController, + required this.hasStoredPassword, + required this.passwordFieldRevision, + required this.statusMessage, + required this.statusPassed, + required this.saving, + required this.testing, + required this.uploading, + required this.restoring, + required this.onSave, + required this.onTest, + required this.onUpload, + required this.onRestore, + required this.onClear, + required this.onPasswordChanged, + }); + + final bool loading; + final TextEditingController serverUrlController; + final TextEditingController remoteDirectoryController; + final TextEditingController usernameController; + final TextEditingController passwordController; + final bool hasStoredPassword; + final int passwordFieldRevision; + final String? statusMessage; + final bool? statusPassed; + final bool saving; + final bool testing; + final bool uploading; + final bool restoring; + final VoidCallback onSave; + final VoidCallback onTest; + final VoidCallback onUpload; + final VoidCallback onRestore; + final VoidCallback onClear; + final VoidCallback onPasswordChanged; + + @override + Widget build(BuildContext context) { + final busy = saving || testing || uploading || restoring; + final hasConfig = serverUrlController.text.trim().isNotEmpty; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1), + const SizedBox(height: AppSpacing.cardGap), + Row( + children: [ + Expanded( + child: Text( + 'WebDAV 云备份', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w900, + ), + ), + ), + IconButton.outlined( + tooltip: '清空 WebDAV 配置', + onPressed: busy ? null : onClear, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + const SizedBox(height: 6), + Text( + '把备份保存到你自己的 WebDAV 空间,可在新设备上恢复。' + '网页版使用时,云盘服务需要允许网页访问。', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.cardGap), + if (loading) + const Center(child: CircularProgressIndicator()) + else ...[ + _SettingRow( + icon: Icons.cloud_outlined, + label: '云端备份', + value: hasConfig ? '已配置' : '未配置', + ), + if (hasStoredPassword) + const _SettingRow( + icon: Icons.security_outlined, + label: '密码状态', + value: '本机安全保存', + ), + const SizedBox(height: AppSpacing.cardGap), + TextField( + controller: serverUrlController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'WebDAV 服务地址', + hintText: 'https://example.com/remote.php/dav/files/me/', + prefixIcon: Icon(Icons.link_outlined), + ), + ), + const SizedBox(height: AppSpacing.cardGap), + TextField( + controller: remoteDirectoryController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: '备份目录', + hintText: WebDavBackupSettingsStore.defaultRemoteDirectory, + prefixIcon: Icon(Icons.folder_outlined), + ), + ), + const SizedBox(height: AppSpacing.cardGap), + TextField( + controller: usernameController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: '用户名', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const SizedBox(height: AppSpacing.cardGap), + TextField( + key: ValueKey('webdav-password-$passwordFieldRevision'), + controller: passwordController, + obscureText: true, + onChanged: (_) => onPasswordChanged(), + decoration: InputDecoration( + labelText: '密码或应用密码', + hintText: hasStoredPassword ? '已安全保存,留空保持不变' : '可留空', + helperText: hasStoredPassword ? '已保存的密码不会明文显示' : '仅保存在本机安全区域', + prefixIcon: const Icon(Icons.key_outlined), + ), + ), + const SizedBox(height: AppSpacing.cardGap), + if (statusMessage != null) ...[ + _InlineStatusMessage( + message: statusMessage!, + passed: statusPassed == true, + ), + const SizedBox(height: AppSpacing.cardGap), + ], + _WebDavBackupActions( + saving: saving, + testing: testing, + uploading: uploading, + restoring: restoring, + onSave: onSave, + onTest: onTest, + onUpload: onUpload, + onRestore: onRestore, + ), + ], + ], + ); + } +} + +class _WebDavBackupActions extends StatelessWidget { + const _WebDavBackupActions({ + required this.saving, + required this.testing, + required this.uploading, + required this.restoring, + required this.onSave, + required this.onTest, + required this.onUpload, + required this.onRestore, + }); + + final bool saving; + final bool testing; + final bool uploading; + final bool restoring; + final VoidCallback onSave; + final VoidCallback onTest; + final VoidCallback onUpload; + final VoidCallback onRestore; + + @override + Widget build(BuildContext context) { + final busy = saving || testing || uploading || restoring; + return Column( + children: [ + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: busy ? null : onSave, + icon: saving + ? const _TinyProgress() + : const Icon(Icons.save_outlined), + label: Text(saving ? '保存中' : '保存配置'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: busy ? null : onTest, + icon: testing + ? const _TinyProgress() + : const Icon(Icons.cloud_sync_outlined), + label: Text(testing ? '测试中' : '测试连接'), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: busy ? null : onUpload, + icon: uploading + ? const _TinyProgress() + : const Icon(Icons.cloud_upload_outlined), + label: Text(uploading ? '上传中' : '上传备份'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: busy ? null : onRestore, + icon: restoring + ? const _TinyProgress() + : const Icon(Icons.cloud_download_outlined), + label: Text(restoring ? '恢复中' : '恢复云备份'), + ), + ), + ], + ), + ], + ); + } +} + class _DataFileActions extends StatelessWidget { const _DataFileActions({ required this.exportingBackup, @@ -1336,7 +1884,7 @@ class _BackupReminderCard extends StatelessWidget { if (state.dirtyCount > 0) ...[ const SizedBox(height: 3), Text( - '累计 ${state.dirtyCount} 行本地变更尚未导出', + '累计 ${state.dirtyCount} 次库存资料变更尚未备份', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textHint, ), @@ -1399,7 +1947,7 @@ class _LegacyImportPreviewDialogState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _ImportCountRow(label: '源数据', counts: preview.source), + _ImportCountRow(label: '待导入', counts: preview.source), _ImportCountRow(label: '将新增', counts: preview.inserts), _ImportCountRow(label: '将更新', counts: preview.updates), _ImportCountRow(label: '将跳过', counts: preview.skipped), @@ -1407,7 +1955,7 @@ class _LegacyImportPreviewDialogState Padding( padding: const EdgeInsets.only(top: 8), child: StatusPill( - label: '${preview.failedRows} 行无法导入', + label: '${preview.failedRows} 条无法导入', color: AppColors.error, backgroundColor: AppColors.errorContainer, ), @@ -1422,7 +1970,7 @@ class _LegacyImportPreviewDialogState }, title: const Text('导入前清空示例资料/库存'), subtitle: const Text( - '只清理内置演示数据,保留分类和用户数据。', + '只清理内置示例资料/库存,保留分类和用户数据。', ), ), if (preview.logs.isNotEmpty) ...[ @@ -1438,7 +1986,7 @@ class _LegacyImportPreviewDialogState _LegacyLogLine(entry: log), if (preview.logs.length > 5) Text( - '还有 ${preview.logs.length - 5} 条日志会在导入后展示', + '还有 ${preview.logs.length - 5} 条记录会在导入后展示', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textHint, ), @@ -1499,7 +2047,7 @@ class _LegacyImportResultSummary extends StatelessWidget { ), ), StatusPill( - label: result.healthPassed ? '健康' : '需检查', + label: result.healthPassed ? '正常' : '需检查', color: result.healthPassed ? AppColors.success : AppColors.error, backgroundColor: result.healthPassed @@ -1510,8 +2058,8 @@ class _LegacyImportResultSummary extends StatelessWidget { ), const SizedBox(height: 8), Text( - '新增 ${result.total} 行,更新 ${result.updates.total} 行,' - '跳过 ${result.skipped.total} 行,失败 ${result.failedRows} 行', + '新增 ${result.total} 条记录,更新 ${result.updates.total} 条记录,' + '跳过 ${result.skipped.total} 条记录,失败 ${result.failedRows} 条记录', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), @@ -1519,7 +2067,7 @@ class _LegacyImportResultSummary extends StatelessWidget { if (result.clearedDemoRows > 0) ...[ const SizedBox(height: 4), Text( - '已清理 ${result.clearedDemoRows} 行示例数据', + '已清理 ${result.clearedDemoRows} 条示例数据', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), @@ -1540,7 +2088,7 @@ class _LegacyImportResultSummary extends StatelessWidget { child: TextButton.icon( onPressed: onShowLog, icon: const Icon(Icons.list_alt_outlined), - label: const Text('查看日志'), + label: const Text('查看详情'), ), ), ], @@ -1557,7 +2105,7 @@ class _LegacyImportLogDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('旧版库存导入日志'), + title: const Text('旧版库存导入详情'), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -1580,7 +2128,7 @@ class _LegacyImportLogDialog extends StatelessWidget { const SizedBox(height: 10), if (result.logs.isEmpty) Text( - '没有详细日志。', + '没有详细记录。', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppColors.textSecondary, ), @@ -1694,83 +2242,11 @@ String _vlmErrorMessage(OrderRecognitionException error) { return error.userMessage; } -class _AcceptanceResults extends StatelessWidget { - const _AcceptanceResults({required this.report}); - - final AcceptanceReport report; - - @override - Widget build(BuildContext context) { - return Column( - children: report.checks - .map( - (check) => _AcceptanceCheckTile(check: check), - ) - .toList(), - ); - } -} - -class _AcceptanceCheckTile extends StatelessWidget { - const _AcceptanceCheckTile({required this.check}); - - final AcceptanceCheckResult check; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - check.passed ? Icons.check_circle : Icons.error_outline, - color: check.passed ? AppColors.success : AppColors.error, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - check.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - if (check.message != null) ...[ - const SizedBox(height: 2), - Text( - check.message!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.error, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Text( - _formatDuration(check.duration), - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: AppColors.textHint, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ); - } -} - -String _formatDuration(Duration duration) { - if (duration.inSeconds >= 1) { - return '${duration.inSeconds}s'; +String _webDavErrorMessage(Object error, {required String fallback}) { + if (error is WebDavBackupException) { + return error.userMessage; } - return '${duration.inMilliseconds}ms'; + return fallback; } String _fileTimestamp() { diff --git a/mobile/lib/utils/import_trace_display.dart b/mobile/lib/utils/import_trace_display.dart new file mode 100644 index 0000000..406aa5b --- /dev/null +++ b/mobile/lib/utils/import_trace_display.dart @@ -0,0 +1,21 @@ +import '../models/inventory_item.dart'; + +bool hasUserVisibleImportTrace(InventoryItem item) { + return importSourceLabel(item.sourceApp) != null || + _hasText(item.sourceOrderId) || + item.recognitionConfidence != null; +} + +String? importSourceLabel(String? sourceApp) { + final value = sourceApp?.trim(); + if (value == null || value.isEmpty) { + return null; + } + return switch (value.toLowerCase()) { + 'legacy' => '旧版库存', + 'shopping-list' => '采购清单', + _ => value, + }; +} + +bool _hasText(String? value) => value != null && value.trim().isNotEmpty; diff --git a/mobile/lib/utils/web_route_state_html.dart b/mobile/lib/utils/web_route_state_html.dart index 9727970..327e64b 100644 --- a/mobile/lib/utils/web_route_state_html.dart +++ b/mobile/lib/utils/web_route_state_html.dart @@ -30,24 +30,29 @@ Stream getWebRouteStateChanges() { return controller.stream; } +bool isCurrentWebRoute(String route) { + return _currentRoute() == _canonicalRoute(route); +} + void setWebRouteState(String route, {bool replace = false}) { final nextRoute = _normalizeRoute(route); + final canonicalNextRoute = _canonicalRoute(nextRoute); final currentRoute = _currentRoute(); final location = web.window.location; final hasHashRoute = location.hash.isNotEmpty; - if (currentRoute == nextRoute && !hasHashRoute) { + if (currentRoute == canonicalNextRoute && !hasHashRoute) { _scheduleHashRouteCleanup( - nextRoute, '${location.pathname}${location.search}'); + canonicalNextRoute, '${location.pathname}${location.search}'); return; } final nextUrl = '${location.pathname}${_queryForRoute(nextRoute)}'; - if (replace || currentRoute == nextRoute) { + if (replace || currentRoute == canonicalNextRoute) { web.window.history.replaceState(null, '', nextUrl); } else { web.window.history.pushState(null, '', nextUrl); } - _scheduleHashRouteCleanup(nextRoute, nextUrl); + _scheduleHashRouteCleanup(canonicalNextRoute, nextUrl); } String _currentRoute() { @@ -84,6 +89,14 @@ String _normalizeRoute(String route) { return trimmed.startsWith('/') ? trimmed : '/$trimmed'; } +String _canonicalRoute(String route) { + final uri = Uri.parse(_normalizeRoute(route)); + return Uri( + path: uri.path, + queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters, + ).toString(); +} + void _clearHashRouteIfStillCurrent(String route, String url) { if (web.window.location.hash.isEmpty || _currentRoute() != route) { return; diff --git a/mobile/lib/utils/web_route_state_stub.dart b/mobile/lib/utils/web_route_state_stub.dart index c9627f6..ca32719 100644 --- a/mobile/lib/utils/web_route_state_stub.dart +++ b/mobile/lib/utils/web_route_state_stub.dart @@ -6,4 +6,6 @@ String getWebRouteState() => ''; Stream getWebRouteStateChanges() => const Stream.empty(); +bool isCurrentWebRoute(String route) => false; + void setWebRouteState(String route, {bool replace = false}) {} diff --git a/mobile/lib/widgets/app_cards.dart b/mobile/lib/widgets/app_cards.dart index 309bcd1..660e80e 100644 --- a/mobile/lib/widgets/app_cards.dart +++ b/mobile/lib/widgets/app_cards.dart @@ -707,7 +707,7 @@ void showAppErrorSnackBar( SnackBar( content: Text(message), action: SnackBarAction( - label: '复制', + label: '复制详情', onPressed: () { Clipboard.setData(ClipboardData(text: details)); }, diff --git a/mobile/macos/Podfile.lock b/mobile/macos/Podfile.lock index 27734ab..281bb9f 100644 --- a/mobile/macos/Podfile.lock +++ b/mobile/macos/Podfile.lock @@ -1,35 +1,21 @@ PODS: - - file_selector_macos (0.0.1): + - flutter_secure_storage_macos (6.1.3): - FlutterMacOS - FlutterMacOS (1.0.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS DEPENDENCIES: - - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) EXTERNAL SOURCES: - file_selector_macos: - :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: :path: Flutter/ephemeral - shared_preferences_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - sqflite_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin SPEC CHECKSUMS: - file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/mobile/macos/Runner.xcodeproj/project.pbxproj b/mobile/macos/Runner.xcodeproj/project.pbxproj index 5252dc3..ab64538 100644 --- a/mobile/macos/Runner.xcodeproj/project.pbxproj +++ b/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -27,8 +27,10 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + A17D001A2F00000100000001 /* MacLocalNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17D00192F00000100000001 /* MacLocalNotificationRequest.swift */; }; B3E1AA6066923C32796E8EE5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 82BEF34339EF9DEE4CEBB9B5 /* Pods_Runner.framework */; }; EFB07E4ABF60B050878836BD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76E0464F0815C6B7A4EE3F22 /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,7 +89,9 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 82BEF34339EF9DEE4CEBB9B5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A17D00192F00000100000001 /* MacLocalNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLocalNotificationRequest.swift; sourceTree = ""; }; CFD068B3511843E0A0AC5010 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,6 +107,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, B3E1AA6066923C32796E8EE5 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -164,6 +169,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -177,6 +183,7 @@ children = ( 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + A17D00192F00000100000001 /* MacLocalNotificationRequest.swift */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, 33E51914231749380026EE4D /* Release.entitlements */, 33CC11242044D66E0003C045 /* Resources */, @@ -231,6 +238,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 33CC10EC2044A3C60003C045 /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -256,6 +266,9 @@ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -437,6 +450,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A17D001A2F00000100000001 /* MacLocalNotificationRequest.swift in Sources */, 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, @@ -482,7 +496,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.vibefridge.vibeFridge.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/vibe_fridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/vibe_fridge"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/vibe-fridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/vibe-fridge"; }; name = Debug; }; @@ -497,7 +511,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.vibefridge.vibeFridge.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/vibe_fridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/vibe_fridge"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/vibe-fridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/vibe-fridge"; }; name = Release; }; @@ -512,7 +526,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.vibefridge.vibeFridge.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/vibe_fridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/vibe_fridge"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/vibe-fridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/vibe-fridge"; }; name = Profile; }; @@ -796,6 +810,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 80246ac..615afe2 100644 --- a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { @@ -35,17 +34,18 @@ class AppDelegate: FlutterAppDelegate, UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - if let itemId = response.notification.request.content.userInfo["itemId"] - as? String { - UserDefaults.standard.set(itemId, forKey: "notification_item_id") - NotificationCenter.default.post( - name: .vibeFridgeNotificationTapped, - object: nil, - userInfo: ["itemId": itemId] - ) - } + handleNotificationResponseUserInfo( + response.notification.request.content.userInfo + ) completionHandler() } + + @discardableResult + func handleNotificationResponseUserInfo( + _ userInfo: [AnyHashable: Any] + ) -> String? { + MacLocalNotificationTapHandler.handleTap(userInfo: userInfo) + } } extension Notification.Name { diff --git a/mobile/macos/Runner/MacLocalNotificationRequest.swift b/mobile/macos/Runner/MacLocalNotificationRequest.swift new file mode 100644 index 0000000..b0c7e22 --- /dev/null +++ b/mobile/macos/Runner/MacLocalNotificationRequest.swift @@ -0,0 +1,254 @@ +import Foundation +import UserNotifications + +struct MacLocalNotificationRequest { + let itemId: String + let title: String + let body: String + let scheduledAt: Date + + var identifier: String { + "inventory-\(itemId)" + } + + var userInfo: [String: String] { + ["itemId": itemId] + } + + func content() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = ["itemId": itemId] + return content + } + + func triggerInterval(now: Date = Date()) -> TimeInterval { + max(scheduledAt.timeIntervalSince(now), 60) + } + + func trigger(now: Date = Date()) -> UNTimeIntervalNotificationTrigger { + UNTimeIntervalNotificationTrigger( + timeInterval: triggerInterval(now: now), + repeats: false + ) + } +} + +struct MacDiagnosticNotificationRequest { + let identifier: String + let title: String + let body: String + let triggerInterval: TimeInterval + + func content() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + return content + } + + func trigger() -> UNTimeIntervalNotificationTrigger { + UNTimeIntervalNotificationTrigger( + timeInterval: triggerInterval, + repeats: false + ) + } +} + +enum MacDiagnosticNotificationRequestFactory { + static func request( + id: String = UUID().uuidString + ) -> MacDiagnosticNotificationRequest { + MacDiagnosticNotificationRequest( + identifier: "diagnostic-\(id)", + title: "库存提醒测试", + body: "看到这条通知说明本地通知可用", + triggerInterval: 1 + ) + } +} + +enum MacLocalNotificationRequestFactory { + static func requests(from arguments: Any?) -> [MacLocalNotificationRequest] { + guard let payload = arguments as? [String: Any], + let notifications = payload["notifications"] as? [[String: Any]] + else { + return [] + } + + return notifications.compactMap { row in + guard let itemId = row["itemId"] as? String, + !itemId.isEmpty, + let title = row["title"] as? String, + let body = row["body"] as? String, + let scheduledAtMillis = row["scheduledAtMillis"] as? NSNumber + else { + return nil + } + + return MacLocalNotificationRequest( + itemId: itemId, + title: title, + body: body, + scheduledAt: Date( + timeIntervalSince1970: scheduledAtMillis.doubleValue / 1000 + ) + ) + } + } +} + +protocol MacNotificationSchedulingCenter { + func removeAllPendingNotificationRequests() + + func add( + _ request: UNNotificationRequest, + withCompletionHandler completionHandler: (@Sendable (Error?) -> Void)? + ) +} + +extension UNUserNotificationCenter: MacNotificationSchedulingCenter {} + +private final class MacNotificationScheduleErrorBox: @unchecked Sendable { + private let lock = NSLock() + private var firstError: Error? + + func record(_ error: Error?) { + guard let error else { + return + } + lock.lock() + if firstError == nil { + firstError = error + } + lock.unlock() + } + + var value: Error? { + lock.lock() + defer { + lock.unlock() + } + return firstError + } +} + +enum MacLocalNotificationScheduler { + static func schedule( + _ notifications: [MacLocalNotificationRequest], + center: MacNotificationSchedulingCenter = UNUserNotificationCenter + .current(), + completion: @escaping (Error?) -> Void + ) { + center.removeAllPendingNotificationRequests() + + guard !notifications.isEmpty else { + completion(nil) + return + } + + let group = DispatchGroup() + let errors = MacNotificationScheduleErrorBox() + + for notification in notifications { + let request = UNNotificationRequest( + identifier: notification.identifier, + content: notification.content(), + trigger: notification.trigger() + ) + group.enter() + center.add(request) { error in + errors.record(error) + group.leave() + } + } + + group.notify(queue: .main) { + completion(errors.value) + } + } +} + +struct MacLocalNotificationPermissionSnapshot { + let granted: Bool + let status: String + + var channelMap: [String: Any] { + [ + "supported": true, + "granted": granted, + "status": status, + ] + } +} + +enum MacLocalNotificationPermissionFactory { + static func snapshot( + for status: UNAuthorizationStatus + ) -> MacLocalNotificationPermissionSnapshot { + let granted = status == .authorized || status == .provisional + return MacLocalNotificationPermissionSnapshot( + granted: granted, + status: statusText(status) + ) + } + + private static func statusText( + _ status: UNAuthorizationStatus + ) -> String { + switch status { + case .authorized: + return "granted" + case .denied: + return "denied" + case .notDetermined: + return "unknown" + case .provisional: + return "provisional" + @unknown default: + return "unknown" + } + } +} + +enum MacLocalNotificationTapHandler { + static let launchItemIdKey = "notification_item_id" + + @discardableResult + static func handleTap( + userInfo: [AnyHashable: Any], + userDefaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) -> String? { + guard let itemId = normalizedItemId(userInfo["itemId"]) else { + return nil + } + + userDefaults.set(itemId, forKey: launchItemIdKey) + notificationCenter.post( + name: .vibeFridgeNotificationTapped, + object: nil, + userInfo: ["itemId": itemId] + ) + return itemId + } + + static func consumeLaunchItemId( + userDefaults: UserDefaults = .standard + ) -> String? { + let itemId = userDefaults.string(forKey: launchItemIdKey) + userDefaults.removeObject(forKey: launchItemIdKey) + return normalizedItemId(itemId) + } + + private static func normalizedItemId(_ rawItemId: Any?) -> String? { + guard let itemId = rawItemId as? String else { + return nil + } + let normalized = itemId.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift index 2779fca..9e6da04 100644 --- a/mobile/macos/Runner/MainFlutterWindow.swift +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -73,12 +73,11 @@ final class MacLocalNotificationBridge { case "requestPermission": requestPermission(result: result) case "getLaunchItemId": - let itemId = UserDefaults.standard.string(forKey: "notification_item_id") - UserDefaults.standard.removeObject(forKey: "notification_item_id") - result(itemId) + result(MacLocalNotificationTapHandler.consumeLaunchItemId()) case "scheduleInventoryReminders": - scheduleInventoryReminders(arguments: call.arguments) - result(nil) + scheduleInventoryReminders(arguments: call.arguments, result: result) + case "sendTestNotification": + sendTestNotification(result: result) case "cancelAll": center.removeAllPendingNotificationRequests() result(nil) @@ -90,13 +89,11 @@ final class MacLocalNotificationBridge { private func permissionStatus(result: @escaping FlutterResult) { center.getNotificationSettings { settings in DispatchQueue.main.async { - let granted = settings.authorizationStatus == .authorized || - settings.authorizationStatus == .provisional - result([ - "supported": true, - "granted": granted, - "status": self.statusText(settings.authorizationStatus) - ]) + result( + MacLocalNotificationPermissionFactory + .snapshot(for: settings.authorizationStatus) + .channelMap + ) } } } @@ -115,41 +112,52 @@ final class MacLocalNotificationBridge { } } - private func scheduleInventoryReminders(arguments: Any?) { - center.removeAllPendingNotificationRequests() - guard let payload = arguments as? [String: Any], - let notifications = payload["notifications"] as? [[String: Any]] - else { - return + private func scheduleInventoryReminders( + arguments: Any?, + result: @escaping FlutterResult + ) { + let notifications = MacLocalNotificationRequestFactory.requests( + from: arguments + ) + MacLocalNotificationScheduler.schedule( + notifications, + center: center + ) { error in + if let error { + result( + FlutterError( + code: "schedule_failed", + message: error.localizedDescription, + details: nil + ) + ) + return + } + result(nil) } - for row in notifications { - guard let itemId = row["itemId"] as? String, - let title = row["title"] as? String, - let body = row["body"] as? String, - let scheduledAtMillis = row["scheduledAtMillis"] as? NSNumber - else { - continue + } + + private func sendTestNotification(result: @escaping FlutterResult) { + let diagnostic = MacDiagnosticNotificationRequestFactory.request() + let request = UNNotificationRequest( + identifier: diagnostic.identifier, + content: diagnostic.content(), + trigger: diagnostic.trigger() + ) + center.add(request) { error in + DispatchQueue.main.async { + if let error { + result( + FlutterError( + code: "schedule_failed", + message: error.localizedDescription, + details: nil + ) + ) + return + } + result(nil) } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - content.userInfo = ["itemId": itemId] - - let scheduledDate = Date( - timeIntervalSince1970: scheduledAtMillis.doubleValue / 1000 - ) - let interval = max(scheduledDate.timeIntervalSinceNow, 60) - let trigger = UNTimeIntervalNotificationTrigger( - timeInterval: interval, - repeats: false - ) - let request = UNNotificationRequest( - identifier: "inventory-\(itemId)", - content: content, - trigger: trigger - ) - center.add(request) } } @@ -157,23 +165,16 @@ final class MacLocalNotificationBridge { guard let itemId = notification.userInfo?["itemId"] as? String else { return } - channel.invokeMethod("notificationTapped", arguments: ["itemId": itemId]) - } - - private func statusText( - _ status: UNAuthorizationStatus - ) -> String { - switch status { - case .authorized: - return "granted" - case .denied: - return "denied" - case .notDetermined: - return "unknown" - case .provisional: - return "provisional" - @unknown default: - return "unknown" + let normalizedItemId = itemId.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !normalizedItemId.isEmpty else { + return } + channel.invokeMethod( + "notificationTapped", + arguments: ["itemId": normalizedItemId] + ) } + } diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift index 61f3bd1..c2a4bda 100644 --- a/mobile/macos/RunnerTests/RunnerTests.swift +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -1,12 +1,523 @@ import Cocoa import FlutterMacOS +import Security +import UserNotifications import XCTest +@testable import vibe_fridge class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testNotificationRequestFactoryBuildsInventoryReminderRequest() { + let scheduledAtMillis: Int64 = 1_782_000_000_000 + let requests = MacLocalNotificationRequestFactory.requests(from: [ + "notifications": [ + [ + "itemId": "item-milk-1", + "title": "鲜牛奶 今天到期", + "body": "2盒 · 冷藏 · 打开查看详情", + "scheduledAtMillis": NSNumber(value: scheduledAtMillis), + ] + ] + ]) + + XCTAssertEqual(requests.count, 1) + let content = requests[0].content() + XCTAssertEqual(requests[0].itemId, "item-milk-1") + XCTAssertEqual(requests[0].identifier, "inventory-item-milk-1") + XCTAssertEqual(requests[0].title, "鲜牛奶 今天到期") + XCTAssertEqual(requests[0].body, "2盒 · 冷藏 · 打开查看详情") + XCTAssertEqual(requests[0].userInfo["itemId"], "item-milk-1") + XCTAssertEqual(content.title, "鲜牛奶 今天到期") + XCTAssertEqual(content.body, "2盒 · 冷藏 · 打开查看详情") + XCTAssertEqual(content.userInfo["itemId"] as? String, "item-milk-1") + XCTAssertNotNil(content.sound) + XCTAssertEqual( + requests[0].scheduledAt.timeIntervalSince1970, + Double(scheduledAtMillis) / 1000, + accuracy: 0.001 + ) + } + + func testNotificationRequestFactorySkipsMalformedRows() { + let requests = MacLocalNotificationRequestFactory.requests(from: [ + "notifications": [ + [ + "itemId": "", + "title": "空 item", + "body": "无效", + "scheduledAtMillis": NSNumber(value: 1_782_000_000_000), + ], + [ + "itemId": "item-without-time", + "title": "缺少时间", + "body": "无效", + ], + [ + "itemId": "item-bread-1", + "title": "面包 2 天后到期", + "body": "1袋 · 常温 · 打开查看详情", + "scheduledAtMillis": NSNumber(value: 1_782_000_060_000), + ], + ], + ]) + + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(requests[0].itemId, "item-bread-1") + } + + func testNotificationTriggerIntervalUsesMinimumDelay() { + let request = MacLocalNotificationRequest( + itemId: "item-past", + title: "已到提醒", + body: "打开查看详情", + scheduledAt: Date(timeIntervalSince1970: 1_782_000_000) + ) + let now = Date(timeIntervalSince1970: 1_782_000_120) + + XCTAssertEqual(request.triggerInterval(now: now), 60, accuracy: 0.001) + } + + func testNotificationTriggerUsesFutureSchedule() { + let scheduledAt = Date(timeIntervalSince1970: 1_782_000_300) + let now = Date(timeIntervalSince1970: 1_782_000_120) + let request = MacLocalNotificationRequest( + itemId: "item-future", + title: "未来提醒", + body: "打开查看详情", + scheduledAt: scheduledAt + ) + let trigger = request.trigger(now: now) + + XCTAssertEqual(trigger.timeInterval, 180, accuracy: 0.001) + XCTAssertFalse(trigger.repeats) + } + + func testNotificationSchedulerAddsRequestsBeforeReportingSuccess() { + let center = FakeNotificationSchedulingCenter() + let done = expectation(description: "scheduler completion") + let notifications = [ + MacLocalNotificationRequest( + itemId: "item-milk-1", + title: "鲜牛奶 今天到期", + body: "2盒 · 冷藏 · 打开查看详情", + scheduledAt: Date().addingTimeInterval(120) + ), + MacLocalNotificationRequest( + itemId: "item-bread-1", + title: "面包 明天到期", + body: "1袋 · 常温 · 打开查看详情", + scheduledAt: Date().addingTimeInterval(180) + ), + ] + var completionError: Error? + + MacLocalNotificationScheduler.schedule( + notifications, + center: center + ) { error in + completionError = error + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertNil(completionError) + XCTAssertEqual(center.removeAllPendingCalls, 1) + XCTAssertEqual( + center.addedRequests.map(\.identifier), + ["inventory-item-milk-1", "inventory-item-bread-1"] + ) + XCTAssertEqual( + center.addedRequests[0].content.userInfo["itemId"] as? String, + "item-milk-1" + ) + } + + func testNotificationSchedulerReportsSystemAddFailure() { + let center = FakeNotificationSchedulingCenter() + let expectedError = NSError( + domain: "vibe-fridge-tests", + code: 42, + userInfo: [NSLocalizedDescriptionKey: "add failed"] + ) + center.errorsByIdentifier["inventory-item-bread-1"] = expectedError + let done = expectation(description: "scheduler completion") + let notifications = [ + MacLocalNotificationRequest( + itemId: "item-milk-1", + title: "鲜牛奶 今天到期", + body: "2盒 · 冷藏 · 打开查看详情", + scheduledAt: Date().addingTimeInterval(120) + ), + MacLocalNotificationRequest( + itemId: "item-bread-1", + title: "面包 明天到期", + body: "1袋 · 常温 · 打开查看详情", + scheduledAt: Date().addingTimeInterval(180) + ), + ] + var completionError: NSError? + + MacLocalNotificationScheduler.schedule( + notifications, + center: center + ) { error in + completionError = error as NSError? + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertEqual(completionError?.domain, expectedError.domain) + XCTAssertEqual(completionError?.code, expectedError.code) + XCTAssertEqual(center.removeAllPendingCalls, 1) + XCTAssertEqual(center.addedRequests.count, 2) + } + + func testDiagnosticNotificationRequestBuildsImmediateTestNotification() { + let request = MacDiagnosticNotificationRequestFactory.request( + id: "test-id" + ) + let content = request.content() + let trigger = request.trigger() + + XCTAssertEqual(request.identifier, "diagnostic-test-id") + XCTAssertEqual(request.title, "库存提醒测试") + XCTAssertEqual(request.body, "看到这条通知说明本地通知可用") + XCTAssertEqual(request.triggerInterval, 1, accuracy: 0.001) + XCTAssertEqual(content.title, "库存提醒测试") + XCTAssertEqual(content.body, "看到这条通知说明本地通知可用") + XCTAssertEqual(trigger.timeInterval, 1, accuracy: 0.001) + XCTAssertFalse(trigger.repeats) + } + + func testPermissionSnapshotMapsAuthorizationStatusesForChannel() { + let authorized = MacLocalNotificationPermissionFactory.snapshot( + for: .authorized + ) + XCTAssertTrue(authorized.granted) + XCTAssertEqual(authorized.status, "granted") + XCTAssertEqual(authorized.channelMap["supported"] as? Bool, true) + XCTAssertEqual(authorized.channelMap["granted"] as? Bool, true) + XCTAssertEqual(authorized.channelMap["status"] as? String, "granted") + + let provisional = MacLocalNotificationPermissionFactory.snapshot( + for: .provisional + ) + XCTAssertTrue(provisional.granted) + XCTAssertEqual(provisional.status, "provisional") + + let denied = MacLocalNotificationPermissionFactory.snapshot(for: .denied) + XCTAssertFalse(denied.granted) + XCTAssertEqual(denied.status, "denied") + + let unknown = MacLocalNotificationPermissionFactory.snapshot( + for: .notDetermined + ) + XCTAssertFalse(unknown.granted) + XCTAssertEqual(unknown.status, "unknown") + } + + func testSystemNotificationCenterReportsRunnerPermissionStatus() { + XCTAssertNotNil(Bundle.main.bundleIdentifier) + + let center = UNUserNotificationCenter.current() + let done = expectation(description: "system permission status") + var snapshot: MacLocalNotificationPermissionSnapshot? + + center.getNotificationSettings { settings in + snapshot = MacLocalNotificationPermissionFactory.snapshot( + for: settings.authorizationStatus + ) + done.fulfill() + } + + wait(for: [done], timeout: 5) + XCTAssertEqual(snapshot?.channelMap["supported"] as? Bool, true) + XCTAssertNotNil(snapshot?.status) + } + + func testAppDelegateRegistersAsNotificationCenterDelegate() { + let appDelegate = NSApp.delegate as? AppDelegate + + XCTAssertNotNil(appDelegate) + XCTAssertTrue( + UNUserNotificationCenter.current().delegate as? AppDelegate === + appDelegate + ) + } + + func testAppDelegateRoutesNotificationResponsePayloadToTapHandler() { + let appDelegate = AppDelegate() + let event = expectation(description: "delegate notification tap event") + var postedItemId: String? + let observer = NotificationCenter.default.addObserver( + forName: .vibeFridgeNotificationTapped, + object: nil, + queue: nil + ) { notification in + postedItemId = notification.userInfo?["itemId"] as? String + event.fulfill() + } + defer { + NotificationCenter.default.removeObserver(observer) + UserDefaults.standard.removeObject( + forKey: MacLocalNotificationTapHandler.launchItemIdKey + ) + } + + let itemId = appDelegate.handleNotificationResponseUserInfo([ + "itemId": " item-from-delegate " + ]) + + XCTAssertEqual(itemId, "item-from-delegate") + XCTAssertEqual( + UserDefaults.standard.string( + forKey: MacLocalNotificationTapHandler.launchItemIdKey + ), + "item-from-delegate" + ) + wait(for: [event], timeout: 1) + XCTAssertEqual(postedItemId, "item-from-delegate") + } + + func testSystemNotificationCenterAcceptsPendingRequestWhenAuthorized() + throws + { + let center = UNUserNotificationCenter.current() + let settings = notificationSettings(from: center) + let snapshot = MacLocalNotificationPermissionFactory.snapshot( + for: settings.authorizationStatus + ) + guard snapshot.granted else { + throw XCTSkip( + "macOS notifications are not authorized for this test host; " + + "permission, delivery, and click routing still need manual validation." + ) + } + + let notification = MacLocalNotificationRequest( + itemId: "system-smoke-\(UUID().uuidString)", + title: "库存提醒系统测试", + body: "这条请求用于验证系统是否接受待投递提醒", + scheduledAt: Date().addingTimeInterval(3600) + ) + let request = UNNotificationRequest( + identifier: notification.identifier, + content: notification.content(), + trigger: notification.trigger() + ) + defer { + center.removePendingNotificationRequests( + withIdentifiers: [request.identifier] + ) + } + + let addDone = expectation(description: "system pending request add") + var addError: Error? + center.add(request) { error in + addError = error + addDone.fulfill() + } + wait(for: [addDone], timeout: 5) + XCTAssertNil(addError) + + let pendingDone = expectation(description: "system pending request lookup") + var pendingRequest: UNNotificationRequest? + center.getPendingNotificationRequests { requests in + pendingRequest = requests.first { candidate in + candidate.identifier == request.identifier + } + pendingDone.fulfill() + } + wait(for: [pendingDone], timeout: 5) + XCTAssertEqual(pendingRequest?.content.title, "库存提醒系统测试") + XCTAssertEqual( + pendingRequest?.content.userInfo["itemId"] as? String, + notification.itemId + ) + } + + func testNotificationTapHandlerStoresLaunchTargetAndPostsEvent() { + let suiteName = "com.vibefridge.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + let center = NotificationCenter() + let event = expectation(description: "notification tap event") + var postedItemId: String? + let observer = center.addObserver( + forName: .vibeFridgeNotificationTapped, + object: nil, + queue: nil + ) { notification in + postedItemId = notification.userInfo?["itemId"] as? String + event.fulfill() + } + defer { + center.removeObserver(observer) + defaults.removePersistentDomain(forName: suiteName) + } + + let itemId = MacLocalNotificationTapHandler.handleTap( + userInfo: ["itemId": " item-milk-1 "], + userDefaults: defaults, + notificationCenter: center + ) + + XCTAssertEqual(itemId, "item-milk-1") + XCTAssertEqual( + defaults.string(forKey: MacLocalNotificationTapHandler.launchItemIdKey), + "item-milk-1" + ) + wait(for: [event], timeout: 1) + XCTAssertEqual(postedItemId, "item-milk-1") + XCTAssertEqual( + MacLocalNotificationTapHandler.consumeLaunchItemId( + userDefaults: defaults + ), + "item-milk-1" + ) + XCTAssertNil( + defaults.string(forKey: MacLocalNotificationTapHandler.launchItemIdKey) + ) } + func testNotificationTapHandlerIgnoresMalformedPayloads() { + let suiteName = "com.vibefridge.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + let center = NotificationCenter() + var eventCount = 0 + let observer = center.addObserver( + forName: .vibeFridgeNotificationTapped, + object: nil, + queue: nil + ) { _ in + eventCount += 1 + } + defer { + center.removeObserver(observer) + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertNil( + MacLocalNotificationTapHandler.handleTap( + userInfo: ["itemId": ""], + userDefaults: defaults, + notificationCenter: center + ) + ) + XCTAssertNil( + MacLocalNotificationTapHandler.handleTap( + userInfo: ["itemId": " "], + userDefaults: defaults, + notificationCenter: center + ) + ) + XCTAssertNil( + MacLocalNotificationTapHandler.handleTap( + userInfo: ["itemId": 42], + userDefaults: defaults, + notificationCenter: center + ) + ) + XCTAssertNil( + defaults.string(forKey: MacLocalNotificationTapHandler.launchItemIdKey) + ) + XCTAssertEqual(eventCount, 0) + } + + func testNotificationTapHandlerClearsEmptyStoredLaunchTarget() { + let suiteName = "com.vibefridge.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set( + " ", + forKey: MacLocalNotificationTapHandler.launchItemIdKey + ) + + XCTAssertNil( + MacLocalNotificationTapHandler.consumeLaunchItemId( + userDefaults: defaults + ) + ) + XCTAssertNil( + defaults.string(forKey: MacLocalNotificationTapHandler.launchItemIdKey) + ) + + defaults.set( + " item-milk-1 ", + forKey: MacLocalNotificationTapHandler.launchItemIdKey + ) + + XCTAssertEqual( + MacLocalNotificationTapHandler.consumeLaunchItemId( + userDefaults: defaults + ), + "item-milk-1" + ) + } + + func testKeychainCanStoreApiSecretWithoutDataProtectionEntitlement() { + let account = "vlm-api-key-\(UUID().uuidString)" + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrService: "vibe-fridge-tests", + kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked, + kSecValueData: Data("secret".utf8), + ] + if #available(macOS 10.15, *) { + query[kSecUseDataProtectionKeychain] = false + } + defer { + SecItemDelete(query as CFDictionary) + } + + XCTAssertEqual(SecItemAdd(query as CFDictionary, nil), errSecSuccess) + + query.removeValue(forKey: kSecValueData) + query[kSecReturnData] = true + var result: AnyObject? + XCTAssertEqual( + SecItemCopyMatching(query as CFDictionary, &result), + errSecSuccess + ) + XCTAssertEqual(result as? Data, Data("secret".utf8)) + XCTAssertEqual(SecItemDelete(query as CFDictionary), errSecSuccess) + } + + private func notificationSettings( + from center: UNUserNotificationCenter + ) -> UNNotificationSettings { + let done = expectation(description: "system notification settings") + var captured: UNNotificationSettings? + + center.getNotificationSettings { settings in + captured = settings + done.fulfill() + } + + wait(for: [done], timeout: 5) + return captured! + } + +} + +private final class FakeNotificationSchedulingCenter: + MacNotificationSchedulingCenter { + var removeAllPendingCalls = 0 + var addedRequests: [UNNotificationRequest] = [] + var errorsByIdentifier: [String: Error] = [:] + + func removeAllPendingNotificationRequests() { + removeAllPendingCalls += 1 + } + + func add( + _ request: UNNotificationRequest, + withCompletionHandler completionHandler: (@Sendable (Error?) -> Void)? + ) { + addedRequests.append(request) + completionHandler?(errorsByIdentifier[request.identifier]) + } } diff --git a/mobile/test/android_notification_wiring_test.dart b/mobile/test/android_notification_wiring_test.dart new file mode 100644 index 0000000..870f944 --- /dev/null +++ b/mobile/test/android_notification_wiring_test.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Android manifest keeps local notification platform wiring', () { + final manifest = _read('android/app/src/main/AndroidManifest.xml'); + + _expectAllContains(manifest, [ + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.RECEIVE_BOOT_COMPLETED', + 'android.intent.action.BOOT_COMPLETED', + 'android.intent.action.MY_PACKAGE_REPLACED', + ]); + expect( + _containsElementWithAttributes(manifest, 'receiver', [ + 'android:name=".ReminderNotificationReceiver"', + 'android:exported="false"', + ]), + isTrue, + ); + expect( + _containsElementWithAttributes(manifest, 'receiver', [ + 'android:name=".ReminderBootReceiver"', + 'android:enabled="true"', + 'android:exported="true"', + ]), + isTrue, + ); + expect( + _containsElementWithAttributes(manifest, 'activity', [ + 'android:name=".MainActivity"', + 'android:launchMode="singleTop"', + ]), + isTrue, + ); + }); + + test('Android native notification code matches Dart channel contract', () { + final dartService = _read('lib/data/local_notification_service.dart'); + final contract = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/' + 'LocalNotificationContract.kt', + ); + final activity = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/MainActivity.kt', + ); + final scheduler = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/' + 'LocalReminderScheduler.kt', + ); + final notificationReceiver = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/' + 'ReminderNotificationReceiver.kt', + ); + final bootReceiver = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/' + 'ReminderBootReceiver.kt', + ); + + _expectAllContains(dartService, [ + "'vibe_fridge/local_notifications'", + "'initialize'", + "'getPermissionStatus'", + "'requestPermission'", + "'getLaunchItemId'", + "'scheduleInventoryReminders'", + "'sendTestNotification'", + "'cancelAll'", + "'notificationTapped'", + "'notifications'", + '_normalizeItemId(', + "arguments is Map ? arguments['itemId'] : null", + ]); + _expectAllContains(contract, [ + 'const val channelName = "vibe_fridge/local_notifications"', + 'const val prefsName = "vibe_fridge_notifications"', + 'const val scheduledItemIdsKey = "scheduled_item_ids"', + 'const val scheduledNotificationsKey = "scheduled_notifications"', + 'const val testNotificationRequestCode = 4818', + 'const val extraItemId = "item_id"', + 'fun requestCodeFor(itemId: String): Int', + ]); + _expectAllContains(activity, [ + 'LocalNotificationContract.channelName', + '"initialize" -> result.success(permissionStatus())', + '"getPermissionStatus" -> result.success(permissionStatus())', + '"requestPermission" -> requestNotificationPermission(result)', + '"getLaunchItemId" -> {', + '"scheduleInventoryReminders" -> {', + 'LocalReminderScheduler.scheduleFromChannel', + '"sendTestNotification" -> sendTestNotification(result)', + '"cancelAll" -> {', + 'LocalReminderScheduler.cancelScheduledReminders(this)', + 'normalizedNotificationItemId(intent)', + '?.getStringExtra(LocalNotificationContract.extraItemId)', + '?.trim()', + '?.takeIf { itemId -> itemId.isNotEmpty() }', + '"notificationTapped"', + 'mapOf("itemId" to itemId)', + 'Manifest.permission.POST_NOTIFICATIONS', + 'setContentTitle("库存提醒测试")', + 'LocalNotificationContract.testNotificationRequestCode', + ]); + _expectAllContains(scheduler, [ + 'fun restoreScheduledReminders(context: Context)', + 'scheduleRows(context, loadRows(context))', + 'Intent(context, ReminderNotificationReceiver::class.java)', + 'LocalNotificationContract.extraItemId', + 'PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE', + 'alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)', + 'LocalNotificationContract.scheduledItemIdsKey', + 'LocalNotificationContract.scheduledNotificationsKey', + 'row["itemId"] as? String', + 'row["scheduledAtMillis"] as? Number', + 'itemId.isBlank() || scheduledAtMillis <= 0L', + ]); + _expectAllContains(notificationReceiver, [ + 'intent.getStringExtra(LocalNotificationContract.extraItemId)', + 'itemId.isBlank()', + 'hasNotificationPermission(context)', + 'Manifest.permission.POST_NOTIFICATIONS', + 'PackageManager.PERMISSION_GRANTED', + 'Intent(context, MainActivity::class.java)', + 'putExtra(LocalNotificationContract.extraItemId, itemId)', + 'setContentIntent(pendingIntent)', + 'setAutoCancel(true)', + 'catch (ignored: SecurityException)', + 'notificationManager.notify', + ]); + _expectAllContains(bootReceiver, [ + 'Intent.ACTION_BOOT_COMPLETED', + 'Intent.ACTION_MY_PACKAGE_REPLACED', + 'LocalReminderScheduler.restoreScheduledReminders(context)', + ]); + }); + + test('Android native notification code rejects malformed reminder ids', () { + final scheduler = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/' + 'LocalReminderScheduler.kt', + ); + final notificationReceiver = _read( + 'android/app/src/main/kotlin/com/vibefridge/vibe_fridge/' + 'ReminderNotificationReceiver.kt', + ); + + expect( + RegExp( + r'if\s*\(\s*itemId\.isBlank\(\)\s*\|\|\s*scheduledAtMillis\s*<=\s*0L\s*\)\s*\{\s*return@mapNotNull null\s*\}', + multiLine: true, + ).hasMatch(scheduler), + isTrue, + ); + expect( + RegExp( + r'if\s*\(\s*itemId\.isBlank\(\)\s*\)\s*\{\s*return\s*\}', + multiLine: true, + ).hasMatch(notificationReceiver), + isTrue, + ); + }); +} + +String _read(String path) => File(path).readAsStringSync(); + +void _expectAllContains(String source, List expectedValues) { + for (final expected in expectedValues) { + expect(source, contains(expected), + reason: 'Missing source token: $expected'); + } +} + +bool _containsElementWithAttributes( + String source, + String elementName, + List attributes, +) { + final elementPattern = RegExp('<$elementName\\b[^>]*>', dotAll: true); + return elementPattern + .allMatches(source) + .map((match) => match.group(0)!) + .any((element) => attributes.every(element.contains)); +} diff --git a/mobile/test/app_database_web_wiring_test.dart b/mobile/test/app_database_web_wiring_test.dart new file mode 100644 index 0000000..d15d763 --- /dev/null +++ b/mobile/test/app_database_web_wiring_test.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('web database avoids shared-worker and main-thread factories', () { + final source = File('lib/data/app_database.dart').readAsStringSync(); + + expect( + source, + contains('return databaseFactoryFfiWebBasicWebWorker;'), + reason: 'The in-app browser can hang while starting the shared worker.', + ); + expect( + source, + isNot(contains('return databaseFactoryFfiWeb;')), + ); + expect( + source, + isNot(contains('return databaseFactoryFfiWebNoWebWorker;')), + reason: 'No-worker SQLite runs on the UI thread and can freeze startup.', + ); + }); +} diff --git a/mobile/test/app_error_snackbar_test.dart b/mobile/test/app_error_snackbar_test.dart new file mode 100644 index 0000000..9d53729 --- /dev/null +++ b/mobile/test/app_error_snackbar_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/widgets/app_cards.dart'; + +void main() { + testWidgets('error snackbar keeps technical details behind explicit copy', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showAppErrorSnackBar( + context, + message: '保存失败', + error: StateError('SQLite internal path'), + stackTrace: StackTrace.fromString( + 'package:vibe_fridge/internal.dart 1:1', + ), + ), + child: const Text('触发错误'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('触发错误')); + await tester.pump(); + + expect(find.text('保存失败'), findsOneWidget); + expect(find.text('复制详情'), findsOneWidget); + expect(find.textContaining('SQLite'), findsNothing); + expect(find.textContaining('internal.dart'), findsNothing); + }); +} diff --git a/mobile/test/app_shell_widget_test.dart b/mobile/test/app_shell_widget_test.dart index 3ef27ff..bb29229 100644 --- a/mobile/test/app_shell_widget_test.dart +++ b/mobile/test/app_shell_widget_test.dart @@ -1,8 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/data/app_database.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; +import 'package:vibe_fridge/data/inventory_repository.dart'; +import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'package:vibe_fridge/models/inventory_item.dart'; +import 'package:vibe_fridge/models/item_status.dart'; +import 'package:vibe_fridge/screens/app_shell.dart'; import 'package:vibe_fridge/theme/app_theme.dart'; +import 'test_database.dart'; + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase appDatabase; + + setUp(() async { + appDatabase = await openTestDatabase(); + }); + + tearDown(() async { + await appDatabase.database.close(); + }); + testWidgets('material shell smoke renders app chrome', (tester) async { await tester.pumpWidget( MaterialApp( @@ -30,4 +52,188 @@ void main() { expect(find.text('首页'), findsOneWidget); expect(find.text('添加'), findsOneWidget); }); + + testWidgets('opens inventory detail when a notification tap is received', + (tester) async { + tester.view.physicalSize = const Size(430, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final item = _notificationTargetItem(); + final repository = _FakeItemRepository(appDatabase, item: item); + final notificationService = _FakeNotificationService(); + final controller = _FakeShellController( + repository, + notificationService: notificationService, + item: item, + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + home: AppShell(controller: controller), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + notificationService.tap(item.id); + await tester.pump(); + await _pumpUntilFound( + tester, + find.text('数量操作'), + timeout: const Duration(seconds: 5), + ); + await tester.pump(); + + expect(find.text('通知跳转测试牛奶'), findsWidgets); + expect(find.text('数量操作'), findsOneWidget); + expect(find.text('3 盒'), findsOneWidget); + expect(find.text('冷藏'), findsWidgets); + }); + + testWidgets('opens launch notification target after app shell starts', + (tester) async { + tester.view.physicalSize = const Size(430, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final item = _notificationTargetItem(); + final repository = _FakeItemRepository(appDatabase, item: item); + final controller = _FakeShellController( + repository, + notificationService: _FakeNotificationService(), + item: item, + initialNotificationItemId: item.id, + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + home: AppShell(controller: controller), + ), + ); + await _pumpUntilFound( + tester, + find.text('数量操作'), + timeout: const Duration(seconds: 5), + ); + await tester.pump(); + + expect(find.text('通知跳转测试牛奶'), findsWidgets); + expect(find.text('库存详情'), findsOneWidget); + expect(find.text('数量操作'), findsOneWidget); + expect(find.text('3 盒'), findsOneWidget); + }); +} + +InventoryItem _notificationTargetItem() { + final now = DateTime(2026, 6, 19); + return InventoryItem( + id: 'notification-item-1', + wikiId: 'notification-wiki-1', + name: '通知跳转测试牛奶', + quantity: 3, + unit: '盒', + purchaseDate: DateTime(2026, 6, 18), + expiryDate: DateTime(2026, 6, 25), + reminderDate: DateTime(2026, 6, 22), + reminderDaysBefore: 3, + status: ItemStatus.active, + isReminderEnabled: true, + storageLocation: '冷藏', + createdAt: now, + updatedAt: now, + ); +} + +Future _pumpUntilFound( + WidgetTester tester, + Finder finder, { + required Duration timeout, +}) async { + final stopwatch = Stopwatch()..start(); + while (finder.evaluate().isEmpty && stopwatch.elapsed < timeout) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +class _FakeItemRepository extends InventoryRepository { + _FakeItemRepository(super.appDatabase, {required this.item}); + + final InventoryItem item; + + @override + Future getItem(String itemId) async { + return itemId == item.id ? item : null; + } +} + +class _FakeShellController extends InventoryController { + _FakeShellController( + super.repository, { + required LocalNotificationService notificationService, + required InventoryItem item, + String? initialNotificationItemId, + }) : super(notificationService: notificationService) { + _initialNotificationItemId = initialNotificationItemId; + isLoading = false; + activeItems = [item]; + expiringItems = [item]; + todayActionItems = [item]; + } + + String? _initialNotificationItemId; + + @override + String? consumeNotificationTappedItemId() { + final itemId = _initialNotificationItemId; + if (itemId != null) { + _initialNotificationItemId = null; + return itemId; + } + return super.consumeNotificationTappedItemId(); + } +} + +class _FakeNotificationService extends LocalNotificationService { + _FakeNotificationService() + : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + void Function(String itemId)? _onTap; + + @override + void setOnNotificationTap(void Function(String itemId)? handler) { + _onTap = handler; + } + + void tap(String itemId) { + _onTap?.call(itemId); + } + + @override + Future getLaunchItemId() async { + return null; + } + + @override + Future syncInventoryReminders( + InventoryRepository repository, { + DateTime? now, + }) async { + return const LocalNotificationSyncResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + scheduledCount: 0, + ); + } } diff --git a/mobile/test/bootstrap_error_app_test.dart b/mobile/test/bootstrap_error_app_test.dart index bac3a88..ea48fe4 100644 --- a/mobile/test/bootstrap_error_app_test.dart +++ b/mobile/test/bootstrap_error_app_test.dart @@ -1,7 +1,66 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; import 'package:vibe_fridge/main.dart'; void main() { + test('bootstrap timeout reports stalled initialization', () async { + final stalled = Completer(); + + await expectLater( + withBootstrapTimeout( + stalled.future, + operation: '测试初始化', + timeout: const Duration(milliseconds: 1), + ), + throwsA( + isA() + .having((error) => error.message, 'message', contains('测试初始化')) + .having( + (error) => error.duration, + 'duration', + const Duration(milliseconds: 1), + ), + ), + ); + }); + + testWidgets('bootstrap app paints loading before initialization finishes', + (tester) async { + final stalled = Completer(); + + await tester.pumpWidget( + VibeFridgeBootstrapApp( + controllerLoader: () => stalled.future, + ), + ); + await tester.pump(); + + expect(find.text('正在整理你的库存'), findsOneWidget); + expect(find.text('这通常只需要几秒。'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + + testWidgets('bootstrap app shows user-safe error when loading fails', + (tester) async { + await tester.pumpWidget( + VibeFridgeBootstrapApp( + controllerLoader: () async { + throw StateError('SQLite open failed: /tmp/private-path.db'); + }, + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('vibe-fridge 启动失败'), findsOneWidget); + expect(find.text('复制错误详情'), findsOneWidget); + expect(find.textContaining('SQLite'), findsNothing); + expect(find.textContaining('/tmp/private-path.db'), findsNothing); + }); + testWidgets('bootstrap error page hides technical details by default', (tester) async { await tester.pumpWidget( diff --git a/mobile/test/debug_service_extensions_test.dart b/mobile/test/debug_service_extensions_test.dart new file mode 100644 index 0000000..407e9b5 --- /dev/null +++ b/mobile/test/debug_service_extensions_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/data/debug_service_extensions.dart'; +import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'package:vibe_fridge/data/vlm_settings_store.dart'; + +void main() { + test('notification status debug payload keeps smoke contract stable', () { + const permission = LocalNotificationPermissionSnapshot( + supported: true, + granted: false, + status: 'unknown', + ); + + expect(debugNotificationPermissionPayload(permission), { + 'supported': true, + 'granted': false, + 'status': 'unknown', + 'displayText': '未确认', + }); + }); + + test('notification test debug payload nests permission details', () { + const permission = LocalNotificationPermissionSnapshot( + supported: true, + granted: false, + status: 'denied', + ); + const result = LocalNotificationTestResult( + permission: permission, + sent: false, + skippedReason: 'permission', + ); + + expect(debugNotificationTestPayload(result), { + 'sent': false, + 'skippedReason': 'permission', + 'displayText': '通知未授权', + 'permission': { + 'supported': true, + 'granted': false, + 'status': 'denied', + 'displayText': '未授权', + }, + }); + }); + + test('secure storage smoke writes reads and deletes a temporary key', + () async { + final secretStore = _MemorySecretStore(); + + final result = await runDebugSecureStorageSmoke( + secretStore: secretStore, + now: DateTime.fromMicrosecondsSinceEpoch(1234), + ); + + expect(result, { + 'wrote': true, + 'readBackMatches': true, + 'deleted': true, + 'keyPrefix': 'debug.secure_storage_smoke', + }); + expect(secretStore.values, isEmpty); + }); +} + +class _MemorySecretStore implements VlmSecretStore { + final values = {}; + + @override + Future read(String key) async { + return values[key]; + } + + @override + Future write(String key, String value) async { + values[key] = value; + } + + @override + Future delete(String key) async { + values.remove(key); + } +} diff --git a/mobile/test/import_trace_display_test.dart b/mobile/test/import_trace_display_test.dart new file mode 100644 index 0000000..a888830 --- /dev/null +++ b/mobile/test/import_trace_display_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/models/inventory_item.dart'; +import 'package:vibe_fridge/models/item_status.dart'; +import 'package:vibe_fridge/utils/import_trace_display.dart'; + +void main() { + test('maps internal import source names to user-facing labels', () { + expect(importSourceLabel('legacy'), '旧版库存'); + expect(importSourceLabel(' LEGACY '), '旧版库存'); + expect(importSourceLabel('shopping-list'), '采购清单'); + expect(importSourceLabel('盒马'), '盒马'); + expect(importSourceLabel(' '), isNull); + }); + + test('does not expose import batch ids as visible import trace', () { + expect( + hasUserVisibleImportTrace( + _item(importBatchId: 'legacy-batch'), + ), + isFalse, + ); + expect( + hasUserVisibleImportTrace( + _item(sourceApp: 'legacy', importBatchId: 'legacy-batch'), + ), + isTrue, + ); + }); +} + +InventoryItem _item({ + String? sourceApp, + String? sourceOrderId, + String? importBatchId, + double? recognitionConfidence, +}) { + final now = DateTime(2026, 6, 18); + return InventoryItem( + id: 'item-test', + wikiId: 'wiki-test', + name: '测试物品', + quantity: 1, + reminderDaysBefore: 3, + status: ItemStatus.active, + isReminderEnabled: true, + sourceApp: sourceApp, + sourceOrderId: sourceOrderId, + importBatchId: importBatchId, + recognitionConfidence: recognitionConfidence, + createdAt: now, + updatedAt: now, + ); +} diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 8e4de76..5220b7d 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vibe_fridge/data/acceptance_test_service.dart'; import 'package:vibe_fridge/data/app_database.dart'; @@ -292,18 +295,71 @@ void main() { ); expect(batches.map((item) => item.quantity), contains(2)); expect(batches.map((item) => item.sourceApp), contains('采购清单')); + final convertedBatch = batches.singleWhere( + (item) => item.sourceApp == '采购清单', + ); + expect(convertedBatch.description, '买嫩一点'); + final wiki = await repository.getWiki(registered.single.wikiId); + expect(wiki?.description, isNull); }); test('runs app acceptance checks without leaving temporary data', () async { + final categories = await repository.getCategories(); + for (var index = 0; index < 10; index += 1) { + await repository.createItem( + name: '用户备份提醒保留-$index', + categoryId: categories.first.id, + quantity: 1, + unit: '个', + ); + } + final reminderBefore = await repository.getBackupReminderState(); + expect(reminderBefore.isPending, isTrue); + expect(reminderBefore.reason, '新增库存'); + expect(reminderBefore.dirtyCount, 10); + final report = await AcceptanceTestService(repository).runCoreInventoryChecks(); - expect(report.passed, isTrue); - expect(report.checks, hasLength(15)); expect( - (await repository.getRegisteredItems(keyword: '自验收测试物品-')), + report.passed, + isTrue, + reason: report.checks + .where((check) => !check.passed) + .map((check) => '${check.name}: ${check.message}') + .join('\n'), + ); + expect(report.checks, hasLength(17)); + expect( + (await repository.getRegisteredItems(keyword: '应用自检测试物品-')), isEmpty, ); + final temporaryShoppingItems = + (await repository.getShoppingListItems(includeConverted: true)) + .where((item) => item.name.startsWith('应用自检测试物品-')); + expect(temporaryShoppingItems, isEmpty); + + final reminderAfter = await repository.getBackupReminderState(); + expect(reminderAfter.isPending, reminderBefore.isPending); + expect(reminderAfter.reason, reminderBefore.reason); + expect(reminderAfter.dirtyCount, reminderBefore.dirtyCount); + expect(reminderAfter.updatedAt, reminderBefore.updatedAt); + expect(reminderAfter.lastExportedAt, reminderBefore.lastExportedAt); + }); + + test('formats app self-check failures without technical prefixes', () { + expect( + selfCheckFailureMessage(StateError('没有可用分类')), + '没有可用分类', + ); + expect( + selfCheckFailureMessage(ArgumentError('数量必须大于 0')), + '数量必须大于 0', + ); + expect( + selfCheckFailureMessage(Exception('临时检查失败')), + '临时检查失败', + ); }); test('uses custom reminder days and returns today action items', () async { @@ -561,6 +617,41 @@ void main() { expect(importedItems.single.importBatchId, 'ORDER-001'); expect(importedItems.single.imagePath, '/tmp/order-001.png'); expect(importedItems.single.recognitionConfidence, 0.91); + + const noDateResult = OrderRecognitionResult( + sourceApp: '手动粘贴', + merchant: '内测超市', + orderId: 'ORDER-NODATE-001', + items: [ + OrderRecognitionItem( + name: '无日期重复苹果', + quantity: 2, + unit: '个', + categoryName: '食品', + confidence: 0.72, + ), + ], + ); + final firstNoDateSummary = await controller.createItemsFromOrder( + result: noDateResult, + items: noDateResult.items, + ); + expect(firstNoDateSummary.addedCount, 1); + + final noDateDuplicates = await controller.findOrderImportDuplicates( + result: noDateResult, + items: noDateResult.items, + ); + expect(noDateDuplicates, hasLength(1)); + expect(noDateDuplicates.single.index, 0); + expect(noDateDuplicates.single.purchaseDate, isNull); + + final secondNoDateSummary = await controller.createItemsFromOrder( + result: noDateResult, + items: noDateResult.items, + ); + expect(secondNoDateSummary.addedCount, 0); + expect(secondNoDateSummary.duplicateCount, 1); }); test('exports and restores backup with a pre-restore snapshot', () async { @@ -604,6 +695,123 @@ void main() { expect((await repository.checkDataHealth()).passed, isTrue); }); + test('backup restore round-trips tags reminders and shopping list', () async { + final categories = await repository.getCategories(); + final foodCategory = categories.firstWhere( + (category) => category.name == '食品', + ); + + await repository.createItem( + name: '备份往返复杂苹果', + categoryId: foodCategory.id, + quantity: 3, + unit: '个', + purchaseDate: DateTime(2026, 6, 10), + expiryDate: DateTime(2026, 6, 20), + storageLocation: '冷藏', + tags: const ['临期优先', '常用'], + reminderDaysBefore: 2, + ); + final registered = (await repository.getRegisteredItems( + keyword: '备份往返复杂苹果', + )) + .single; + final item = + (await repository.getInventoryByWikiId(registered.wikiId)).single; + await repository.ignoreReminderForToday( + item.id, + now: DateTime(2026, 6, 18, 9), + ); + final shoppingId = await repository.addShoppingListItem( + ShoppingListDraft( + name: '备份往返采购盐', + categoryId: foodCategory.id, + quantity: 2, + unit: '袋', + note: '恢复后仍应保留', + source: 'manual', + ), + ); + + final backup = await repository.exportBackup(); + + await repository.deleteShoppingListItem(shoppingId); + await repository.deleteItem(item.id); + await repository.deleteWiki(registered.wikiId, force: true); + await appDatabase.database.delete( + 'reminder_logs', + where: 'item_id = ?', + whereArgs: [item.id], + ); + + expect( + await repository.getRegisteredItems(keyword: '备份往返复杂苹果'), + isEmpty, + ); + expect( + (await repository.getShoppingListItems()).map((item) => item.name), + isNot(contains('备份往返采购盐')), + ); + + await repository.restoreBackup(backup, replaceExisting: true); + + final restoredRegistered = (await repository.getRegisteredItems( + keyword: '备份往返复杂苹果', + )) + .single; + final restoredItem = + (await repository.getInventoryByWikiId(restoredRegistered.wikiId)) + .singleWhere((item) => item.name == '备份往返复杂苹果'); + expect(restoredItem.quantity, 3); + expect(restoredItem.storageLocation, '冷藏'); + expect(restoredItem.tags, containsAll(['临期优先', '常用'])); + + final restoredShopping = (await repository.getShoppingListItems()) + .singleWhere((item) => item.name == '备份往返采购盐'); + expect(restoredShopping.quantity, 2); + expect(restoredShopping.unit, '袋'); + expect(restoredShopping.note, '恢复后仍应保留'); + expect(restoredShopping.categoryName, '食品'); + + final reminderLogs = await appDatabase.database.query( + 'reminder_logs', + where: 'item_id = ? AND reminder_type = ?', + whereArgs: [restoredItem.id, 'ignored'], + ); + expect(reminderLogs, hasLength(1)); + expect(reminderLogs.single['message'], '忽略本次提醒'); + expect((await repository.checkDataHealth()).passed, isTrue); + }); + + test('escapes special characters in inventory table export', () async { + final categories = await repository.getCategories(); + + await repository.createItem( + name: '导出测试 "牛奶,大盒"\n第二行', + categoryId: categories.first.id, + quantity: 1, + unit: '盒', + purchaseDate: DateTime(2026, 6, 18), + expiryDate: DateTime(2026, 6, 25), + storageLocation: '冷藏,第二层', + tags: const ['临期,优先', '常用"标签'], + sourceApp: '采购"清单,手动', + ); + + final csv = await repository.exportInventoryCsv(); + + expect(csv, contains('"导出测试 ""牛奶,大盒""\n第二行"')); + expect(csv, contains('"冷藏,第二层"')); + expect( + csv, + anyOf( + contains('"临期,优先;常用""标签"'), + contains('"常用""标签;临期,优先"'), + ), + ); + expect(csv, contains('"采购""清单,手动"')); + }); + test('rejects incomplete backup before changing current data', () async { final categories = await repository.getCategories(); await repository.createItem( @@ -651,12 +859,13 @@ void main() { final pending = await repository.getBackupReminderState(); expect(pending.isPending, isTrue); expect(pending.dirtyCount, 10); - expect(pending.message, contains('建议导出一份备份')); + expect(pending.message, '因为新增库存,建议备份一次'); await repository.markBackupExported(); final cleared = await repository.getBackupReminderState(); expect(cleared.isPending, isFalse); expect(cleared.dirtyCount, 0); + expect(cleared.message, '当前没有待处理的备份提醒'); expect(cleared.lastExportedAt, isNotNull); }); @@ -786,6 +995,35 @@ void main() { expect(await repository.getRegisteredItems(keyword: '鲜牛奶'), isEmpty); }); + test('loads default legacy asset without probing missing local override', + () async { + const legacyAsset = 'assets/import/legacy_inventory.json'; + const localAsset = 'assets/import/legacy_inventory.local.json'; + final bundle = _FakeLegacyAssetBundle( + manifestAssets: const [legacyAsset], + assets: const { + legacyAsset: ''' +{ + "format": "vibe-fridge-legacy-export", + "version": 1, + "categories": [], + "wikis": [], + "items": [], + "tags": [], + "item_tags": [] +} +''', + }, + ); + final controller = InventoryController(repository, assetBundle: bundle); + + final preview = await controller.previewLegacyAssetImport(); + + expect(preview.source.total, 0); + expect(bundle.loadedAssets, contains(legacyAsset)); + expect(bundle.loadedAssets, isNot(contains(localAsset))); + }); + test('reports invariant violations in health check', () async { final now = DateTime.now().toIso8601String(); await appDatabase.database.insert('items', { @@ -825,6 +1063,9 @@ void main() { 'invalid_date', ]), ); + expect(health.summary, contains('使用中库存不应带有消耗时间 1 处')); + expect(health.summary, isNot(contains('active'))); + expect(health.summary, isNot(contains('consumed_at'))); }); test( @@ -951,3 +1192,38 @@ void main() { }, ); } + +class _FakeLegacyAssetBundle extends CachingAssetBundle { + _FakeLegacyAssetBundle({ + required List manifestAssets, + required Map assets, + }) : _assets = assets, + _manifestData = _encodeManifest(manifestAssets); + + final Map _assets; + final ByteData _manifestData; + final loadedAssets = []; + + static ByteData _encodeManifest(List assets) { + final manifest = { + for (final asset in assets) + asset: [ + {'asset': asset}, + ], + }; + return const StandardMessageCodec().encodeMessage(manifest)!; + } + + @override + Future load(String key) async { + loadedAssets.add(key); + if (key == 'AssetManifest.bin') { + return _manifestData; + } + final content = _assets[key]; + if (content == null) { + throw StateError('Missing fake asset: $key'); + } + return ByteData.sublistView(Uint8List.fromList(utf8.encode(content))); + } +} diff --git a/mobile/test/items_screen_test.dart b/mobile/test/items_screen_test.dart new file mode 100644 index 0000000..a732921 --- /dev/null +++ b/mobile/test/items_screen_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/data/app_database.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; +import 'package:vibe_fridge/data/inventory_repository.dart'; +import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'package:vibe_fridge/models/shopping_list_item.dart'; +import 'package:vibe_fridge/screens/items_screen.dart'; +import 'package:vibe_fridge/theme/app_theme.dart'; + +import 'test_database.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase appDatabase; + + setUp(() async { + appDatabase = await openTestDatabase(); + }); + + tearDown(() async { + await appDatabase.database.close(); + }); + + testWidgets('shopping view confirms before converting checked items', + (tester) async { + tester.view.physicalSize = const Size(430, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = _FakeShoppingController( + InventoryRepository(appDatabase), + convertedCount: 1, + notificationService: _FakeNotificationService(), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: ItemsScreen(controller: controller), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('采购')); + await tester.pumpAndSettle(); + + expect(find.text('已买到'), findsOneWidget); + expect(find.text('转换测试牛奶'), findsOneWidget); + expect(find.text('2盒 · 备注:已经买到'), findsOneWidget); + + final convertButton = find.widgetWithText(FilledButton, '入库'); + expect(convertButton, findsOneWidget); + + await tester.tap(convertButton); + await tester.pumpAndSettle(); + + expect(find.text('采购项入库'), findsOneWidget); + expect(find.text('确定将 1 个已买到采购项转为库存记录吗?'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, '取消')); + await tester.pumpAndSettle(); + + expect(controller.convertCalls, 0); + expect(find.text('已入库 1 项'), findsNothing); + + await tester.tap(convertButton); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '确认入库')); + await tester.pumpAndSettle(); + + expect(controller.convertCalls, 1); + expect(find.text('已入库 1 项'), findsOneWidget); + }); +} + +class _FakeShoppingController extends InventoryController { + _FakeShoppingController( + super.repository, { + required this.convertedCount, + required LocalNotificationService notificationService, + }) : super(notificationService: notificationService) { + final now = DateTime(2026, 6, 19); + isLoading = false; + shoppingListItems = [ + ShoppingListItem( + id: 'shopping-checked-1', + name: '转换测试牛奶', + categoryId: 'cat-food', + categoryName: '食品', + quantity: 2, + unit: '盒', + note: '已经买到', + source: 'manual', + isChecked: true, + checkedAt: now, + createdAt: now, + updatedAt: now, + ), + ]; + } + + final int convertedCount; + int convertCalls = 0; + + @override + Future convertCheckedShoppingItemsToInventory() async { + convertCalls += 1; + return convertedCount; + } +} + +class _FakeNotificationService extends LocalNotificationService { + _FakeNotificationService() + : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + @override + void setOnNotificationTap(void Function(String itemId)? handler) {} +} diff --git a/mobile/test/local_notification_service_test.dart b/mobile/test/local_notification_service_test.dart index 01a686f..580a27a 100644 --- a/mobile/test/local_notification_service_test.dart +++ b/mobile/test/local_notification_service_test.dart @@ -1,7 +1,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; +import 'package:vibe_fridge/data/inventory_repository.dart'; import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'test_database.dart'; + void main() { const channel = MethodChannel('vibe_fridge/local_notifications_test'); @@ -68,6 +72,37 @@ void main() { ).displayText, '同步失败,请稍后重试', ); + expect( + const LocalNotificationTestResult( + permission: granted, + sent: true, + ).displayText, + '已发送测试通知', + ); + expect( + const LocalNotificationTestResult( + permission: denied, + sent: false, + skippedReason: 'permission', + ).displayText, + '通知未授权', + ); + expect( + const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot.unsupported, + sent: false, + skippedReason: 'unsupported', + ).displayText, + '当前平台不可用', + ); + expect( + const LocalNotificationTestResult( + permission: granted, + sent: false, + skippedReason: 'platform_internal_error', + ).displayText, + '测试通知发送失败,请稍后重试', + ); }); test('permission calls parse platform status snapshots', () async { @@ -125,6 +160,42 @@ void main() { expect(status.displayText, '未确认'); }); + test('unsupported platform skips notification method channel calls', + () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + final repository = InventoryRepository(appDatabase); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + fail('Unsupported platforms must not call notification channel.'); + }); + final service = LocalNotificationService( + channel: channel, + supportsPlatformNotifications: false, + ); + + final initialized = await service.initialize(); + final status = await service.getPermissionStatus(); + final requested = await service.requestPermission(); + final launchItemId = await service.getLaunchItemId(); + final testResult = await service.sendTestNotification(); + final syncResult = await service.syncInventoryReminders(repository); + + expect(initialized, LocalNotificationPermissionSnapshot.unsupported); + expect(status, LocalNotificationPermissionSnapshot.unsupported); + expect(requested, LocalNotificationPermissionSnapshot.unsupported); + expect(launchItemId, isNull); + expect(testResult.sent, isFalse); + expect(testResult.skippedReason, 'unsupported'); + expect(syncResult.scheduledCount, 0); + expect(syncResult.skippedReason, 'unsupported'); + expect(calls, isEmpty); + }); + test('notification tap callback receives valid item id from platform', () async { final service = LocalNotificationService(channel: channel); @@ -149,6 +220,10 @@ void main() { channel, const MethodCall('notificationTapped', {'itemId': ''}), ); + await _sendPlatformCall( + channel, + const MethodCall('notificationTapped', {'itemId': ' '}), + ); await _sendPlatformCall( channel, const MethodCall('notificationTapped', {'itemId': 42}), @@ -170,6 +245,327 @@ void main() { expect(await service.getLaunchItemId(), isNull); }); + + test('get launch item id returns only non-empty platform targets', () async { + final service = LocalNotificationService(channel: channel); + final returnedItemIds = [' item-123 ', ' ', '', null]; + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return returnedItemIds.removeAt(0); + }); + + expect(await service.getLaunchItemId(), 'item-123'); + expect(await service.getLaunchItemId(), isNull); + expect(await service.getLaunchItemId(), isNull); + expect(await service.getLaunchItemId(), isNull); + expect(calls, [ + 'getLaunchItemId', + 'getLaunchItemId', + 'getLaunchItemId', + 'getLaunchItemId', + ]); + }); + + test('send test notification requests permission before platform call', + () async { + final service = LocalNotificationService(channel: channel); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return switch (call.method) { + 'requestPermission' => { + 'supported': true, + 'granted': true, + 'status': 'granted', + }, + 'sendTestNotification' => null, + _ => null, + }; + }); + + final result = await service.sendTestNotification(); + + expect(result.sent, isTrue); + expect(result.displayText, '已发送测试通知'); + expect(calls, ['requestPermission', 'sendTestNotification']); + }); + + test('send test notification stops when permission is denied', () async { + final service = LocalNotificationService(channel: channel); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return { + 'supported': true, + 'granted': false, + 'status': 'denied', + }; + }); + + final result = await service.sendTestNotification(); + + expect(result.sent, isFalse); + expect(result.displayText, '通知未授权'); + expect(calls, ['requestPermission']); + }); + + test('sync inventory reminders sends pending reminder payload to platform', + () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + final repository = InventoryRepository(appDatabase); + final service = LocalNotificationService(channel: channel); + final now = DateTime(2026, 6, 19, 8, 30); + + await repository.createItem( + name: '平台同步测试酸奶', + quantity: 3, + unit: '盒', + purchaseDate: now.subtract(const Duration(days: 1)), + expiryDate: DateTime(2026, 6, 21), + storageLocation: '冷藏', + reminderDaysBefore: 2, + ); + + final calls = []; + Object? scheduleArguments; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return switch (call.method) { + 'getPermissionStatus' => { + 'supported': true, + 'granted': true, + 'status': 'granted', + }, + 'scheduleInventoryReminders' => scheduleArguments = call.arguments, + _ => null, + }; + }); + + final result = await service.syncInventoryReminders( + repository, + now: now, + ); + + expect(result.scheduledCount, 1); + expect(result.displayText, '已同步 1 条提醒'); + expect(calls, ['getPermissionStatus', 'scheduleInventoryReminders']); + + final payload = scheduleArguments as Map; + final notifications = payload['notifications'] as List; + final notification = notifications.single as Map; + expect(notification['title'], contains('平台同步测试酸奶')); + expect(notification['body'], '3盒 · 冷藏 · 打开查看详情'); + expect( + notification['scheduledAtMillis'], + DateTime(2026, 6, 19, 9).millisecondsSinceEpoch, + ); + }); + + test('sync inventory reminders skips platform schedule when denied', + () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + final repository = InventoryRepository(appDatabase); + final service = LocalNotificationService(channel: channel); + final now = DateTime(2026, 6, 19, 8, 30); + + await repository.createItem( + name: '未授权同步测试酸奶', + quantity: 1, + unit: '盒', + purchaseDate: now.subtract(const Duration(days: 1)), + expiryDate: DateTime(2026, 6, 21), + reminderDaysBefore: 2, + ); + + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'scheduleInventoryReminders') { + fail('Denied notification permission must not schedule reminders.'); + } + return { + 'supported': true, + 'granted': false, + 'status': 'denied', + }; + }); + + final result = await service.syncInventoryReminders( + repository, + now: now, + ); + + expect(result.scheduledCount, 0); + expect(result.skippedReason, 'permission'); + expect(result.displayText, '通知未授权'); + expect(calls, ['getPermissionStatus']); + }); + + test('sync inventory reminders reports platform schedule failure', () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + final repository = InventoryRepository(appDatabase); + final service = LocalNotificationService(channel: channel); + final now = DateTime(2026, 6, 19, 8, 30); + + await repository.createItem( + name: '调度失败测试酸奶', + quantity: 1, + unit: '盒', + purchaseDate: now.subtract(const Duration(days: 1)), + expiryDate: DateTime(2026, 6, 21), + reminderDaysBefore: 2, + ); + + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'scheduleInventoryReminders') { + throw PlatformException(code: 'schedule_failed'); + } + return { + 'supported': true, + 'granted': true, + 'status': 'granted', + }; + }); + + final result = await service.syncInventoryReminders( + repository, + now: now, + ); + + expect(result.scheduledCount, 0); + expect(result.skippedReason, 'schedule_failed'); + expect(result.displayText, '同步失败,请稍后重试'); + expect(calls, ['getPermissionStatus', 'scheduleInventoryReminders']); + }); + + test('inventory controller consumes notification tap target once', () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + + final notificationService = _FakeNotificationService(); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: notificationService, + ); + addTearDown(controller.dispose); + + notificationService.tap('item-123'); + + expect(controller.consumeNotificationTappedItemId(), 'item-123'); + expect(controller.consumeNotificationTappedItemId(), isNull); + }); + + test('inventory controller syncs reminders after permission is granted', + () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + + final notificationService = _FakeNotificationService( + requestPermissionResult: const LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + syncResult: const LocalNotificationSyncResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + scheduledCount: 2, + ), + ); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: notificationService, + ); + addTearDown(controller.dispose); + + final permission = await controller.requestNotificationPermission(); + + expect(permission.granted, isTrue); + expect(notificationService.syncCalls, 1); + expect(controller.lastNotificationSyncResult?.scheduledCount, 2); + }); + + test('inventory controller skips sync when permission is denied', () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + + final notificationService = _FakeNotificationService( + requestPermissionResult: const LocalNotificationPermissionSnapshot( + supported: true, + granted: false, + status: 'denied', + ), + ); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: notificationService, + ); + addTearDown(controller.dispose); + + final permission = await controller.requestNotificationPermission(); + + expect(permission.granted, isFalse); + expect(notificationService.syncCalls, 0); + expect(controller.lastNotificationSyncResult, isNull); + }); + + test('inventory controller updates permission after test notification', + () async { + final appDatabase = await openTestDatabase(); + addTearDown(() async { + await appDatabase.database.close(); + }); + + const permission = LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ); + final notificationService = _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: permission, + sent: true, + ), + ); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: notificationService, + ); + addTearDown(controller.dispose); + + final result = await controller.sendTestNotification(); + + expect(result.sent, isTrue); + expect(notificationService.testCalls, 1); + expect(controller.notificationPermission, permission); + }); } Future _sendPlatformCall(MethodChannel channel, MethodCall call) async { @@ -182,3 +578,56 @@ Future _sendPlatformCall(MethodChannel channel, MethodCall call) async { (ByteData? data) {}, ); } + +class _FakeNotificationService extends LocalNotificationService { + _FakeNotificationService({ + this.requestPermissionResult = + LocalNotificationPermissionSnapshot.unsupported, + this.syncResult = const LocalNotificationSyncResult( + permission: LocalNotificationPermissionSnapshot.unsupported, + scheduledCount: 0, + skippedReason: 'unsupported', + ), + this.testResult = const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot.unsupported, + sent: false, + skippedReason: 'unsupported', + ), + }) : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + final LocalNotificationPermissionSnapshot requestPermissionResult; + final LocalNotificationSyncResult syncResult; + final LocalNotificationTestResult testResult; + void Function(String itemId)? _onTap; + int syncCalls = 0; + int testCalls = 0; + + @override + void setOnNotificationTap(void Function(String itemId)? handler) { + _onTap = handler; + } + + void tap(String itemId) { + _onTap?.call(itemId); + } + + @override + Future requestPermission() async { + return requestPermissionResult; + } + + @override + Future syncInventoryReminders( + InventoryRepository repository, { + DateTime? now, + }) async { + syncCalls += 1; + return syncResult; + } + + @override + Future sendTestNotification() async { + testCalls += 1; + return testResult; + } +} diff --git a/mobile/test/macos_notification_wiring_test.dart b/mobile/test/macos_notification_wiring_test.dart new file mode 100644 index 0000000..0a59d75 --- /dev/null +++ b/mobile/test/macos_notification_wiring_test.dart @@ -0,0 +1,123 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('macOS bridge keeps the Dart local notification channel contract', () { + final dartService = _read('lib/data/local_notification_service.dart'); + final mainWindow = _read('macos/Runner/MainFlutterWindow.swift'); + final requestFactory = _read( + 'macos/Runner/MacLocalNotificationRequest.swift', + ); + + _expectAllContains(dartService, [ + "'vibe_fridge/local_notifications'", + "'initialize'", + "'getPermissionStatus'", + "'requestPermission'", + "'getLaunchItemId'", + "'scheduleInventoryReminders'", + "'sendTestNotification'", + "'cancelAll'", + "'notificationTapped'", + "'notifications'", + '_normalizeItemId(', + "arguments is Map ? arguments['itemId'] : null", + ]); + _expectAllContains(mainWindow, [ + 'FlutterMethodChannel(', + 'name: "vibe_fridge/local_notifications"', + 'case "initialize", "getPermissionStatus":', + 'case "requestPermission":', + 'case "getLaunchItemId":', + 'MacLocalNotificationTapHandler.consumeLaunchItemId()', + 'case "scheduleInventoryReminders":', + 'scheduleInventoryReminders(arguments: call.arguments, result: result)', + 'case "sendTestNotification":', + 'sendTestNotification(result: result)', + 'case "cancelAll":', + 'center.removeAllPendingNotificationRequests()', + 'MacLocalNotificationRequestFactory.requests', + 'MacLocalNotificationScheduler.schedule(', + 'center: center', + 'code: "schedule_failed"', + 'MacDiagnosticNotificationRequestFactory.request()', + 'diagnostic.content()', + 'diagnostic.trigger()', + 'let normalizedItemId = itemId.trimmingCharacters(', + 'guard !normalizedItemId.isEmpty else', + 'arguments: ["itemId": normalizedItemId]', + ]); + _expectAllContains(requestFactory, [ + 'payload["notifications"] as? [[String: Any]]', + 'row["itemId"] as? String', + 'row["title"] as? String', + 'row["body"] as? String', + 'row["scheduledAtMillis"] as? NSNumber', + '["itemId": itemId]', + 'content.userInfo = ["itemId": itemId]', + 'func trigger(now: Date = Date())', + 'max(scheduledAt.timeIntervalSince(now), 60)', + 'protocol MacNotificationSchedulingCenter', + 'MacLocalNotificationScheduler', + 'center.removeAllPendingNotificationRequests()', + 'center.add(request)', + 'group.notify(queue: .main)', + 'notification.content()', + 'notification.trigger()', + 'MacDiagnosticNotificationRequestFactory', + 'identifier: "diagnostic-\\(id)"', + 'title: "库存提醒测试"', + 'body: "看到这条通知说明本地通知可用"', + 'triggerInterval: 1', + ]); + }); + + test('macOS notification delegate routes taps into the launch target', () { + final appDelegate = _read('macos/Runner/AppDelegate.swift'); + final requestSupport = _read( + 'macos/Runner/MacLocalNotificationRequest.swift', + ); + + _expectAllContains(appDelegate, [ + 'UNUserNotificationCenter.current().delegate = self', + 'UNUserNotificationCenterDelegate', + 'willPresent notification: UNNotification', + 'completionHandler([.banner, .sound])', + 'completionHandler([.alert, .sound])', + 'didReceive response: UNNotificationResponse', + 'MacLocalNotificationTapHandler.handleTap', + 'response.notification.request.content.userInfo', + 'completionHandler()', + 'Notification.Name("vibeFridgeNotificationTapped")', + ]); + expect( + appDelegate, + isNot(contains('super.applicationDidFinishLaunching(notification)')), + reason: + 'FlutterAppDelegate does not implement this optional AppKit selector ' + 'in the current runtime; calling super raises an NSInvalidArgumentException.', + ); + _expectAllContains(requestSupport, [ + 'static let launchItemIdKey = "notification_item_id"', + 'normalizedItemId(userInfo["itemId"])', + 'userDefaults.set(itemId, forKey: launchItemIdKey)', + 'notificationCenter.post(', + 'name: .vibeFridgeNotificationTapped', + 'userInfo: ["itemId": itemId]', + 'consumeLaunchItemId(', + 'userDefaults.removeObject(forKey: launchItemIdKey)', + 'trimmingCharacters(in: .whitespacesAndNewlines)', + 'normalized.isEmpty ? nil : normalized', + ]); + }); +} + +String _read(String path) => File(path).readAsStringSync(); + +void _expectAllContains(String source, List expectedValues) { + for (final expected in expectedValues) { + expect(source, contains(expected), + reason: 'Missing source token: $expected'); + } +} diff --git a/mobile/test/order_import_review_screen_test.dart b/mobile/test/order_import_review_screen_test.dart new file mode 100644 index 0000000..58e3e86 --- /dev/null +++ b/mobile/test/order_import_review_screen_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vibe_fridge/data/app_database.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; +import 'package:vibe_fridge/data/inventory_repository.dart'; +import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'package:vibe_fridge/models/item_wiki_category.dart'; +import 'package:vibe_fridge/models/order_recognition.dart'; +import 'package:vibe_fridge/screens/order_import_review_screen.dart'; +import 'package:vibe_fridge/theme/app_theme.dart'; + +import 'test_database.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase appDatabase; + + setUp(() async { + appDatabase = await openTestDatabase(); + }); + + tearDown(() async { + await appDatabase.database.close(); + }); + + testWidgets( + 'review requires confirmation before importing low confidence row', + (tester) async { + tester.view.physicalSize = const Size(430, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = _FakeOrderImportController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService(), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: OrderImportReviewScreen( + controller: controller, + result: _recognizedOrder(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('2/2 已选'), findsOneWidget); + expect(find.text('1 可入库'), findsOneWidget); + expect(find.text('1 需要确认'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '添加 1 个物品'), findsOneWidget); + + await tester.tap(find.widgetWithText(OutlinedButton, '标记已确认')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('2 可入库'), findsOneWidget); + expect(find.text('1 需要确认'), findsNothing); + expect(find.widgetWithText(FilledButton, '添加 2 个物品'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, '添加 2 个物品')); + await _pumpUntilFound( + tester, + find.text('确认批量入库'), + timeout: const Duration(seconds: 5), + ); + await tester.pump(); + + expect(find.text('确认批量入库'), findsOneWidget); + expect(find.text('将新增 2 条库存记录。重复跳过 0 条,仍需确认 0 条。'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, '确认入库')); + await _pumpUntilFound( + tester, + find.text('导入完成'), + timeout: const Duration(seconds: 5), + ); + await tester.pump(); + + expect(controller.createCalls, 1); + expect(controller.createdItems.map((item) => item.name), [ + '鲜牛奶', + '赠品纸巾', + ]); + expect(controller.createdItems.map((item) => item.quantity), [2, 1]); + expect(controller.createdItems.map((item) => item.unit), ['盒', '包']); + expect(find.text('导入完成'), findsOneWidget); + expect(find.text('新增'), findsWidgets); + expect(find.text('2'), findsWidgets); + }); +} + +Future _pumpUntilFound( + WidgetTester tester, + Finder finder, { + required Duration timeout, +}) async { + final stopwatch = Stopwatch()..start(); + while (finder.evaluate().isEmpty && stopwatch.elapsed < timeout) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +OrderRecognitionResult _recognizedOrder() { + return OrderRecognitionResult( + sourceApp: '手动粘贴', + merchant: '盒马鲜生', + orderId: 'REVIEW-001', + purchaseDate: DateTime(2026, 6, 18), + items: [ + OrderRecognitionItem( + name: '鲜牛奶', + quantity: 2, + unit: '盒', + categoryName: '食品', + predictedExpiryDate: DateTime(2026, 6, 25), + confidence: 0.92, + ), + const OrderRecognitionItem( + name: '赠品纸巾', + quantity: 1, + unit: '包', + categoryName: '日用品', + notes: '赠品,低置信度,需要确认是否入库', + confidence: 0.45, + ), + ], + ); +} + +class _FakeOrderImportController extends InventoryController { + _FakeOrderImportController( + super.repository, { + required LocalNotificationService notificationService, + }) : super(notificationService: notificationService) { + final now = DateTime(2026, 1, 1); + categories = [ + ItemWikiCategory( + id: 'cat-food', + name: '食品', + sortOrder: 1, + createdAt: now, + updatedAt: now, + ), + ItemWikiCategory( + id: 'cat-daily', + name: '日用品', + sortOrder: 2, + createdAt: now, + updatedAt: now, + ), + ]; + } + + int duplicateChecks = 0; + int createCalls = 0; + List createdItems = const []; + + @override + Future> findOrderImportDuplicates({ + required OrderRecognitionResult result, + required List items, + }) async { + duplicateChecks += 1; + return const []; + } + + @override + Future createItemsFromOrder({ + required OrderRecognitionResult result, + required Iterable items, + String? imagePath, + int uncheckedCount = 0, + int duplicateSkippedBeforeImport = 0, + int needsManualReviewCount = 0, + }) async { + createCalls += 1; + createdItems = items.toList(); + return OrderImportSummary( + addedCount: createdItems.length, + uncheckedCount: uncheckedCount, + duplicateCount: duplicateSkippedBeforeImport, + needsManualReviewCount: needsManualReviewCount, + ); + } +} + +class _FakeNotificationService extends LocalNotificationService { + _FakeNotificationService() + : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + @override + void setOnNotificationTap(void Function(String itemId)? handler) {} +} diff --git a/mobile/test/order_recognition_test.dart b/mobile/test/order_recognition_test.dart index fd725fc..b558633 100644 --- a/mobile/test/order_recognition_test.dart +++ b/mobile/test/order_recognition_test.dart @@ -124,6 +124,21 @@ Organic Milk x2 expect(banana.unit, '根'); }); + test('skips standalone order reference lines in pasted order text', () { + final result = parseOrderTextImport(''' +BETA-BACKUP-001 +备份提醒米 1袋 +ABC维生素C 1瓶 +'''); + + expect( + result.items.map((item) => item.name), + isNot(contains('BETA-BACKUP-001')), + ); + expect(result.items.map((item) => item.name), contains('备份提醒米')); + expect(result.items.map((item) => item.name), contains('ABC维生素C')); + }); + test('validates VLM configuration successfully', () async { final service = VlmOrderService( client: MockClient( diff --git a/mobile/test/recipe_preferences_store_test.dart b/mobile/test/recipe_preferences_store_test.dart new file mode 100644 index 0000000..bafcb47 --- /dev/null +++ b/mobile/test/recipe_preferences_store_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vibe_fridge/data/recipe_preferences_store.dart'; + +void main() { + test('fresh recipe preferences load defaults', () async { + SharedPreferences.setMockInitialValues({}); + final store = RecipePreferencesStore(); + + final loaded = await store.load(); + + expect(loaded.flavorProfile, isEmpty); + expect(loaded.dietaryRestrictions, isEmpty); + expect(loaded.tools, isEmpty); + expect(loaded.cookMinutes, 30); + expect(loaded.servings, 2); + }); + + test('save trims text and bounds numeric preferences', () async { + SharedPreferences.setMockInitialValues({}); + final store = RecipePreferencesStore(); + + await store.save( + const RecipePreferences( + flavorProfile: ' 清淡少油 ', + dietaryRestrictions: ' 不吃辣 ', + tools: ' 电饭煲 ', + cookMinutes: 999, + servings: 0, + ), + ); + + final loaded = await store.load(); + + expect(loaded.flavorProfile, '清淡少油'); + expect(loaded.dietaryRestrictions, '不吃辣'); + expect(loaded.tools, '电饭煲'); + expect(loaded.cookMinutes, 180); + expect(loaded.servings, 1); + }); + + test('load bounds damaged numeric preferences', () async { + SharedPreferences.setMockInitialValues({ + 'recipe.cook_minutes': 1, + 'recipe.servings': 99, + }); + final store = RecipePreferencesStore(); + + final loaded = await store.load(); + + expect(loaded.cookMinutes, 5); + expect(loaded.servings, 12); + }); +} diff --git a/mobile/test/recipes_screen_test.dart b/mobile/test/recipes_screen_test.dart new file mode 100644 index 0000000..7a3701e --- /dev/null +++ b/mobile/test/recipes_screen_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vibe_fridge/data/app_database.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; +import 'package:vibe_fridge/data/inventory_repository.dart'; +import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'package:vibe_fridge/models/inventory_item.dart'; +import 'package:vibe_fridge/models/recipe_suggestion.dart'; + +import 'test_database.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase appDatabase; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + appDatabase = await openTestDatabase(); + }); + + tearDown(() async { + await appDatabase.database.close(); + }); + + test('recipe cooking deduction updates inventory through controller', + () async { + final repository = InventoryRepository(appDatabase); + await repository.seedDefaults(); + await repository.createItem( + name: '食谱扣减测试鸡蛋', + quantity: 3, + unit: '个', + expiryDate: DateTime.now().add(const Duration(days: 1)), + ); + await repository.createItem( + name: '食谱扣减测试番茄', + quantity: 2, + unit: '个', + expiryDate: DateTime.now().add(const Duration(days: 2)), + ); + + final controller = InventoryController( + repository, + notificationService: _FakeNotificationService(), + ); + addTearDown(controller.dispose); + + final egg = await _activeItem(repository, '食谱扣减测试鸡蛋'); + final tomato = await _activeItem(repository, '食谱扣减测试番茄'); + final suggestion = RecipeSuggestion( + id: 'recipe-cook-test', + title: '番茄炒蛋', + summary: '验证做菜后会扣减库存', + estimatedMinutes: 12, + inventoryUses: [ + RecipeInventoryUse(item: egg, quantity: 2), + RecipeInventoryUse(item: tomato), + ], + missingIngredients: const [], + steps: const ['打散鸡蛋', '炒熟番茄和鸡蛋'], + ); + + for (final use in suggestion.inventoryUses) { + await controller.updateItemQuantity(use.item.id, -use.quantity); + } + + final updatedEgg = await repository.getItem(egg.id); + final updatedTomato = await repository.getItem(tomato.id); + expect(updatedEgg?.quantity, 1); + expect(updatedTomato?.quantity, 1); + }); +} + +Future _activeItem( + InventoryRepository repository, + String name, +) async { + final items = await repository.getActiveItems(limit: 20); + return items.singleWhere((item) => item.name == name); +} + +class _FakeNotificationService extends LocalNotificationService { + _FakeNotificationService() + : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + @override + void setOnNotificationTap(void Function(String itemId)? handler) {} + + @override + Future getLaunchItemId() async { + return null; + } + + @override + Future syncInventoryReminders( + InventoryRepository repository, { + DateTime? now, + }) async { + return const LocalNotificationSyncResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + scheduledCount: 0, + ); + } +} diff --git a/mobile/test/settings_screen_test.dart b/mobile/test/settings_screen_test.dart new file mode 100644 index 0000000..da9661d --- /dev/null +++ b/mobile/test/settings_screen_test.dart @@ -0,0 +1,1063 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vibe_fridge/data/app_database.dart'; +import 'package:vibe_fridge/data/inventory_controller.dart'; +import 'package:vibe_fridge/data/inventory_repository.dart'; +import 'package:vibe_fridge/data/local_notification_service.dart'; +import 'package:vibe_fridge/data/recipe_preferences_store.dart'; +import 'package:vibe_fridge/data/vlm_settings_store.dart'; +import 'package:vibe_fridge/data/webdav_backup_service.dart'; +import 'package:vibe_fridge/data/webdav_backup_store.dart'; +import 'package:vibe_fridge/screens/settings_screen.dart'; +import 'package:vibe_fridge/theme/app_theme.dart'; + +import 'test_database.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase appDatabase; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + appDatabase = await openTestDatabase(); + }); + + tearDown(() async { + await appDatabase.database.close(); + }); + + testWidgets('settings test notification action calls notification service', + (tester) async { + tester.view.physicalSize = const Size(430, 1700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final notificationService = _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: notificationService, + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final button = find.widgetWithText(OutlinedButton, '测试通知'); + expect(button, findsOneWidget); + + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(notificationService.testCalls, 1); + expect(find.text('已发送测试通知'), findsOneWidget); + }); + + testWidgets('settings does not expose app self-check controls', + (tester) async { + tester.view.physicalSize = const Size(430, 2200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('订单识别 AI'), findsOneWidget); + expect(find.text('应用自检'), findsNothing); + expect(find.text('核心闭环'), findsNothing); + expect(find.widgetWithText(FilledButton, '运行自检'), findsNothing); + }); + + testWidgets('settings shows backup reminder with user-facing copy', + (tester) async { + tester.view.physicalSize = const Size(430, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + controller.backupReminderState = const BackupReminderState( + isPending: true, + reason: '新增库存', + dirtyCount: 10, + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('建议导出备份'), findsOneWidget); + expect(find.text('因为新增库存,建议备份一次'), findsOneWidget); + expect(find.text('累计 10 次库存资料变更尚未备份'), findsOneWidget); + expect( + find.textContaining('网页版使用时,云盘服务需要允许网页访问'), + findsOneWidget, + ); + expect(find.widgetWithText(TextButton, '导出'), findsOneWidget); + }); + + testWidgets('settings saves WebDAV config without showing stored password', + (tester) async { + tester.view.physicalSize = const Size(430, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final webDavSettingsStore = WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: webDavSettingsStore, + ), + ), + ), + ); + + final serverUrlField = _textFieldWithLabel('WebDAV 服务地址'); + await _pumpUntilFound( + tester, + serverUrlField, + timeout: const Duration(seconds: 5), + ); + await tester.enterText(serverUrlField, ' https://dav.example.com/dav '); + await tester.enterText(_textFieldWithLabel('备份目录'), ' /fridge/backups/ '); + await tester.enterText(_textFieldWithLabel('用户名'), ' alice '); + await tester.enterText(_textFieldWithLabel('密码或应用密码'), ' secret '); + + final saveButton = find.widgetWithText(OutlinedButton, '保存配置'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + final loaded = await webDavSettingsStore.load(); + final passwordField = tester.widget( + _textFieldWithLabel('密码或应用密码'), + ); + + expect(find.text('WebDAV 配置已保存'), findsOneWidget); + expect(find.text('本机安全保存'), findsWidgets); + expect(find.text('secret'), findsNothing); + expect(passwordField.controller?.text, isEmpty); + expect( + FocusManager.instance.primaryFocus?.context?.widget, + isNot(isA()), + ); + expect(loaded.serverUrl, 'https://dav.example.com/dav'); + expect(loaded.remoteDirectory, 'fridge/backups'); + expect(loaded.username, 'alice'); + expect(loaded.password, 'secret'); + }); + + testWidgets('settings uploads backup to WebDAV and marks backup exported', + (tester) async { + tester.view.physicalSize = const Size(430, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final webDavService = _FakeWebDavBackupService(); + final controller = _FakeBackupController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupService: webDavService, + ), + ), + ), + ); + + final serverUrlField = _textFieldWithLabel('WebDAV 服务地址'); + await _pumpUntilFound( + tester, + serverUrlField, + timeout: const Duration(seconds: 5), + ); + await tester.enterText(serverUrlField, 'https://dav.example.com/dav'); + await tester.enterText(_textFieldWithLabel('备份目录'), 'fridge/backups'); + await tester.enterText(_textFieldWithLabel('用户名'), 'alice'); + await tester.enterText(_textFieldWithLabel('密码或应用密码'), 'secret'); + + final uploadButton = find.widgetWithText(FilledButton, '上传备份'); + await tester.ensureVisible(uploadButton); + await tester.tap(uploadButton); + await tester.pumpAndSettle(); + + expect(webDavService.uploadCalls, 1); + expect(controller.exportBackupCalls, 1); + expect(controller.markBackupExportedCalls, 1); + expect(webDavService.lastUploadSettings?.serverUrl, + 'https://dav.example.com/dav'); + expect(webDavService.lastUploadSettings?.password, 'secret'); + expect(webDavService.lastBackupJson, contains('"items"')); + expect(find.text('备份已上传到 WebDAV'), findsOneWidget); + expect(find.textContaining('最近上传:vibe-fridge-backup-'), findsOneWidget); + }); + + testWidgets('settings clears stale WebDAV error after successful retry', + (tester) async { + tester.view.physicalSize = const Size(430, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final webDavService = _FakeWebDavBackupService( + validateErrors: [ + const WebDavBackupException( + 'wrong password', + type: WebDavBackupErrorType.authentication, + ), + ], + ); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupService: webDavService, + ), + ), + ), + ); + + final serverUrlField = _textFieldWithLabel('WebDAV 服务地址'); + await _pumpUntilFound( + tester, + serverUrlField, + timeout: const Duration(seconds: 5), + ); + await tester.enterText(serverUrlField, 'https://dav.example.com/dav'); + await tester.enterText(_textFieldWithLabel('用户名'), 'alice'); + await tester.enterText(_textFieldWithLabel('密码或应用密码'), 'wrong'); + + final testButton = find.widgetWithText(OutlinedButton, '测试连接'); + await tester.ensureVisible(testButton); + await tester.tap(testButton); + await tester.pumpAndSettle(); + + expect(find.text('WebDAV 鉴权失败:请检查用户名和密码'), findsWidgets); + + await tester.tap(testButton); + await tester.pumpAndSettle(); + + expect(find.text('WebDAV 连接可用'), findsOneWidget); + expect(find.text('WebDAV 鉴权失败:请检查用户名和密码'), findsNothing); + expect(webDavService.validateCalls, 2); + }); + + testWidgets('settings restores latest WebDAV backup after confirmation', + (tester) async { + tester.view.physicalSize = const Size(430, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final webDavService = _FakeWebDavBackupService( + downloadResult: WebDavBackupDownloadResult( + fileName: 'vibe-fridge-backup-20260619-120000.json', + uri: Uri.parse( + 'https://dav.example.com/dav/fridge/backups/vibe-fridge-backup-20260619-120000.json', + ), + backupJson: '{"metadata":{"format":"vibe-fridge"},"data":{}}', + ), + ); + final controller = _FakeBackupController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupService: webDavService, + ), + ), + ), + ); + + final serverUrlField = _textFieldWithLabel('WebDAV 服务地址'); + await _pumpUntilFound( + tester, + serverUrlField, + timeout: const Duration(seconds: 5), + ); + await tester.enterText(serverUrlField, 'https://dav.example.com/dav'); + + final restoreButton = find.widgetWithText(OutlinedButton, '恢复云备份'); + await tester.ensureVisible(restoreButton); + await tester.tap(restoreButton); + await tester.pumpAndSettle(); + + expect(webDavService.downloadCalls, 1); + expect(find.text('恢复云端备份'), findsOneWidget); + expect( + find.textContaining('vibe-fridge-backup-20260619-120000.json'), + findsOneWidget, + ); + + await tester.tap(find.widgetWithText(FilledButton, '恢复')); + await tester.pumpAndSettle(); + + expect(controller.restoreBackupCalls, 1); + expect(controller.restoredBackup?['metadata'], isA()); + expect( + find.text('已从 WebDAV 恢复:vibe-fridge-backup-20260619-120000.json'), + findsOneWidget, + ); + }); + + testWidgets('settings does not restore WebDAV backup when cancelled', + (tester) async { + tester.view.physicalSize = const Size(430, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final webDavService = _FakeWebDavBackupService(); + final controller = _FakeBackupController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupService: webDavService, + ), + ), + ), + ); + + final serverUrlField = _textFieldWithLabel('WebDAV 服务地址'); + await _pumpUntilFound( + tester, + serverUrlField, + timeout: const Duration(seconds: 5), + ); + await tester.enterText(serverUrlField, 'https://dav.example.com/dav'); + + final restoreButton = find.widgetWithText(OutlinedButton, '恢复云备份'); + await tester.ensureVisible(restoreButton); + await tester.tap(restoreButton); + await tester.pumpAndSettle(); + + expect(webDavService.downloadCalls, 1); + expect(find.text('恢复云端备份'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, '取消')); + await tester.pumpAndSettle(); + + expect(controller.restoreBackupCalls, 0); + expect(controller.restoredBackup, isNull); + expect(find.text('恢复云端备份'), findsNothing); + expect( + find.text('已从 WebDAV 恢复:vibe-fridge-backup-20260619-120000.json'), + findsNothing, + ); + }); + + testWidgets('settings saves recipe preferences from user inputs', + (tester) async { + tester.view.physicalSize = const Size(430, 2300); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final recipePreferencesStore = RecipePreferencesStore(); + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + recipePreferencesStore: recipePreferencesStore, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + + final flavorField = _textFieldWithLabel('口味偏好'); + final dietaryField = _textFieldWithLabel('忌口/饮食限制'); + final toolsField = _textFieldWithLabel('可用厨具'); + final minutesField = _textFieldWithLabel('时间'); + final servingsField = _textFieldWithLabel('人数'); + await _pumpUntilFound( + tester, + flavorField, + timeout: const Duration(seconds: 5), + ); + + await tester.enterText(flavorField, ' 清淡内测 '); + await tester.enterText(dietaryField, ' 不吃辣 '); + await tester.enterText(toolsField, ' 电饭煲 '); + await tester.enterText(minutesField, '25'); + await tester.enterText(servingsField, '3'); + final saveButton = find.widgetWithText(OutlinedButton, '保存食谱偏好'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + final loaded = await recipePreferencesStore.load(); + final savedFlavorField = tester.widget(flavorField); + final savedDietaryField = tester.widget(dietaryField); + final savedToolsField = tester.widget(toolsField); + final savedMinutesField = tester.widget(minutesField); + final savedServingsField = tester.widget(servingsField); + + expect(find.text('食谱偏好已保存'), findsOneWidget); + expect(loaded.flavorProfile, '清淡内测'); + expect(loaded.dietaryRestrictions, '不吃辣'); + expect(loaded.tools, '电饭煲'); + expect(loaded.cookMinutes, 25); + expect(loaded.servings, 3); + expect(savedFlavorField.controller?.text, '清淡内测'); + expect(savedDietaryField.controller?.text, '不吃辣'); + expect(savedToolsField.controller?.text, '电饭煲'); + expect(savedMinutesField.controller?.text, '25'); + expect(savedServingsField.controller?.text, '3'); + }); + + testWidgets('settings confirms before resetting demo data', (tester) async { + tester.view.physicalSize = const Size(430, 1300); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = _FakeResetDemoController( + InventoryRepository(appDatabase), + clearedRows: 9, + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: VlmSettingsStore( + secretStore: _MemorySecretStore(), + ), + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final resetButton = find.widgetWithText(OutlinedButton, '重置示例数据'); + expect(resetButton, findsOneWidget); + + await tester.tap(resetButton); + await tester.pumpAndSettle(); + + expect(find.text('重置示例数据'), findsNWidgets(2)); + expect(find.text('仅清理并重建内置示例资料/库存,不会删除用户自己创建的数据。'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, '取消')); + await tester.pumpAndSettle(); + + expect(controller.resetDemoCalls, 0); + expect(find.text('示例数据已重置,清理 9 条旧示例数据'), findsNothing); + + await tester.tap(resetButton); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '重置')); + await tester.pumpAndSettle(); + + expect(controller.resetDemoCalls, 1); + expect(find.text('示例数据已重置,清理 9 条旧示例数据'), findsOneWidget); + }); + + testWidgets('settings keeps stored order recognition key hidden', + (tester) async { + tester.view.physicalSize = const Size(430, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final secretStore = _MemorySecretStore(); + final vlmSettingsStore = VlmSettingsStore(secretStore: secretStore); + await vlmSettingsStore.save( + const VlmSettings( + endpoint: 'https://local.test/v1/chat/completions', + model: 'local-vlm', + apiKey: 'stored-secret-key', + ), + ); + + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: vlmSettingsStore, + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + + final apiKeyFieldFinder = find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.labelText == 'API 密钥', + description: 'API key text field', + ); + await _pumpUntilFound( + tester, + apiKeyFieldFinder, + timeout: const Duration(seconds: 5), + ); + await tester.pump(); + + final apiKeyField = tester.widget(apiKeyFieldFinder); + expect(apiKeyField.controller?.text, isEmpty); + + expect(find.text('订单识别 AI'), findsOneWidget); + expect(find.text('已配置'), findsOneWidget); + expect(find.text('本机安全保存'), findsOneWidget); + expect(find.text('已安全保存,留空保持不变'), findsOneWidget); + expect(find.text('已保存的密钥不会明文显示'), findsOneWidget); + expect(find.text('stored-secret-key'), findsNothing); + }); + + testWidgets('settings clears order recognition config after confirmation', + (tester) async { + tester.view.physicalSize = const Size(430, 2800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final secretStore = _MemorySecretStore(); + final vlmSettingsStore = VlmSettingsStore(secretStore: secretStore); + await vlmSettingsStore.save( + const VlmSettings( + endpoint: 'https://local.test/v1/chat/completions', + model: 'local-vlm', + apiKey: 'stored-secret-key', + ), + ); + + final controller = InventoryController( + InventoryRepository(appDatabase), + notificationService: _FakeNotificationService( + testResult: const LocalNotificationTestResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + sent: true, + ), + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SettingsScreen( + controller: controller, + vlmSettingsStore: vlmSettingsStore, + webDavBackupSettingsStore: WebDavBackupSettingsStore( + secretStore: _MemorySecretStore(), + ), + ), + ), + ), + ); + + final clearButton = find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == '清空配置', + description: 'order recognition clear button', + ); + await _pumpUntilFound( + tester, + clearButton, + timeout: const Duration(seconds: 5), + ); + await tester.ensureVisible(clearButton); + await tester.tap(clearButton); + await tester.pumpAndSettle(); + + expect(find.text('清空订单识别配置'), findsOneWidget); + expect(find.text('服务地址、模型名称和已保存的 API 密钥都会被清空。'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, '清空')); + await tester.pumpAndSettle(); + + final endpointField = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.labelText == '服务地址', + description: 'endpoint text field', + ), + ); + final modelField = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.labelText == '模型名称', + description: 'model text field', + ), + ); + final apiKeyField = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.labelText == 'API 密钥', + description: 'API key text field', + ), + ); + final loaded = await vlmSettingsStore.load(); + + expect(endpointField.controller?.text, isEmpty); + expect(modelField.controller?.text, isEmpty); + expect(apiKeyField.controller?.text, isEmpty); + expect(find.text('未配置'), findsWidgets); + expect(find.text('订单识别配置已清空'), findsOneWidget); + expect(find.text('stored-secret-key'), findsNothing); + expect(loaded.endpoint, isEmpty); + expect(loaded.model, isEmpty); + expect(loaded.apiKey, isEmpty); + expect(loaded.hasStoredApiKey, isFalse); + }); +} + +Future _pumpUntilFound( + WidgetTester tester, + Finder finder, { + required Duration timeout, +}) async { + final stopwatch = Stopwatch()..start(); + while (finder.evaluate().isEmpty && stopwatch.elapsed < timeout) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +Finder _textFieldWithLabel(String label) { + return find.byWidgetPredicate( + (widget) => widget is TextField && widget.decoration?.labelText == label, + description: '$label text field', + ); +} + +class _FakeResetDemoController extends InventoryController { + _FakeResetDemoController( + super.repository, { + required this.clearedRows, + required LocalNotificationService notificationService, + }) : super(notificationService: notificationService); + + final int clearedRows; + int resetDemoCalls = 0; + + @override + Future resetDemoData() async { + resetDemoCalls += 1; + return clearedRows; + } +} + +class _FakeBackupController extends InventoryController { + _FakeBackupController( + super.repository, { + required LocalNotificationService notificationService, + }) : super(notificationService: notificationService); + + int exportBackupCalls = 0; + int markBackupExportedCalls = 0; + int restoreBackupCalls = 0; + Map? restoredBackup; + + @override + Future> exportBackup() async { + exportBackupCalls += 1; + return { + 'metadata': {'format': 'vibe-fridge'}, + 'data': { + 'items': [ + {'name': 'WebDAV 测试牛奶'}, + ], + }, + }; + } + + @override + Future markBackupExported() async { + markBackupExportedCalls += 1; + } + + @override + Future restoreBackup( + Map backup, { + bool replaceExisting = true, + }) async { + restoreBackupCalls += 1; + restoredBackup = backup; + return const BackupRestoreResult( + restoredRows: 1, + preRestoreSnapshotId: 'snapshot-before-webdav-restore', + ); + } +} + +class _FakeWebDavBackupService extends WebDavBackupService { + _FakeWebDavBackupService({ + WebDavBackupDownloadResult? downloadResult, + List validateErrors = const [], + }) : downloadResult = downloadResult ?? + WebDavBackupDownloadResult( + fileName: 'vibe-fridge-backup-20260619-120000.json', + uri: Uri.parse( + 'https://dav.example.com/dav/fridge/backups/vibe-fridge-backup-20260619-120000.json', + ), + backupJson: '{"metadata":{"format":"vibe-fridge"},"data":{}}', + ), + validateErrors = List.of(validateErrors); + + final WebDavBackupDownloadResult downloadResult; + final List validateErrors; + int validateCalls = 0; + int uploadCalls = 0; + int downloadCalls = 0; + WebDavBackupSettings? lastUploadSettings; + String? lastBackupJson; + + @override + Future validateConfiguration(WebDavBackupSettings settings) async { + validateCalls += 1; + if (validateErrors.isNotEmpty) { + throw validateErrors.removeAt(0); + } + } + + @override + Future uploadBackup({ + required WebDavBackupSettings settings, + required String fileName, + required String backupJson, + }) async { + uploadCalls += 1; + lastUploadSettings = settings; + lastBackupJson = backupJson; + return WebDavBackupUploadResult( + fileName: fileName, + uri: Uri.parse('https://dav.example.com/dav/$fileName'), + ); + } + + @override + Future downloadLatestBackup( + WebDavBackupSettings settings, + ) async { + downloadCalls += 1; + return downloadResult; + } + + @override + void close() {} +} + +class _FakeNotificationService extends LocalNotificationService { + _FakeNotificationService({ + required this.testResult, + }) : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + final LocalNotificationTestResult testResult; + int testCalls = 0; + + @override + void setOnNotificationTap(void Function(String itemId)? handler) {} + + @override + Future getLaunchItemId() async { + return null; + } + + @override + Future syncInventoryReminders( + InventoryRepository repository, { + DateTime? now, + }) async { + return const LocalNotificationSyncResult( + permission: LocalNotificationPermissionSnapshot( + supported: true, + granted: true, + status: 'granted', + ), + scheduledCount: 0, + ); + } + + @override + Future sendTestNotification() async { + testCalls += 1; + return testResult; + } +} + +class _MemorySecretStore implements VlmSecretStore { + final _values = {}; + + @override + Future read(String key) async { + return _values[key]; + } + + @override + Future write(String key, String value) async { + _values[key] = value; + } + + @override + Future delete(String key) async { + _values.remove(key); + } +} diff --git a/mobile/test/user_facing_copy_test.dart b/mobile/test/user_facing_copy_test.dart new file mode 100644 index 0000000..37e91dd --- /dev/null +++ b/mobile/test/user_facing_copy_test.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('screen and widget copy avoids internal engineering terms', () { + final scannedFiles = [ + ..._dartFilesIn('lib/screens'), + ..._dartFilesIn('lib/widgets'), + ]; + final violations = []; + + for (final file in scannedFiles) { + final source = file.readAsStringSync(); + final literals = _stringLiterals(source); + for (final literal in literals) { + final visibleText = _withoutInterpolations(literal); + for (final bannedTerm in _bannedUserFacingTerms) { + if (visibleText.contains(bannedTerm)) { + violations.add('${file.path}: "$bannedTerm" in "$literal"'); + } + } + } + } + + expect( + violations, + isEmpty, + reason: 'User-facing UI copy should not expose internal terminology.', + ); + }); +} + +const _bannedUserFacingTerms = [ + '应用自检', + '运行自检', + '核心闭环', + '数据库', + 'SQLite', + '迁移状态', + 'legacy', + 'Wiki', + 'ISO-8601', + 'payload', +]; + +List _dartFilesIn(String path) { + return Directory(path) + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); +} + +List _stringLiterals(String source) { + final literals = []; + var index = 0; + while (index < source.length) { + final char = source[index]; + final next = index + 1 < source.length ? source[index + 1] : ''; + + if (char == '/' && next == '/') { + index = source.indexOf('\n', index + 2); + if (index == -1) { + break; + } + continue; + } + if (char == '/' && next == '*') { + final end = source.indexOf('*/', index + 2); + index = end == -1 ? source.length : end + 2; + continue; + } + + var raw = false; + var quoteIndex = index; + if ((char == 'r' || char == 'R') && _isQuote(next)) { + raw = true; + quoteIndex = index + 1; + } + + if (!_isQuote(source[quoteIndex])) { + index += 1; + continue; + } + + final parsed = _readStringLiteral(source, quoteIndex, raw: raw); + if (parsed == null) { + index += 1; + continue; + } + literals.add(parsed.value); + index = parsed.end; + } + return literals; +} + +_ParsedString? _readStringLiteral( + String source, + int quoteIndex, { + required bool raw, +}) { + final quote = source[quoteIndex]; + final triple = quoteIndex + 2 < source.length && + source[quoteIndex + 1] == quote && + source[quoteIndex + 2] == quote; + final buffer = StringBuffer(); + var index = quoteIndex + (triple ? 3 : 1); + + while (index < source.length) { + if (triple) { + if (index + 2 < source.length && + source[index] == quote && + source[index + 1] == quote && + source[index + 2] == quote) { + return _ParsedString(buffer.toString(), index + 3); + } + } else if (source[index] == quote) { + return _ParsedString(buffer.toString(), index + 1); + } + + if (!raw && source[index] == r'\') { + if (index + 1 < source.length) { + buffer.write(source[index + 1]); + index += 2; + continue; + } + } + + buffer.write(source[index]); + index += 1; + } + + return null; +} + +bool _isQuote(String char) => char == "'" || char == '"'; + +String _withoutInterpolations(String value) { + return value + .replaceAll(RegExp(r'\$\{[^{}]*\}'), '') + .replaceAll(RegExp(r'\$[A-Za-z_][A-Za-z0-9_]*'), ''); +} + +class _ParsedString { + const _ParsedString(this.value, this.end); + + final String value; + final int end; +} diff --git a/mobile/test/vlm_settings_store_test.dart b/mobile/test/vlm_settings_store_test.dart index 9af87fb..3b0e966 100644 --- a/mobile/test/vlm_settings_store_test.dart +++ b/mobile/test/vlm_settings_store_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:vibe_fridge/data/vlm_settings_store.dart'; @@ -9,6 +10,17 @@ void main() { secretStore = _MemorySecretStore(); }); + test('fresh settings load default endpoint and model', () async { + SharedPreferences.setMockInitialValues({}); + final store = VlmSettingsStore(secretStore: secretStore); + + final loaded = await store.load(); + + expect(loaded.endpoint, VlmSettingsStore.defaultEndpoint); + expect(loaded.model, VlmSettingsStore.defaultModel); + expect(loaded.hasStoredApiKey, isFalse); + }); + test('migrates legacy plaintext API key into secure storage', () async { SharedPreferences.setMockInitialValues({ 'vlm.endpoint': 'https://example.test/v1/chat/completions', @@ -55,7 +67,8 @@ void main() { expect(loaded.apiKey, 'saved-secret'); }); - test('clear removes shared preferences and secure API key', () async { + test('clear keeps endpoint and model blank and removes secure API key', + () async { SharedPreferences.setMockInitialValues({}); final store = VlmSettingsStore(secretStore: secretStore); await store.save( @@ -70,13 +83,30 @@ void main() { final preferences = await SharedPreferences.getInstance(); final loaded = await store.load(); - expect(preferences.getString('vlm.endpoint'), isNull); - expect(preferences.getString('vlm.model'), isNull); + expect(preferences.getString('vlm.endpoint'), isEmpty); + expect(preferences.getString('vlm.model'), isEmpty); expect(await secretStore.read('vlm.secure_api_key'), isNull); - expect(loaded.endpoint, VlmSettingsStore.defaultEndpoint); - expect(loaded.model, VlmSettingsStore.defaultModel); + expect(loaded.endpoint, isEmpty); + expect(loaded.model, isEmpty); expect(loaded.hasStoredApiKey, isFalse); }); + + test('secure API key storage disables macOS data protection keychain', + () async { + final storage = _CapturingSecureStorage(); + final secretStore = FlutterSecureVlmSecretStore(storage: storage); + + await secretStore.write('vlm.secure_api_key', 'saved-secret'); + await secretStore.read('vlm.secure_api_key'); + await secretStore.delete('vlm.secure_api_key'); + + expect( + storage.macOsOptions, + everyElement( + containsPair('useDataProtectionKeyChain', 'false'), + ), + ); + }); } class _MemorySecretStore implements VlmSecretStore { @@ -97,3 +127,48 @@ class _MemorySecretStore implements VlmSecretStore { _values.remove(key); } } + +class _CapturingSecureStorage extends FlutterSecureStorage { + final macOsOptions = >[]; + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + macOsOptions.add(mOptions?.toMap() ?? const {}); + } + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + macOsOptions.add(mOptions?.toMap() ?? const {}); + return null; + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + macOsOptions.add(mOptions?.toMap() ?? const {}); + } +} diff --git a/mobile/test/web_bootstrap_test.dart b/mobile/test/web_bootstrap_test.dart new file mode 100644 index 0000000..b4a7a3a --- /dev/null +++ b/mobile/test/web_bootstrap_test.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('web bootstrap clears stale service workers without registering one', + () { + final script = File('web/flutter_bootstrap.js').readAsStringSync(); + + expect(script, contains('{{flutter_js}}')); + expect(script, contains('{{flutter_build_config}}')); + expect(script, isNot(contains('serviceWorkerSettings'))); + expect(script, isNot(contains('{{flutter_service_worker_version}}'))); + expect(script, contains('navigator.serviceWorker.getRegistrations()')); + expect(script, contains('registration.unregister()')); + expect(script, contains("cacheName.startsWith('flutter-')")); + expect(script, contains('caches.delete(cacheName)')); + expect(script, contains('window.location.reload()')); + expect(script, contains('_flutter.loader.load();')); + }); + + test('web entry manages bootstrap caching and loading screen', () { + final index = File('web/index.html').readAsStringSync(); + const cacheBustedBootstrapSource = + r'bootstrapScript.src = `flutter_bootstrap.js?v=${Date.now()}`;'; + + expect( + index, + contains( + '', + ), + ); + expect(index, contains('')); + expect(index, contains('')); + expect(index, contains('document.createElement(\'script\')')); + expect(index, contains('bootstrapScript.async = true')); + expect(index, contains(cacheBustedBootstrapSource)); + expect(index, contains('document.body.appendChild(bootstrapScript)')); + expect(index, contains("loadingScreen.classList.add('is-hidden')")); + expect(index, contains("loadingScreen.style.opacity = '0'")); + expect(index, contains("loadingScreen.style.pointerEvents = 'none'")); + expect(index, contains("loadingScreen.style.visibility = 'hidden'")); + expect(index, isNot(contains(' + diff --git a/tools/check_android_environment.mjs b/tools/check_android_environment.mjs new file mode 100644 index 0000000..f29d0af --- /dev/null +++ b/tools/check_android_environment.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const defaultFlutterBin = path.join( + repoRoot, + '.tools', + 'flutter', + 'bin', + 'flutter', +); +const androidLocalProperties = path.join( + repoRoot, + 'mobile', + 'android', + 'local.properties', +); + +try { + await main(); +} catch (error) { + console.error(`Android environment check failed: ${error.message}`); + process.exit(1); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const flutterBin = + options.flutterBin ?? + (existsSync(defaultFlutterBin) ? defaultFlutterBin : 'flutter'); + const env = { + ...process.env, + HOME: options.home ?? process.env.HOME, + }; + + const devices = await run(flutterBin, ['devices'], env); + const emulators = await run(flutterBin, ['emulators'], env); + const doctor = await run(flutterBin, ['doctor', '-v'], env); + const localProperties = readAndroidLocalProperties(); + const report = buildReport({ + devices, + emulators, + doctor, + localProperties, + }); + + console.log(JSON.stringify(report, null, 2)); + if (!report.ready) { + process.exit(1); + } +} + +function parseArgs(args) { + const options = { + flutterBin: undefined, + home: '/private/tmp/vibe-fridge-flutter-home', + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--flutter-bin') { + options.flutterBin = requireValue(arg, args[++index]); + continue; + } + if (arg === '--home') { + options.home = requireValue(arg, args[++index]); + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + return options; +} + +function requireValue(name, value) { + if (!value || value.startsWith('--')) { + throw new Error(`${name} requires a value.`); + } + return value; +} + +function run(command, args, env) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: repoRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.once('error', (error) => { + resolve({ + command: [command, ...args].join(' '), + exitCode: null, + stdout, + stderr: `${stderr}${error.message}`, + }); + }); + child.once('exit', (code, signal) => { + resolve({ + command: [command, ...args].join(' '), + exitCode: code, + signal, + stdout, + stderr, + }); + }); + }); +} + +function buildReport({ + devices, + emulators, + doctor, + localProperties, +}) { + const doctorText = `${doctor.stdout}\n${doctor.stderr}`; + const devicesText = `${devices.stdout}\n${devices.stderr}`; + const emulatorsText = `${emulators.stdout}\n${emulators.stderr}`; + const androidSdkMissing = + doctorText.includes('Unable to locate Android SDK') || + doctorText.includes('No Android SDK found'); + const androidToolchainReady = + doctorText.includes('[✓] Android toolchain') && !androidSdkMissing; + const androidDeviceReady = detectAndroidDevice(devicesText); + const emulatorAvailable = + emulators.exitCode === 0 && + !emulatorsText.includes('Unable to find any emulator sources') && + !emulatorsText.includes('No emulators available'); + const sdkPath = localProperties['sdk.dir'] ?? null; + const ready = androidToolchainReady && (androidDeviceReady || emulatorAvailable); + + return { + ready, + androidToolchainReady, + androidDeviceReady, + emulatorAvailable, + sdkPath, + blockers: blockers({ + androidToolchainReady, + androidDeviceReady, + emulatorAvailable, + androidSdkMissing, + sdkPath, + }), + commands: { + devices: summarizeCommand(devices), + emulators: summarizeCommand(emulators), + doctor: summarizeCommand(doctor), + }, + }; +} + +function detectAndroidDevice(output) { + return output + .split('\n') + .some((line) => /\b(android|android-arm|android-arm64|android-x64)\b/i.test(line)); +} + +function blockers({ + androidToolchainReady, + androidDeviceReady, + emulatorAvailable, + androidSdkMissing, + sdkPath, +}) { + const items = []; + if (!androidToolchainReady) { + items.push( + androidSdkMissing + ? 'Android SDK is not configured for Flutter.' + : 'Flutter Android toolchain is not ready.', + ); + } + if (!androidDeviceReady && !emulatorAvailable) { + items.push('No Android device or emulator is available.'); + } + return items; +} + +function summarizeCommand(result) { + return { + command: result.command, + exitCode: result.exitCode, + signal: result.signal ?? null, + outputTail: `${result.stdout}${result.stderr}`.trim().slice(-2000), + }; +} + +function readAndroidLocalProperties() { + if (!existsSync(androidLocalProperties)) { + return {}; + } + const entries = {}; + const text = readFileSync(androidLocalProperties, 'utf8'); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const separator = trimmed.indexOf('='); + if (separator === -1) { + continue; + } + entries[trimmed.slice(0, separator).trim()] = trimmed + .slice(separator + 1) + .trim(); + } + return entries; +} diff --git a/tools/check_notification_status.mjs b/tools/check_notification_status.mjs new file mode 100644 index 0000000..4ac54f0 --- /dev/null +++ b/tools/check_notification_status.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +try { + await main(); +} catch (error) { + console.error(`Notification status smoke failed: ${error.message}`); + process.exit(1); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const rawVmServiceUrl = options.vmServiceUrl; + + if (!rawVmServiceUrl) { + console.error(usage()); + process.exit(64); + } + + const baseUrl = normalizeBaseUrl(rawVmServiceUrl); + const vm = await getJson(baseUrl, 'getVM'); + const isolateId = vm.result?.isolates?.find( + (isolate) => isolate?.isSystemIsolate !== true, + )?.id; + if (!isolateId) { + throw new Error('No runnable app isolate found in VM Service response.'); + } + + const extensionName = options.sendTest + ? 'ext.vibe_fridge.notificationTest' + : 'ext.vibe_fridge.notificationStatus'; + const status = await getJsonWithRetry( + baseUrl, + `${extensionName}?isolateId=${encodeURIComponent(isolateId)}`, + { + retryMs: options.waitExtensionMs, + shouldRetry: isMethodNotFoundError, + }, + ); + const result = status.result; + const permission = options.sendTest ? result?.permission : result; + validatePermissionPayload(permission); + if (options.sendTest) { + validateTestPayload(result); + } + validateExpectations(result, permission, options); + + console.log( + JSON.stringify( + { + isolateId, + [options.sendTest ? 'notificationTest' : 'notificationStatus']: result, + }, + null, + 2, + ), + ); +} + +function usage() { + return ( + 'Usage: node tools/check_notification_status.mjs [options] ' + + '\n\n' + + 'Options:\n' + + ' --expect-supported true|false\n' + + ' --expect-granted true|false\n' + + ' --expect-status \n' + + ' --wait-extension-ms \n' + + ' --send-test\n' + + ' --expect-sent true|false\n' + + ' --expect-skipped-reason ' + ); +} + +function parseArgs(args) { + const options = { + expectSupported: undefined, + expectGranted: undefined, + expectStatus: undefined, + expectSent: undefined, + expectSkippedReason: undefined, + sendTest: false, + waitExtensionMs: 0, + vmServiceUrl: undefined, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--expect-supported') { + options.expectSupported = parseBooleanOption(arg, args[++index]); + continue; + } + if (arg === '--expect-granted') { + options.expectGranted = parseBooleanOption(arg, args[++index]); + continue; + } + if (arg === '--expect-status') { + options.expectStatus = requireValue(arg, args[++index]); + continue; + } + if (arg === '--wait-extension-ms') { + options.waitExtensionMs = parseNonNegativeInteger(arg, args[++index]); + continue; + } + if (arg === '--send-test') { + options.sendTest = true; + continue; + } + if (arg === '--expect-sent') { + options.expectSent = parseBooleanOption(arg, args[++index]); + continue; + } + if (arg === '--expect-skipped-reason') { + options.expectSkippedReason = requireValue(arg, args[++index]); + continue; + } + if (arg.startsWith('--')) { + throw new Error(`Unknown option: ${arg}`); + } + if (options.vmServiceUrl) { + throw new Error('Only one Flutter VM Service URL can be provided.'); + } + options.vmServiceUrl = arg; + } + if (!options.sendTest) { + if (options.expectSent !== undefined) { + throw new Error('--expect-sent requires --send-test.'); + } + if (options.expectSkippedReason !== undefined) { + throw new Error('--expect-skipped-reason requires --send-test.'); + } + } + return options; +} + +function validatePermissionPayload(result) { + if ( + typeof result?.supported !== 'boolean' || + typeof result?.granted !== 'boolean' || + typeof result?.status !== 'string' || + typeof result?.displayText !== 'string' + ) { + throw new Error('Notification status extension returned invalid payload.'); + } +} + +function validateTestPayload(result) { + if ( + typeof result?.sent !== 'boolean' || + typeof result?.displayText !== 'string' || + (result.skippedReason !== null && + result.skippedReason !== undefined && + typeof result.skippedReason !== 'string') + ) { + throw new Error('Notification test extension returned invalid payload.'); + } +} + +function validateExpectations(result, permission, options) { + const checks = [ + ['supported', permission.supported, options.expectSupported], + ['granted', permission.granted, options.expectGranted], + ['status', permission.status, options.expectStatus], + ['sent', result.sent, options.expectSent], + ['skippedReason', result.skippedReason, options.expectSkippedReason], + ]; + + for (const [field, actual, expected] of checks) { + if (expected === undefined || actual === expected) { + continue; + } + throw new Error( + `Expected notification ${field} to be ${expected}, got ${actual}.`, + ); + } +} + +function parseBooleanOption(name, value) { + const raw = requireValue(name, value); + if (raw === 'true') { + return true; + } + if (raw === 'false') { + return false; + } + throw new Error(`${name} must be true or false.`); +} + +function requireValue(name, value) { + if (!value || value.startsWith('--')) { + throw new Error(`${name} requires a value.`); + } + return value; +} + +function parseNonNegativeInteger(name, value) { + const raw = requireValue(name, value); + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative integer.`); + } + return parsed; +} + +function normalizeBaseUrl(value) { + const url = new URL(value); + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + return url; +} + +async function getJson(baseUrl, methodPath) { + const url = new URL(methodPath, baseUrl); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${url.href} returned HTTP ${response.status}`); + } + const payload = await response.json(); + if (payload.error) { + throw new Error(payload.error.message ?? JSON.stringify(payload.error)); + } + return payload; +} + +async function getJsonWithRetry(baseUrl, methodPath, options) { + const deadline = Date.now() + options.retryMs; + let lastError; + + do { + try { + return await getJson(baseUrl, methodPath); + } catch (error) { + lastError = error; + if (!options.shouldRetry(error) || Date.now() >= deadline) { + throw error; + } + await delay(500); + } + } while (Date.now() <= deadline); + + throw lastError; +} + +function isMethodNotFoundError(error) { + return /Method not found/i.test(error.message); +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/tools/check_secure_storage_smoke.mjs b/tools/check_secure_storage_smoke.mjs new file mode 100644 index 0000000..0873f60 --- /dev/null +++ b/tools/check_secure_storage_smoke.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +try { + await main(); +} catch (error) { + console.error(`Secure storage smoke failed: ${error.message}`); + process.exit(1); +} + +async function main() { + const vmServiceUrl = parseArgs(process.argv.slice(2)); + if (!vmServiceUrl) { + console.error(usage()); + process.exit(64); + } + + const baseUrl = normalizeBaseUrl(vmServiceUrl); + const vm = await getJson(baseUrl, 'getVM'); + const isolateId = vm.result?.isolates?.find( + (isolate) => isolate?.isSystemIsolate !== true, + )?.id; + if (!isolateId) { + throw new Error('No runnable app isolate found in VM Service response.'); + } + + const smoke = await getExtensionJson( + baseUrl, + `ext.vibe_fridge.secureStorageSmoke?isolateId=${encodeURIComponent( + isolateId, + )}`, + ); + const result = smoke.result; + validateSmokePayload(result); + + console.log(JSON.stringify({ isolateId, secureStorageSmoke: result }, null, 2)); +} + +function usage() { + return 'Usage: node tools/check_secure_storage_smoke.mjs '; +} + +function parseArgs(args) { + if (args.length > 1) { + throw new Error('Only one Flutter VM Service URL can be provided.'); + } + if (args[0]?.startsWith('--')) { + throw new Error(`Unknown option: ${args[0]}`); + } + return args[0]; +} + +function validateSmokePayload(result) { + if ( + result?.wrote !== true || + result?.readBackMatches !== true || + result?.deleted !== true || + result?.keyPrefix !== 'debug.secure_storage_smoke' + ) { + throw new Error('Secure storage extension returned invalid payload.'); + } +} + +function normalizeBaseUrl(value) { + const url = new URL(value); + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + return url; +} + +async function getJson(baseUrl, methodPath) { + const url = new URL(methodPath, baseUrl); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${url.href} returned HTTP ${response.status}`); + } + const payload = await response.json(); + if (payload.error) { + throw new Error(payload.error.message ?? JSON.stringify(payload.error)); + } + return payload; +} + +async function getExtensionJson(baseUrl, methodPath) { + const deadline = Date.now() + 30000; + let lastError; + while (Date.now() < deadline) { + try { + return await getJson(baseUrl, methodPath); + } catch (error) { + lastError = error; + if (!String(error.message).includes('Method not found')) { + throw error; + } + await delay(500); + } + } + throw lastError ?? new Error('Timed out waiting for service extension.'); +} + +function delay(milliseconds) { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/tools/run_macos_notification_smoke.mjs b/tools/run_macos_notification_smoke.mjs new file mode 100644 index 0000000..98e48e2 --- /dev/null +++ b/tools/run_macos_notification_smoke.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const defaultFlutterBin = path.join( + repoRoot, + '.tools', + 'flutter', + 'bin', + 'flutter', +); +const checkScript = path.join(repoRoot, 'tools', 'check_notification_status.mjs'); + +try { + await main(); +} catch (error) { + console.error(`macOS notification smoke failed: ${error.message}`); + process.exit(1); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const flutterBin = + options.flutterBin ?? + (existsSync(defaultFlutterBin) ? defaultFlutterBin : 'flutter'); + + const run = spawn(flutterBin, ['run', '-d', 'macos'], { + cwd: path.join(repoRoot, 'mobile'), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const vmServiceUrl = await waitForVmServiceUrl(run, options.timeoutMs); + const checkArgs = [ + checkScript, + '--wait-extension-ms', + String(options.extensionWaitMs), + ...expectationArgs(options), + vmServiceUrl, + ]; + const status = await runNode(checkArgs); + process.stdout.write(status); + } finally { + await stopProcess(run); + } +} + +function parseArgs(args) { + const options = { + expectSupported: undefined, + expectGranted: undefined, + expectStatus: undefined, + expectSent: undefined, + expectSkippedReason: undefined, + sendTest: false, + flutterBin: undefined, + extensionWaitMs: 30000, + timeoutMs: 120000, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--expect-supported') { + options.expectSupported = parseBooleanOption(arg, args[++index]); + continue; + } + if (arg === '--expect-granted') { + options.expectGranted = parseBooleanOption(arg, args[++index]); + continue; + } + if (arg === '--expect-status') { + options.expectStatus = requireValue(arg, args[++index]); + continue; + } + if (arg === '--send-test') { + options.sendTest = true; + continue; + } + if (arg === '--expect-sent') { + options.expectSent = parseBooleanOption(arg, args[++index]); + continue; + } + if (arg === '--expect-skipped-reason') { + options.expectSkippedReason = requireValue(arg, args[++index]); + continue; + } + if (arg === '--flutter-bin') { + options.flutterBin = requireValue(arg, args[++index]); + continue; + } + if (arg === '--wait-extension-ms') { + options.extensionWaitMs = parseNonNegativeInteger(arg, args[++index]); + continue; + } + if (arg === '--timeout-ms') { + options.timeoutMs = parsePositiveInteger(arg, args[++index]); + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + if (!options.sendTest) { + if (options.expectSent !== undefined) { + throw new Error('--expect-sent requires --send-test.'); + } + if (options.expectSkippedReason !== undefined) { + throw new Error('--expect-skipped-reason requires --send-test.'); + } + } + return options; +} + +function expectationArgs(options) { + const args = []; + if (options.sendTest) { + args.push('--send-test'); + } + if (options.expectSupported !== undefined) { + args.push('--expect-supported', String(options.expectSupported)); + } + if (options.expectGranted !== undefined) { + args.push('--expect-granted', String(options.expectGranted)); + } + if (options.expectStatus !== undefined) { + args.push('--expect-status', options.expectStatus); + } + if (options.expectSent !== undefined) { + args.push('--expect-sent', String(options.expectSent)); + } + if (options.expectSkippedReason !== undefined) { + args.push('--expect-skipped-reason', options.expectSkippedReason); + } + return args; +} + +function waitForVmServiceUrl(run, timeoutMs) { + return new Promise((resolve, reject) => { + let settled = false; + let outputTail = ''; + const timer = setTimeout(() => { + fail( + new Error( + `Timed out after ${timeoutMs}ms waiting for Flutter VM Service URL.`, + ), + ); + }, timeoutMs); + + run.once('error', fail); + run.once('exit', (code, signal) => { + if (settled) { + return; + } + fail( + new Error( + `flutter run exited before VM Service was ready ` + + `(code=${code}, signal=${signal}).\n${outputTail}`, + ), + ); + }); + + const handleChunk = (chunk) => { + const text = chunk.toString(); + process.stderr.write(text); + outputTail = `${outputTail}${text}`.slice(-4000); + const match = text.match( + /A Dart VM Service on macOS is available at:\s*(http:\/\/[^\s]+)/, + ); + if (match) { + settle(match[1]); + } + }; + + run.stdout.on('data', handleChunk); + run.stderr.on('data', handleChunk); + + function settle(value) { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(value); + } + + function fail(error) { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + } + }); +} + +function runNode(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: repoRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(stdout); + return; + } + reject(new Error(stderr.trim() || `node exited with ${code}`)); + }); + }); +} + +function stopProcess(child) { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.stdin.write('q'); + const terminateTimer = setTimeout(() => { + child.kill('SIGTERM'); + }, 5000); + const killTimer = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 10000); + child.once('exit', () => { + clearTimeout(terminateTimer); + clearTimeout(killTimer); + resolve(); + }); + }); +} + +function parseBooleanOption(name, value) { + const raw = requireValue(name, value); + if (raw === 'true') { + return true; + } + if (raw === 'false') { + return false; + } + throw new Error(`${name} must be true or false.`); +} + +function parsePositiveInteger(name, value) { + const raw = requireValue(name, value); + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + return parsed; +} + +function parseNonNegativeInteger(name, value) { + const raw = requireValue(name, value); + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative integer.`); + } + return parsed; +} + +function requireValue(name, value) { + if (!value || value.startsWith('--')) { + throw new Error(`${name} requires a value.`); + } + return value; +} diff --git a/tools/run_macos_secure_storage_smoke.mjs b/tools/run_macos_secure_storage_smoke.mjs new file mode 100644 index 0000000..b94587e --- /dev/null +++ b/tools/run_macos_secure_storage_smoke.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const defaultFlutterBin = path.join( + repoRoot, + '.tools', + 'flutter', + 'bin', + 'flutter', +); +const checkScript = path.join(repoRoot, 'tools', 'check_secure_storage_smoke.mjs'); + +try { + await main(); +} catch (error) { + console.error(`macOS secure storage smoke failed: ${error.message}`); + process.exit(1); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const flutterBin = + options.flutterBin ?? + (existsSync(defaultFlutterBin) ? defaultFlutterBin : 'flutter'); + + const run = spawn(flutterBin, ['run', '-d', 'macos'], { + cwd: path.join(repoRoot, 'mobile'), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const vmServiceUrl = await waitForVmServiceUrl(run, options.timeoutMs); + const status = await runNode([checkScript, vmServiceUrl]); + process.stdout.write(status); + } finally { + await stopProcess(run); + } +} + +function parseArgs(args) { + const options = { + flutterBin: undefined, + timeoutMs: 120000, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--flutter-bin') { + options.flutterBin = requireValue(arg, args[++index]); + continue; + } + if (arg === '--timeout-ms') { + options.timeoutMs = parsePositiveInteger(arg, args[++index]); + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + return options; +} + +function waitForVmServiceUrl(run, timeoutMs) { + return new Promise((resolve, reject) => { + let settled = false; + let outputTail = ''; + const timer = setTimeout(() => { + fail( + new Error( + `Timed out after ${timeoutMs}ms waiting for Flutter VM Service URL.`, + ), + ); + }, timeoutMs); + + run.once('error', fail); + run.once('exit', (code, signal) => { + if (settled) { + return; + } + fail( + new Error( + `flutter run exited before VM Service was ready ` + + `(code=${code}, signal=${signal}).\n${outputTail}`, + ), + ); + }); + + const handleChunk = (chunk) => { + const text = chunk.toString(); + process.stderr.write(text); + outputTail = `${outputTail}${text}`.slice(-4000); + const match = text.match( + /A Dart VM Service on macOS is available at:\s*(http:\/\/[^\s]+)/, + ); + if (match) { + settle(match[1]); + } + }; + + run.stdout.on('data', handleChunk); + run.stderr.on('data', handleChunk); + + function settle(value) { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(value); + } + + function fail(error) { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + } + }); +} + +function runNode(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: repoRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(stdout); + return; + } + reject(new Error(stderr.trim() || `node exited with ${code}`)); + }); + }); +} + +function stopProcess(child) { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.stdin.write('q'); + const terminateTimer = setTimeout(() => { + child.kill('SIGTERM'); + }, 5000); + const killTimer = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 10000); + child.once('exit', () => { + clearTimeout(terminateTimer); + clearTimeout(killTimer); + resolve(); + }); + }); +} + +function parsePositiveInteger(name, value) { + const raw = requireValue(name, value); + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + return parsed; +} + +function requireValue(name, value) { + if (!value || value.startsWith('--')) { + throw new Error(`${name} requires a value.`); + } + return value; +}