Android 每日飲食營養素紀錄器(Kotlin + Compose)。app 顯示名稱是「肥胖日記」, 專案代號維持 NutriLog —— package、repo、APK 檔名與簽章都綁在它身上。
Why NutriLog? 市面上的飲食紀錄 app 幾乎都要你先開帳號、再把三餐上傳到別人的伺服器。 這支不用:沒有後端、沒有帳號,紀錄全部躺在你自己的手機裡。
- 🔒 完全離線 — 唯一的對外連線是影像辨識與條碼查詢兩支公開 API,兩者都是你主動觸發才會發生。
- 🍱 四條輸入路徑 — 自己填數字、拍照或打一句話交給 Gemini 估、掃商品條碼查 Open Food Facts。
- 🔎 先搜自己吃過的,容錯 — 中文沒有空白可拆詞,改用單字+相鄰兩字加權比對:「烤肉」找得到「煎烤豬肉排/五花肉」,而「咖啡」不會撈到咖哩飯。
- 🌐 需要的時候才上網查 —— 打了店名就按「AI 查」,它先去找該店公布的官方營養標示再算;平常按「AI 估」就好。要不要查是你按的,不是模型猜的。
- 🔢 五段雙速份數縮放 — 支援
±1與±0.1步進,自動縮放公克/毫升/份量文字與所有營養素,具備基準持久化無損還原。 - 📰 「紙與墨」出版物排版美學 — 內嵌 jf open 粉圓中文與 Neucha 手寫數字、自繪精準向量圖示、形狀即層級,無任何預設 Material 容器與色塊。
- ✅ AI 的數字一律要你點頭 — 模型給的是估算值,一定先經過確認畫面才入庫。
- 📅 看得出空白 — 月曆式歷史讓「哪幾天忘了記」一眼就有形狀,清單做不到這件事。
- 📤 CSV 匯出 — 唯一能把資料帶出手機的路徑,定位是完整備份,預設全部匯出。
- 🔑 權限只有一個 — Manifest 裡只有
INTERNET,相機在系統相機與 Play 服務中執行,連相機權限都不需要。
| 元件 | 細節 | |
|---|---|---|
| ⚙️ | 架構 |
|
| 🔩 | 程式品質 |
|
| 📄 | 文件 |
|
| 🔌 | 整合 |
|
| 🧩 | 模組化 |
|
| 🧪 | 測試 |
|
| ⚡️ | 效能 |
|
| 🛡️ | 安全 |
|
| 📦 | 相依 |
|
| 🚀 | 擴充性 |
|
└── NutriLog/
├── .github/
│ └── workflows/
│ └── release.yml
├── app/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/watson/nutrilog/
│ │ │ ├── MainActivity.kt
│ │ │ ├── data/
│ │ │ │ ├── CsvExport.kt
│ │ │ │ ├── CsvImport.kt
│ │ │ │ ├── DriveAuth.kt
│ │ │ │ ├── DriveBackup.kt
│ │ │ │ ├── SettingsStore.kt
│ │ │ │ ├── db/
│ │ │ │ │ ├── CachedProduct.kt
│ │ │ │ │ ├── FoodEntry.kt
│ │ │ │ │ ├── FoodSuggestion.kt
│ │ │ │ │ ├── NutriDao.kt
│ │ │ │ │ └── NutriDatabase.kt
│ │ │ │ └── net/
│ │ │ │ ├── AiPrompts.kt
│ │ │ │ ├── DriveClient.kt
│ │ │ │ ├── GeminiClient.kt
│ │ │ │ ├── ImageCompressor.kt
│ │ │ │ ├── OpenFoodFactsClient.kt
│ │ │ │ ├── OpenRouterClient.kt
│ │ │ │ ├── SharedHttp.kt
│ │ │ │ └── TavilyClient.kt
│ │ │ ├── work/
│ │ │ │ └── BackupWorker.kt
│ │ │ └── ui/
│ │ │ ├── App.kt
│ │ │ ├── BarcodeScreen.kt
│ │ │ ├── Common.kt
│ │ │ ├── EditEntryScreen.kt
│ │ │ ├── HistoryScreen.kt
│ │ │ ├── NutriViewModel.kt
│ │ │ ├── PortionMultiplier.kt
│ │ │ ├── ReviewScreen.kt
│ │ │ ├── SearchScreen.kt
│ │ │ ├── SettingsScreen.kt
│ │ │ ├── TextLookupScreen.kt
│ │ │ ├── TodayScreen.kt
│ │ │ └── theme/
│ │ │ └── Theme.kt
│ │ └── res/
│ │ ├── font/
│ │ │ ├── jf_open_huninn.ttf
│ │ │ └── neucha.ttf
│ │ └── values/
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── test/
│ └── java/com/watson/nutrilog/
│ ├── CsvRoundTripTest.kt
│ ├── DriveBackupPruneTest.kt
│ ├── FoodLibraryMatchTest.kt
│ └── NutrientScalingTest.kt
├── design/
│ ├── canvas.json
│ ├── Main.dc.html
│ └── v2/
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
├── tools/
│ ├── emu.ps1
│ ├── setup-signing.sh
│ └── ui.ps1
├── build.gradle.kts
├── settings.gradle.kts
├── CLAUDE.md
└── README.mdNUTRILOG/
__root__
⦿ __root__
檔案 說明 app/build.gradle.kts 模組建置設定。版號由 CI 從 tag 傳入的 property 覆蓋,本機建置才用預設值。
- 簽章讀 `keystore.properties`,檔案不存在就退回 debug 簽章,讓別人 clone 下來照樣建得起來。gradle/libs.versions.toml 版本目錄,所有相依與外掛的版號單一來源。KSP 的版號前半段必須和 Kotlin 完全一致。 CLAUDE.md 這台機器的環境設定與專案慣例:建置指令、模擬器規則、配色與「紙與墨」版面語言、回歸清單。
com.watson.nutrilog
⦿ app/src/main/java/com/watson/nutrilog
檔案 說明 MainActivity.kt 唯一的 Activity。開啟 edge-to-edge、套上主題,並建立 activity-scoped 的 ViewModel 串接全域狀態。
data
⦿ app/src/main/java/com/watson/nutrilog/data
檔案 說明 CsvExport.kt 把飲食紀錄轉成 CSV,是把資料帶出手機的路徑。
- 純函式、不碰 Android API。
- 檔頭有 UTF-8 BOM,避免 Excel 中文亂碼。
- 欄位名稱本身就是格式:`CsvImport` 靠名字對應欄位。CsvImport.kt 把匯出的 CSV 讀回資料庫,換手機或重裝之後接回原本的紀錄。
- 靠欄位名稱對應,舊版少兩欄的匯出檔也讀得回來。
- 依「日期+名稱+份量+記錄時間」去重,同一份檔匯入兩次不會變兩份。
- 壞掉的資料列跳過並回報,不讓整份檔案失敗。DriveAuth.kt Drive 授權(Identity AuthorizationClient,非已淘汰的 GoogleSignIn)。
- 只索取drive.file:僅能存取本 app 自行建立的檔案,非受限範圍、免安全評估。
- app 內不含任何 client id:Android OAuth client 以套件名 + 簽章 SHA-1 辨識。DriveBackup.kt 備份與還原的流程編排:建立 Drive 主頁 NutriLog/資料夾、上傳當日 CSV、保留最近 30 天。
- 備份內容與本地匯出完全相同,可自行下載或改用本地匯入讀回。
- 保留規則為純函式並有測試涵蓋。SettingsStore.kt 使用者設定與每日目標。用 DataStore Preferences 儲存單份無關聯之輕量偏好設定。
data.db
⦿ app/src/main/java/com/watson/nutrilog/data/db
檔案 說明 FoodEntry.kt 一筆吃下去的飲食紀錄實體,包含份數倍率 `portionMultiplier`、延伸四項營養素與全天合計 `Totals`。日期以本地 YYYY-MM-DD 字串儲存。 NutriDao.kt Room DAO。合計走 SQL `GROUP BY` 計算,不把龐大明細撈進記憶體。常吃/最近以「名稱+份量文字」分組聚合。 FoodSuggestion.kt 個人食物庫品項 —— 從既有紀錄聚合出來的品項模型,不額外建立實體表,提供快速一鍵帶入。 CachedProduct.kt 查過的條碼商品快取表(每 100g 營養素),節省 OFF 頻率限制並支援離線再次掃碼。 NutriDatabase.kt Room 資料庫單例與 Migrations。
data.net
⦿ app/src/main/java/com/watson/nutrilog/data/net
檔案 說明 GeminiClient.kt 照片與文字描述的營養估算。以 `responseSchema` 強制結構化 JSON 輸出,自動重試 5xx 與網路逾時。
- 四個進階營養素(糖/鈉/膳食纖維/飽和脂肪)列為 `required` 但仍可為 `null`:選填等於給模型一個整個略過的藉口。DriveClient.kt Google Drive REST v3,僅實作備份所需的四支端點(建資料夾、上傳/覆蓋、列檔、下載)。
- 以 OkHttp 手寫,不引官方 Drive client 函式庫(會拖進 google-api-client 與 guava)。
- 錯誤訊息帶上 Drive 回傳內容,權杖過期與配額不足才分得開。OpenFoodFactsClient.kt 條碼查詢客戶端。自動附帶規範之自訂 User-Agent,並把鈉公克轉換為毫克。 ImageCompressor.kt 將原始照片等比例縮放到長邊 1024 px 並壓為 base64 JPEG,大幅降低頻寬與延遲。 SharedHttp.kt 全 app 共用之 `OkHttpClient` 單例,維持高效連線池與執行緒管理。 OpenRouterClient.kt 文字辨識的另一家供應商(**只做文字**,拍照永遠走 Gemini)。
- 以**強制函式呼叫**(`tool_choice`)鎖住 JSON,而不是 `response_format` —— 想用的免費模型不支援後者。
- 錯誤碼比 Gemini 多一種:**402 是餘額不足**(免費模型也需要帳號裡有額度)。TavilyClient.kt 「AI 查」按下去時的網路搜尋。把清洗過的頁面正文接到 prompt 前面,**與供應商無關**,兩家都適用。
- 不需要 tool calling,也不多消耗模型的請求次數。
- 搜尋失敗一律回 `null`:它只是輔助,不該因為搜尋壞掉讓整條辨識失敗。AiPrompts.kt 兩家供應商**共用的 prompt**。同一段話兩邊各抄一份遲早會漂,而漂掉的症狀是「換一家之後回來的東西長得不一樣」。
ui
⦿ app/src/main/java/com/watson/nutrilog/ui
檔案 說明 NutriViewModel.kt 唯一的 ViewModel:管理全 App 狀態機、草稿狀態、辨識生命週期、搜尋與預設餐別。 App.kt 根 Composable。分派畫面與管理相機、相簿、SAF 與條碼掃描之 ActivityResultLauncher。 TodayScreen.kt 今日主畫面:一週長條、已吃熱量計數器、餐別分段進度條、三大營養素組成與兩級超標警示、固定四餐清單與五合一懸浮選單。 PortionMultiplier.kt 五段純數字雙速步進列(`±1` 與 `±0.1` 圓章按鍵),中間顯示倍率與襯線數字,支援基線對齊與無損還原。 EditEntryScreen.kt 共用飲食編輯表單:2×2 核心營養素網格、自繪圓章數字鍵盤(避免擋住儲存鈕)、份數縮放步進列、折疊進階營養素與熱量交叉檢驗。 ReviewScreen.kt AI 辨識結果確認頁面:品項勾選、單品份數縮放、信心度指標與目標餐別預選。 SearchScreen.kt 搜尋與個人食物庫(90 天常吃/最近兩頁切換,即時多關鍵字全文搜尋,點擊直接進入編輯表單)。 TextLookupScreen.kt 常吃食物快捷與自然語言文字描述 AI 辨識合成頁面。 HistoryScreen.kt 月曆式歷史視圖:熱量深淺與超標警示色塊、一眼辨識空白未記錄日,下方統計當月總覽。 BarcodeScreen.kt 條碼掃描與手動輸入條碼,支援自訂實際食用克數自動等比換算。 SettingsScreen.kt 外觀模式(系統/淺色/深色)、Gemini API Key 與模型攤開圈選(刻意不用下拉選單)、每日營養目標數字欄位、進階營養素開關與 CSV 備份匯出。 Common.kt 「紙與墨」設計系統元件:`Hairline`(1px)、`Rule`(2px)、`StampButton`、`PillButton`、`TextAction`、`RoundKey`、`BallotRow`、`SquareCheck`、`NutriTextField`、`dismissKeyboardOnTap`(點空白處收鍵盤)、`SwipeToReveal` / `UndoStamp`(左滑刪除與復原)與全自繪向量 `*Mark` 圖示。
ui.theme
⦿ app/src/main/java/com/watson/nutrilog/ui/theme
檔案 說明 Theme.kt 「紙與墨」出版物色票(淺色米紙 `#F7F3E9`、深色暖黑 `#17150F`)、三大營養素色階、兩級超標警示(橘 `#B8791F` / 紅 `#D8462A`),以及內嵌的 jf open 粉圓中文字型與 Neucha 數字字型。
work
⦿ app/src/main/java/com/watson/nutrilog/work
檔案 說明 BackupWorker.kt 每日一次的 Drive 備份排程(WorkManager)。
- 選用 WorkManager 而非 AlarmManager:Doze 與重新開機後仍可靠。
- 網路類失敗一律 retry;僅「需重新授權」回 failure,因背景無畫面可詢問使用者。
test
⦿ app/src/test/java/com/watson/nutrilog
檔案 說明 NutrientScalingTest.kt 單元測試:驗證份量文字縮放、DetectedFood 營養素等比計算、EntryDraft 基準導出與還原無損計算。 CsvRoundTripTest.kt 單元測試:CSV 匯出→匯入來回逐欄一致、逗號/引號/換行跳脫、缺資料維持 null、舊版欄位相容、去重鍵與壞資料列跳過。 DriveBackupPruneTest.kt 單元測試:雲端備份的 30 天保留規則 —— 只刪自己產生的日期檔、跨月跨年排序正確、使用者自行放入的檔案一律不動。 FoodLibraryMatchTest.kt 單元測試:食物庫的模糊比對 —— 描述比庫裡更細仍找得到、名稱裡被拆開的詞仍找得到、只共用一個字不算命中、整串命中排在近似之前。
tools
⦿ tools
檔案 說明 emu.ps1 Windows 模擬器輔助腳本:啟動 AVD 並等待 `boot_completed`,建置與部署。 ui.ps1 UI 驗證工具:傾印畫面所有文字節點與座標,並以元件文字進行精準點擊測試。 setup-signing.sh 一次性正式發佈簽章金鑰設定精靈(產金鑰 → 驗指紋 → 設 GitHub Secrets)。
.github.workflows
⦿ .github/workflows
檔案 說明 release.yml 推 `v*` tag 自動觸發建置、覆寫版號、以正式簽章產出 APK 並發佈至 GitHub Release。
- 語言: Kotlin 2.0.21
- 建置工具: Gradle 8.11.1(或使用 repo 內之
./gradlew) - JDK: 17
- Android SDK: compileSdk 35,最低支援 Android 8.0(minSdk 26)
只是想使用 app 的話不需要安裝上述環境 —— 直接至 Releases 下載最新 APK 安裝即可。
從原始碼編譯:
-
Clone 專案:
❯ git clone https://github.com/rowing195/NutriLog
-
進入目錄:
❯ cd NutriLog -
建置 Debug APK:
❯ ./gradlew assembleDebug
APK 產出於 app/build/outputs/apk/debug/app-debug.apk。
若本地無 keystore.properties,Gradle 將自動退回 debug 簽章以確保可順利編譯。
安裝至已連線的實機或模擬器:
❯ ./gradlew installDebugWindows 平台可使用隨附腳本:
& ".\tools\emu.ps1" start # 啟動模擬器並等待 boot_completed
& ".\tools\emu.ps1" deploy # 自動編譯並安裝執行自動化單元測試套件:
❯ ./gradlew test單元測試覆蓋:
- 份量字串縮放演算法(克、毫升、碗、份)
DetectedFood浮點營養素精確度與可空欄位保持EntryDraft基準值導出與無損還原(避免浮點進位累積漂移)
- 雲端備份的 30 天保留規則:只刪自己產生的日期檔、跨月跨年排序正確、使用者自行放入的檔案一律不動
- 描述打得比食物庫裡更細仍找得到(「手沖藝妓黑咖啡」→「手沖黑咖啡」)
- 換一種說法仍找得到(「美式黑咖啡」→「手沖黑咖啡」)
- 名稱裡被拆開的詞仍找得到(「烤肉」→「煎烤豬肉排/五花肉」)
- 只共用一個字不算命中(「咖啡」不會撈到「咖哩飯」)
- 整串命中一定排在近似命中之前,篩掉不相干的並依相符程度排序
- 每日備份對齊到凌晨 3 點的延遲計算(跨日、剛好 3 點、深夜與傍晚各一種)
- 這一項是純函式,因為它決定了「每一個日期檔是不是前一天結束時的完整狀態」
- 匯出→匯入來回逐欄一致(含份數倍率與記錄時間)
- 食物名稱裡的逗號、引號與換行照 RFC 4180 跳脫與還原
- 缺資料維持
null而不是變成 0 - 舊版(少「記錄時間」「份數倍率」兩欄)的匯出檔仍可匯入
- 去重鍵:同一筆重複匯入會撞在一起,但同名不同時間的兩筆不會
UI 部分使用 tools/ui.ps1 依元件文字進行模擬器自動化操作:
& ".\tools\ui.ps1" dump # 列出畫面所有文字節點與中心座標
& ".\tools\ui.ps1" tap "記一筆"
& ".\tools\ui.ps1" type "Chicken" # input text 只吃 ASCII,測試資料一律用英數- 一週長條與日紀錄聯動:上方為一週每日熱量達成率長條,滑動切換日期時自動維持同步,跨週時平滑換頁。
- 主數字顯示已吃熱量:主視覺直接顯示當日已攝取總熱量,目標與剩餘額度退居次要輔助行。
- 餐別分段熱量條:以早、午、晚、點心四色區段直觀呈現熱量攝取分佈結構。
- 三大營養素組成與兩級超標警示:
- 蛋白質、脂肪、碳水化合物轉換為熱量比例長條。
- 圖例整合兩級警示邏輯:超標 10% 以內顯示暖橘(
Warning),超過 10% 顯示朱紅(Over)。 - 下方進階營養素(糖/鈉/膳食纖維/飽和脂肪)一行到底,窄螢幕放不下時可以左右拖,高度永遠固定(見 issue #12)。
- 固定四餐區塊:早餐、午餐、晚餐、點心四格永遠列出,未記錄時提供直接補登入口,並自動預選該餐別。
- 五合一懸浮章印選單:右下角自繪墨印按鈕展開拍照、相簿、常吃/文字、條碼與手動五大入口。
- 左滑刪除與復原:紀錄列左滑時整張字卡跟著位移,放手後以彈簧回彈定位、刪除區留在原地,點擊後刪除,左下角滑出與「記一筆」同尺寸的深灰復原章,章體下沿墨線線性收縮呈現剩餘秒數,提供 5 秒復原視窗。復原以原 id 還原紀錄,備份與去重鍵均不受影響。
- 五段純數字雙速步進列:提供
−1、−0.1、+0.1、+1四顆自繪圓章按鍵,中間展示當前倍率與襯線數字。 - 基準值持久化與無損還原:
- 資料庫記錄
portionMultiplier。 - 編輯已放大紀錄時,系統以
deriveBase精確逆推原始 1.0x 基準,避免多次縮放產生的浮點數捨入漂移。
- 資料庫記錄
- 全自動字串與數值同步:
- 同步調整份量文字(例如
1 碗 (250g)縮放為1.5 碗 (375g)、700ml縮放為1050ml)。 - 熱量取整數、三大營養素保留一位小數、可空進階營養素正確保持
null。
- 同步調整份量文字(例如
| 方式 | 運作流程 |
|---|---|
| 輸入營養素 | 2×2 核心營養素網格,搭配自繪圓章數字鍵盤與份數步進列,完全避免系統鍵盤遮擋儲存鈕問題。 |
| 拍照辨識 | 拍照或自相簿選取 → 壓縮長邊至 1024 px → Gemini 結構化辨識 → 確認畫面逐項勾選與微調後入庫。 |
| 常吃/文字輸入 | 同一個輸入框服務兩條路:打字即時模糊篩選個人食物庫,找到直接點;篩不到時才把那句描述(如「無糖綠茶 700ml」)交給 Gemini 估算。 |
| 掃條碼 | 掃描條碼或手動輸入 → 優先讀取本機快取,無快取則查詢 Open Food Facts → 輸入食用公克數自動換算。 |
所有有輸入的畫面(上表三條打字路徑 + 搜尋 + 設定的每日目標)共通一件事:點輸入框與鍵盤以外的空白處即可收鍵盤,回到沒在打字的版面,已經打的字與數值都保留。編輯表單裡自繪的數字鍵盤同樣照這個方式收 —— 對使用者而言那與系統鍵盤是同一件事。
常吃頁與搜尋頁另有第二個入口:手指一開始捲清單,鍵盤就自己收起來。捲清單本身就表示使用者不在打字、正在看結果,而鍵盤佔掉半個畫面時剩下的清單只有兩三列。左右滑換分頁與點分頁標籤刻意不收(前者可能只是看一眼另一頁就要繼續打字,後者是畫面自己在捲);編輯表單也不掛這條 —— 那裡捲動是為了把儲存鈕拉回畫面上,收掉數字鍵盤正好相反。
- 最上面一個搜尋框,打字即時篩常吃/最近兩頁,找到直接點那一列帶進編輯表單 —— 不必在清單裡慢慢翻,也不必跳去搜尋頁。
- 篩選是模糊比對(原理見〈設計決策〉):「烤肉」找得到「煎烤豬肉排/五花肉」,而「咖啡」不會把咖哩飯撈上來。
- 找得到的排在前面,同分的維持原本「常吃」的次數順序與「最近」的日期順序。
- 底下那行會看情況講話:上面還篩得到東西時是「不是上面這些?」,真的一筆都沒有才說「沒有『⋯』?」—— 上面明明列著相近的卻說沒有,等於這個 app 沒在看自己的清單。
- 底下是成對的兩顆章:「AI 估」(憑模型印象,快)與「AI 查」(先上網找官方營養標示再算,慢一點)。兩個名字只差一個字,而那個字正好就是唯一真正的差別(見〈要不要查網路,由使用者按鈕決定〉)。
- 鍵盤一開,底下那區自動收到只剩兩顆章,把高度讓給清單;真的篩不到時標題會留著,因為那時候它是畫面上唯一還在講話的東西。兩顆章不跟著收 —— 收掉說明是省版面,收掉動作本身會讓人以為按鈕不見了。
- 離開搜尋框時是兩段動畫:先落地,再長出來。 等鍵盤真的退完、整區沉到定位,文字才從章的底邊往上長出來。兩件事一起做的話,畫面在同一段時間裡往兩個方向動,讀起來是彈一下而不是一個動作。等多久是問系統鍵盤的,不是寫死的秒數,所以各家輸入法快慢不一樣也都接得上。
連鎖店的品項網路上有官方營養標示,模型憑印象估的跟官方公布的差得不少。 所以常吃頁底下是兩顆章,打完描述自己選:
| 按哪一顆 | 發生什麼事 | 實測「麥當勞 大麥克」 |
|---|---|---|
| AI 估(主章) | 模型憑自己的知識估,快、不花搜尋額度 | 540 kcal(美國規格) |
| AI 查(次章,深灰) | 先上網找那個品項的營養標示,再把找到的表格交給模型讀 | 503 kcal(台灣麥當勞官方頁) |
- 要用第二顆章得先到 設定 → API 管理 選一個搜尋來源;沒選的話那顆是外框章、 按不下去,底下會講一句為什麼。
- 搜尋壞掉不會讓辨識失敗:查不到就讓模型照原本的方式估,錯誤留在 logcat。
- 搜尋結果放在使用者輸入前面並明講它是參考資料:它是外部來的、可能過期或根本在講別的品項 (實測結果裡混著部落格整理的表格,數字和官方差了將近 100 大卡)。
設定裡可以選文字描述要送去 Gemini 還是 OpenRouter。拍照不受它影響,永遠是 Gemini —— 拍照要吃得下圖片的模型,而這條路上想用的 OpenRouter 免費模型是純文字的。 做成一個總開關的話,選了 OpenRouter 之後拍照會神祕地失敗或偷偷跑去別家,兩種都比在設定頁講清楚差。
兩家共用同一份 prompt(AiPrompts),但傳輸格式、強制 JSON 的手法、錯誤訊息全都不一樣,
所以是兩個獨立的 client、沒有抽共同介面。
- 一格一天的月曆視圖,格子內顯示當日熱量,並以背景深淺及超標朱紅色直觀呈現。
- 「看得出空白」設計:未記錄天數一眼即可辨識,避免清單模式造成的漏記遮蔽。
- 左右滑就換月,拖的時候上方那個「2026 / 09」也跟著手指走,下一個月的月份從旁邊補進來 —— 和今日頁的週長條同一種手感。兩側箭頭留著,兩條路做同一件事。一次滑動就是一個月,不管滑多快。
- 下方即時由 SQLite
GROUP BY計算當月總記錄天數、平均熱量與超標天數。 - 不在本月時,畫面最底下會出現一顆空心章「回到本月」。它不在報頭裡: 它是一個動作而不是某一個月的內容,放到分頁器外面的底部,它出現時吃掉的是月曆底下那塊 本來就空的地方,格子一格都不會動。
- 點擊右上角放大鏡開啟。
- 未輸入關鍵字時:展示個人食物庫,支援左右滑動切換「90 天常吃」與「全部最近」。
- 輸入關鍵字時:切換為即時全文搜尋模式,支援多關鍵字空白分割比對(名稱 + 份量文字)。
- 點擊任一項目直接帶入編輯表單,兼顧便捷與可編輯性。
這裡的搜尋與常吃頁那一個搜的不是同一種東西:這頁搜的是逐筆紀錄(每一筆帶日期),回答的是「我哪天吃過這個」;常吃頁搜的是聚合後的品項,回答的是「拿一個品項來記一筆」——日期在那裡是雜訊,而且同一樣東西會重複出現二十次。兩頁共用同一個食物庫元件,但主要工作不同,所以沒有合併成一個要切換模式的畫面。
- 經由 Android 儲存存取框架(Storage Access Framework, SAF)將全量飲食紀錄匯出為標準 CSV,或把匯出過的 CSV 讀回來。
- 檔案開頭內嵌 UTF-8 BOM,確保 Excel 與 Google 試算表正確辨識繁體中文。
- 缺失營養素輸出為空白欄位而非 0,匯入時也維持
null,忠實保留原始資料型態。 - 匯入前先停在確認面板:會先算好「新增幾筆、日期範圍、略過幾筆重複、跳過幾列壞資料」再問要不要寫進去。
- 重複自動略過:以「日期+名稱+份量+記錄時間」辨識同一筆,同一份檔案匯入兩次不會變成兩份,也能把兩支手機的紀錄合併起來。
- 匯出→匯入→再匯出實測為完全相同的檔案,換手機可以無損接回。
- 於設定頁連結 Google 帳號後,每天自動將紀錄備份至雲端硬碟主頁
NutriLog/資料夾,一天一個日期檔、僅保留最近 30 天。 - 背景排程採用 WorkManager(非 AlarmManager),可於 Doze 省電模式與重新開機後維持運作。排程對齊至每日凌晨 3 時,因此每個日期檔即為「前一日結束時的完整狀態」;實際執行時間會受 Doze 影響而順延至裝置下次喚醒,WorkManager 保證的是頻率而非準點。
- 每份備份皆為資料庫完整快照而非當日增量,最新一份永遠包含全部紀錄。
- 授權範圍僅
drive.file:只能存取本 app 自行建立的檔案,讀不到雲端硬碟上的其他資料。此範圍非 Google 定義之受限範圍,無需安全評估審查。 - 備份內容與本地匯出完全相同,可直接於 Drive 下載、以試算表開啟,或改用本地匯入讀回 —— 資料不會被鎖在 app 裡。
- 「連結 Google Drive」會順便把雲端的紀錄接回來:換手機時自動比對雲端備份,走與本地匯入相同的確認面板(新增幾筆/略過幾筆重複),確認後才寫入資料庫。
- 此功能為選配。未連結時 app 不會存取網路,也不會排入任何背景工作。
- 首次使用需自行於 Google Cloud 建立 OAuth client,可執行
tools/setup-google-drive.sh精靈完成設定。
設定是這支 app 唯一有兩層的畫面:先是一排項目,點進去才是內容。 六段疊成一條長捲軸的話,找一個開關要捲很久;選單每一列右邊直接寫著現在的值 (深淺模式、熱量目標、key 設了沒、Drive 連了沒),不用點進去就看得到自己設過什麼。 子頁的返回鍵回選單,不是回今日頁 —— 不然每改一項設定都要重新點兩次進來。
三把 key 都在 設定 → API 管理 底下,各自一頁:
| key | 用在哪 | 怎麼拿 |
|---|---|---|
| Gemini | 拍照辨識(必需)、文字辨識(預設) | Google AI Studio 免費申請 |
| OpenRouter | 文字辨識的另一家(選配) | openrouter.ai |
| Tavily | 「AI 查」的搜尋來源(選配) | tavily.com,免費層 1000 次/月 |
Key 僅安全儲存於本地 DataStore,不會打包進 APK 或上傳第三方伺服器。
同一頁還可以選模型(預設推薦 gemini-3.7-flash,亦可選用 gemini-3.5-flash-lite)、
文字辨識要走哪一家,以及「AI 查」要用誰查。只有 Gemini 那把是必需的:
沒有它拍照辨識就不能用,其餘三項不設也不影響 app 的其他功能。
這一節是「為什麼這樣設計」;「什麼東西壞過、怎麼追出來的」在 已關閉的 issues。
飲食紀錄具備日增長、關聯查詢(依日期範圍、餐別合計、分組統計)特性,採用具備索引的 Room SQLite 關聯式資料庫是最可靠做法。設定資料量極小且單一,採用 DataStore Preferences 即可滿足需求。
- 拍照:使用
ActivityResultContracts.TakePicture()委託系統相機 App 處理。 - 掃碼:使用 Google Play 服務之 Google Code Scanner,掃描視窗獨立於 Google Play 服務行程執行。
- 相簿:使用系統
PickVisualMedia照片選擇器。
本 App 本身無需宣告 CAMERA 或儲存權限,僅需 INTERNET 權限進行外部查詢。
- 色票:淺色米紙底色
#F7F3E9、深色暖黑#17150F、朱紅焦點#D8462A、琥珀警示#B8791F。 - 規線取代色塊:版面層次完全依靠 2px 墨線(
Rule)與 1px 細線(Hairline)劃分,堅決不用 Material 浮凸色塊卡片。 - 字型:純數字、日期、單位與按鍵採用內嵌 Neucha(
res/font/neucha.ttf)手寫體,並已正規化數字與標點的側邊留白(原版1/2/3/4/5/7側邊留白為 0,導致11、0.2等組合會黏在一起) —— 每天隨手記一筆的東西,數字長得像手寫的比像印刷品更貼近它在做的事;中文採用內嵌 jf open 粉圓(res/font/jf_open_huninn.ttf)—— 圓體的柔和調性搭配手寫數字,而粗細均勻、小字級撐得住;標題輔以拉開字距(letterSpacing)建立清晰層級。
常吃頁最上面只有一個輸入框,它同時是「篩自己的食物庫」與「把描述交給 AI」的入口。做成上下兩個框、或一個框配兩顆同級按鈕,都要使用者在打字之前先決定用哪一種搜尋,而選錯是安靜的:想篩清單卻送去 AI,等於白花一次 API 呼叫與數秒等待;想問 AI 卻打進篩選框,只會看到空清單、像是壞了。一個框則沒有東西要選 —— 打字時清單自己收斂,收斂到空的那一刻正好就是該問 AI 的時候。同理,鍵盤上的送出鍵只收鍵盤、不送 AI:那條要花錢也要等的路,一定要明確按下那顆章才走。
比對不能只用 contains。 中文沒有空白可以拆詞,而使用者為了讓 AI 估得準,打的往往比食物庫裡存的更細、或根本是另一種寫法。實際做法是單字與相鄰兩字(bigram)各算一份重疊比例,相鄰兩字加權 2 倍,門檻 0.3:
| 關鍵字 → 食物庫裡的 | 該不該中 | 只看相鄰兩字 | 只看單字 | 加權合分(現行) |
|---|---|---|---|---|
| 手沖藝妓黑咖啡 → 手沖黑咖啡 | 該中 | 0.50 ✅ | ✅ | 0.58 ✅ |
| 美式黑咖啡 → 手沖黑咖啡 | 該中 | 0.50 ✅ | ✅ | 0.54 ✅ |
| 烤肉 → 煎烤豬肉排/五花肉 | 該中 | 0.00 ❌ | ✅ | 0.50 ✅ |
| 咖啡 → 咖哩飯 | 不該中 | 0.00 ✅ | 1.00 ❌ | 0.25 ✅ |
兩種 n-gram 各自補對方的洞:只看相鄰兩字會漏掉在名稱裡被拆開的詞(「烤肉」在「煎烤豬肉排」裡是烤…肉,相鄰兩字一個都對不上),只看單字則會把「咖」對上咖哩、「肉」對上任何有肉的東西。加權相加之後兩件事同時成立,這也是 CJK 搜尋的標準形狀 —— 單字與相鄰兩字各建一份索引再加權合分。門檻 0.3 最好記的意義是「兩個字的關鍵字,兩個字都要出現」:只中一個是 0.25,剛好落在門檻外。整串命中另外給 2.0,因為近似分數的上限就是 1.0,撞在一起就無法保證「真的有這個」排在「長得有點像」前面。
它沒有語意。「拿鐵」與「牛奶咖啡」一個字都不共用,這裡就是配不起來,跨語言(latte/拿鐵)亦然。那正是底下那兩顆章存在的理由,不在這裡補同義詞表。規則由 FoodLibraryMatchTest 釘住 —— adb shell input text 只吃 ASCII,中文行為在模擬器上根本打不出來,只能靠單元測試驗。
不是設定裡的總開關,也不是讓模型自己判斷。理由很簡單:使用者在打字的當下就已經 知道自己要哪一種了 —— 他在食物前面加店名,就是想要官方資料;打「兩顆蛋」那種東西本來 就沒有官方標示可查,多等十秒只是浪費。這個判斷在使用者腦裡,不在模型那邊。
三條路都實際試過,兩條失敗:
| 試過的做法 | 結果 |
|---|---|
Gemini 搜尋 grounding(tools: [{google_search:{}}]) |
免費層的配額是 0,每一次文字辨識都變 429,整條路直接掛掉 |
| 讓模型自己下查詢(tool calling) | 不強制就不收斂(三輪查詢後仍未回傳結果);強制之後它自己下的查詢反而撈到美規數字 |
| 自己先查、把正文接進 prompt(現行) | 拿到台灣官方頁的數字,而且模型仍然只收到一個普通請求 |
第二條的失敗是結構性的:多給一個工具就不能再強制 tool_choice,而那正是 OpenRouter
那條路鎖住 JSON 的唯一手段。這個題目的搜尋意圖是恆定的(永遠在問營養標示),
沒有需要模型推敲的餘地,放手讓它推敲反而弄丟穩定性。實驗留在 tool-calling-search
分支當紀錄,不合併。
搜尋來源選 Tavily 而不是 Brave:Brave 免費層回的是 SERP 片段,實測同一個查詢四筆裡 三筆在講麥克雞塊和薯條,唯一有數字的是 2018 年的新聞稿,而它的 AI 摘要要付費方案; Tavily 免費層回的就是清洗過的頁面正文。
糖、鈉、膳食纖維、飽和脂肪這四欄在 Gemini 那份是 required 但仍可為 null,
OpenRouter 那份維持選填。這是實測出來的,不是兩邊忘了同步。
選填等於給模型一個整個略過的藉口,改成必填之後 Gemini 那邊就填得出來了;
但同樣的改法在 OpenRouter 那個免費健康模型上,兩次都把鈉 1092.5 毫克換算成 1.092 公克
填進膳食纖維。逃不掉鍵之後它選了隨便找個欄位塞,而不是老實填 null —— 而
錯的數字比空白更糟:確認畫面只列出熱量與三大營養素,進階那四欄沒人看得到,
進去就是默默落地。詳細的追查過程見
issue #11。
常吃頁離開搜尋框時同時有兩件事想發生:imePadding() 跟著鍵盤退場縮回去(整區往下沉),
以及剛剛收起來的說明文字要長回來。兩件事一起做的話畫面在同一段時間裡往兩個方向動,
讀起來就是彈一下;排成兩段之後是「先落地、再長出來」,那才讀得成一個動作。
第二段什麼時候開始,問 WindowInsets.ime 的 bottom 是不是 0,不自己數毫秒:
各家 IME 的退場長度不一樣(大約 200~300ms),寫死一個延遲在慢的機器上會提早搶拍、
在根本沒有鍵盤動畫的機器上則是乾等一段什麼都沒發生的空檔。
月曆報頭那個月份用的是借位:它兩側站著箭頭、做不成分頁器的一頁,所以改成讀分頁器的 即時位移自己位移,鄰月的字從旁邊補進來。純視覺,從頭到尾不碰分頁器自己的捲動狀態 —— 反過來做(拿即時值去驅動另一個分頁器的位置)正是今日頁那兩個換頁 bug 的共同根源。
不用 Material 成品容器就得自己承擔兩件事,兩者都曾經在深色模式下造成整段文字看不見。
LocalContentColor的預設值是純黑,只有 M3 的Surface會覆蓋它。本專案的畫面是Modifier.background()疊出來的,畫在Scaffold之外的覆蓋層(新增選單、Dialog)裡沒指定color的Text會一路吃到黑色 —— 淺色模式下黑字配米底剛好正確,所以只有深色模式會現形。現已於NutriLogTheme根部統一提供LocalContentColor = onSurface。- 遮罩用
scrim而非inverseSurface。inverseSurface的語意是「與目前主題相反的表面」,深色模式下它是亮色,拿來當遮罩會把背景刷亮、使面板成為畫面上最暗的一塊。scrim於兩套配色皆明確指定為Paper.Ink,永遠是壓暗。
完全替換所有 M3 預設外觀元件:
StampButton:墨色實心印章(主要確認動作)。成對的動作(匯出/匯入)維持相同形狀,靠退一階的深灰底色區分方向;次要動作用空心章,破壞性動作用空心朱紅章PillButton:圓角藥丸(就地確認、查詢)TextAction:純文字按鈕(次要切換)RoundKey:圓章按鍵(自製數字鍵盤、步進器)BallotRow/MealPicker:單選圓形圈選SquareCheck:複選方形打勾框NutriTextField:全封閉外框 + 3px 底部加重規線- 全自繪 24 格 1.6dp 圓端點
*Mark向量圖示,杜絕通用 Material 圖示造成的粗糙感。
GET https://world.openfoodfacts.org/api/v2/product/{barcode}.json
- 無需 API Key,請求需帶規範之 User-Agent。
- 每 IP 每分鐘限制 15 次,查詢結果自動寫入
cached_products本機快取。
POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
- API Key 走
x-goog-api-keyHTTP Header。 - 透過
responseSchema鎖定純 JSON 結構化輸出。 - 內建 5xx / 逾時自動指數退避重試 3 次。
- 不加搜尋 grounding(
tools: [{google_search:{}}]):實測免費層的 grounding 配額是 0, 開了之後每一次文字辨識都變 429,而且那個 429 的 body 沒有QuotaFailure明細, 只能開關對照才分離得出來。
POST https://openrouter.ai/api/v1/chat/completions
- 文字辨識的另一家供應商,預設模型
inclusionai/ling-3.0-flash-sante:free。 - 用強制函式呼叫鎖 JSON(定義一個函式、參數就是那份 schema,再用
tool_choice強制呼叫), 因為那個模型的supported_parameters裡沒有response_format。 回傳在choices[0].message.tool_calls[0].function.arguments,而那是字串包著的 JSON。 - 402 是餘額不足(免費模型也需要帳號裡有額度才跑得動),401 才是 key 的問題 —— Gemini 那邊沒有前者這種狀態。
- 換模型之前先查
openrouter.ai/api/v1/models,確認新模型的supported_parameters有tools。
POST https://api.tavily.com/search
- 「AI 查」按下去時才呼叫,免費層 1000 次/月。
- 不開
include_answer:那會回一段 AI 摘要,而摘要傾向給「約 500 至 550」這種跨地區區間; 自己的 prompt 讀官方表格比讀別人的摘要準。 max_results = 3、每筆正文截到 1500 字元;search_depth維持basic(理由見 issue #11)。- 失敗一律回
null不拋例外:它只是輔助,不該因為搜尋壞掉讓整條辨識失敗。
推動 v* 格式之 Git Tag 將自動觸發 GitHub Actions 進行正式 APK 編譯與 Release 建立:
git tag -a v1.10.0 -m "Release v1.10.0: 中文換成 jf open 粉圓"
git push origin v1.10.0版號由 Tag 動態注入,確保發佈檔名與內部版本號完全一致。
| 項目 | 規格值 |
|---|---|
| Kotlin / AGP / Gradle | 2.0.21 / 8.7.3 / 8.11.1 |
| minSdk / targetSdk / compileSdk | 26 / 35 / 35 |
| JDK | 17 |
| UI 框架 | Jetpack Compose (BOM 2024.10.01) + 自訂「紙與墨」元件庫 |
| 本地儲存 | Room 2.6.1 + DataStore Preferences 1.1.1 |
| 網路通訊 | OkHttp 4.12.0 + kotlinx-serialization 1.7.3 |
| 條碼辨識 | Google Play services Code Scanner 16.1.0 |
| 雲端備份 | Google Play services Auth 22.0.0(drive.file)+ WorkManager 2.10.0 |
| 測試框架 | JUnit 4 + Kotlin Test |
| 內嵌字型 | jf open 粉圓 2.1(中文)+ Neucha(數字,已正規化側邊留白) |
| 發佈 APK 大小 | 約 11.4 MB(其中內嵌字型約 2.9 MB) |
| 應用權限 | android.permission.INTERNET |
- 🐛 回報問題:提交 Bug 或功能建議。
- 📓 看以前踩過的坑:已關閉的 issues 是當紀錄用的,不是待辦清單。每一篇都是「症狀 → 成因 → 修法」,包括猜錯的方向 —— 改到相關的地方之前先翻一下,有些看起來很合理的「簡化」前人已經試過並且壞過一次。
- 💡 提交 Pull Request:Fork 專案 → 建立分支 → 完成修改與驗證 → 提交 PR。
開發時請遵循 CLAUDE.md 規範:註解撰寫繁體中文說明決策原因、遵守無 M3 預設元件原則、修改 Room Entity 需提供 Migration 與版本升級。
NutriLog 採用 MIT License 授權。
- Open Food Facts —— 開放食品條碼資料庫。
- Google Gemini API —— 多模態影像與自然語言營養估算。
- Google Code Scanner —— 免相機權限之系統級條碼掃描模組。
- Neucha —— 手寫風格數字字型(OFL,Jovanny Lemonad)。
- jf open 粉圓 —— 台灣在地化圓體中文字型(OFL,justfont)。
