Pet App Internationalization: English and Chinese from Day One — PupPal
2026-08-28
Pet App Internationalization: English and Chinese from Day One — PupPal
A two-year-old Shiba named Doudou lives in a bilingual household. His owner, Lin, writes her check-in notes in Chinese and reads daily reviews over breakfast in Mandarin. Her mother, who cares for Doudou during business trips, has used the app for a year and only ever sees the Chinese interface. But the vet who treated Doudou's ear infection last spring is an English-only practice, and when Lin stood in the exam room trying to show the doctor the photo log from the bad week, she realized the app could not be a language silo. Pet app internationalization is not a cosmetic feature for a care product — it is the difference between a health record the vet can read and a diary only one person can understand.
This post goes inside how PupPal solves that problem, in the real source. The stack is slang, the Flutter i18n toolkit, paired with flutter_localizations and intl, configured with English as the base locale and Chinese as the second language from the very first scaffolding commit. The interesting part is deeper than the setup, though. PupPal stores raw i18n keys in its Isar database layer for template data, and translates them at the display boundary — a decision that keeps check-in data, habit records, and history independent of the language the owner happens to be looking at. A dog's life should not be rewritten every time the owner switches the app from 中文 to English, and the architecture makes sure it is not.
The Bilingual Household Problem: One Dog, Two Languages, One Record
Start with why a care app is a special case for i18n. A shopping app translated into sixteen languages is still the same catalog of products; the strings change and nothing else does. A pet care app stores something much closer to a biography: photo check-ins, habit streaks, symptom timelines, care handbooks, the record of what the dog ate and when. That data has to survive language switches, because the same animal is being cared for by different people in different languages.
The real scenario is ordinary once you look for it. In many Chinese families with pets, the owner is the one who travels for work, and the grandparents who step in to care for the dog read Chinese only. The vet, meanwhile, may run an English-language practice or need an English summary for a specialist referral. The caregiver typing the nine-digit care code plus PIN into PupPal might be a grandmother on an old Android tablet; the owner reviewing the check-ins at 21:00 from a laptop in another city might be reading the daily review in English. Both people are looking at the same dog's data, and neither should be locked into the other's language. This is exactly what the care handbook feature exists for — turning a dog's routine into instructions any caregiver can follow — and the handbook has to be readable in the caregiver's own language, which means the underlying data cannot be written in only one.
There is also a subtler requirement hiding under the obvious one. When the nightly review computes trends — the S1 happiness score against the 7-day baseline, the S3 feeding count, the S4 activity comparison that the nightly health review post documents — it is comparing records across days. If the same habit were stored as "每日喂食" on the day the phone was in Chinese and "Daily Feeding" on the day it was in English, the aggregation layer would see two different things and the streak would silently break. A bilingual system has to keep a single canonical identity for every record, regardless of what language the screen shows. That single requirement drives the most important design decision in this post.
English and Chinese from Day One: The i18n Commits in PupPal History
The git history of the Puppal repository makes the "from day one" claim literal. The earliest app-shell commit, 63d428d, titled "页面框架创建完成" (page scaffolding complete), includes assets/i18n/en.json in its added files — the i18n asset directory existed before most of the product did. The Flutter project declares the localization dependencies in pubspec.yaml: slang: ^4.18.0, slang_flutter: ^4.18.0, and slang_build_runner: ^4.18.0 alongside flutter_localizations and intl: ^0.20.2, with assets/i18n/ listed in the app's asset bundle. From the first commit to the current 2.1.50 release, the string layer has grown with the product instead of being retrofitted onto it.
That growth shows up in the commit log as a stream of i18n work riding alongside feature work. a940b66 feat(i18n) added the Pal profile, skills, and 3D visual copy plus onboarding and exit-prompt keys. 74a8541 added the home.aiCheckin key and fixed settings persistence in the same breath. e622d76, which productized voice input with a WeChat-style press-and-hold-to-talk interaction, carried an explicit (i18n+file_picker) label — the new screen shipped with its strings from the start. 94f5da3 regenerated strings.g.dart after a merge, and d62e7e3 translated the preset AI skill content in assets/ai/skills from Chinese to English. Anyone who has maintained a bilingual app knows this rhythm: the multilingual string file is not a side project, it is part of every feature's definition of done, and PupPal's log shows it being treated exactly that way.
Today the two source files are nearly identical in size — en.json at roughly 46.8 KB and zh.json at 46.3 KB — and they cover twenty-nine top-level groups that track the product surface: general, tabs, stats, home, settings, userPage, onboarding, habitCategories, habitActivities, habitDescriptions, habitPage, addPetPage, breeds, photoWall, pro, aiTrack, agentMemory, habitTree, petStory, photoAnalyze, diary, aiHandbook, aiChat, aiScheduler, proactive, care, and pet3d. Read that list as a map of the product: check-in photos, habit tracking, the AI chat, the care flow, the 3D pet scene, the proactive scheduler — every module has its own key namespace from the beginning. The cross-platform post noted that the capability layer falls back to zh_Hans_CN as the default locale when localeName() cannot be determined (notably on the web build); the i18n layer resolves the rest.
The slang Stack: One JSON Pair, Typed Translations Everywhere
The i18n engine itself is slang, configured in build.yaml at the Flutter project root. The relevant block is small and worth quoting exactly:
targets:
$default:
builders:
slang_build_runner:
options:
base_locale: en
input_directory: assets/i18n
input_file_pattern: .json
output_directory: lib/i18n
output_file_name: strings.g.dart
English is the base locale. The source is the pair of JSON files in assets/i18n/. Build-time code generation writes typed translation classes into lib/i18n/ — the files in the source tree are strings.g.dart, plus per-locale variants strings_en.g.dart and strings_zh.g.dart. The interesting consequence is type safety: t is not a bag of loose strings, it is a generated tree of getters. t.home.shareTitle resolves to the interpolated share string for the current locale, and the generated file even documents the interpolation contract — the English version reads '{habitName} with {petName}' while the Chinese version is '{habitName} · {petName}', two different phrasings of the same share title, both typed.
Runtime locale handling is wired through slang's LocaleSettings. The app entry in lib/app.dart calls LocaleSettings.setLocaleRawSync(localeCode) to apply the persisted choice, and hands AppLocaleUtils.supportedLocales to the framework as supportedLocales for flutter_localizations. There is a small dedicated helper, lib/core/utils/i18n.dart, that sets slang's plural resolver (LocaleSettings.setPluralResolver) — an example of the locale-aware pluralization that Chinese makes trivial and English makes necessary. The global t accessor — Translations get t => LocaleSettings.instance.currentTranslations — lets translation happen anywhere, including outside widgets, which turns out to matter a lot for the next section.
Slang's flat-map lookup is also the mechanism behind the fallback behavior at the heart of this post. Because t supports key-addressed access (t['habitActivities.daily_feeding']), code can ask for a translation by raw key and receive either the localized string or the key itself. The generated file notes the standard usage in its doc header: LocaleSettings.setLocale(AppLocale.en) to switch, LocaleSettings.currentLocale == AppLocale.en to check. And because the whole thing is generated, deleting or renaming a key in the JSON is caught at compile time rather than surfacing at runtime as a blank label on a dog's feeding habit.
Why the Database Stores Raw i18n Keys
Here is the decision this post is really about. Open the database package, packages/db, and look at the Habit model — an Isar collection via isar_community. It has the fields you would expect: remoteId as a unique index, sourceId referencing the JSON template, streakFrequency, completionTrackingType, reminderWeekDays, and the rest of the tracking metadata. One field in particular matters for i18n:
bool isTemplate = false;
The flag splits the habit universe into two classes with two completely different string policies. A habit with isTemplate == true came from the curated template library — the JSON files in packages/db/assets/, habit_templates.json (version 2.0.0, sourced from APPA 2024 US Pet Market Research, 49 activities) and habit_categories.json (8 categories). For these habits, name, description, and category are raw i18n keys, not display text. The template JSON stores "activity": "daily_feeding", "category": "food_treats", "description": "daily_feeding_desc" — keys that only mean something once you look them up in the translation files, where habitActivities.daily_feeding renders as "Daily Feeding" in English and "每日喂食" in Chinese. The category IDs follow the same pattern: food_treats, vet_health, grooming, each with a _desc sibling.
A habit with isTemplate == false is a custom or per-pet instance — the owner's own "Paddle Pool Splash" or "晚上楼下绕圈走路" — and stores its name as literal text, deliberately untouched. The policy is precise: the system's words are stored as keys, the user's words are stored as words. Why is this split worth the extra lookup indirection?
Three reasons, and they are the core of the design. First, data is locale-independent. The same Isar row renders differently depending on the viewer's language, without any migration — Lin's mother sees 中文 habit names, the English-speaking vet sees English ones, and the row underneath is byte-identical. The check-in record for "daily_feeding" is the same record in both languages because it never contained a language. Second, matching and aggregation never have to worry about language. The timeline view groups check-in counts per habit, and it can group by the stable key along the whole pipeline — the translated name is only applied at the final rendering step. Third, switching locales is free. There is no re-seeding, no re-query, no data rewrite; LocaleSettings.setLocaleRawSync changes the lens, not the data.
The trade, honestly stated, is that any code displaying a template habit must remember to translate. The app pays that tax in exactly one place, which the next section shows — and it is a small, central, well-tested place rather than a rule scattered across every widget.
Translating at the Display Boundary: context.habitName
All of the raw-key translation in the Flutter app funnels through one file, lib/core/utils/habit_i18n.dart. It defines an extension on BuildContext — HabitTranslations — with three accessors: habitName(Habit habit) resolves with the habitActivities prefix, habitDescription(Habit habit) with habitDescriptions, and habitCategory(Habit habit) with habitCategories. The implementation of _translate is short enough to read in full:
String _translate({required Habit habit, required String prefix}) {
final key = _keyForPrefix(habit, prefix);
if (key == null || key.isEmpty) return '';
if (habit.isTemplate) {
final translated = t['$prefix.$key'];
if (translated is String && translated.isNotEmpty && translated != key) {
return translated;
}
}
return key;
}
The logic is a clean boundary: if the habit is a template (keys in the database), look the key up in the current locale's flat map; use the translation when it resolves to something real and different from the key; otherwise fall back to the raw key itself. If a key ever disappears from the translation files, the UI shows the key — never a blank label on a dog's daily feeding record. If the habit is custom, its literal text passes through unchanged. The fallback is also the debuggability story: a raw key on screen is an immediately greppable breadcrumb pointing at the exact string that failed to translate.
The same file provides context-free helpers for the occasions where widgets are not available: translateHabitName, translateHabitDescription, translateHabitCategory use the global t (this is why slang's t must work outside the widget tree), translateHabitCategoryByKey resolves the category tags used on the timeline, and translateHabitSnapshotName exists for legacy data. That last one deserves a beat: older snapshots may have stored a template i18n key un-translated at instance time, so the display layer tries the key lookup and returns the text as-is when it is not a key. A commit titled fix(habit) formalized this whole path — "habit names unified through translate conversion," with the UI switched to context.habitName, matching logic matching translated names, and snapshots gaining the translation fallback.
The consumers of this boundary are visible across the UI. The habit selector widget renders context.habitName(habit) in its list; the habit bar calls the same accessor; the timeline view-model aggregates counts with translateHabitName(habit) so the chart labels are localized while the underlying counts stay keyed. Notice what does not happen: the db package never imports strings.g.dart. The database layer is i18n-free by construction — it stores keys and text and knows nothing about locales — and all translation happens one layer up, at the moment a human is about to read something. That separation is what makes the "raw keys in the database" policy safe to maintain.
When the AI Speaks the Owner's Language
Pet app internationalization in PupPal is not only about labels and buttons — the product's most personal output is generated prose, and that prose has to match the human reading it. The Hermes Agent skills suite produces three kinds of language-heavy content the owner actually reads: the daily voice message at 09:00, written in the pet's first person ("today I chased a squirrel, my ear feels better than yesterday"); the daily review at 21:00, with the S1/S3/S4 trend summary; and the care handbook, generated instructions for whoever holds the care code. An English owner reading a Chinese daily voice message would defeat the entire habit-building loop the feature exists to create — the daily voice post makes the case that the message is the emotional hook that keeps owners returning to the check-in.
The repository shows the same day-one discipline applied to AI content. Commit d62e7e3, "preset AI skill content translated from Chinese to English," moved the built-in skill prompts in assets/ai/skills into English so that the agent's default behavior is authored bilingually rather than accidentally Chinese. And commit 6e64a1d and the voice work around it show the app's voice features being treated as first-class on every platform — voice input itself (the press-and-hold-to-talk interaction from e622d76) is a language-agnostic input path that pairs naturally with a bilingual app. For a product whose whole thesis is "photo = check-in, share = care," the i18n layer guarantees that the share part — handing the dog's story to a caregiver, a vet, or a foster family — does not depend on everyone sharing the owner's language.
The habit layer connects to this too. The nightly review's feeding analysis compares detected eating scenes against the habit instance's cadence; the owner reads the verdict in her own language, but the underlying record is the keyed habit, so a Chinese-language review of an English-language week is the same dog, the same streaks, the same history. The AI habit tracking post walks how templates stay curated while instances accumulate lived data; the i18n split maps onto that exactly — templates are keyed and translated, instances are the owner's own words, and the agent reads both without caring which language the screen is in.
i18n Lessons for Any Pet App Team
Step back from the source and the PupPal approach distills into five rules any bilingual pet app can adopt. First, ship the second language in the first commit. The scaffolding that created the app shell also created assets/i18n/; retrofitting i18n later means translating every string you ever wrote and migrating every row of user data that assumed one language. Second, make the string layer part of every feature's definition of done — the commit log shows i18n keys landing in the same commit as the screens they label, including the AI chat, the proactive scheduler, and the voice input UI. Third, store system words as keys and user words as text. The isTemplate split is the single decision that keeps a four-thousand-day feeding streak intact across a language switch. Fourth, translate at the display boundary, in one file. habit_i18n.dart is the only place that knows template habits are keyed; every widget consumes context.habitName and never thinks about locales. Fifth, fall back to the key, never to blank. A raw key on screen is a bug you can grep; an empty label is a data-loss bug you cannot.
There is a deeper principle underneath those rules, and it is the one worth keeping. Internationalization is a data-modeling decision before it is a UI decision. PupPal treats the dog's record as a fact that exists independent of any human's reading language, and treats translation as a lens applied at the moment of reading. That is why a photo check-in taken by a grandmother in Sichuan and a trend report read by a vet in Toronto are the same record, why a habit streak survives the phone switching from 中文 to English mid-week, and why the care handbook a caregiver reads on day one is in her language, not the owner's.
That last point is the product payoff. PupPal's whole model is shared care — care codes and PINs so any family member can step in without an account, foster care with tighter anomaly thresholds, a handbook that turns a stranger into a competent caregiver in one reading. None of that works if the shared artifact is readable in only one language. Pet app internationalization is what makes the shared record genuinely shareable: the same bytes, translated at the edge, for every human in the dog's care circle.
So when the bilingual family sets up PupPal for a new dog, the onboarding is the same in either language — the onboarding flow post shows what the first ten minutes look like — and every habit, photo, and note that follows is stored once and read in any language. The first photo is the only onboarding there is, and it does not care whether you take it from a Chinese phone or an English one.