diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Bold.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Bold.ttf
new file mode 100644
index 00000000..52aadc6d
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Bold.ttf differ
diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Medium.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Medium.ttf
new file mode 100644
index 00000000..d6deb87a
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Medium.ttf differ
diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Regular.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Regular.ttf
new file mode 100644
index 00000000..dfa34c09
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Regular.ttf differ
diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-SemiBold.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-SemiBold.ttf
new file mode 100644
index 00000000..b25bad31
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-SemiBold.ttf differ
diff --git a/apps/mobile-flutter/assets/google_fonts/Lora-Italic.ttf b/apps/mobile-flutter/assets/google_fonts/Lora-Italic.ttf
new file mode 100644
index 00000000..b2314c7b
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/Lora-Italic.ttf differ
diff --git a/apps/mobile-flutter/assets/google_fonts/Lora-Medium.ttf b/apps/mobile-flutter/assets/google_fonts/Lora-Medium.ttf
new file mode 100644
index 00000000..913c1dd9
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/Lora-Medium.ttf differ
diff --git a/apps/mobile-flutter/assets/google_fonts/Lora-Regular.ttf b/apps/mobile-flutter/assets/google_fonts/Lora-Regular.ttf
new file mode 100644
index 00000000..bd2b7cb7
Binary files /dev/null and b/apps/mobile-flutter/assets/google_fonts/Lora-Regular.ttf differ
diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json
index 489080cb..b8cbf3a7 100644
--- a/apps/mobile-flutter/assets/l10n/en.json
+++ b/apps/mobile-flutter/assets/l10n/en.json
@@ -6,7 +6,7 @@
"finish": "Finish",
"save": "Save",
"cancel": "Cancel",
- "loading": "Loading...",
+ "loading": "Loading…",
"error": "Something went wrong",
"retry": "Try again",
"search": "Search",
@@ -18,7 +18,8 @@
"notFound": "Page not found",
"notSignedIn": "Not signed in.",
"increase": "Increase",
- "decrease": "Decrease"
+ "decrease": "Decrease",
+ "copied": "Link copied"
},
"nav": {
"logging": "Logging",
@@ -53,6 +54,11 @@
"openMenu": "Open menu",
"closeMenu": "Close menu"
},
+ "tabBar": {
+ "today": "Today",
+ "log": "Log",
+ "patterns": "Patterns"
+ },
"mainSidebar": {
"navigationLabel": "Main navigation",
"sectionLabel": "Main",
@@ -95,14 +101,38 @@
"nutrition": {
"eyebrow": "Pattern analysis",
"title": "Nutrition",
- "subtitle": "Review long-term macro and micronutrient patterns, with confidence labels when the food data is incomplete.",
+ "subtitle": "Review long-term macro and micronutrient patterns.",
"loading": "Loading nutrition overview…",
"range": {
"label": "Nutrition range",
- "7d": "7 days",
- "30d": "30 days",
+ "1d": "Today",
+ "7d": "7d",
+ "30d": "30d",
"90d": "90 days"
},
+ "nutrientGroups": {
+ "vitamins": "Vitamins",
+ "minerals": "Minerals"
+ },
+ "cell": {
+ "limited": "limited",
+ "noTarget": "no target"
+ },
+ "suggestedFoods": {
+ "button": "Suggested foods",
+ "title": "Foods to close your gaps",
+ "subtitle": "Ideas for the nutrients you're lowest on over this timeframe — for variety and coverage, not prescriptions.",
+ "short": "{value}% short",
+ "refresh": "Don't have these?"
+ },
+ "sources": {
+ "caption": "Targets from WHO/FAO · Vietnam RDA · NASEM DRI",
+ "title": "Where targets come from",
+ "intro": "Daily targets are drawn from internationally recognized reference intakes, matched to your profile (age, sex, and region).",
+ "whoFao": "WHO/FAO — Vitamin and Mineral Requirements in Human Nutrition, 2nd ed. (2004)",
+ "vietnamRda": "Vietnam Ministry of Health — Recommended Dietary Allowances (2016)",
+ "nasem": "NASEM — Dietary Reference Intakes (1997–2019)"
+ },
"summary": {
"label": "Nutrition summary",
"mostConsistent": "Most consistent",
@@ -111,10 +141,8 @@
"macroConsistency": "Macro consistency",
"confidentOnly": "Only confident gaps are flagged",
"coverageHint": "Nutrients with thin coverage",
- "averageConfidence": "Avg confidence: {value}",
"weakest": "Weakest",
"none": "None yet",
- "trustLine": "{days} logged days · {confidence} avg confidence · {range} view",
"verdict": {
"label": "Pattern verdict",
"attention": "{nutrients} need attention",
@@ -148,7 +176,6 @@
"card": {
"average": "Average",
"target": "Target",
- "targetProgress": "Target progress",
"context": "Context",
"noData": "No data",
"noTargetClaim": "No target claim",
@@ -156,6 +183,9 @@
"expand": "View trend",
"collapse": "Hide trend"
},
+ "detail": {
+ "averageVsTarget": "Average vs target"
+ },
"confidence": {
"normal": "Good confidence",
"limitedData": "Limited data",
@@ -469,8 +499,7 @@
"quiet": "A quiet stretch. Keep logging to sharpen the pattern.",
"gathering": "Gathering your pattern.",
"gatheringSub": "{days} complete days so far — keep going and the picture sharpens.",
- "trustLine": "{days} complete days · {confidence}% avg confidence",
- "trustLineNoConfidence": "{days} complete days",
+ "trustLine": "{days} complete days",
"partialNote": {
"one": "· {} partial day set aside",
"other": "· {} partial days set aside"
@@ -534,7 +563,9 @@
"saveError": "Couldn't save this step. Please try again.",
"setup": {
"title": "Setting up your account",
- "subtitle": "Personalizing your targets — this'll just take a moment."
+ "subtitle": "Personalizing your targets — this'll just take a moment.",
+ "targetReadyLabel": "Your daily target",
+ "perDay": "kcal/day"
},
"origin": {
"title": "Where are you from?",
@@ -548,12 +579,14 @@
"noCountries": "No countries found",
"fallbackNote": "Leave one or both empty if you want. The AI will fall back to your photo and meal text only.",
"preferredLanguage": "Preferred language",
- "preferredLanguageHint": "Choose your language for the app interface"
+ "preferredLanguageHint": "Choose your language for the app interface",
+ "commonCountries": "Common"
},
"bodyMetrics": {
"title": "Your body metrics",
"subtitle": "We use these to calculate your daily calorie and macro targets.",
"biologicalSex": "Biological sex",
+ "sexPlaceholder": "Select…",
"male": "Male",
"female": "Female",
"weight": "Weight",
@@ -594,7 +627,11 @@
"fat": "Fat",
"macroSummary": "Daily targets",
"kcal": "kcal",
- "grams": "g"
+ "grams": "g",
+ "aboutYou": "About you",
+ "aboutYouHint": "These stay optional, but once you fill them in, Nhẩm can compute more tailored targets locally.",
+ "unlockTitle": "Fill the basics to unlock targets.",
+ "unlockHint": "Once sex, weight, height, age, and activity are filled, this becomes your live calorie target and macro planner."
},
"cooking": {
"title": "Your cooking habits",
@@ -647,7 +684,6 @@
"noAccount": "Don't have an account?",
"signUpLink": "Sign up",
"forgotPassword": "Forgot password?",
- "error": "Invalid email or password",
"success": "Signed in successfully",
"emailPlaceholder": "you@example.com",
"passwordPlaceholder": "Enter your password",
@@ -662,9 +698,7 @@
"submit": "Create Account",
"hasAccount": "Already have an account?",
"signInLink": "Sign in",
- "success": "Check your email to confirm your account",
"successSignedIn": "Account created. Welcome!",
- "error": "Could not create account",
"emailPlaceholder": "you@example.com",
"passwordPlaceholder": "Create a password",
"emailError": "Enter a valid email address",
@@ -677,16 +711,57 @@
"signUpSubtitle": "Start tracking Vietnamese meals with AI-powered nutrition analysis.",
"signInTab": "Sign in",
"signUpTab": "Sign up",
+ "continueWithApple": "Continue with Apple",
+ "appleError": "Apple sign-in failed. Please try again.",
"continueWithGoogle": "Continue with Google",
"orContinueWithEmail": "or continue with email",
"googleError": "Google sign-in failed. Please try again."
},
+ "forgot": {
+ "title": "Reset your password",
+ "description": "Enter the email you signed up with and we'll send a reset link.",
+ "email": "Email",
+ "emailPlaceholder": "you@example.com",
+ "emailError": "Enter a valid email address",
+ "submit": "Send reset link",
+ "back": "Back to sign in",
+ "sentTitle": "Check your email",
+ "sentDescription": "We sent a password reset link to",
+ "backToSignIn": "Back to sign in"
+ },
"otp": {
"title": "Check your email",
"subtitle": "We sent a verification code to {email}",
"submit": "Verify",
"resend": "Resend code",
"error": "Invalid code"
+ },
+ "welcome": {
+ "tagline": "Track Vietnamese meals",
+ "taglineHighlight": "without the guesswork",
+ "demoMeal": "2 mực kho mặn + 1 chén cơm + canh chua",
+ "demoResult": "{kcal} kcal",
+ "continueWithEmail": "Continue with email",
+ "terms": "By continuing you agree to our terms and privacy policy."
+ },
+ "confirm": {
+ "title": "Check your email",
+ "description": "We sent a confirmation link to",
+ "hint": "Tap the link to finish creating your account.",
+ "resend": "Resend email",
+ "resendIn": "Resend in {seconds}s",
+ "resent": "Email sent again",
+ "back": "Back"
+ },
+ "pending": {
+ "finishing": "Finishing sign-in…"
+ },
+ "errors": {
+ "invalidCredentials": "That email and password don't match — try again or reset your password.",
+ "emailNotConfirmed": "That email hasn't been confirmed yet — check your inbox for the link.",
+ "network": "Couldn't reach the server — check your connection and try again.",
+ "rateLimited": "Too many attempts — give it a moment, then try again.",
+ "generic": "Something went wrong — please try again."
}
},
"landing": {
@@ -715,7 +790,7 @@
"analysis": "AI nutrition analysis",
"highConfidence": "High confidence",
"totalCalories": "Total calories",
- "inputPlaceholder": "Describe your meal...",
+ "inputPlaceholder": "Describe your meal…",
"smartContext": "Smart context",
"smartContextText": "Uses your region, cooking habits, and meal text to tighten estimates."
}
@@ -833,10 +908,19 @@
},
"dashboard": {
"title": "Dashboard",
+ "greeting": {
+ "morning": "Good morning",
+ "afternoon": "Good afternoon",
+ "evening": "Good evening",
+ "night": "Good evening"
+ },
"today": "Today",
"thisWeek": "This Week",
"caloriesConsumed": "Calories consumed",
"caloriesRemaining": "Calories remaining",
+ "caloriesOverTarget": "Over target",
+ "left": "left",
+ "over": "over",
"protein": "Protein",
"carbs": "Carbs",
"fat": "Fat",
@@ -891,7 +975,9 @@
"logWeight": "Log weight",
"update": "Update",
"editHint": "Editing today's entry — saves overwrite the previous value.",
- "saveFailed": "Failed to save weight"
+ "saveFailed": "Failed to save weight",
+ "netChange": "Net change",
+ "steady": "Steady"
},
"adherenceHeatmap": {
"onTrack": "{percent}% on track",
@@ -912,35 +998,40 @@
},
"caloriesLogged": "kcal logged",
"mealReceiptsHint": "Your meal receipts will show up here",
- "progressStatus": {
- "insufficient": {
- "label": "Tracking started",
- "detail": "Log tomorrow to see your trend."
- },
- "on_pace": {
- "label": "On pace",
- "detail": "Your current pace is matching the plan."
- },
- "ahead": {
- "label": "Ahead of plan",
- "detail": "Progress is moving faster than expected."
- },
- "behind": {
- "label": "Needs attention",
- "detail": "The trend is softer than the plan right now."
- },
- "stable": {
- "label": "Stable",
- "detail": "Your weight is staying within a quiet maintenance band."
- }
- }
+ "firstRunQuestion": "What did you eat today?",
+ "firstRunHint": "Describe your first meal — Nhẩm estimates the rest.",
+ "firstRunChips": {
+ "morning1": "1 tô phở bò + quẩy",
+ "morning2": "2 trứng ốp la + bánh mì",
+ "morning3": "1 ly cà phê sữa đá",
+ "midday1": "1 dĩa cơm tấm sườn",
+ "midday2": "1 tô bún chả",
+ "midday3": "1 chén cơm + thịt kho",
+ "evening1": "2 mực kho + 1 chén cơm",
+ "evening2": "1 tô canh chua cá lóc",
+ "evening3": "1 tô bún bò Huế"
+ },
+ "yesterday": "Yesterday"
},
"logging": {
"title": "What did you eat?",
- "placeholder": "Describe your meal...",
+ "placeholder": "Describe your meal…",
+ "composerPlaceholder": "Add a meal…",
+ "modeSelector": {
+ "title": "How do you want to log?",
+ "button": "Mode",
+ "soon": "Soon",
+ "comingSoon": "Cheat meal mode is coming soon",
+ "normal": "Normal",
+ "normalDesc": "Describe a meal in your own words",
+ "cheat": "Cheat meal",
+ "cheatDesc": "Log a treat without the breakdown",
+ "manual": "Manual",
+ "manualDesc": "Search foods, enter exact grams"
+ },
"submit": "Analyze",
"stopAnalyzing": "Stop analyzing",
- "analyzing": "Analyzing your meal...",
+ "analyzing": "Analyzing your meal…",
"breakfast": "Breakfast",
"brunch": "Brunch",
"lunch": "Lunch",
@@ -949,11 +1040,13 @@
"mealSlot": "Meal type",
"confirm": "Save meal",
"discard": "Discard",
+ "remove": "Remove",
+ "mealRemoved": "Meal removed",
+ "undo": "Undo",
"edit": "Edit",
"nutritionSummary": "Nutrition summary",
"ingredients": "Ingredients",
"calories": "Calories",
- "confidence": "Confidence",
"high": "High",
"medium": "Medium",
"low": "Low",
@@ -965,7 +1058,10 @@
"todaysMeals": "Today's meals",
"emptyState": {
"title": "What did you eat?",
- "subtitle": "Describe your meal and get a macro breakdown."
+ "title2": "What's on your plate?",
+ "title3": "What did you have?",
+ "title4": "Ready to log a meal?",
+ "title5": "What's for this meal?"
},
"feedArea": {
"savedMeal": "Meal saved",
@@ -985,12 +1081,17 @@
}
},
"streaming": {
- "connecting": "Connecting...",
- "decomposing": "Breaking down your meal...",
- "matching": "Matching ingredients...",
- "estimating": "Estimating nutrition...",
- "assembling": "Putting it all together...",
- "analyzing": "Analyzing..."
+ "connecting": "Connecting…",
+ "decomposing": "Breaking down your meal…",
+ "matching": "Matching ingredients…",
+ "estimating": "Estimating nutrition…",
+ "assembling": "Putting it all together…",
+ "analyzing": "Analyzing…",
+ "stillWorking": "Still working on it — almost there."
+ },
+ "failedAttempt": {
+ "message": "We couldn't read this one. Your note is safe — give it another try.",
+ "tryAgain": "Try again"
},
"mealEntry": {
"edit": "Edit",
@@ -1032,6 +1133,7 @@
"subtitle": "Search foods, enter exact grams — no AI",
"searchPlaceholder": "Search foods...",
"recentFoods": "Recent foods",
+ "recentsHint": "Search to add foods",
"selected": "Selected",
"noResults": "No foods found",
"searching": "Searching...",
@@ -1122,6 +1224,21 @@
},
"settings": {
"title": "Settings",
+ "account": {
+ "title": "Account",
+ "exportTitle": "Export my data",
+ "exportError": "Could not export your data. Please try again.",
+ "signOut": "Sign out",
+ "signOutConfirmTitle": "Sign out of Nhẩm?",
+ "cancel": "Cancel",
+ "delete": "Delete account",
+ "deleteScreenTitle": "Delete account",
+ "deleteConsequence": "This removes your profile, every meal you've logged, and your weight history. There is no undo.",
+ "deleteConfirmLabel": "Type {word} to confirm",
+ "deleteConfirmWord": "DELETE",
+ "deleteConfirmAction": "Delete my account",
+ "deleteError": "Could not delete your account. Please try again."
+ },
"profile": "Profile",
"preferences": "Preferences",
"bodyMetrics": "Body Metrics",
@@ -1154,7 +1271,22 @@
"regionalSubtitle": "Country of origin and current residence",
"cookingSubtitle": "Oil usage, rice, sugar, protein, and broth defaults",
"saveError": "Failed to save settings. Please try again.",
- "invalidError": "Some entries are invalid — please review your body metrics."
+ "invalidError": "Some entries are invalid — please review your body metrics.",
+ "retry": "Try again",
+ "incompleteHint": "Nothing saved yet — fill in weight, height, and age first."
+ },
+ "rows": {
+ "goalPace": "Goal & pace",
+ "cooking": "Cooking habits",
+ "region": "Region & language",
+ "notSet": "Not set",
+ "pacePerWeek": "{pace} {unit}/wk"
+ },
+ "about": {
+ "title": "About",
+ "version": "Version",
+ "privacy": "Privacy policy",
+ "terms": "Terms of service"
}
},
"errors": {
@@ -1166,6 +1298,7 @@
},
"validation": {
"bodyMetrics": {
+ "sexRequired": "Please select your biological sex.",
"weightRequired": "Please enter your weight.",
"weightMin": "Weight must be at least 30 kg.",
"weightMax": "Weight must be at most 300 kg.",
diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json
index 649d25a3..77a2df23 100644
--- a/apps/mobile-flutter/assets/l10n/vi.json
+++ b/apps/mobile-flutter/assets/l10n/vi.json
@@ -6,7 +6,7 @@
"finish": "Hoàn tất",
"save": "Lưu",
"cancel": "Hủy",
- "loading": "Đang tải...",
+ "loading": "Đang tải…",
"error": "Đã xảy ra lỗi",
"retry": "Thử lại",
"search": "Tìm kiếm",
@@ -18,7 +18,8 @@
"notFound": "Không tìm thấy trang",
"notSignedIn": "Chưa đăng nhập.",
"increase": "Tăng",
- "decrease": "Giảm"
+ "decrease": "Giảm",
+ "copied": "Đã sao chép liên kết"
},
"nav": {
"logging": "Ghi nhận",
@@ -53,6 +54,11 @@
"openMenu": "Mở menu",
"closeMenu": "Đóng menu"
},
+ "tabBar": {
+ "today": "Hôm nay",
+ "log": "Ghi món",
+ "patterns": "Xu hướng"
+ },
"mainSidebar": {
"navigationLabel": "Điều hướng chính",
"sectionLabel": "Chính",
@@ -95,14 +101,38 @@
"nutrition": {
"eyebrow": "Phân tích xu hướng",
"title": "Dinh dưỡng",
- "subtitle": "Nhìn lại xu hướng macro và vi chất dài hạn, kèm nhãn độ tin cậy khi dữ liệu món ăn còn thiếu.",
+ "subtitle": "Nhìn lại xu hướng macro và vi chất dài hạn.",
"loading": "Đang tải tổng quan dinh dưỡng…",
"range": {
"label": "Khoảng thời gian dinh dưỡng",
- "7d": "7 ngày",
- "30d": "30 ngày",
+ "1d": "Hôm nay",
+ "7d": "7n",
+ "30d": "30n",
"90d": "90 ngày"
},
+ "nutrientGroups": {
+ "vitamins": "Vitamin",
+ "minerals": "Khoáng chất"
+ },
+ "cell": {
+ "limited": "hạn chế",
+ "noTarget": "chưa có"
+ },
+ "suggestedFoods": {
+ "button": "Gợi ý món ăn",
+ "title": "Món ăn lấp đầy thiếu hụt",
+ "subtitle": "Gợi ý cho các chất bạn đang thiếu nhất trong khoảng thời gian này — để đa dạng và đầy đủ, không phải chỉ định y khoa.",
+ "short": "thiếu {value}%",
+ "refresh": "Không có sẵn?"
+ },
+ "sources": {
+ "caption": "Mục tiêu theo WHO/FAO · RDA Việt Nam · NASEM DRI",
+ "title": "Nguồn của mục tiêu",
+ "intro": "Mục tiêu hằng ngày dựa trên các mức tham chiếu được công nhận quốc tế, điều chỉnh theo hồ sơ của bạn (tuổi, giới tính và khu vực).",
+ "whoFao": "WHO/FAO — Nhu cầu Vitamin và Khoáng chất trong Dinh dưỡng, tái bản lần 2 (2004)",
+ "vietnamRda": "Bộ Y tế Việt Nam — Nhu cầu Dinh dưỡng Khuyến nghị (2016)",
+ "nasem": "NASEM — Dietary Reference Intakes (1997–2019)"
+ },
"summary": {
"label": "Tóm tắt dinh dưỡng",
"mostConsistent": "Ổn định nhất",
@@ -111,10 +141,8 @@
"macroConsistency": "Độ ổn định macro",
"confidentOnly": "Chỉ đánh dấu khi đủ tin cậy",
"coverageHint": "Vi chất có độ phủ mỏng",
- "averageConfidence": "Độ tin cậy TB: {value}",
"weakest": "Yếu nhất",
"none": "Chưa có",
- "trustLine": "{days} ngày có bữa ăn · độ tin cậy TB {confidence} · chế độ xem {range}",
"verdict": {
"label": "Kết luận xu hướng",
"attention": "{nutrients} cần chú ý",
@@ -148,7 +176,6 @@
"card": {
"average": "Trung bình",
"target": "Mục tiêu",
- "targetProgress": "Tiến độ mục tiêu",
"context": "Ngữ cảnh",
"noData": "Chưa có dữ liệu",
"noTargetClaim": "Chưa kết luận theo mục tiêu",
@@ -156,6 +183,9 @@
"expand": "Xem xu hướng",
"collapse": "Ẩn xu hướng"
},
+ "detail": {
+ "averageVsTarget": "Trung bình so với mục tiêu"
+ },
"confidence": {
"normal": "Độ tin cậy tốt",
"limitedData": "Dữ liệu hạn chế",
@@ -469,9 +499,9 @@
"quiet": "Một giai đoạn yên ả. Tiếp tục ghi nhận để hoàn thiện xu hướng.",
"gathering": "Vẫn đang gom dần xu hướng của bạn.",
"gatheringSub": "Đã có {days} ngày đủ bữa — cứ tiếp tục, bức tranh sẽ rõ dần.",
- "trustLine": "{days} ngày đủ bữa · độ tin cậy TB {confidence}%",
- "trustLineNoConfidence": "{days} ngày đủ bữa",
+ "trustLine": "{days} ngày đủ bữa",
"partialNote": {
+ "one": "· tạm bỏ qua {} ngày ghi thiếu",
"other": "· tạm bỏ qua {} ngày ghi thiếu"
},
"period7d": "7 ngày qua",
@@ -533,7 +563,9 @@
"saveError": "Không thể lưu bước này. Vui lòng thử lại.",
"setup": {
"title": "Đang thiết lập tài khoản của bạn",
- "subtitle": "Đang cá nhân hoá mục tiêu của bạn — chỉ mất một chút thôi."
+ "subtitle": "Đang cá nhân hoá mục tiêu của bạn — chỉ mất một chút thôi.",
+ "targetReadyLabel": "Mục tiêu mỗi ngày của bạn",
+ "perDay": "kcal/ngày"
},
"origin": {
"title": "Bạn đến từ đâu?",
@@ -547,12 +579,14 @@
"noCountries": "Không tìm thấy quốc gia",
"fallbackNote": "Bỏ trống nếu muốn. AI sẽ chỉ dựa vào ảnh và mô tả bữa ăn của bạn.",
"preferredLanguage": "Ngôn ngữ ưa thích",
- "preferredLanguageHint": "Chọn ngôn ngữ hiển thị cho ứng dụng"
+ "preferredLanguageHint": "Chọn ngôn ngữ hiển thị cho ứng dụng",
+ "commonCountries": "Phổ biến"
},
"bodyMetrics": {
"title": "Chỉ số cơ thể",
"subtitle": "Chúng tôi dùng thông tin này để tính mục tiêu calo và dinh dưỡng hàng ngày.",
"biologicalSex": "Giới tính sinh học",
+ "sexPlaceholder": "Chọn…",
"male": "Nam",
"female": "Nữ",
"weight": "Cân nặng",
@@ -593,7 +627,11 @@
"fat": "Chất béo",
"macroSummary": "Mục tiêu hàng ngày",
"kcal": "kcal",
- "grams": "g"
+ "grams": "g",
+ "aboutYou": "Về bạn",
+ "aboutYouHint": "Những mục này không bắt buộc, nhưng khi bạn điền vào, Nhẩm có thể tính mục tiêu phù hợp hơn ngay trên máy.",
+ "unlockTitle": "Điền thông tin cơ bản để mở khóa mục tiêu.",
+ "unlockHint": "Khi đã điền giới tính, cân nặng, chiều cao, tuổi và mức vận động, đây sẽ thành mục tiêu calo và bảng macro của bạn."
},
"cooking": {
"title": "Thói quen nấu ăn",
@@ -646,7 +684,6 @@
"noAccount": "Chưa có tài khoản?",
"signUpLink": "Đăng ký",
"forgotPassword": "Quên mật khẩu?",
- "error": "Email hoặc mật khẩu không đúng",
"success": "Đăng nhập thành công",
"emailPlaceholder": "ban@example.com",
"passwordPlaceholder": "Nhập mật khẩu",
@@ -661,9 +698,7 @@
"submit": "Tạo tài khoản",
"hasAccount": "Đã có tài khoản?",
"signInLink": "Đăng nhập",
- "success": "Kiểm tra email để xác nhận tài khoản",
"successSignedIn": "Đã tạo tài khoản. Chào mừng!",
- "error": "Không thể tạo tài khoản",
"emailPlaceholder": "ban@example.com",
"passwordPlaceholder": "Tạo mật khẩu",
"emailError": "Nhập địa chỉ email hợp lệ",
@@ -676,16 +711,57 @@
"signUpSubtitle": "Bắt đầu theo dõi bữa ăn Việt với phân tích dinh dưỡng bằng AI.",
"signInTab": "Đăng nhập",
"signUpTab": "Đăng ký",
+ "continueWithApple": "Tiếp tục với Apple",
+ "appleError": "Đăng nhập bằng Apple thất bại. Vui lòng thử lại.",
"continueWithGoogle": "Tiếp tục với Google",
"orContinueWithEmail": "hoặc tiếp tục với email",
"googleError": "Đăng nhập bằng Google thất bại. Vui lòng thử lại."
},
+ "forgot": {
+ "title": "Đặt lại mật khẩu",
+ "description": "Nhập email bạn đã đăng ký, chúng tôi sẽ gửi liên kết đặt lại.",
+ "email": "Email",
+ "emailPlaceholder": "ban@example.com",
+ "emailError": "Nhập địa chỉ email hợp lệ",
+ "submit": "Gửi liên kết",
+ "back": "Quay lại đăng nhập",
+ "sentTitle": "Kiểm tra email",
+ "sentDescription": "Chúng tôi đã gửi liên kết đặt lại mật khẩu đến",
+ "backToSignIn": "Quay lại đăng nhập"
+ },
"otp": {
"title": "Kiểm tra email",
"subtitle": "Chúng tôi đã gửi mã xác nhận đến {email}",
"submit": "Xác nhận",
"resend": "Gửi lại mã",
"error": "Mã không hợp lệ"
+ },
+ "welcome": {
+ "tagline": "Theo dõi bữa ăn Việt",
+ "taglineHighlight": "không cần phỏng đoán",
+ "demoMeal": "2 mực kho mặn + 1 chén cơm + canh chua",
+ "demoResult": "{kcal} kcal",
+ "continueWithEmail": "Tiếp tục với email",
+ "terms": "Khi tiếp tục, bạn đồng ý với điều khoản và chính sách bảo mật của chúng tôi."
+ },
+ "confirm": {
+ "title": "Kiểm tra email của bạn",
+ "description": "Chúng tôi đã gửi liên kết xác nhận đến",
+ "hint": "Nhấn vào liên kết để hoàn tất tạo tài khoản.",
+ "resend": "Gửi lại email",
+ "resendIn": "Gửi lại sau {seconds}s",
+ "resent": "Đã gửi lại email",
+ "back": "Quay lại"
+ },
+ "pending": {
+ "finishing": "Đang hoàn tất đăng nhập…"
+ },
+ "errors": {
+ "invalidCredentials": "Email và mật khẩu không khớp — thử lại hoặc đặt lại mật khẩu nhé.",
+ "emailNotConfirmed": "Email này chưa được xác nhận — kiểm tra hộp thư để tìm liên kết nhé.",
+ "network": "Không kết nối được máy chủ — kiểm tra mạng rồi thử lại nhé.",
+ "rateLimited": "Bạn thao tác hơi nhanh — đợi một chút rồi thử lại nhé.",
+ "generic": "Đã có lỗi xảy ra — bạn thử lại nhé."
}
},
"landing": {
@@ -714,7 +790,7 @@
"analysis": "Phân tích dinh dưỡng bằng AI",
"highConfidence": "Độ tin cậy cao",
"totalCalories": "Tổng calo",
- "inputPlaceholder": "Mô tả bữa ăn của bạn...",
+ "inputPlaceholder": "Mô tả bữa ăn của bạn…",
"smartContext": "Ngữ cảnh thông minh",
"smartContextText": "Dùng vùng miền, thói quen nấu ăn và mô tả bữa ăn để siết chặt ước tính."
}
@@ -832,10 +908,19 @@
},
"dashboard": {
"title": "Tổng quan",
+ "greeting": {
+ "morning": "Chào buổi sáng",
+ "afternoon": "Chào buổi chiều",
+ "evening": "Chào buổi tối",
+ "night": "Chào buổi tối"
+ },
"today": "Hôm nay",
"thisWeek": "Tuần này",
"caloriesConsumed": "Calo đã nạp",
"caloriesRemaining": "Calo còn lại",
+ "caloriesOverTarget": "Vượt mục tiêu",
+ "left": "còn lại",
+ "over": "vượt",
"protein": "Đạm",
"carbs": "Carb",
"fat": "Chất béo",
@@ -890,7 +975,9 @@
"logWeight": "Ghi cân nặng",
"update": "Cập nhật",
"editHint": "Đang sửa số liệu hôm nay — lần lưu sau sẽ ghi đè giá trị trước.",
- "saveFailed": "Không thể lưu cân nặng"
+ "saveFailed": "Không thể lưu cân nặng",
+ "netChange": "Thay đổi",
+ "steady": "Ổn định"
},
"adherenceHeatmap": {
"onTrack": "{percent}% đúng mục tiêu",
@@ -911,35 +998,40 @@
},
"caloriesLogged": "kcal đã ghi",
"mealReceiptsHint": "Những bữa ăn sẽ hiển thị ở đây",
- "progressStatus": {
- "insufficient": {
- "label": "Đã bắt đầu theo dõi",
- "detail": "Ghi cân nặng vào ngày mai để thấy xu hướng."
- },
- "on_pace": {
- "label": "Đúng kế hoạch",
- "detail": "Tốc độ hiện tại của bạn phù hợp với kế hoạch."
- },
- "ahead": {
- "label": "Vượt kế hoạch",
- "detail": "Tiến độ nhanh hơn dự kiến."
- },
- "behind": {
- "label": "Cần chú ý",
- "detail": "Xu hướng hiện tại yếu hơn kế hoạch."
- },
- "stable": {
- "label": "Ổn định",
- "detail": "Cân nặng của bạn đang ổn định ở mức duy trì."
- }
- }
+ "firstRunQuestion": "Hôm nay bạn đã ăn gì?",
+ "firstRunHint": "Mô tả bữa đầu tiên — Nhẩm lo phần còn lại.",
+ "firstRunChips": {
+ "morning1": "1 tô phở bò + quẩy",
+ "morning2": "2 trứng ốp la + bánh mì",
+ "morning3": "1 ly cà phê sữa đá",
+ "midday1": "1 dĩa cơm tấm sườn",
+ "midday2": "1 tô bún chả",
+ "midday3": "1 chén cơm + thịt kho",
+ "evening1": "2 mực kho + 1 chén cơm",
+ "evening2": "1 tô canh chua cá lóc",
+ "evening3": "1 tô bún bò Huế"
+ },
+ "yesterday": "Hôm qua"
},
"logging": {
"title": "Bạn đã ăn gì?",
- "placeholder": "Mô tả bữa ăn...",
+ "placeholder": "Mô tả bữa ăn…",
+ "composerPlaceholder": "Thêm món ăn…",
+ "modeSelector": {
+ "title": "Bạn muốn ghi thế nào?",
+ "button": "Chế độ",
+ "soon": "Sắp có",
+ "comingSoon": "Chế độ bữa xả sắp ra mắt",
+ "normal": "Bình thường",
+ "normalDesc": "Mô tả bữa ăn bằng lời của bạn",
+ "cheat": "Bữa xả",
+ "cheatDesc": "Ghi nhanh bữa xả, không cần phân tích",
+ "manual": "Thủ công",
+ "manualDesc": "Tìm món, nhập gram chính xác"
+ },
"submit": "Phân tích",
"stopAnalyzing": "Dừng phân tích",
- "analyzing": "Đang phân tích bữa ăn...",
+ "analyzing": "Đang phân tích bữa ăn…",
"breakfast": "Sáng",
"brunch": "Sáng muộn",
"lunch": "Trưa",
@@ -948,11 +1040,13 @@
"mealSlot": "Bữa ăn",
"confirm": "Lưu bữa ăn",
"discard": "Bỏ",
+ "remove": "Xóa",
+ "mealRemoved": "Đã xóa bữa ăn",
+ "undo": "Hoàn tác",
"edit": "Sửa",
"nutritionSummary": "Tóm tắt dinh dưỡng",
"ingredients": "Nguyên liệu",
"calories": "Calo",
- "confidence": "Độ tin cậy",
"high": "Cao",
"medium": "Trung bình",
"low": "Thấp",
@@ -964,7 +1058,10 @@
"todaysMeals": "Bữa ăn hôm nay",
"emptyState": {
"title": "Bạn đã ăn gì?",
- "subtitle": "Mô tả bữa ăn để nhận phân tích macro."
+ "title2": "Hôm nay ăn gì?",
+ "title3": "Bữa này có món gì?",
+ "title4": "Ghi lại bữa ăn nhé?",
+ "title5": "Bạn vừa ăn gì?"
},
"feedArea": {
"savedMeal": "Đã lưu bữa ăn",
@@ -984,12 +1081,17 @@
}
},
"streaming": {
- "connecting": "Đang kết nối...",
- "decomposing": "Đang tách bữa ăn của bạn...",
- "matching": "Đang khớp nguyên liệu...",
- "estimating": "Đang ước tính dinh dưỡng...",
- "assembling": "Đang tổng hợp kết quả...",
- "analyzing": "Đang phân tích..."
+ "connecting": "Đang kết nối…",
+ "decomposing": "Đang tách bữa ăn của bạn…",
+ "matching": "Đang khớp nguyên liệu…",
+ "estimating": "Đang ước tính dinh dưỡng…",
+ "assembling": "Đang tổng hợp kết quả…",
+ "analyzing": "Đang phân tích…",
+ "stillWorking": "Vẫn đang xử lý — sắp xong rồi."
+ },
+ "failedAttempt": {
+ "message": "Chưa đọc được món này. Ghi chú của bạn vẫn còn — thử lại nhé.",
+ "tryAgain": "Thử lại"
},
"mealEntry": {
"edit": "Sửa",
@@ -1031,6 +1133,7 @@
"subtitle": "Tìm món, nhập chính xác số gam — không dùng AI",
"searchPlaceholder": "Tìm món ăn...",
"recentFoods": "Món gần đây",
+ "recentsHint": "Tìm món để thêm",
"selected": "Đã chọn",
"noResults": "Không tìm thấy món nào",
"searching": "Đang tìm...",
@@ -1121,6 +1224,21 @@
},
"settings": {
"title": "Cài đặt",
+ "account": {
+ "title": "Tài khoản",
+ "exportTitle": "Xuất dữ liệu của tôi",
+ "exportError": "Không thể xuất dữ liệu. Vui lòng thử lại.",
+ "signOut": "Đăng xuất",
+ "signOutConfirmTitle": "Đăng xuất khỏi Nhẩm?",
+ "cancel": "Hủy",
+ "delete": "Xóa tài khoản",
+ "deleteScreenTitle": "Xóa tài khoản",
+ "deleteConsequence": "Thao tác này xóa hồ sơ, mọi bữa ăn bạn đã ghi, và lịch sử cân nặng của bạn. Không thể hoàn tác.",
+ "deleteConfirmLabel": "Nhập {word} để xác nhận",
+ "deleteConfirmWord": "XOA",
+ "deleteConfirmAction": "Xóa tài khoản của tôi",
+ "deleteError": "Không thể xóa tài khoản. Vui lòng thử lại."
+ },
"profile": "Hồ sơ",
"preferences": "Tùy chỉnh",
"bodyMetrics": "Chỉ số cơ thể",
@@ -1153,7 +1271,22 @@
"regionalSubtitle": "Quê quán và nơi ở hiện tại",
"cookingSubtitle": "Thiết lập dầu mỡ, cơm, đường, đạm và nước dùng mặc định",
"saveError": "Không thể lưu cài đặt. Vui lòng thử lại.",
- "invalidError": "Một số giá trị không hợp lệ — vui lòng kiểm tra lại chỉ số cơ thể."
+ "invalidError": "Một số giá trị không hợp lệ — vui lòng kiểm tra lại chỉ số cơ thể.",
+ "retry": "Thử lại",
+ "incompleteHint": "Chưa lưu được gì — hãy điền cân nặng, chiều cao và tuổi trước nhé."
+ },
+ "rows": {
+ "goalPace": "Mục tiêu & tốc độ",
+ "cooking": "Thói quen nấu nướng",
+ "region": "Khu vực & ngôn ngữ",
+ "notSet": "Chưa thiết lập",
+ "pacePerWeek": "{pace} {unit}/tuần"
+ },
+ "about": {
+ "title": "Giới thiệu",
+ "version": "Phiên bản",
+ "privacy": "Chính sách bảo mật",
+ "terms": "Điều khoản dịch vụ"
}
},
"errors": {
@@ -1165,6 +1298,7 @@
},
"validation": {
"bodyMetrics": {
+ "sexRequired": "Vui lòng chọn giới tính sinh học.",
"weightRequired": "Vui lòng nhập cân nặng.",
"weightMin": "Cân nặng phải từ 30 kg trở lên.",
"weightMax": "Cân nặng phải từ 300 kg trở xuống.",
diff --git a/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj
index d818373d..f69dc6a5 100644
--- a/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj
+++ b/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj
@@ -491,6 +491,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = com.khoivo.nham;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -673,6 +674,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = com.khoivo.nham;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -695,6 +697,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = com.khoivo.nham;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
diff --git a/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard
index f2e259c7..7b11df06 100644
--- a/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -19,7 +19,7 @@
-
+
diff --git a/apps/mobile-flutter/ios/Runner/Runner.entitlements b/apps/mobile-flutter/ios/Runner/Runner.entitlements
new file mode 100644
index 00000000..a812db50
--- /dev/null
+++ b/apps/mobile-flutter/ios/Runner/Runner.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.applesignin
+
+ Default
+
+
+
diff --git a/apps/mobile-flutter/lib/data/api_client.dart b/apps/mobile-flutter/lib/data/api_client.dart
index eaa63aa7..05fedae6 100644
--- a/apps/mobile-flutter/lib/data/api_client.dart
+++ b/apps/mobile-flutter/lib/data/api_client.dart
@@ -15,11 +15,14 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
+import 'package:supabase_flutter/supabase_flutter.dart' show Session;
import '../models/streaming.dart';
import '../services/supabase_service.dart';
import 'env.dart';
+const _authRefreshSkew = Duration(minutes: 5);
+
/// Client-side mirror of the server `ApiError` envelope.
///
/// Re-implemented (not imported) exactly as the RN client does, because the
@@ -40,7 +43,8 @@ class ApiError implements Exception {
]);
@override
- String toString() => 'ApiError($code, $status, retryable=$retryable): $message';
+ String toString() =>
+ 'ApiError($code, $status, retryable=$retryable): $message';
}
/// Parse a `Retry-After` header: numeric seconds first, else HTTP-date delta.
@@ -96,33 +100,45 @@ class StreamAnalyzeInput {
});
Map toJson() => {
- 'message': message,
- 'loggedDate': loggedDate,
- 'timezoneOffset': timezoneOffset,
- if (locale != null) 'locale': locale,
- };
+ 'message': message,
+ 'loggedDate': loggedDate,
+ 'timezoneOffset': timezoneOffset,
+ if (locale != null) 'locale': locale,
+ };
}
class ApiClient {
ApiClient({http.Client? httpClient})
- : _http = httpClient ?? http.Client(),
- _baseUrl = Env.apiBaseUrl;
+ : _http = httpClient ?? http.Client(),
+ _baseUrl = Env.apiBaseUrl;
final http.Client _http;
final String _baseUrl;
+ bool _expiresSoon(Session session) {
+ final expiresAt = session.expiresAt;
+ if (expiresAt == null) return false;
+ final expiry = DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000);
+ return DateTime.now().add(_authRefreshSkew).isAfter(expiry);
+ }
+
/// Bearer header from the current Supabase session token, or empty when
/// signed out. Mirrors `authHeaders()` in the RN client.
Future