Show original
Try the app
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.
AI translation
Five things I stumbled upon while personally developing Tabememo and releasing it to the App Store. App review, how to handle API keys, CloudKit constraints, widget balance discrepancies, and the UI I kept rebuilding.
Try the app
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.
The reason calorie-tracking apps don't stick is usually the friction of input. Search the food database, select portion size, convert units... 3 minutes per meal. If you could do this three times a day every day, you wouldn't be overweight in the first place.
What I wanted wasn't nutrition management, but just one number: "How many more kcal can I eat today?" So I made it so that if you casually write "3 Alforts," the AI estimates it and your remaining balance decreases—that's all the app does.
But when I actually built it, I got stuck not on the app itself, but on "making it in a form I could release." Here are 5 things I actually stumbled on.
In the v1.0 review, I was flagged on Guideline 5.1.1(i) / 5.1.2(i). The gist was: if you're sending input text to a third-party AI service, you must get explicit consent within the app before sending it.
What I did in build 5 was three things:
The second one was what worked. This app has a recording path from Siri (App Intent), which doesn't go through the consent screen. If I'd only controlled it at the UI level, that entry point would stay open. By making "getting consent" a network-layer invariant rather than a UI responsibility, even if new entry points are added, they stay blocked.
In my response to the reviewer, I explained in bullet points that only the meal text the user entered is sent (voice input is only the device-side transcription result, not the audio itself), and that account info, location, or history records are never sent. Writing out "what we're NOT sending" actually made the explanation clearer.
Initially, I had users enter their own Anthropic API key in the settings screen. It's a common pattern for solo developers, and it works fine for personal use.
But thinking about public release, the first step for anyone installing the app becomes "create an API account and issue a pay-as-you-go key." Most people drop out here.
So I switched the estimation to go through my own backend proxy (Vercel), keeping the API key on the server side. But if the developer holds the key, that means anyone who hits it will incur real costs. To mitigate this, I added App Attest to verify at the device level that requests are coming from the legitimate app.
And the fact that "real costs are incurred" directly led to adding ads and a one-time purchase (¥300 to remove ads). I didn't decide on monetization first—rather, once I put the key on the server, the structure became one where I had to monetize to keep it running. That's the order it happened in.
I put SwiftData with CloudKit as the backend for cross-device sync, but two constraints kicked in:
#Unique etc.) aren't availableThe latter was subtly annoying. I'd written code assuming settings would "always be 1 record," but the DB can't guarantee that. In reality, if multiple devices initialize simultaneously, you get 2 settings records.
In the end, I had the app side handle "when fetching, if duplicates are found, deterministically merge field-by-field into 1 record." I also banned read-only screens and widgets from using the .first pattern of picking the first record (since order isn't guaranteed, different devices would show different target values). If the DB can't enforce an invariant, you have to create a place for it in the app side, or it won't be enforced.
The heart of this app is "the balance matches no matter where you record from." The app, widget, and Siri share the same SwiftData store on App Group.
But it still drifted. The cause was simple: forgetting to call WidgetCenter.shared.reloadAllTimelines() after writes. Save, delete, record via Siri... every time a write path increased, I'd miss calling it in one place.
The balance calculation formula itself was also originally written separately in the app and widget. Now I've consolidated the calculation into a single function, placed it in a framework without UI, and call it from all 3 targets. "Don't calculate the same number in two places" is unglamorous, but it was the most impactful decision for this app.
More embarrassing than technical. The edit screen's "breakfast/lunch/dinner/snack" selector alone has nearly 10 commits. Colored pill → segment → unified capsule style → then reverted that → double-border issue so custom segment → align width with other fields... and so on.
Functionally they're all the same. The time lost here could have built a feature. Design tweaks feel rewarding but never end, so now I've decided: "only after features are locked, do one pass of polish." That's the rule.
PFC analysis, meal scoring, advice, reminder notifications, food database, barcodes. None of it.
They all look like they'd be good to have, but adding them just turns it back into an app where "you have to put in effort on input," and the original reason it doesn't stick comes roaring back. I apply the same thinking to full-screen ads—I gate them so they only show when all these conditions are met: 4 records in, 3 times max per day, 3+ minutes since last time. So the feel of "write casually → balance goes down" never gets killed.
It's live on the App Store and up to 8 languages. Though the most recent fix was "the estimated item name comes back in a different language than the input"—a prompt-side bug. The recent lesson: multilingual support isn't just translating the UI and calling it done.
カロリー記録アプリが続かない理由は、だいたい入力の重さにあります。食品DBを検索して、分量を選んで、単位を合わせて……1食3分。これを毎日3回やれるなら、そもそも太っていません。
ほしかったのは栄養管理ではなく、「今日、あと何kcal食べられるか」という残高の数字ひとつでした。だから「アルフォート3枚」と雑に書いたら、AIが推定して残高が減る——それだけのアプリにしました。
ところが作ってみると、アプリの中身より「公開できる形にする」ところで詰まりました。以下、実際につまずいた5つです。
v1.0 の審査で Guideline 5.1.1(i) / 5.1.2(i) を指摘されました。入力テキストを第三者のAIサービスへ送るなら、送る前に、アプリ内で明示的な同意を取れ、という趣旨です。
build 5 でやったのは次の3つでした。
効いたのは2つ目です。このアプリには Siri(App Intent)からの記録経路があり、そこは同意画面を通りません。画面側だけで制御していたら、その入口が空いたままになっていました。「同意を取る」を画面の仕事ではなく通信層の不変条件にしたことで、入口が増えても塞がったままになります。
審査への返信では、送るのはユーザーが入力した食事テキストだけ(音声入力も端末側で文字起こしした結果だけで、音声そのものは送らない)、アカウントも位置情報も記録履歴も送らない、と箇条書きで説明しました。「送っていないもの」まで書いたほうが話が早かったです。
最初は、ユーザー自身の Anthropic APIキーを設定画面に入れてもらう作りでした。個人開発ではよくある形で、自分で使うぶんにはこれで十分動きます。
ただ一般公開を考えると、アプリを入れた人の最初の一歩が「APIのアカウントを作って、従量課金のキーを発行する」になります。ここで大半の人は消えます。
そこで推定を自前のバックエンドプロキシ(Vercel)経由に変え、APIキーはサーバ側で持つことにしました。ただしキーを開発者が持つということは、誰かが叩けば実費が飛ぶということでもあります。対策として App Attest を入れ、正規のアプリからのリクエストであることを端末レベルで検証しています。
そして「実費が出ていく」ことが、そのまま広告と買い切り(¥300で広告を消す)を入れた理由になりました。収益化を先に決めたのではなく、キーをサーバに置いた時点で、収益化しないと続かない構造になったという順番です。
端末間同期のために SwiftData を CloudKit バックドにしたのですが、ここで制約が2つ効いてきました。
#Unique など)が使えない後者が地味に厄介でした。設定は「常に1件」という前提でコードを書いていたのに、DB側でそれを保証できません。実際、複数端末で同時に初期化されると設定レコードが2件できます。
結局、「取得時に重複を見つけたら、フィールド単位で決定的にマージして1件に畳む」という処理をアプリ側に持たせました。あわせて、読むだけの画面やウィジェットが .first で1件目を拾う書き方も禁止にしています(順序が保証されないので、端末によって違う目標値が出ます)。DBが守ってくれない不変条件は、アプリ側に置き場所を作らないと守れません。
このアプリの肝は「どこから記録しても残高が一致する」ことです。アプリ本体・ウィジェット・Siri の3経路が、App Group 上の同じ SwiftData ストアを共有しています。
それでもズレました。原因は単純で、書き込みのあとに WidgetCenter.shared.reloadAllTimelines() を呼び忘れるだけでズレます。保存・削除・Siri経由の記録……書き込み経路が増えるたびに、呼び忘れが1か所生まれました。
残高の計算式自体も、最初はアプリとウィジェットに別々に書いていました。今は計算をひとつの関数に集約し、UIを持たない framework に置いて3ターゲットから呼んでいます。「同じ数字を2か所で計算しない」は、地味ですがこのアプリでいちばん効いた判断でした。
技術の話より恥ずかしい話です。編集画面で「朝/昼/夕/間食」を選ぶUIだけで、コミットが10本近くあります。色付きピル → セグメント → カプセル調に統一 → それを撤回 → 枠が二重に見えるので自前セグメント → 幅を他のフィールドに揃える……という具合です。
機能としてはどれも同じです。ここで溶けた時間は、機能を1つ足せた時間でした。デザインの微調整は手応えがある割に終わりがないので、いまは「機能が固まってから、まとめて1パスだけ」と決めています。
PFC分析、食事の採点、アドバイス、リマインド通知、食品DB、バーコード。全部入れていません。
どれも「あったほうが良さそう」に見えますが、足すと結局「入力を頑張る」アプリに戻り、続かない理由そのものが復活します。全画面広告にも同じ考えで歯止めを入れていて、4回の記録につき1回・1日3回まで・前回から3分以上、という条件を全部満たしたときだけ出します。「雑に書く→残高が減る」の手触りを毎回潰さないためです。
App Store で公開中で、8言語まで来ました。とはいえ直近で直したのは「推定結果の品目名が、入力と違う言語で返ってくる」というプロンプト側のバグです。多言語対応はUIを訳して終わりではない、というのが最近の学びでした。