From ebf562f3a570628c92a5bb966656770935ebee7f Mon Sep 17 00:00:00 2001 From: Aucannot Date: Wed, 17 Jun 2026 23:51:06 +0800 Subject: [PATCH 001/153] Document backup export smoke test --- docs/ux-test-findings-2026-06-17.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 347cd0c..5b04a95 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -100,6 +100,10 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. +- Mobile Web backup export smoke check on port 54365: 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. - 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 @@ -275,6 +279,8 @@ check confirmed it hands off cleanly to the rendered Home screen. edited values still visible. - 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. - Repository backup/restore tests cover pre-restore snapshots, replacement restore, post-restore health checks, and backup reminder clearing. - Repository backup/restore tests now also reject incomplete or damaged backup From 7c1f114a7373840e5123326d065a8e2ec968bc01 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:12:14 +0800 Subject: [PATCH 002/153] Document order recognition settings smoke test --- docs/ux-test-findings-2026-06-17.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 5b04a95..6a0cc5b 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -72,6 +72,14 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 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 @@ -277,6 +285,9 @@ check confirmed it hands off cleanly to the rendered Home screen. mobile Web UI shows the 15/15 result plus readable per-check timings. - Settings recipe preferences save from the mobile Web UI and reload with the edited values still visible. +- 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 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 From cc8eea9b2402e943d6da9618fec0b0c002e672c7 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:16:46 +0800 Subject: [PATCH 003/153] Document demo data reset smoke test --- docs/ux-test-findings-2026-06-17.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 6a0cc5b..40fc9b3 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -80,6 +80,12 @@ check confirmed it hands off cleanly to the rendered Home screen. `未配置` 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 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 @@ -288,6 +294,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 demo-data reset rebuilds the built-in sample data from the mobile + Web UI while preserving user-created inventory. - 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 From bab4beb939dc61591d9f968a677c150c8058479f Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:21:36 +0800 Subject: [PATCH 004/153] Document full manual add smoke test --- docs/ux-test-findings-2026-06-17.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 40fc9b3..f654d3b 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -90,6 +90,14 @@ check confirmed it hands off cleanly to the rendered Home screen. `内测手动橙子` 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 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 @@ -247,6 +255,9 @@ 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. - 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 From 43d820e8081c721630dc7f4b8aab6e86c8b88bcb Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:26:25 +0800 Subject: [PATCH 005/153] Document tagged manual add smoke test --- docs/ux-test-findings-2026-06-17.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index f654d3b..610dea8 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -98,6 +98,11 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 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 @@ -258,6 +263,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. - 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 From 63c87ddcf8e1b402c55f95aeda7eb04245bdf860 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:30:33 +0800 Subject: [PATCH 006/153] Document inventory edit reminder smoke test --- docs/ux-test-findings-2026-06-17.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 610dea8..8b2f2e9 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -146,6 +146,12 @@ 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 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 @@ -269,6 +275,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. - 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 batch mode can update selected inventory storage locations, and From cf2563a4b539596521b59cb8112fc3ed5aec87cb Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:34:27 +0800 Subject: [PATCH 007/153] Document inventory reminder disable smoke test --- docs/ux-test-findings-2026-06-17.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 8b2f2e9..06463c0 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -152,6 +152,12 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 @@ -277,6 +283,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 batch mode can update selected inventory storage locations, and From aa05b185220a69c962864369a2bed0e8e90c8d35 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:47:14 +0800 Subject: [PATCH 008/153] Avoid probing missing legacy import asset --- docs/ux-test-findings-2026-06-17.md | 11 ++++ mobile/lib/data/inventory_controller.dart | 20 ++++--- mobile/test/inventory_repository_test.dart | 67 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 06463c0..320b93b 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -131,6 +131,12 @@ check confirmed it hands off cleanly to the rendered Home screen. `导出备份`, 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. +- 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 @@ -334,6 +340,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 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 also reject incomplete or damaged backup @@ -416,6 +424,9 @@ 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 diff --git a/mobile/lib/data/inventory_controller.dart b/mobile/lib/data/inventory_controller.dart index cd9d654..e5636cb 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; @@ -306,9 +309,7 @@ class InventoryController extends ChangeNotifier { sourceItemId: item.id, quantity: 1, unit: item.unit, - note: item.status == ItemStatus.consumed - ? '上次已消耗' - : '从库存记录加入', + note: item.status == ItemStatus.consumed ? '上次已消耗' : '从库存记录加入', ), ); } @@ -524,12 +525,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) { diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 8e4de76..181b4f1 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'; @@ -786,6 +789,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', { @@ -951,3 +983,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))); + } +} From d46a6b90e7fb2efd754cd1d9174f0fd92eec5c17 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 00:53:02 +0800 Subject: [PATCH 009/153] Document order recognition config smoke --- docs/ux-test-findings-2026-06-17.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 320b93b..8cbe0c7 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -80,6 +80,13 @@ check confirmed it hands off cleanly to the rendered Home screen. `未配置` 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 @@ -334,6 +341,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 `测试配置` 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 inventory-table export can be triggered from the live mobile Web UI, From b03e0152528608b2da478128a3c8a738c251044e Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:02:38 +0800 Subject: [PATCH 010/153] Fix non-ASCII web route cleanup --- docs/ux-test-findings-2026-06-17.md | 15 +++++++++++++++ mobile/lib/utils/web_route_state_html.dart | 17 +++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 8cbe0c7..fe865da 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -57,6 +57,13 @@ check confirmed it hands off cleanly to the rendered Home screen. `规则兜底` 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 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 @@ -260,6 +267,8 @@ 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 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, @@ -330,6 +339,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. - Recipe detail shows consumed inventory, missing ingredients, steps, and the inventory deduction action. - Running a recipe deduction updates priority consumable counts in the live @@ -446,6 +458,9 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 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 diff --git a/mobile/lib/utils/web_route_state_html.dart b/mobile/lib/utils/web_route_state_html.dart index 9727970..e6e5f0e 100644 --- a/mobile/lib/utils/web_route_state_html.dart +++ b/mobile/lib/utils/web_route_state_html.dart @@ -32,22 +32,23 @@ Stream getWebRouteStateChanges() { 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 +85,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; From cc9b43aab9187d3c9d9986b40a0f2a0c947c9d2e Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:08:14 +0800 Subject: [PATCH 011/153] Restore direct recipe web routes --- docs/ux-test-findings-2026-06-17.md | 11 +++++++++++ mobile/lib/screens/app_shell.dart | 19 ++++++++++++++++++- mobile/lib/screens/recipes_screen.dart | 16 ++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index fe865da..d2e2510 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -52,6 +52,11 @@ check confirmed it hands off cleanly to the rendered Home screen. `?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. - 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 @@ -269,6 +274,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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, @@ -342,6 +349,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. - Recipe detail shows consumed inventory, missing ingredients, steps, and the inventory deduction action. - Running a recipe deduction updates priority consumable counts in the live @@ -461,6 +470,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 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 diff --git a/mobile/lib/screens/app_shell.dart b/mobile/lib/screens/app_shell.dart index fa06ede..ffe667b 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 @@ -117,6 +118,8 @@ class _AppShellState extends State { if (index == 1) { _itemsScreenKey.currentState?.applyRequest(_itemsRequestFromUri(uri)); _openItemsDetailForRoute(uri); + } else if (index == 3) { + _openRecipeDetailForRoute(uri); } }); } @@ -202,6 +205,17 @@ class _AppShellState extends State { } } + void _openRecipeDetailForRoute(Uri uri) { + if (uri.pathSegments.length < 2 || uri.pathSegments.first != 'recipes') { + return; + } + final id = uri.pathSegments[1]; + if (id.isEmpty) { + return; + } + _recipesScreenKey.currentState?.openRecipeById(id); + } + String _routeForIndex(int index) { return switch (index) { 1 => '/items', @@ -307,7 +321,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/recipes_screen.dart b/mobile/lib/screens/recipes_screen.dart index 6d9504f..e275a7d 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,18 @@ class _RecipesScreenState extends State { return null; } + void openRecipeById(String id) { + final suggestions = [ + if (_aiSuggestions != null) ..._aiSuggestions!, + ..._service.generate(widget.controller.activeItems), + ]; + final suggestion = _suggestionById(suggestions, id); + if (suggestion == null) { + return; + } + _openRecipe(suggestion); + } + Future _openRecipe(RecipeSuggestion suggestion) async { setState(() { _recentIds From 2e2c361906f6f6ca795a84fef72a68e42eefd757 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:16:07 +0800 Subject: [PATCH 012/153] Fallback stale recipe web routes --- docs/ux-test-findings-2026-06-17.md | 10 ++++++++++ mobile/lib/screens/app_shell.dart | 5 ++++- mobile/lib/screens/recipes_screen.dart | 5 +++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index d2e2510..29ac05c 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -57,6 +57,11 @@ check confirmed it hands off cleanly to the rendered Home screen. `快手蛋奶早餐` 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. +- 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 @@ -351,6 +356,9 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. - Running a recipe deduction updates priority consumable counts in the live @@ -472,6 +480,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 diff --git a/mobile/lib/screens/app_shell.dart b/mobile/lib/screens/app_shell.dart index ffe667b..7cd1eee 100644 --- a/mobile/lib/screens/app_shell.dart +++ b/mobile/lib/screens/app_shell.dart @@ -213,7 +213,10 @@ class _AppShellState extends State { if (id.isEmpty) { return; } - _recipesScreenKey.currentState?.openRecipeById(id); + final opened = _recipesScreenKey.currentState?.openRecipeById(id) ?? false; + if (!opened) { + setWebRouteState('/recipes', replace: true); + } } String _routeForIndex(int index) { diff --git a/mobile/lib/screens/recipes_screen.dart b/mobile/lib/screens/recipes_screen.dart index e275a7d..1a7cbb6 100644 --- a/mobile/lib/screens/recipes_screen.dart +++ b/mobile/lib/screens/recipes_screen.dart @@ -214,16 +214,17 @@ class RecipesScreenState extends State { return null; } - void openRecipeById(String id) { + bool openRecipeById(String id) { final suggestions = [ if (_aiSuggestions != null) ..._aiSuggestions!, ..._service.generate(widget.controller.activeItems), ]; final suggestion = _suggestionById(suggestions, id); if (suggestion == null) { - return; + return false; } _openRecipe(suggestion); + return true; } Future _openRecipe(RecipeSuggestion suggestion) async { From ef20ffe72b7c6a2ae218b279439a1dc4285d22e5 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:26:38 +0800 Subject: [PATCH 013/153] Document recipe favorite smoke --- docs/ux-test-findings-2026-06-17.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 29ac05c..2a28649 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -67,6 +67,12 @@ check confirmed it hands off cleanly to the rendered Home screen. `规则兜底` 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, @@ -361,6 +367,8 @@ check confirmed it hands off cleanly to the rendered Home screen. route in the address bar. - Recipe detail shows consumed inventory, missing ingredients, steps, and the inventory deduction action. +- 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 From b346193eba7f27561046ba6ce07a036ef5668e81 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:33:13 +0800 Subject: [PATCH 014/153] Document item profile delete smoke --- docs/ux-test-findings-2026-06-17.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 2a28649..a4b1680 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -230,6 +230,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 @@ -343,6 +350,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. From 2ab3676733005c49540d0049ca9f986bab8c1f28 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:39:29 +0800 Subject: [PATCH 015/153] Document item profile batch delete smoke --- docs/ux-test-findings-2026-06-17.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index a4b1680..568012b 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -219,6 +219,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 @@ -340,6 +347,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. - Marking an inventory batch consumed removes it from active priority handling From c0e976b62cc7be3a7f9aceba15618d4256fd3f60 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:44:22 +0800 Subject: [PATCH 016/153] Document catalog shopping smoke --- docs/ux-test-findings-2026-06-17.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 568012b..1fe2576 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -85,6 +85,12 @@ check confirmed it hands off cleanly to the rendered Home screen. purchased, confirmed `采购项入库`, saw `已入库 1 项`, and verified the catalog count for `感冒药` increased from 1 to 2 with no browser warning or error logs. +- 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 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, @@ -372,6 +378,9 @@ check confirmed it hands off cleanly to the rendered Home screen. replenishment suggestion reappear. - Purchased shopping items can be converted into inventory after confirmation, and the catalog count updates in the live mobile Web UI. +- 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. - 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. From 5d7a292fbe0d1f5b8d1cd3d67f50a2fa36c0acad Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:49:54 +0800 Subject: [PATCH 017/153] Document focused inventory shopping smoke --- docs/ux-test-findings-2026-06-17.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 1fe2576..a2904bb 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -91,6 +91,13 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. - 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, @@ -381,6 +388,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. From 1a847534788d6b52ee2a4a3d9d8b0dcee77b491e Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 01:57:53 +0800 Subject: [PATCH 018/153] Document order review edit smoke --- docs/ux-test-findings-2026-06-17.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index a2904bb..bbdcb24 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -155,6 +155,14 @@ 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 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 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 @@ -455,6 +463,9 @@ 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. ## Fixes Made During This Pass From 022da4fcbc8079dac688dce5ae571968063e0bbd Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:11:06 +0800 Subject: [PATCH 019/153] Show item profile notes --- docs/ux-test-findings-2026-06-17.md | 13 +++++++++++++ mobile/lib/screens/item_wiki_detail_screen.dart | 2 ++ 2 files changed, 15 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index bbdcb24..a685d64 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -221,6 +221,14 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 @@ -362,6 +370,9 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 @@ -553,6 +564,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. diff --git a/mobile/lib/screens/item_wiki_detail_screen.dart b/mobile/lib/screens/item_wiki_detail_screen.dart index ea06f71..6b0d5f9 100644 --- a/mobile/lib/screens/item_wiki_detail_screen.dart +++ b/mobile/lib/screens/item_wiki_detail_screen.dart @@ -636,6 +636,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}'), ], ), From b510a35b3155ed219d75071c2a887538baa89b33 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:26:09 +0800 Subject: [PATCH 020/153] Preserve shopping notes on conversion --- docs/ux-test-findings-2026-06-17.md | 23 +++++++++++++++++++++- mobile/lib/data/inventory_repository.dart | 13 +++++++++--- mobile/test/inventory_repository_test.dart | 6 ++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index a685d64..c38eedf 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -85,6 +85,16 @@ check confirmed it hands off cleanly to the rendered Home screen. purchased, confirmed `采购项入库`, saw `已入库 1 项`, and verified the catalog count for `感冒药` increased from 1 to 2 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 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, @@ -403,7 +413,9 @@ 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. - 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. @@ -477,6 +489,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - Order-text review supports editing recognized quantities, filling missing units, excluding selected rows such as gifts, and confirming low-confidence items before batch import. +- 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 @@ -512,6 +527,9 @@ 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. - 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 @@ -584,3 +602,6 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. +- Shopping items left with the blank category label show as `未分类` in the + shopping list but become `其他` after conversion into inventory, which is a + minor cross-surface copy inconsistency still worth smoothing later. diff --git a/mobile/lib/data/inventory_repository.dart b/mobile/lib/data/inventory_repository.dart index e793a98..5993599 100644 --- a/mobile/lib/data/inventory_repository.dart +++ b/mobile/lib/data/inventory_repository.dart @@ -537,6 +537,7 @@ class InventoryRepository { required String name, String? categoryId, String? description, + bool syncDescriptionToWiki = true, int quantity = 1, String? unit, DateTime? purchaseDate, @@ -584,7 +585,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 +600,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 +814,8 @@ class InventoryRepository { await createItem( name: item.name, categoryId: item.categoryId, + description: item.note, + syncDescriptionToWiki: false, quantity: item.quantity, unit: item.unit, purchaseDate: boughtAt, diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 181b4f1..daff91a 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -295,6 +295,12 @@ 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 { From ffbbd76cb36ce44082420308564d8180d79b5f02 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:33:51 +0800 Subject: [PATCH 021/153] Unify uncategorized item copy --- docs/ux-test-findings-2026-06-17.md | 15 ++++++++++++--- mobile/lib/screens/item_detail_screen.dart | 2 +- mobile/lib/screens/item_wiki_detail_screen.dart | 2 +- mobile/lib/screens/items_screen.dart | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index c38eedf..7ad48ad 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -95,6 +95,13 @@ check confirmed it hands off cleanly to the rendered Home screen. 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, @@ -416,6 +423,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. +- 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. @@ -530,6 +539,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 @@ -602,6 +614,3 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. -- Shopping items left with the blank category label show as `未分类` in the - shopping list but become `其他` after conversion into inventory, which is a - minor cross-surface copy inconsistency still worth smoothing later. diff --git a/mobile/lib/screens/item_detail_screen.dart b/mobile/lib/screens/item_detail_screen.dart index 7067e23..5b67ee0 100644 --- a/mobile/lib/screens/item_detail_screen.dart +++ b/mobile/lib/screens/item_detail_screen.dart @@ -818,7 +818,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)), diff --git a/mobile/lib/screens/item_wiki_detail_screen.dart b/mobile/lib/screens/item_wiki_detail_screen.dart index 6b0d5f9..8a596ab 100644 --- a/mobile/lib/screens/item_wiki_detail_screen.dart +++ b/mobile/lib/screens/item_wiki_detail_screen.dart @@ -623,7 +623,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: '建议保质期', diff --git a/mobile/lib/screens/items_screen.dart b/mobile/lib/screens/items_screen.dart index 8cbc548..d9092d6 100644 --- a/mobile/lib/screens/items_screen.dart +++ b/mobile/lib/screens/items_screen.dart @@ -1422,7 +1422,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(' · '), From b556797e05a25d4bd6664374a2937d3f513ba386 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:44:02 +0800 Subject: [PATCH 022/153] Skip standalone order references --- docs/ux-test-findings-2026-06-17.md | 19 +++++++++++++++++++ mobile/lib/data/order_text_import_parser.dart | 19 ++++++++++++++++--- mobile/test/order_recognition_test.dart | 15 +++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 7ad48ad..7ccc613 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -180,6 +180,15 @@ check confirmed it hands off cleanly to the rendered Home screen. `标记已确认` 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 `建议导出备份` with + `累计 10 行本地变更尚未导出`; 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 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 @@ -463,6 +472,9 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 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 @@ -498,6 +510,10 @@ check confirmed it hands off cleanly to the rendered Home screen. - Order-text review supports editing recognized quantities, filling missing units, excluding selected rows such as gifts, and confirming low-confidence items before batch import. +- 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. @@ -526,6 +542,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. 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/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( From 0425903916975eb751fdd4fe7e40a68e389c33d8 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:49:10 +0800 Subject: [PATCH 023/153] Document backup reminder export smoke --- docs/ux-test-findings-2026-06-17.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 7ccc613..171c783 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -189,6 +189,12 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 `累计 10 行本地变更尚未导出`, 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 @@ -475,6 +481,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 From 230946f351cc031cb8e5ac259d91c5dfb1978618 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:55:19 +0800 Subject: [PATCH 024/153] Document manual add validation smoke --- docs/ux-test-findings-2026-06-17.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 171c783..57d49f2 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -163,6 +163,13 @@ check confirmed it hands off cleanly to the rendered Home screen. `袋`, 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 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 @@ -392,6 +399,9 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 From ea47ec8c9fad7697a5da428f6599505ec34b1390 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 03:14:28 +0800 Subject: [PATCH 025/153] Detect duplicate orders without dates --- docs/ux-test-findings-2026-06-17.md | 15 ++++++++-- mobile/lib/data/inventory_controller.dart | 8 ++--- mobile/lib/data/inventory_repository.dart | 22 ++++++++------ mobile/test/inventory_repository_test.dart | 35 ++++++++++++++++++++++ 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 57d49f2..b22c516 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -27,14 +27,17 @@ check confirmed it hands off cleanly to the rendered Home screen. restoration, Web route cleanup, notification channel coverage, startup error copy coverage, plus AI recipe and order-recognition error-copy coverage, 48 tests. +- `flutter test test/inventory_repository_test.dart`: passed after the + no-date order duplicate fix, 18 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. + the edit-page Material fix, and no-date order duplicate handling. - `flutter test test/local_notification_service_test.dart`: passed after the Android reminder scheduler refactor, 6 tests. - `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. - 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. @@ -179,6 +182,14 @@ 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 需要确认`; diff --git a/mobile/lib/data/inventory_controller.dart b/mobile/lib/data/inventory_controller.dart index e5636cb..dabfcb6 100644 --- a/mobile/lib/data/inventory_controller.dart +++ b/mobile/lib/data/inventory_controller.dart @@ -148,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( @@ -184,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, @@ -564,7 +562,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 5993599..7eac6ef 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; } diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index daff91a..e1ed297 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -570,6 +570,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 { From 506c365b4cff6d2755228603b1a53e46944b2852 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 03:24:20 +0800 Subject: [PATCH 026/153] Document reminder action smoke --- docs/ux-test-findings-2026-06-17.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index b22c516..8cf98bf 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -50,6 +50,14 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 @@ -397,6 +405,9 @@ 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. - 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 From 5a44d76efb3e4e2cc03811f1978c6a917535abb4 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 03:54:33 +0800 Subject: [PATCH 027/153] Document browser history navigation risk --- docs/ux-test-findings-2026-06-17.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 8cf98bf..5dbb292 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -250,6 +250,14 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 ports 54404-54408: loaded + `?route=items&q=牛奶`, opened the `鲜牛奶` item-profile detail, and used the + browser Back/Forward controls. Back correctly returned to the searched + catalog state with `q=牛奶`, but Forward stayed on the searched catalog + instead of reopening `?route=items%2Fwiki%2Fwiki-milk`. Several route-restore + experiments did not produce a verified fix, so the trial code was discarded + and the issue is kept as a remaining Web navigation risk. 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 `牙膏`, @@ -447,6 +455,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 @@ -674,3 +684,7 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. +- Web browser Forward after returning from a detail page can lose the detail + entry. Reproduced from `?route=items&q=牛奶` -> `鲜牛奶` detail -> browser + Back -> browser Forward; the Forward action remained on the searched catalog + instead of reopening the detail route. From b7d95e2cbd49643dd098a9e081a5715268c39cb4 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:05:55 +0800 Subject: [PATCH 028/153] Delay web loading handoff for fonts --- docs/ux-test-findings-2026-06-17.md | 21 ++++++++++++++++++--- mobile/web/index.html | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 5dbb292..b18c5a4 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -37,7 +37,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - `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, the edit-page Material fix, and no-date order - duplicate handling. + duplicate handling; passed again after extending the native Web loading + screen delay to cover desktop CanvasKit font settling. - 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. @@ -46,6 +47,15 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. +- 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 @@ -412,6 +422,9 @@ 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 @@ -650,8 +663,10 @@ check confirmed it hands off cleanly to the rendered Home screen. `/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. - 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 diff --git a/mobile/web/index.html b/mobile/web/index.html index 2f4b4d7..66e3ac8 100644 --- a/mobile/web/index.html +++ b/mobile/web/index.html @@ -168,7 +168,7 @@ const hideLoadingScreen = () => loadingScreen?.classList.add('is-hidden'); window.addEventListener('flutter-first-frame', () => { - window.setTimeout(hideLoadingScreen, 240); + window.setTimeout(hideLoadingScreen, 4200); }); window.setTimeout(hideLoadingScreen, 8000); From ecfd92b47d19772236c1511b446ff39b80d87f76 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:20:56 +0800 Subject: [PATCH 029/153] Document browser history retest --- docs/ux-test-findings-2026-06-17.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index b18c5a4..a63d8c3 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -260,14 +260,17 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 ports 54404-54408: loaded +- Mobile Web browser-history smoke check on ports 54404-54408, then repeated + on a rebuilt desktop Web preview at port 54370: loaded `?route=items&q=牛奶`, opened the `鲜牛奶` item-profile detail, and used the browser Back/Forward controls. Back correctly returned to the searched - catalog state with `q=牛奶`, but Forward stayed on the searched catalog + catalog state with `q=牛奶`, but Forward stayed on the catalog and most + recently rewrote the address to `?route=items` instead of reopening `?route=items%2Fwiki%2Fwiki-milk`. Several route-restore - experiments did not produce a verified fix, so the trial code was discarded - and the issue is kept as a remaining Web navigation risk. No browser warning - or error logs appeared. + experiments, including a Navigator stack observer and anonymous route-driven + detail pushes, did not produce a verified fix, so the trial code was + discarded and the issue is kept as a remaining Web navigation risk. 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 `牙膏`, @@ -702,4 +705,4 @@ check confirmed it hands off cleanly to the rendered Home screen. - Web browser Forward after returning from a detail page can lose the detail entry. Reproduced from `?route=items&q=牛奶` -> `鲜牛奶` detail -> browser Back -> browser Forward; the Forward action remained on the searched catalog - instead of reopening the detail route. + and rewrote the URL to `?route=items` instead of reopening the detail route. From a267435ed7d1a8ece3d4894fc6121ab3ab01eb31 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:33:58 +0800 Subject: [PATCH 030/153] Hide internal import trace fields --- docs/ux-test-findings-2026-06-17.md | 9 +++- mobile/lib/screens/item_detail_screen.dart | 12 ++--- mobile/lib/utils/import_trace_display.dart | 21 +++++++++ mobile/test/import_trace_display_test.dart | 53 ++++++++++++++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 mobile/lib/utils/import_trace_display.dart create mode 100644 mobile/test/import_trace_display_test.dart diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index a63d8c3..05658e1 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -377,9 +377,13 @@ 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 +- 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. +- 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. - `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`. @@ -694,6 +698,9 @@ 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. ## Remaining Risks diff --git a/mobile/lib/screens/item_detail_screen.dart b/mobile/lib/screens/item_detail_screen.dart index 5b67ee0..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 { @@ -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/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/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, + ); +} From fc45eb045cf009a4215ef505c09b814b426ea535 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:38:11 +0800 Subject: [PATCH 031/153] Reword settings import copy --- docs/ux-test-findings-2026-06-17.md | 5 +++++ mobile/lib/screens/settings_screen.dart | 14 +++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 05658e1..166e875 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -384,6 +384,9 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. - `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`. @@ -701,6 +704,8 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. +- Reworded Settings restore/import feedback from snapshot, health-check, and + log terminology to backup, check, detail, and record wording. ## Remaining Risks diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 76db451..b14e729 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -876,7 +876,7 @@ class _SettingsScreenState extends State { builder: (context) => AlertDialog( title: const Text('恢复备份'), content: const Text( - '会先创建恢复前快照,然后用备份替换当前库存数据。', + '会先自动保留一份恢复前备份,然后用所选备份替换当前库存数据。', ), actions: [ TextButton( @@ -1010,7 +1010,7 @@ class _SettingsScreenState extends State { ? '没有可导入的数据' : '导入完成:${result.items} 条库存,' '${result.wikis} 个物品资料,${result.tags} 个标签,' - '健康检查${result.healthPassed ? '通过' : '未通过'}', + '资料检查${result.healthPassed ? '通过' : '未通过'}', ), ), ); @@ -1438,7 +1438,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 +1499,7 @@ class _LegacyImportResultSummary extends StatelessWidget { ), ), StatusPill( - label: result.healthPassed ? '健康' : '需检查', + label: result.healthPassed ? '正常' : '需检查', color: result.healthPassed ? AppColors.success : AppColors.error, backgroundColor: result.healthPassed @@ -1540,7 +1540,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 +1557,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 +1580,7 @@ class _LegacyImportLogDialog extends StatelessWidget { const SizedBox(height: 10), if (result.logs.isEmpty) Text( - '没有详细日志。', + '没有详细记录。', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppColors.textSecondary, ), From 57c593d2e40872c900ad4faca8997f4ae7dcba04 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:42:29 +0800 Subject: [PATCH 032/153] Reword settings self check copy --- docs/ux-test-findings-2026-06-17.md | 9 +++++++-- mobile/lib/data/acceptance_test_service.dart | 20 ++++++++++---------- mobile/lib/screens/settings_screen.dart | 10 +++++----- mobile/test/inventory_repository_test.dart | 2 +- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 166e875..cac2fef 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 run the built-in app self-check. ## Summary @@ -137,7 +137,7 @@ check confirmed it hands off cleanly to the rendered Home screen. `待采购 0` while `面包` returned to replenishment suggestions, 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 + 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. @@ -387,6 +387,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 built-in check still used + acceptance-style wording. The visible card, action, toast, and check-data + labels now use `应用自检` language. - `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`. @@ -706,6 +709,8 @@ check confirmed it hands off cleanly to the rendered Home screen. displayed as user-facing details. - 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. ## Remaining Risks diff --git a/mobile/lib/data/acceptance_test_service.dart b/mobile/lib/data/acceptance_test_service.dart index 35a4461..e8c8577 100644 --- a/mobile/lib/data/acceptance_test_service.dart +++ b/mobile/lib/data/acceptance_test_service.dart @@ -16,8 +16,8 @@ class AcceptanceTestService { 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; @@ -65,7 +65,7 @@ class AcceptanceTestService { await repository.createItem( name: testName, categoryId: categories.isEmpty ? null : categories.first.id, - description: 'app 自验收临时数据', + description: '应用自检临时数据', quantity: 2, unit: '份', purchaseDate: startedAt, @@ -130,12 +130,12 @@ class AcceptanceTestService { 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('同日同类型提醒没有正确去重'); @@ -199,8 +199,8 @@ class AcceptanceTestService { categoryId: categories.isEmpty ? null : categories.first.id, quantity: 1, unit: '份', - note: '自验收补货', - source: 'acceptance', + note: '应用自检补货', + source: '应用自检', ), ); @@ -290,7 +290,7 @@ class AcceptanceTestService { } }); - await check('清理验收测试数据', () async { + await check('清理自检临时数据', () async { await _cleanupTemporaryData(testNamePrefix); final remaining = await repository.getRegisteredItems( keyword: testNamePrefix, @@ -300,7 +300,7 @@ class AcceptanceTestService { } }); - await check('数据健康检查通过', () async { + await check('资料一致性检查通过', () async { final health = await repository.checkDataHealth(); if (!health.passed) { throw StateError(health.summary); @@ -329,7 +329,7 @@ class AcceptanceTestService { .where( (item) => item.status == ItemStatus.active && - item.name.startsWith('自验收测试物品-'), + item.name.startsWith('应用自检测试物品-'), ) .toList(); if (activeItems.isEmpty) { diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index b14e729..1fd40d4 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -360,7 +360,7 @@ class _SettingsScreenState extends State { children: [ Expanded( child: Text( - '自验收测试', + '应用自检', style: Theme.of(context) .textTheme .titleMedium @@ -423,7 +423,7 @@ class _SettingsScreenState extends State { ) : const Icon(Icons.play_arrow_outlined), label: Text( - _runningAcceptance ? '运行中' : '运行自验收', + _runningAcceptance ? '运行中' : '运行自检', ), ), ), @@ -1092,8 +1092,8 @@ class _SettingsScreenState extends State { SnackBar( content: Text( report.passed - ? '自验收通过:${report.passedCount}/${report.checks.length}' - : '自验收失败:${report.passedCount}/${report.checks.length}', + ? '应用自检通过:${report.passedCount}/${report.checks.length}' + : '应用自检失败:${report.passedCount}/${report.checks.length}', ), ), ); @@ -1103,7 +1103,7 @@ class _SettingsScreenState extends State { } showAppErrorSnackBar( context, - message: '自验收无法运行', + message: '应用自检无法运行', error: error, stackTrace: stackTrace, ); diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index e1ed297..057e37a 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -310,7 +310,7 @@ void main() { expect(report.passed, isTrue); expect(report.checks, hasLength(15)); expect( - (await repository.getRegisteredItems(keyword: '自验收测试物品-')), + (await repository.getRegisteredItems(keyword: '应用自检测试物品-')), isEmpty, ); }); From da584883b4d3536a870b7c4ac8eb5daeae2430ef Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:46:10 +0800 Subject: [PATCH 033/153] Clean self check failure messages --- docs/ux-test-findings-2026-06-17.md | 5 +++ mobile/lib/data/acceptance_test_service.dart | 42 +++++++++++++++----- mobile/test/inventory_repository_test.dart | 15 +++++++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index cac2fef..5ff0857 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -390,6 +390,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - A follow-up Settings copy pass found the built-in check still used acceptance-style wording. The visible card, action, toast, and check-data labels now use `应用自检` language. +- Inspecting the app self-check failure path found failed check details would + display raw Dart prefixes such as `Bad state:` before the useful message. + Self-check failures now strip those technical prefixes before rendering. - `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`. @@ -711,6 +714,8 @@ check confirmed it hands off cleanly to the rendered Home screen. log terminology to backup, check, detail, and record wording. - Reworded Settings built-in check copy from acceptance wording to app self-check wording. +- Cleaned app self-check failure details so users see the actionable reason + without raw exception prefixes. ## Remaining Risks diff --git a/mobile/lib/data/acceptance_test_service.dart b/mobile/lib/data/acceptance_test_service.dart index e8c8577..ddfaf7d 100644 --- a/mobile/lib/data/acceptance_test_service.dart +++ b/mobile/lib/data/acceptance_test_service.dart @@ -40,7 +40,7 @@ class AcceptanceTestService { AcceptanceCheckResult.failed( name: name, duration: stopwatch.elapsed, - message: error.toString(), + message: selfCheckFailureMessage(error), ), ); } @@ -87,7 +87,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 +106,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,7 +126,7 @@ 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', @@ -227,7 +227,7 @@ class AcceptanceTestService { }); 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 +237,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 +256,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 +273,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) { @@ -333,7 +333,7 @@ class AcceptanceTestService { ) .toList(); if (activeItems.isEmpty) { - throw StateError('没有找到 active 测试库存记录'); + throw StateError('没有找到使用中的自检库存记录'); } return activeItems.first; } @@ -378,6 +378,26 @@ class AcceptanceTestService { } } +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 { const AcceptanceReport({ required this.startedAt, diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 057e37a..b1224ae 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -315,6 +315,21 @@ void main() { ); }); + 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 { final categories = await repository.getCategories(); final today = DateTime.now(); From e68271debacaec98df42538594cae6c4d2476dad Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 04:53:57 +0800 Subject: [PATCH 034/153] Clarify error detail copy action --- docs/ux-test-findings-2026-06-17.md | 10 +++++-- mobile/lib/widgets/app_cards.dart | 2 +- mobile/test/app_error_snackbar_test.dart | 37 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 mobile/test/app_error_snackbar_test.dart diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 5ff0857..b45f2bd 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -25,8 +25,11 @@ check confirmed it hands off cleanly to the rendered Home screen. - `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. + copy coverage, AI recipe and order-recognition error-copy coverage, plus the + generic error snackbar detail-copy coverage, 54 tests. +- `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/inventory_repository_test.dart`: passed after the no-date order duplicate fix, 18 tests. - `flutter analyze`: passed after the latest beta fixes including Web route @@ -716,6 +719,9 @@ check confirmed it hands off cleanly to the rendered Home screen. app self-check wording. - Cleaned app self-check failure details so users see the actionable reason without raw exception prefixes. +- Clarified the generic error snackbar action from `复制` to `复制详情`, keeping + technical diagnostics out of the visible message while making the hidden copy + action understandable. ## Remaining Risks 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/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); + }); +} From bd6e624624796f635e24929f78a0e942fe5079d8 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 05:03:31 +0800 Subject: [PATCH 035/153] Document settings self check retest --- docs/ux-test-findings-2026-06-17.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index b45f2bd..fcd6424 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -41,7 +41,8 @@ check confirmed it hands off cleanly to the rendered Home screen. fixes including Web route cleanup, direct Web detail URL hash cleanup, 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. + screen delay to cover desktop CanvasKit font settling, and again before the + latest Settings smoke check. - 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. @@ -144,6 +145,12 @@ check confirmed it hands off cleanly to the rendered Home screen. the detailed check list rendered readable rows for inventory, reminders, recipes, shopping, batch edits, and history, with no browser warning or error logs. +- Desktop Web Settings self-check smoke check on fresh port 54372 after a + rebuild: opened Settings, verified the 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 + service-worker or browser cache can keep stale Web assets during validation. - 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 `食谱偏好已保存`, @@ -734,3 +741,8 @@ check confirmed it hands off cleanly to the rendered Home screen. entry. Reproduced from `?route=items&q=牛奶` -> `鲜牛奶` detail -> browser Back -> browser Forward; the Forward action remained on the searched catalog and rewrote the URL to `?route=items` instead of reopening the detail route. +- Web app updates can be masked by same-origin cache or 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. Release + validation should use a fresh origin or cache clear until the Web update + experience is designed explicitly. From 8dcaadbbabb5b5bf3e61235c657ec1e5a9569b3a Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 05:10:51 +0800 Subject: [PATCH 036/153] Reword backup reminder copy --- docs/ux-test-findings-2026-06-17.md | 20 ++++++++++++-------- mobile/lib/data/inventory_repository.dart | 6 ++++-- mobile/lib/screens/settings_screen.dart | 2 +- mobile/test/inventory_repository_test.dart | 3 ++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index fcd6424..1ee866a 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -31,7 +31,7 @@ check confirmed it hands off cleanly to the rendered Home screen. the generic error snackbar copy action and covering that technical details stay hidden from the visible message. - `flutter test test/inventory_repository_test.dart`: passed after the - no-date order duplicate fix, 18 tests. + no-date order duplicate fix and backup-reminder copy cleanup, 19 tests. - `flutter analyze`: passed after the latest beta fixes including Web route cleanup, direct Web detail URL hash cleanup, notification channel coverage, the edit-page Material fix, and no-date order duplicate handling. @@ -229,16 +229,17 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 `建议导出备份` with - `累计 10 行本地变更尚未导出`; 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. + 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 `累计 10 行本地变更尚未导出`, tapped the card's `导出` action, saw + 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 @@ -729,6 +730,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. ## Remaining Risks diff --git a/mobile/lib/data/inventory_repository.dart b/mobile/lib/data/inventory_repository.dart index 7eac6ef..ae1967b 100644 --- a/mobile/lib/data/inventory_repository.dart +++ b/mobile/lib/data/inventory_repository.dart @@ -2927,8 +2927,10 @@ class BackupReminderState { if (!isPending) { return '当前没有待处理的备份提醒'; } - final prefix = reason == null || reason!.isEmpty ? '数据已变更' : reason!; - return '$prefix 后建议导出一份备份'; + if (reason == null || reason!.isEmpty) { + return '库存资料有更新,建议备份一次'; + } + return '因为$reason,建议备份一次'; } } diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 1fd40d4..233ab55 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1336,7 +1336,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, ), diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index b1224ae..500ed1d 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -710,12 +710,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); }); From 4c25f18a737a21e9d98a68fa527450be5bd5fd43 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 05:16:23 +0800 Subject: [PATCH 037/153] Clean data health check copy --- docs/ux-test-findings-2026-06-17.md | 3 +++ mobile/lib/data/inventory_repository.dart | 12 ++++++------ mobile/test/inventory_repository_test.dart | 3 +++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 1ee866a..b341183 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -733,6 +733,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. ## Remaining Risks diff --git a/mobile/lib/data/inventory_repository.dart b/mobile/lib/data/inventory_repository.dart index ae1967b..722ab14 100644 --- a/mobile/lib/data/inventory_repository.dart +++ b/mobile/lib/data/inventory_repository.dart @@ -2400,7 +2400,7 @@ class InventoryRepository { final health = await _checkDataHealth(txn); if (!health.passed) { - throw StateError('恢复后的数据健康检查失败:${health.summary}'); + throw StateError('恢复后的资料检查未通过:${health.summary}'); } }); @@ -2452,7 +2452,7 @@ class InventoryRepository { executor, issues, code: 'invalid_status', - message: '库存状态不在允许集合内', + message: '库存状态需要修正', query: ''' SELECT COUNT(*) FROM items @@ -2497,7 +2497,7 @@ class InventoryRepository { executor, issues, code: 'active_with_consumed_at', - message: 'active 库存不应带 consumed_at', + message: '使用中库存不应带有消耗时间', query: ''' SELECT COUNT(*) FROM items @@ -2508,7 +2508,7 @@ class InventoryRepository { executor, issues, code: 'consumed_without_consumed_at', - message: 'consumed 库存应记录 consumed_at', + message: '已消耗库存缺少消耗时间', query: ''' SELECT COUNT(*) FROM items @@ -2565,7 +2565,7 @@ class InventoryRepository { issues.add( DataHealthIssue( code: 'foreign_key_violation', - message: '数据关联检查失败', + message: '资料关联需要修正', count: foreignKeyRows.length, ), ); @@ -3008,7 +3008,7 @@ class DataHealthReport { String get summary { if (passed) { - return '数据健康'; + return '资料检查正常'; } return issues.map((issue) => '${issue.message} ${issue.count} 处').join(';'); } diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 500ed1d..9b99ff7 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -914,6 +914,9 @@ void main() { 'invalid_date', ]), ); + expect(health.summary, contains('使用中库存不应带有消耗时间 1 处')); + expect(health.summary, isNot(contains('active'))); + expect(health.summary, isNot(contains('consumed_at'))); }); test( From 0eb49294e1bc7f2bd8e9420c3a482706e25e2ee5 Mon Sep 17 00:00:00 2001 From: Aucannot Date: Thu, 18 Jun 2026 05:23:07 +0800 Subject: [PATCH 038/153] Reword legacy import result copy --- docs/ux-test-findings-2026-06-17.md | 5 ++++- mobile/lib/data/inventory_repository.dart | 2 +- mobile/lib/screens/settings_screen.dart | 14 +++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index b341183..483f43c 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -175,7 +175,7 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 + `示例数据已重置,清理 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 @@ -736,6 +736,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. ## Remaining Risks diff --git a/mobile/lib/data/inventory_repository.dart b/mobile/lib/data/inventory_repository.dart index 722ab14..b7ceb9f 100644 --- a/mobile/lib/data/inventory_repository.dart +++ b/mobile/lib/data/inventory_repository.dart @@ -1624,7 +1624,7 @@ class InventoryRepository { LegacyImportLogEntry.updated( table: 'demo', name: '示例数据', - reason: '导入前清理 $clearedDemoRows 行示例资料/库存', + reason: '导入前清理 $clearedDemoRows 条示例资料/库存', ), ); } diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 233ab55..87e5beb 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1059,7 +1059,7 @@ class _SettingsScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '示例数据已重置,清理 $clearedRows 行旧示例数据', + '示例数据已重置,清理 $clearedRows 条旧示例数据', ), ), ); @@ -1399,7 +1399,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 +1407,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 +1422,7 @@ class _LegacyImportPreviewDialogState }, title: const Text('导入前清空示例资料/库存'), subtitle: const Text( - '只清理内置演示数据,保留分类和用户数据。', + '只清理内置示例资料/库存,保留分类和用户数据。', ), ), if (preview.logs.isNotEmpty) ...[ @@ -1510,8 +1510,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 +1519,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, ), From 74362a4d28fa2d80efbe83551e92cfec9ee7d395 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:12:39 +0800 Subject: [PATCH 039/153] Improve beta notification and routing validation --- docs/ux-test-findings-2026-06-17.md | 239 +++++++++++++++--- mobile/lib/main.dart | 56 +++- mobile/lib/screens/app_shell.dart | 43 +++- mobile/lib/screens/home_screen.dart | 8 +- .../lib/screens/item_wiki_detail_screen.dart | 4 +- mobile/lib/screens/items_screen.dart | 9 +- mobile/lib/screens/recipes_screen.dart | 4 +- mobile/lib/screens/settings_screen.dart | 6 +- mobile/lib/utils/web_route_state_html.dart | 4 + mobile/lib/utils/web_route_state_stub.dart | 2 + mobile/macos/Podfile.lock | 24 +- mobile/macos/Runner.xcodeproj/project.pbxproj | 32 ++- .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++ .../Runner/MacLocalNotificationRequest.swift | 93 +++++++ mobile/macos/Runner/MainFlutterWindow.swift | 59 +---- mobile/macos/RunnerTests/RunnerTests.swift | 94 ++++++- .../test/local_notification_service_test.dart | 124 +++++++++ 17 files changed, 691 insertions(+), 128 deletions(-) create mode 100644 mobile/macos/Runner/MacLocalNotificationRequest.swift diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 483f43c..e865505 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -21,28 +21,130 @@ 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. +## 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, backup/export, restore/import copy, settings, and app self-check 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. +- 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 local notification runtime behavior. Dart fallback behavior, + static bridge wiring, repository notification payload tests, and macOS native + scheduling-payload construction and permission-status mapping tests are + covered, but real device/desktop notification permission, delivery, and + tap-to-detail behavior still need runtime validation on the target + platforms. +- Web update experience on an existing origin. Fresh origins load the current + build and the current service worker unregisters itself, but older browser + cache or prior registration state can still mask a rebuilt app during manual + validation. + +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 and permission-status mapping are now covered + locally; the next gate is manual permission, delivery, and click routing + 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, sync reminders, + wait for a due reminder, tap the delivered 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, sync reminders, wait for a due + reminder, tap the delivered notification, and confirm the matching inventory + detail opens. +- Re-run the Settings 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, AI recipe and order-recognition error-copy coverage, plus the - generic error snackbar detail-copy coverage, 54 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. - `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/inventory_repository_test.dart`: passed after the no-date order duplicate fix and backup-reminder copy cleanup, 19 tests. - `flutter analyze`: passed after the latest beta fixes including Web route - cleanup, direct Web detail URL hash cleanup, notification channel coverage, - the edit-page Material fix, and no-date order duplicate handling. + 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, and after the macOS + native notification request builder extraction, with no issues. - `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, and notification permission-to-sync controller coverage, 9 tests. - `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, 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, and again before the - latest Settings smoke check. + 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. +- `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. +- `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration + Debug -destination 'platform=macOS' -derivedDataPath + /private/tmp/vibe-fridge-xcode-derived-permission + -clonedSourcePackagesDirPath /private/tmp/vibe-fridge-xcode-spm-permission`: + 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, and native permission-status channel mapping. +- `flutter run -d macos`: launched the debug macOS target successfully and + exposed a Dart VM Service. The app process started, though `open` could not + automatically foreground the window in this shell session. +- `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. +- 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. 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. @@ -82,6 +184,17 @@ check confirmed it hands off cleanly to the rendered Home screen. `快手蛋奶早餐` 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. +- 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 @@ -150,7 +263,17 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 - service-worker or browser cache can keep stale Web assets during validation. + older same-origin cache or previous registration state can keep stale Web + assets during validation. The current Web build emits an unregistering + `flutter_service_worker.js`, so fresh origins should not retain a Flutter + service worker. +- Desktop and mobile Web Settings regression smoke check on fresh port 54384: + opened Settings on the current 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 `食谱偏好已保存`, @@ -271,17 +394,13 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 ports 54404-54408, then repeated - on a rebuilt desktop Web preview at port 54370: loaded - `?route=items&q=牛奶`, opened the `鲜牛奶` item-profile detail, and used the - browser Back/Forward controls. Back correctly returned to the searched - catalog state with `q=牛奶`, but Forward stayed on the catalog and most - recently rewrote the address to `?route=items` - instead of reopening `?route=items%2Fwiki%2Fwiki-milk`. Several route-restore - experiments, including a Navigator stack observer and anonymous route-driven - detail pushes, did not produce a verified fix, so the trial code was - discarded and the issue is kept as a remaining Web navigation risk. No - browser warning or error logs appeared. +- 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 `牙膏`, @@ -388,6 +507,11 @@ 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. +- 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. @@ -404,16 +528,31 @@ check confirmed it hands off cleanly to the rendered Home screen. - Inspecting the app self-check failure path found failed check details would display raw Dart prefixes such as `Bad state:` before the useful message. Self-check failures now strip those technical prefixes before rendering. -- `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`. +- `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. - `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, @@ -587,6 +726,16 @@ check confirmed it hands off cleanly to the rendered Home screen. 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 + native notification permission statuses map to the Flutter channel contract. - Android notification scheduling now persists pending reminder payloads and registers boot/package-replaced restoration points; runtime proof still needs an Android SDK/device environment. @@ -674,8 +823,8 @@ check confirmed it hands off cleanly to the rendered Home screen. 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. @@ -721,6 +870,9 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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 @@ -739,20 +891,23 @@ check confirmed it hands off cleanly to the rendered Home screen. - 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. ## 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. -- Web browser Forward after returning from a detail page can lose the detail - entry. Reproduced from `?route=items&q=牛奶` -> `鲜牛奶` detail -> browser - Back -> browser Forward; the Forward action remained on the searched catalog - and rewrote the URL to `?route=items` instead of reopening the detail route. -- Web app updates can be masked by same-origin cache or 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. Release - validation should use a fresh origin or cache clear until the Web update - experience is designed explicitly. +- Android local notification behavior still needs runtime validation on a + machine with Android SDK configured. +- macOS app build and launch are now proven locally, but macOS notification + permission, due reminder delivery, and notification-click routing still need + targeted desktop runtime validation. The native scheduling payload builder + and permission-status mapper are now covered by RunnerTests, 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 current Flutter build emits an + unregistering `flutter_service_worker.js`, but release validation should + still use a fresh origin or cache clear until the Web update experience is + designed explicitly. diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0516731..8a9b8c4 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/services.dart'; @@ -34,7 +35,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 +50,58 @@ class VibeFridgeApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - home: AppShell(controller: controller), + routeInformationParser: const _VibeFridgeRouteParser(), + routerDelegate: _VibeFridgeRouterDelegate(controller), + ); + } +} + +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 7cd1eee..05f4c3d 100644 --- a/mobile/lib/screens/app_shell.dart +++ b/mobile/lib/screens/app_shell.dart @@ -110,20 +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) { @@ -185,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') { @@ -201,10 +222,16 @@ 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; @@ -219,6 +246,10 @@ class _AppShellState extends State { } } + bool _isRecipeDetailRoute(Uri uri) { + return uri.pathSegments.length >= 2 && uri.pathSegments.first == 'recipes'; + } + String _routeForIndex(int index) { return switch (index) { 1 => '/items', @@ -297,7 +328,9 @@ class _AppShellState extends State { ), ) .then((_) { - setWebRouteState('/items', replace: true); + if (!supportsWebRouteState || isCurrentWebRoute(route)) { + setWebRouteState('/items', replace: true); + } }); setWebRouteState(route, replace: true); }); 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_wiki_detail_screen.dart b/mobile/lib/screens/item_wiki_detail_screen.dart index 8a596ab..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(); diff --git a/mobile/lib/screens/items_screen.dart b/mobile/lib/screens/items_screen.dart index d9092d6..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(); } diff --git a/mobile/lib/screens/recipes_screen.dart b/mobile/lib/screens/recipes_screen.dart index 1a7cbb6..7ba113b 100644 --- a/mobile/lib/screens/recipes_screen.dart +++ b/mobile/lib/screens/recipes_screen.dart @@ -251,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 87e5beb..0a7a1bf 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -453,8 +453,8 @@ class _SettingsScreenState extends State { ), const _SettingRow( icon: Icons.security_outlined, - label: '密钥存储', - value: '系统安全存储', + label: '密钥状态', + value: '本机安全保存', ), const SizedBox(height: AppSpacing.cardGap), TextField( @@ -483,7 +483,7 @@ class _SettingsScreenState extends State { labelText: 'API 密钥', hintText: _hasStoredVlmApiKey ? '已安全保存,留空保持不变' - : '只保存在系统安全存储', + : '只保存在本机安全区域', helperText: _hasStoredVlmApiKey ? '已保存的密钥不会明文显示' : '仅保存在本机安全区域', diff --git a/mobile/lib/utils/web_route_state_html.dart b/mobile/lib/utils/web_route_state_html.dart index e6e5f0e..327e64b 100644 --- a/mobile/lib/utils/web_route_state_html.dart +++ b/mobile/lib/utils/web_route_state_html.dart @@ -30,6 +30,10 @@ 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); 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/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 @@ + + + + + + + + + + TimeInterval { + max(scheduledAt.timeIntervalSince(now), 60) + } +} + +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 + ) + ) + } + } +} + +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" + } + } +} diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift index 2779fca..1fbc891 100644 --- a/mobile/macos/Runner/MainFlutterWindow.swift +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -90,13 +90,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 + ) } } } @@ -117,35 +115,22 @@ 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 - } - 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 - } + let notifications = MacLocalNotificationRequestFactory.requests( + from: arguments + ) + for notification in notifications { let content = UNMutableNotificationContent() - content.title = title - content.body = body + content.title = notification.title + content.body = notification.body content.sound = .default - content.userInfo = ["itemId": itemId] + content.userInfo = notification.userInfo - let scheduledDate = Date( - timeIntervalSince1970: scheduledAtMillis.doubleValue / 1000 - ) - let interval = max(scheduledDate.timeIntervalSinceNow, 60) let trigger = UNTimeIntervalNotificationTrigger( - timeInterval: interval, + timeInterval: notification.triggerInterval(), repeats: false ) let request = UNNotificationRequest( - identifier: "inventory-\(itemId)", + identifier: notification.identifier, content: content, trigger: trigger ) @@ -160,20 +145,4 @@ final class MacLocalNotificationBridge { 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" - } - } } diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift index 61f3bd1..c026884 100644 --- a/mobile/macos/RunnerTests/RunnerTests.swift +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -1,12 +1,100 @@ import Cocoa import FlutterMacOS 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) + 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( + 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 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") } } diff --git a/mobile/test/local_notification_service_test.dart b/mobile/test/local_notification_service_test.dart index 01a686f..c709d19 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'); @@ -170,6 +174,86 @@ void main() { expect(await service.getLaunchItemId(), isNull); }); + + 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); + }); } Future _sendPlatformCall(MethodChannel channel, MethodCall call) async { @@ -182,3 +266,43 @@ 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', + ), + }) : super(channel: const MethodChannel('vibe_fridge/fake_notifications')); + + final LocalNotificationPermissionSnapshot requestPermissionResult; + final LocalNotificationSyncResult syncResult; + void Function(String itemId)? _onTap; + int syncCalls = 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; + } +} From 5ac5284e43f169935c516eb10c0e73142119bc31 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:18:22 +0800 Subject: [PATCH 040/153] Record post-push web smoke test --- docs/ux-test-findings-2026-06-17.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index e865505..3137a06 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -107,7 +107,9 @@ Platform notification checklist for that gate: 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. + 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. - `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 @@ -147,7 +149,8 @@ Platform notification checklist for that gate: `密钥状态` / `本机安全保存` / `本机安全区域`. - 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. +- App self-check from Settings: passed, 15/15; passed again on the restarted + latest Web target on port 54390 in about 354ms. - 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 @@ -190,6 +193,10 @@ Platform notification checklist for that gate: 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 From 79d4c2df3cbedad4c9e9204eb7e29091c14b7f92 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:28:47 +0800 Subject: [PATCH 041/153] Cover macOS notification tap handoff --- docs/ux-test-findings-2026-06-17.md | 29 ++++--- mobile/macos/Runner/AppDelegate.swift | 12 +-- .../Runner/MacLocalNotificationRequest.swift | 34 ++++++++ mobile/macos/Runner/MainFlutterWindow.swift | 4 +- mobile/macos/RunnerTests/RunnerTests.swift | 80 +++++++++++++++++++ 5 files changed, 134 insertions(+), 25 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 3137a06..09ba07f 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -41,9 +41,9 @@ Not proven yet: - Android/macOS local notification runtime behavior. Dart fallback behavior, static bridge wiring, repository notification payload tests, and macOS native - scheduling-payload construction and permission-status mapping tests are - covered, but real device/desktop notification permission, delivery, and - tap-to-detail behavior still need runtime validation on the target + scheduling-payload construction, permission-status mapping, and tap payload + handoff tests are covered, but real device/desktop notification permission, + delivery, and user-click behavior still need runtime validation on the target platforms. - Web update experience on an existing origin. Fresh origins load the current build and the current service worker unregisters itself, but older browser @@ -55,9 +55,9 @@ 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 and permission-status mapping are now covered - locally; the next gate is manual permission, delivery, and click routing - validation. + 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: @@ -116,15 +116,16 @@ Platform notification checklist for that gate: 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. + status mapper, and again after adding the native tap payload handoff helper. - `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination 'platform=macOS' -derivedDataPath - /private/tmp/vibe-fridge-xcode-derived-permission - -clonedSourcePackagesDirPath /private/tmp/vibe-fridge-xcode-spm-permission`: + /private/tmp/vibe-fridge-xcode-derived-tap -clonedSourcePackagesDirPath + /private/tmp/vibe-fridge-xcode-spm-tap`: 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, and native permission-status channel mapping. + notification trigger delay, native permission-status channel mapping, and + native tap payload handoff into the launch target plus Flutter event path. - `flutter run -d macos`: launched the debug macOS target successfully and exposed a Dart VM Service. The app process started, though `open` could not automatically foreground the window in this shell session. @@ -742,7 +743,8 @@ Platform notification checklist for that gate: 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 - native notification permission statuses map to the Flutter channel contract. + 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. @@ -909,8 +911,9 @@ Platform notification checklist for that gate: - macOS app build and launch are now proven locally, but macOS notification permission, due reminder delivery, and notification-click routing still need targeted desktop runtime validation. The native scheduling payload builder - and permission-status mapper are now covered by RunnerTests, but system - notification behavior is not fully replaceable with unit tests. + permission-status mapper, and tap payload handoff are now covered by + RunnerTests, 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 diff --git a/mobile/macos/Runner/AppDelegate.swift b/mobile/macos/Runner/AppDelegate.swift index dd09e7f..8c325e2 100644 --- a/mobile/macos/Runner/AppDelegate.swift +++ b/mobile/macos/Runner/AppDelegate.swift @@ -35,15 +35,9 @@ 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] - ) - } + MacLocalNotificationTapHandler.handleTap( + userInfo: response.notification.request.content.userInfo + ) completionHandler() } } diff --git a/mobile/macos/Runner/MacLocalNotificationRequest.swift b/mobile/macos/Runner/MacLocalNotificationRequest.swift index ef905d9..ac11919 100644 --- a/mobile/macos/Runner/MacLocalNotificationRequest.swift +++ b/mobile/macos/Runner/MacLocalNotificationRequest.swift @@ -91,3 +91,37 @@ enum MacLocalNotificationPermissionFactory { } } } + +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 = userInfo["itemId"] as? String, !itemId.isEmpty 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) + guard let itemId, !itemId.isEmpty else { + return nil + } + return itemId + } +} diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift index 1fbc891..810b2fd 100644 --- a/mobile/macos/Runner/MainFlutterWindow.swift +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -73,9 +73,7 @@ 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) diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift index c026884..7ce52f5 100644 --- a/mobile/macos/RunnerTests/RunnerTests.swift +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -97,4 +97,84 @@ class RunnerTests: XCTestCase { XCTAssertEqual(unknown.status, "unknown") } + 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": 42], + userDefaults: defaults, + notificationCenter: center + ) + ) + XCTAssertNil( + defaults.string(forKey: MacLocalNotificationTapHandler.launchItemIdKey) + ) + XCTAssertEqual(eventCount, 0) + } + } From cafcb63f0bd5fe4d3010fcc9b306def2c80196d9 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:39:57 +0800 Subject: [PATCH 042/153] Disable stale web service worker registration --- docs/ux-test-findings-2026-06-17.md | 39 +++++++++++++++++--------- mobile/test/web_bootstrap_test.dart | 18 ++++++++++++ mobile/web/flutter_bootstrap.js | 43 +++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 mobile/test/web_bootstrap_test.dart create mode 100644 mobile/web/flutter_bootstrap.js diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 09ba07f..272b85e 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -45,10 +45,10 @@ Not proven yet: handoff tests are covered, but real device/desktop notification permission, delivery, and user-click behavior still need runtime validation on the target platforms. -- Web update experience on an existing origin. Fresh origins load the current - build and the current service worker unregisters itself, but older browser - cache or prior registration state can still mask a rebuilt app during manual - validation. +- Web update experience on an existing origin. The custom bootstrap no longer + registers Flutter's service worker and clears stale registrations when the + current index loads, but an old service worker or browser cache can still + mask the first request for the new index during manual validation. Recommended next beta gate: @@ -101,6 +101,9 @@ Platform notification checklist for that gate: - `flutter test test/local_notification_service_test.dart`: passed after the Android reminder scheduler refactor, notification tap controller handoff coverage, and notification permission-to-sync controller coverage, 9 tests. +- `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. - `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, the edit-page Material fix, and no-date order @@ -109,7 +112,9 @@ Platform notification checklist for that gate: 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. + 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. - `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 @@ -150,6 +155,11 @@ Platform notification checklist for that gate: `密钥状态` / `本机安全保存` / `本机安全区域`. - Web build output now includes the native HTML loading screen used to cover Flutter Web's cold-start font fallback window. +- Web bootstrap output now omits Flutter service-worker registration settings + and unregisters stale same-origin service workers 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. - App self-check from Settings: passed, 15/15; passed again on the restarted latest Web target on port 54390 in about 354ms. - Fresh Web smoke check on a new local port: no new console warnings or errors @@ -272,9 +282,9 @@ Platform notification checklist for that gate: 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 current Web build emits an unregistering - `flutter_service_worker.js`, so fresh origins should not retain a Flutter - service worker. + assets during validation. The Web bootstrap now avoids registering a + replacement Flutter service worker and clears stale registrations once the + current index is loaded. - Desktop and mobile Web Settings regression smoke check on fresh port 54384: opened Settings on the current build, verified backup/notification copy, scrolled to recipe preferences and app self-check, ran self-check, and saw @@ -852,6 +862,9 @@ Platform notification checklist for that gate: - 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. +- Added a custom Web bootstrap that omits Flutter service-worker registration, + unregisters stale same-origin service workers, and reloads once when the + current page is still controlled by an old worker. - 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 @@ -910,14 +923,14 @@ Platform notification checklist for that gate: machine with Android SDK configured. - macOS app build and launch are now proven locally, but macOS notification permission, due reminder delivery, and notification-click routing still need - targeted desktop runtime validation. The native scheduling payload builder + targeted desktop runtime validation. The native scheduling payload builder, permission-status mapper, and tap payload handoff are now covered by RunnerTests, 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 current Flutter build emits an - unregistering `flutter_service_worker.js`, but release validation should - still use a fresh origin or cache clear until the Web update experience is - designed explicitly. + 54372 loaded the current build. The custom bootstrap now avoids registering a + replacement Flutter service worker and clears stale registrations once the + current index is loaded, but release validation should still use a fresh + origin or cache clear until the Web update experience is designed explicitly. diff --git a/mobile/test/web_bootstrap_test.dart b/mobile/test/web_bootstrap_test.dart new file mode 100644 index 0000000..2b296ae --- /dev/null +++ b/mobile/test/web_bootstrap_test.dart @@ -0,0 +1,18 @@ +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('window.location.reload()')); + expect(script, contains('_flutter.loader.load();')); + }); +} diff --git a/mobile/web/flutter_bootstrap.js b/mobile/web/flutter_bootstrap.js new file mode 100644 index 0000000..0fd61d2 --- /dev/null +++ b/mobile/web/flutter_bootstrap.js @@ -0,0 +1,43 @@ +{{flutter_js}} +{{flutter_build_config}} + +const staleServiceWorkerReloadKey = 'vibe_fridge_stale_service_worker_reload'; + +async function clearStaleServiceWorkers() { + if (!('serviceWorker' in navigator)) { + return true; + } + + const registrations = await navigator.serviceWorker.getRegistrations(); + if (registrations.length === 0) { + sessionStorage.removeItem(staleServiceWorkerReloadKey); + return true; + } + + await Promise.all( + registrations.map((registration) => registration.unregister()), + ); + + if ( + navigator.serviceWorker.controller && + sessionStorage.getItem(staleServiceWorkerReloadKey) !== 'true' + ) { + sessionStorage.setItem(staleServiceWorkerReloadKey, 'true'); + window.location.reload(); + return false; + } + + sessionStorage.removeItem(staleServiceWorkerReloadKey); + return true; +} + +clearStaleServiceWorkers() + .catch((error) => { + console.warn('Failed to clear stale service workers:', error); + return true; + }) + .then((shouldLoadApp) => { + if (shouldLoadApp) { + _flutter.loader.load(); + } + }); From 4a27275c826e555d4514075518eb03322e0d27d8 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:43:14 +0800 Subject: [PATCH 043/153] Record full Flutter regression test --- docs/ux-test-findings-2026-06-17.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 272b85e..dc235eb 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -85,7 +85,8 @@ Platform notification checklist for that gate: 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. + after the macOS native notification request builder extraction. Passed again + after adding the custom Web bootstrap guard, 58 tests. - `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. From a9df1914130cfa5bf36c523aa8a1da23290dfe5e Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:51:01 +0800 Subject: [PATCH 044/153] Cover Android notification wiring --- docs/ux-test-findings-2026-06-17.md | 29 ++-- .../android_notification_wiring_test.dart | 143 ++++++++++++++++++ 2 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 mobile/test/android_notification_wiring_test.dart diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index dc235eb..b97c177 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -40,11 +40,11 @@ Proven enough for beta: Not proven yet: - Android/macOS local notification runtime behavior. Dart fallback behavior, - static bridge wiring, repository notification payload tests, and macOS native - scheduling-payload construction, permission-status mapping, and tap payload - handoff tests are covered, but real device/desktop notification permission, - delivery, and user-click behavior still need runtime validation on the target - platforms. + Android manifest/channel wiring source tests, repository notification payload + tests, and macOS native scheduling-payload construction, permission-status + mapping, and tap payload handoff tests are covered, but real device/desktop + notification permission, delivery, and user-click behavior still need runtime + validation on the target platforms. - Web update experience on an existing origin. The custom bootstrap no longer registers Flutter's service worker and clears stale registrations when the current index loads, but an old service worker or browser cache can still @@ -86,7 +86,8 @@ Platform notification checklist for that gate: 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. + after adding the custom Web bootstrap guard, 58 tests. Passed again after + adding Android notification wiring source tests, 60 tests. - `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. @@ -102,6 +103,10 @@ Platform notification checklist for that gate: - `flutter test test/local_notification_service_test.dart`: passed after the Android reminder scheduler refactor, notification tap controller handoff coverage, and notification permission-to-sync controller coverage, 9 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. - `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. @@ -144,9 +149,10 @@ Platform notification checklist for that gate: 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. No obvious channel-name, payload-key, or click - callback mismatch was found, but this does not replace runtime validation on - real Android/macOS environments. + the platform supports them. Android manifest/channel/payload-key wiring is + now covered by `android_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, @@ -921,7 +927,10 @@ Platform notification checklist for that gate: ## Remaining Risks - Android local notification behavior still needs runtime validation on a - machine with Android SDK configured. + machine with Android SDK configured. Android source-level + manifest/channel/payload wiring is now covered by tests, but delivery, + permission prompts, and notification-click routing still need a device or + emulator. - macOS app build and launch are now proven locally, but macOS notification permission, due reminder delivery, and notification-click routing still need targeted desktop runtime validation. The native scheduling payload builder, diff --git a/mobile/test/android_notification_wiring_test.dart b/mobile/test/android_notification_wiring_test.dart new file mode 100644 index 0000000..50c9082 --- /dev/null +++ b/mobile/test/android_notification_wiring_test.dart @@ -0,0 +1,143 @@ +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'", + "'cancelAll'", + "'notificationTapped'", + "'notifications'", + "arguments['itemId'] as String?", + ]); + _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 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', + '"cancelAll" -> {', + 'LocalReminderScheduler.cancelScheduledReminders(this)', + 'intent.getStringExtra(LocalNotificationContract.extraItemId)', + '"notificationTapped"', + 'mapOf("itemId" to itemId)', + 'Manifest.permission.POST_NOTIFICATIONS', + ]); + _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', + ]); + _expectAllContains(notificationReceiver, [ + 'intent.getStringExtra(LocalNotificationContract.extraItemId)', + 'Intent(context, MainActivity::class.java)', + 'putExtra(LocalNotificationContract.extraItemId, itemId)', + 'setContentIntent(pendingIntent)', + 'setAutoCancel(true)', + 'notificationManager.notify', + ]); + _expectAllContains(bootReceiver, [ + 'Intent.ACTION_BOOT_COMPLETED', + 'Intent.ACTION_MY_PACKAGE_REPLACED', + 'LocalReminderScheduler.restoreScheduledReminders(context)', + ]); + }); +} + +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)); +} From 0d18365a8a567286f4c498c3c996d73183af00d9 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:54:40 +0800 Subject: [PATCH 045/153] Cover macOS notification wiring --- docs/ux-test-findings-2026-06-17.md | 35 +++++--- .../test/macos_notification_wiring_test.dart | 87 +++++++++++++++++++ 2 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 mobile/test/macos_notification_wiring_test.dart diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index b97c177..738ff6a 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -40,11 +40,11 @@ Proven enough for beta: Not proven yet: - Android/macOS local notification runtime behavior. Dart fallback behavior, - Android manifest/channel wiring source tests, repository notification payload - tests, and macOS native scheduling-payload construction, permission-status - mapping, and tap payload handoff tests are covered, but real device/desktop - notification permission, delivery, and user-click behavior still need runtime - validation on the target platforms. + Android manifest wiring tests, macOS bridge wiring source tests, repository + notification payload tests, and macOS native scheduling-payload construction, + permission-status mapping, and tap payload handoff tests are covered, but real + device/desktop notification permission, delivery, and user-click behavior + still need runtime validation on the target platforms. - Web update experience on an existing origin. The custom bootstrap no longer registers Flutter's service worker and clears stale registrations when the current index loads, but an old service worker or browser cache can still @@ -87,7 +87,8 @@ Platform notification checklist for that gate: 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. + adding Android notification wiring source tests, 60 tests. Passed again after + adding macOS notification wiring source tests, 62 tests. - `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. @@ -107,6 +108,10 @@ Platform notification checklist for that gate: 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. +- `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. - `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. @@ -149,10 +154,12 @@ Platform notification checklist for that gate: 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 is - now covered by `android_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. + 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, @@ -933,10 +940,10 @@ Platform notification checklist for that gate: emulator. - macOS app build and launch are now proven locally, but macOS notification permission, due reminder delivery, and notification-click routing still need - targeted desktop runtime validation. The native scheduling payload builder, - permission-status mapper, and tap payload handoff are now covered by - RunnerTests, but system notification behavior is not fully replaceable with - unit tests. + targeted desktop runtime validation. The native bridge/delegate source wiring, + scheduling payload builder, permission-status mapper, and tap payload handoff + are now covered by tests, 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 diff --git a/mobile/test/macos_notification_wiring_test.dart b/mobile/test/macos_notification_wiring_test.dart new file mode 100644 index 0000000..c82ce78 --- /dev/null +++ b/mobile/test/macos_notification_wiring_test.dart @@ -0,0 +1,87 @@ +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'", + "'cancelAll'", + "'notificationTapped'", + "'notifications'", + "arguments['itemId'] as String?", + ]); + _expectAllContains(mainWindow, [ + 'FlutterMethodChannel(', + 'name: "vibe_fridge/local_notifications"', + 'case "initialize", "getPermissionStatus":', + 'case "requestPermission":', + 'case "getLaunchItemId":', + 'MacLocalNotificationTapHandler.consumeLaunchItemId()', + 'case "scheduleInventoryReminders":', + 'scheduleInventoryReminders(arguments: call.arguments)', + 'case "cancelAll":', + 'center.removeAllPendingNotificationRequests()', + 'MacLocalNotificationRequestFactory.requests', + 'channel.invokeMethod("notificationTapped", arguments: ["itemId": itemId])', + ]); + _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]', + 'max(scheduledAt.timeIntervalSince(now), 60)', + ]); + }); + + 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")', + ]); + _expectAllContains(requestSupport, [ + 'static let launchItemIdKey = "notification_item_id"', + 'userDefaults.set(itemId, forKey: launchItemIdKey)', + 'notificationCenter.post(', + 'name: .vibeFridgeNotificationTapped', + 'userInfo: ["itemId": itemId]', + 'consumeLaunchItemId(', + 'userDefaults.removeObject(forKey: launchItemIdKey)', + ]); + }); +} + +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'); + } +} From a5faac17f2cd5463abb8af1d022e13d2278883c5 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:00:56 +0800 Subject: [PATCH 046/153] Clear stale Flutter web caches --- docs/ux-test-findings-2026-06-17.md | 36 ++++++++++++++++++----------- mobile/test/web_bootstrap_test.dart | 5 +++- mobile/web/flutter_bootstrap.js | 16 +++++++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 738ff6a..80201d5 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -46,9 +46,10 @@ Not proven yet: device/desktop notification permission, delivery, and user-click behavior still need runtime validation on the target platforms. - Web update experience on an existing origin. The custom bootstrap no longer - registers Flutter's service worker and clears stale registrations when the - current index loads, but an old service worker or browser cache can still - mask the first request for the new index during manual validation. + registers Flutter's service worker, clears stale registrations, and deletes + stale Flutter Cache Storage entries when the current index loads, but an old + service worker or browser cache can still mask the first request for the new + index during manual validation. Recommended next beta gate: @@ -114,7 +115,8 @@ Platform notification checklist for that gate: handoff into the launch target. - `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. + replacement Flutter service worker; passed again after extending the guard to + delete stale Flutter Cache Storage entries. - `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, the edit-page Material fix, and no-date order @@ -169,11 +171,15 @@ Platform notification checklist for that gate: `密钥状态` / `本机安全保存` / `本机安全区域`. - Web build output now includes the native HTML loading screen used to cover Flutter Web's cold-start font fallback window. -- Web bootstrap output now omits Flutter service-worker registration settings - and unregisters stale same-origin service workers 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. +- 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. - App self-check from Settings: passed, 15/15; passed again on the restarted latest Web target on port 54390 in about 354ms. - Fresh Web smoke check on a new local port: no new console warnings or errors @@ -877,8 +883,9 @@ Platform notification checklist for that gate: testing showed CanvasKit could briefly expose square Chinese glyphs before fonts settled. - Added a custom Web bootstrap that omits Flutter service-worker registration, - unregisters stale same-origin service workers, and reloads once when the - current page is still controlled by an old worker. + 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. - 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 @@ -948,6 +955,7 @@ Platform notification checklist for that gate: 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 and clears stale registrations once the - current index is loaded, but release validation should still use a fresh - origin or cache clear until the Web update experience is designed explicitly. + replacement Flutter service worker, clears stale registrations, and deletes + stale Flutter Cache Storage entries once the current index is loaded, but + release validation should still use a fresh origin or cache clear until the + Web update experience is designed explicitly. diff --git a/mobile/test/web_bootstrap_test.dart b/mobile/test/web_bootstrap_test.dart index 2b296ae..4f43671 100644 --- a/mobile/test/web_bootstrap_test.dart +++ b/mobile/test/web_bootstrap_test.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('web bootstrap clears stale service workers without registering one', () { + test('web bootstrap clears stale service workers without registering one', + () { final script = File('web/flutter_bootstrap.js').readAsStringSync(); expect(script, contains('{{flutter_js}}')); @@ -12,6 +13,8 @@ void main() { 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();')); }); diff --git a/mobile/web/flutter_bootstrap.js b/mobile/web/flutter_bootstrap.js index 0fd61d2..3d88222 100644 --- a/mobile/web/flutter_bootstrap.js +++ b/mobile/web/flutter_bootstrap.js @@ -3,13 +3,28 @@ const staleServiceWorkerReloadKey = 'vibe_fridge_stale_service_worker_reload'; +async function clearStaleFlutterCaches() { + if (!('caches' in window)) { + return; + } + + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames + .filter((cacheName) => cacheName.startsWith('flutter-')) + .map((cacheName) => caches.delete(cacheName)), + ); +} + async function clearStaleServiceWorkers() { if (!('serviceWorker' in navigator)) { + await clearStaleFlutterCaches(); return true; } const registrations = await navigator.serviceWorker.getRegistrations(); if (registrations.length === 0) { + await clearStaleFlutterCaches(); sessionStorage.removeItem(staleServiceWorkerReloadKey); return true; } @@ -17,6 +32,7 @@ async function clearStaleServiceWorkers() { await Promise.all( registrations.map((registration) => registration.unregister()), ); + await clearStaleFlutterCaches(); if ( navigator.serviceWorker.controller && From df1f3208a3f6765db9a896e9fc04896c03416cb5 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:05:21 +0800 Subject: [PATCH 047/153] Cover inventory table CSV escaping --- docs/ux-test-findings-2026-06-17.md | 5 ++++ mobile/test/inventory_repository_test.dart | 29 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 80201d5..6acffcf 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -95,6 +95,8 @@ Platform notification checklist for that gate: stay hidden from the visible message. - `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. - `flutter analyze`: passed after the latest beta fixes including Web route cleanup, direct Web detail URL hash cleanup, the `MaterialApp.router` browser-history fix, notification channel coverage, the edit-page Material @@ -754,6 +756,9 @@ Platform notification checklist for that gate: 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, diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 9b99ff7..9cc3cb5 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -663,6 +663,35 @@ void main() { 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( From fc7c26227fffbfc83c11a9c38039bdb1ea8092f6 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:08:55 +0800 Subject: [PATCH 048/153] Cover backup round trip data --- docs/ux-test-findings-2026-06-17.md | 7 +- mobile/test/inventory_repository_test.dart | 88 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 6acffcf..3c8b08f 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -96,7 +96,9 @@ Platform notification checklist for that gate: - `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. + 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. - `flutter analyze`: passed after the latest beta fixes including Web route cleanup, direct Web detail URL hash cleanup, the `MaterialApp.router` browser-history fix, notification channel coverage, the edit-page Material @@ -749,6 +751,9 @@ Platform notification checklist for that gate: 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 diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 9cc3cb5..d0841da 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -663,6 +663,94 @@ 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(); From 2e1bcc3111e24e453cdc3a399bed0e3b0d67ff6c Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:15:52 +0800 Subject: [PATCH 049/153] Extend app self-check backup coverage --- docs/ux-test-findings-2026-06-17.md | 16 ++++-- mobile/lib/data/acceptance_test_service.dart | 56 ++++++++++++++++++++ mobile/test/inventory_repository_test.dart | 2 +- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 3c8b08f..42a5db8 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -89,7 +89,8 @@ Platform notification checklist for that gate: 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. + adding macOS notification wiring source tests, 62 tests. Passed again after + extending app self-check backup coverage, 64 tests. - `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. @@ -98,7 +99,9 @@ Platform notification checklist for that gate: 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. + logs, and shopping-list data, 21 tests. Passed again after extending the + Settings app self-check to verify backup content includes inventory, tags, + reminder logs, and shopping-list data, 21 tests. - `flutter analyze`: passed after the latest beta fixes including Web route cleanup, direct Web detail URL hash cleanup, the `MaterialApp.router` browser-history fix, notification channel coverage, the edit-page Material @@ -185,7 +188,9 @@ Platform notification checklist for that gate: port 54390 loaded `vibe-fridge`, hid the loading screen, captured a non-empty page screenshot, and reported no browser warnings or errors. - App self-check from Settings: passed, 15/15; passed again on the restarted - latest Web target on port 54390 in about 354ms. + latest Web target on port 54390 in about 354ms. After adding the backup + content check and rebuilding Web, the current Settings self-check passed + 16/16 on port 54390 in about 372ms with no browser warning or error logs. - 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 @@ -726,7 +731,7 @@ Platform notification checklist for that gate: - 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. + current Web UI shows the 16/16 result plus readable per-check timings. - Settings recipe preferences save from the mobile Web UI and reload with the edited values still visible. - Settings order-recognition configuration saves from the mobile Web UI, @@ -932,6 +937,9 @@ Platform notification checklist for that gate: app self-check wording. - Cleaned app self-check failure details so users see the actionable reason without raw exception prefixes. +- Extended the app self-check so the user-visible Settings check 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. diff --git a/mobile/lib/data/acceptance_test_service.dart b/mobile/lib/data/acceptance_test_service.dart index ddfaf7d..8248141 100644 --- a/mobile/lib/data/acceptance_test_service.dart +++ b/mobile/lib/data/acceptance_test_service.dart @@ -226,6 +226,42 @@ 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, '物品资料')); await repository.updateItemQuantity(item.id, 1); @@ -376,6 +412,26 @@ 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) { diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index d0841da..9f93931 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -308,7 +308,7 @@ void main() { await AcceptanceTestService(repository).runCoreInventoryChecks(); expect(report.passed, isTrue); - expect(report.checks, hasLength(15)); + expect(report.checks, hasLength(16)); expect( (await repository.getRegisteredItems(keyword: '应用自检测试物品-')), isEmpty, From 9602eb7ed3c2f9f143cdd5e97d91f29cf4c2c067 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:27:49 +0800 Subject: [PATCH 050/153] Preserve backup reminder state during self-check --- mobile/lib/data/acceptance_test_service.dart | 6 +++- mobile/lib/data/inventory_repository.dart | 36 ++++++++++++++++---- mobile/test/inventory_repository_test.dart | 25 ++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/mobile/lib/data/acceptance_test_service.dart b/mobile/lib/data/acceptance_test_service.dart index 8248141..c8bb547 100644 --- a/mobile/lib/data/acceptance_test_service.dart +++ b/mobile/lib/data/acceptance_test_service.dart @@ -13,7 +13,11 @@ 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}'; diff --git a/mobile/lib/data/inventory_repository.dart b/mobile/lib/data/inventory_repository.dart index b7ceb9f..c0a9a01 100644 --- a/mobile/lib/data/inventory_repository.dart +++ b/mobile/lib/data/inventory_repository.dart @@ -2333,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; @@ -2356,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({ @@ -2717,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', diff --git a/mobile/test/inventory_repository_test.dart b/mobile/test/inventory_repository_test.dart index 9f93931..ff3f206 100644 --- a/mobile/test/inventory_repository_test.dart +++ b/mobile/test/inventory_repository_test.dart @@ -304,6 +304,20 @@ void main() { }); 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(); @@ -313,6 +327,17 @@ void main() { (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', () { From 37287b9eeffee65a37e6cb28df23d06030050a7d Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:38:10 +0800 Subject: [PATCH 051/153] Add local notification test action --- docs/ux-test-findings-2026-06-17.md | 48 ++++--- .../vibe_fridge/LocalNotificationContract.kt | 1 + .../vibefridge/vibe_fridge/MainActivity.kt | 44 +++++++ mobile/lib/data/inventory_controller.dart | 7 + .../lib/data/local_notification_service.dart | 57 +++++++++ mobile/lib/screens/settings_screen.dart | 53 +++++++- mobile/macos/Runner/MainFlutterWindow.swift | 34 +++++ .../android_notification_wiring_test.dart | 5 + .../test/local_notification_service_test.dart | 120 ++++++++++++++++++ .../test/macos_notification_wiring_test.dart | 5 + 10 files changed, 349 insertions(+), 25 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 42a5db8..fe4b518 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -16,7 +16,10 @@ 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 +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 notification-click routing still require target-platform manual 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. @@ -39,12 +42,13 @@ Proven enough for beta: Not proven yet: -- Android/macOS local notification runtime behavior. Dart fallback behavior, - Android manifest wiring tests, macOS bridge wiring source tests, repository - notification payload tests, and macOS native scheduling-payload construction, - permission-status mapping, and tap payload handoff tests are covered, but real - device/desktop notification permission, delivery, and user-click behavior - still need runtime validation on the target platforms. +- 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, and macOS native scheduling-payload construction, permission-status + mapping, and tap payload handoff tests are covered, but real device/desktop + scheduled reminder delivery and user-click behavior still need runtime + validation on the target platforms. - Web update experience on an existing origin. The custom bootstrap no longer registers Flutter's service worker, clears stale registrations, and deletes stale Flutter Cache Storage entries when the current index loads, but an old @@ -64,16 +68,16 @@ 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, sync reminders, - wait for a due reminder, tap the delivered notification, and confirm the - matching inventory detail opens. +- 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, sync reminders, wait for a due - reminder, tap the delivered notification, and confirm the matching inventory - detail opens. +- 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 Settings app self-check after platform notification testing to confirm notification experiments did not leave invalid inventory, shopping, recipe, reminder, or history state behind. @@ -90,7 +94,8 @@ Platform notification checklist for that gate: 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. + extending app self-check backup coverage, 64 tests. Passed again after adding + the Settings test-notification action and channel coverage, 67 tests. - `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. @@ -111,7 +116,8 @@ Platform notification checklist for that gate: native notification request builder extraction, with no issues. - `flutter test test/local_notification_service_test.dart`: passed after the Android reminder scheduler refactor, notification tap controller handoff - coverage, and notification permission-to-sync controller coverage, 9 tests. + coverage, notification permission-to-sync controller coverage, and Settings + test-notification flow coverage, 12 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, @@ -960,15 +966,15 @@ Platform notification checklist for that gate: - Android local notification behavior still needs runtime validation on a machine with Android SDK configured. Android source-level - manifest/channel/payload wiring is now covered by tests, but delivery, - permission prompts, and notification-click routing still need a device or - emulator. + 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 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, - scheduling payload builder, permission-status mapper, and tap payload handoff - are now covered by tests, but system notification behavior is not fully - replaceable with unit tests. + immediate test-notification channel, scheduling payload builder, + permission-status mapper, and tap payload handoff are now covered by tests, + 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 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/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/vibefridge/vibe_fridge/MainActivity.kt index 89d1215..bb68dde 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 @@ -41,6 +43,7 @@ class MainActivity : FlutterActivity() { ) result.success(null) } + "sendTestNotification" -> sendTestNotification(result) "cancelAll" -> { LocalReminderScheduler.cancelScheduledReminders(this) result.success(null) @@ -120,4 +123,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/lib/data/inventory_controller.dart b/mobile/lib/data/inventory_controller.dart index dabfcb6..43f5af2 100644 --- a/mobile/lib/data/inventory_controller.dart +++ b/mobile/lib/data/inventory_controller.dart @@ -397,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; diff --git a/mobile/lib/data/local_notification_service.dart b/mobile/lib/data/local_notification_service.dart index e9988ea..b6ed023 100644 --- a/mobile/lib/data/local_notification_service.dart +++ b/mobile/lib/data/local_notification_service.dart @@ -39,6 +39,37 @@ class LocalNotificationService { 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 { try { final itemId = await _channel.invokeMethod('getLaunchItemId'); @@ -202,3 +233,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/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 0a7a1bf..2895edd 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -204,9 +204,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 +221,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 +235,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 @@ -603,6 +621,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 { diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift index 810b2fd..d93a1b5 100644 --- a/mobile/macos/Runner/MainFlutterWindow.swift +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -77,6 +77,8 @@ final class MacLocalNotificationBridge { case "scheduleInventoryReminders": scheduleInventoryReminders(arguments: call.arguments) result(nil) + case "sendTestNotification": + sendTestNotification(result: result) case "cancelAll": center.removeAllPendingNotificationRequests() result(nil) @@ -136,6 +138,38 @@ final class MacLocalNotificationBridge { } } + private func sendTestNotification(result: @escaping FlutterResult) { + let content = UNMutableNotificationContent() + content.title = "库存提醒测试" + content.body = "看到这条通知说明本地通知可用" + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: 1, + repeats: false + ) + let request = UNNotificationRequest( + identifier: "diagnostic-\(UUID().uuidString)", + content: content, + trigger: 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) + } + } + } + @objc private func notificationTapped(_ notification: Notification) { guard let itemId = notification.userInfo?["itemId"] as? String else { return diff --git a/mobile/test/android_notification_wiring_test.dart b/mobile/test/android_notification_wiring_test.dart index 50c9082..dbc5813 100644 --- a/mobile/test/android_notification_wiring_test.dart +++ b/mobile/test/android_notification_wiring_test.dart @@ -65,6 +65,7 @@ void main() { "'requestPermission'", "'getLaunchItemId'", "'scheduleInventoryReminders'", + "'sendTestNotification'", "'cancelAll'", "'notificationTapped'", "'notifications'", @@ -75,6 +76,7 @@ void main() { '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', ]); @@ -86,12 +88,15 @@ void main() { '"getLaunchItemId" -> {', '"scheduleInventoryReminders" -> {', 'LocalReminderScheduler.scheduleFromChannel', + '"sendTestNotification" -> sendTestNotification(result)', '"cancelAll" -> {', 'LocalReminderScheduler.cancelScheduledReminders(this)', 'intent.getStringExtra(LocalNotificationContract.extraItemId)', '"notificationTapped"', 'mapOf("itemId" to itemId)', 'Manifest.permission.POST_NOTIFICATIONS', + 'setContentTitle("库存提醒测试")', + 'LocalNotificationContract.testNotificationRequestCode', ]); _expectAllContains(scheduler, [ 'fun restoreScheduledReminders(context: Context)', diff --git a/mobile/test/local_notification_service_test.dart b/mobile/test/local_notification_service_test.dart index c709d19..55f640e 100644 --- a/mobile/test/local_notification_service_test.dart +++ b/mobile/test/local_notification_service_test.dart @@ -72,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 { @@ -175,6 +206,51 @@ void main() { expect(await service.getLaunchItemId(), isNull); }); + 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('inventory controller consumes notification tap target once', () async { final appDatabase = await openTestDatabase(); addTearDown(() async { @@ -254,6 +330,37 @@ void main() { 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 { @@ -276,12 +383,19 @@ class _FakeNotificationService extends LocalNotificationService { 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) { @@ -305,4 +419,10 @@ class _FakeNotificationService extends LocalNotificationService { 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 index c82ce78..3dac1b2 100644 --- a/mobile/test/macos_notification_wiring_test.dart +++ b/mobile/test/macos_notification_wiring_test.dart @@ -17,6 +17,7 @@ void main() { "'requestPermission'", "'getLaunchItemId'", "'scheduleInventoryReminders'", + "'sendTestNotification'", "'cancelAll'", "'notificationTapped'", "'notifications'", @@ -31,9 +32,13 @@ void main() { 'MacLocalNotificationTapHandler.consumeLaunchItemId()', 'case "scheduleInventoryReminders":', 'scheduleInventoryReminders(arguments: call.arguments)', + 'case "sendTestNotification":', + 'sendTestNotification(result: result)', 'case "cancelAll":', 'center.removeAllPendingNotificationRequests()', 'MacLocalNotificationRequestFactory.requests', + 'content.title = "库存提醒测试"', + 'UNTimeIntervalNotificationTrigger(', 'channel.invokeMethod("notificationTapped", arguments: ["itemId": itemId])', ]); _expectAllContains(requestFactory, [ From 1ec71151ad6325c5d866b974a698d3ce5c1fccda Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:54:20 +0800 Subject: [PATCH 052/153] Cover settings notification test action --- docs/ux-test-findings-2026-06-17.md | 7 +- mobile/lib/screens/settings_screen.dart | 18 +++- mobile/test/settings_screen_test.dart | 135 ++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 mobile/test/settings_screen_test.dart diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index fe4b518..e64500a 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -95,7 +95,9 @@ Platform notification checklist for that gate: 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. + the Settings test-notification action and channel coverage, 67 tests. Passed + again after adding Settings widget coverage for the test-notification action, + 68 tests. - `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. @@ -118,6 +120,9 @@ Platform notification checklist for that gate: Android reminder scheduler refactor, notification tap controller handoff coverage, notification permission-to-sync controller coverage, and Settings test-notification flow coverage, 12 tests. +- `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. - `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, diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 2895edd..2ecd1dc 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -14,16 +14,24 @@ 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, + }); final InventoryController controller; + final VlmSettingsStore? vlmSettingsStore; + final RecipePreferencesStore? recipePreferencesStore; + final VlmOrderService? vlmOrderService; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { - final _vlmSettingsStore = VlmSettingsStore(); final _vlmEndpointController = TextEditingController(); final _vlmModelController = TextEditingController(); final _vlmApiKeyController = TextEditingController(); @@ -32,8 +40,10 @@ class _SettingsScreenState extends State { final _recipeToolsController = TextEditingController(); final _recipeMinutesController = TextEditingController(); final _recipeServingsController = TextEditingController(); - final _vlmOrderService = VlmOrderService(); - final _recipePreferencesStore = RecipePreferencesStore(); + late final _vlmSettingsStore = widget.vlmSettingsStore ?? VlmSettingsStore(); + late final _vlmOrderService = widget.vlmOrderService ?? VlmOrderService(); + late final _recipePreferencesStore = + widget.recipePreferencesStore ?? RecipePreferencesStore(); bool _importing = false; bool _exportingBackup = false; bool _restoringBackup = false; diff --git a/mobile/test/settings_screen_test.dart b/mobile/test/settings_screen_test.dart new file mode 100644 index 0000000..452d58a --- /dev/null +++ b/mobile/test/settings_screen_test.dart @@ -0,0 +1,135 @@ +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/vlm_settings_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, 1200); + 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(), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final button = find.widgetWithText(OutlinedButton, '测试通知'); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(notificationService.testCalls, 1); + expect(find.text('已发送测试通知'), findsOneWidget); + }); +} + +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); + } +} From 6af51115b345b5c7e63ade7f57d8878066445cb8 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:00:16 +0800 Subject: [PATCH 053/153] Cover macOS diagnostic notification request --- docs/ux-test-findings-2026-06-17.md | 15 +++++--- .../Runner/MacLocalNotificationRequest.swift | 35 +++++++++++++++++++ mobile/macos/Runner/MainFlutterWindow.swift | 16 +++------ mobile/macos/RunnerTests/RunnerTests.swift | 17 +++++++++ .../test/macos_notification_wiring_test.dart | 10 ++++-- 5 files changed, 74 insertions(+), 19 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index e64500a..15f5793 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -130,7 +130,8 @@ Platform notification checklist for that gate: - `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. + handoff into the launch target. Passed again after moving test-notification + request construction into the native request factory. - `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 @@ -160,8 +161,10 @@ Platform notification checklist for that gate: 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, native permission-status channel mapping, and - native tap payload handoff into the launch target plus Flutter event path. + 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. - `flutter run -d macos`: launched the debug macOS target successfully and exposed a Dart VM Service. The app process started, though `open` could not automatically foreground the window in this shell session. @@ -799,8 +802,10 @@ Platform notification checklist for that gate: 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 - native notification permission statuses map to the Flutter channel contract - and notification tap payloads store/emit the selected inventory item id. + the immediate Settings test notification request uses the expected + 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. diff --git a/mobile/macos/Runner/MacLocalNotificationRequest.swift b/mobile/macos/Runner/MacLocalNotificationRequest.swift index ac11919..53a0f79 100644 --- a/mobile/macos/Runner/MacLocalNotificationRequest.swift +++ b/mobile/macos/Runner/MacLocalNotificationRequest.swift @@ -20,6 +20,41 @@ struct MacLocalNotificationRequest { } } +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], diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift index d93a1b5..5ac85fa 100644 --- a/mobile/macos/Runner/MainFlutterWindow.swift +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -139,19 +139,11 @@ final class MacLocalNotificationBridge { } private func sendTestNotification(result: @escaping FlutterResult) { - let content = UNMutableNotificationContent() - content.title = "库存提醒测试" - content.body = "看到这条通知说明本地通知可用" - content.sound = .default - - let trigger = UNTimeIntervalNotificationTrigger( - timeInterval: 1, - repeats: false - ) + let diagnostic = MacDiagnosticNotificationRequestFactory.request() let request = UNNotificationRequest( - identifier: "diagnostic-\(UUID().uuidString)", - content: content, - trigger: trigger + identifier: diagnostic.identifier, + content: diagnostic.content(), + trigger: diagnostic.trigger() ) center.add(request) { error in DispatchQueue.main.async { diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift index 7ce52f5..02848f5 100644 --- a/mobile/macos/RunnerTests/RunnerTests.swift +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -70,6 +70,23 @@ class RunnerTests: XCTestCase { XCTAssertEqual(request.triggerInterval(now: now), 60, accuracy: 0.001) } + 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 diff --git a/mobile/test/macos_notification_wiring_test.dart b/mobile/test/macos_notification_wiring_test.dart index 3dac1b2..d5184bf 100644 --- a/mobile/test/macos_notification_wiring_test.dart +++ b/mobile/test/macos_notification_wiring_test.dart @@ -37,8 +37,9 @@ void main() { 'case "cancelAll":', 'center.removeAllPendingNotificationRequests()', 'MacLocalNotificationRequestFactory.requests', - 'content.title = "库存提醒测试"', - 'UNTimeIntervalNotificationTrigger(', + 'MacDiagnosticNotificationRequestFactory.request()', + 'diagnostic.content()', + 'diagnostic.trigger()', 'channel.invokeMethod("notificationTapped", arguments: ["itemId": itemId])', ]); _expectAllContains(requestFactory, [ @@ -49,6 +50,11 @@ void main() { 'row["scheduledAtMillis"] as? NSNumber', '["itemId": itemId]', 'max(scheduledAt.timeIntervalSince(now), 60)', + 'MacDiagnosticNotificationRequestFactory', + 'identifier: "diagnostic-\\(id)"', + 'title: "库存提醒测试"', + 'body: "看到这条通知说明本地通知可用"', + 'triggerInterval: 1', ]); }); From 8cd0bd40a0aab77dbcc4eee5c7ec24b48d79dba9 Mon Sep 17 00:00:00 2001 From: Aucannot <83545058+Aucannot@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:09:06 +0800 Subject: [PATCH 054/153] Reduce stale web bootstrap caching --- docs/ux-test-findings-2026-06-17.md | 26 +++++++++++++++++++------- mobile/test/web_bootstrap_test.dart | 20 ++++++++++++++++++++ mobile/web/index.html | 10 +++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/ux-test-findings-2026-06-17.md b/docs/ux-test-findings-2026-06-17.md index 15f5793..0af597d 100644 --- a/docs/ux-test-findings-2026-06-17.md +++ b/docs/ux-test-findings-2026-06-17.md @@ -51,9 +51,11 @@ Not proven yet: validation on the target platforms. - Web update experience on an existing origin. The custom bootstrap no longer registers Flutter's service worker, clears stale registrations, and deletes - stale Flutter Cache Storage entries when the current index loads, but an old - service worker or browser cache can still mask the first request for the new - index during manual validation. + stale Flutter Cache Storage entries when the current index loads. The entry + HTML now also discourages stale browser caching and requests + `flutter_bootstrap.js` with a cache-busting query, but an old service worker + or browser cache can still mask the first request for the new index during + manual validation. Recommended next beta gate: @@ -135,7 +137,8 @@ Platform notification checklist for that gate: - `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. + delete stale Flutter Cache Storage entries; passed again after adding + no-cache entry hints and a cache-busted bootstrap script request. - `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, the edit-page Material fix, and no-date order @@ -147,6 +150,8 @@ Platform notification checklist for that gate: 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`. - `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 @@ -201,6 +206,9 @@ Platform notification checklist for that gate: 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. +- 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. - App self-check from Settings: passed, 15/15; passed again on the restarted latest Web target on port 54390 in about 354ms. After adding the backup content check and rebuilding Web, the current Settings self-check passed @@ -917,6 +925,9 @@ Platform notification checklist for that gate: 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. - 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 @@ -990,6 +1001,7 @@ Platform notification checklist for that gate: 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, but - release validation should still use a fresh origin or cache clear until the - Web update experience is designed explicitly. + 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. Release validation should still use a fresh origin or cache clear + until HTTP-level cache policy is controlled by the deployment host. diff --git a/mobile/test/web_bootstrap_test.dart b/mobile/test/web_bootstrap_test.dart index 4f43671..d290680 100644 --- a/mobile/test/web_bootstrap_test.dart +++ b/mobile/test/web_bootstrap_test.dart @@ -18,4 +18,24 @@ void main() { expect(script, contains('window.location.reload()')); expect(script, contains('_flutter.loader.load();')); }); + + test('web entry discourages stale bootstrap caching', () { + 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, isNot(contains(' +