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> _authHeaders() async { - final token = SupabaseService.client.auth.currentSession?.accessToken; + final auth = SupabaseService.client.auth; + var session = auth.currentSession; + if (session != null && _expiresSoon(session)) { + try { + session = (await auth.refreshSession()).session ?? auth.currentSession; + } catch (_) { + session = auth.currentSession; + } + } + final token = session?.accessToken; return token != null ? {'Authorization': 'Bearer $token'} : {}; } - Future _request( - String method, - String path, [ - Object? body, - ]) async { + Future _request(String method, String path, [Object? body]) async { final headers = await _authHeaders(); if (body != null) { headers['Content-Type'] = 'application/json'; @@ -158,6 +174,16 @@ class ApiClient { /// DELETE. Future delete(String path) => _request('DELETE', path); + /// Permanently delete the signed-in user's account and all their data + /// (`DELETE /api/v1/account`). The server removes the Supabase auth user, + /// which cascades to every app row. There is no undo. + Future deleteAccount() => delete('/api/v1/account'); + + /// Fetch a complete JSON snapshot of the user's data + /// (`GET /api/v1/account`): profile, meals (with items), and weights. + Future> exportMyData() => + get>('/api/v1/account'); + /// Fire-and-forget ping to wake a scale-to-zero backend on launch. Failures /// are swallowed (offline / unreachable is fine). Mirrors `warmupApi()`. Future warmup() async { @@ -182,9 +208,10 @@ class ApiClient { headers['Content-Type'] = 'application/json'; headers['Accept'] = 'text/event-stream'; - final req = http.Request('POST', Uri.parse('$_baseUrl/api/analyze-meal')) - ..headers.addAll(headers) - ..body = jsonEncode(input.toJson()); + final req = + http.Request('POST', Uri.parse('$_baseUrl/api/analyze-meal')) + ..headers.addAll(headers) + ..body = jsonEncode(input.toJson()); http.StreamedResponse streamed; try { diff --git a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart index 8567226e..80cd6a4c 100644 --- a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart +++ b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart @@ -1,6 +1,11 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../../data/session_provider.dart'; @@ -13,14 +18,39 @@ const String kAuthRedirect = 'nham://auth-callback'; /// Which auth action is currently in flight. Lets the UI spin only the button /// that was tapped while still disabling the others — replacing the old single /// `busy` bool that made one tap spin every control. -enum AuthAction { email, google } +enum AuthAction { email, google, apple } + +/// Maps a Supabase [AuthException] to warm, localized copy — the raw English +/// `e.message` never reaches the UI. Wrong-password and no-account are +/// deliberately indistinguishable (Supabase anti-enumeration returns the same +/// `invalid_credentials` for both), so they share one neutral line. +String authErrorMessage(AuthException e) { + if (e is AuthRetryableFetchException) return tr('auth.errors.network'); + switch (e.code) { + case 'invalid_credentials': + return tr('auth.errors.invalidCredentials'); + case 'email_not_confirmed': + return tr('auth.errors.emailNotConfirmed'); + case 'over_request_rate_limit': + case 'over_email_send_rate_limit': + return tr('auth.errors.rateLimited'); + default: + if (e.statusCode == '429') return tr('auth.errors.rateLimited'); + return tr('auth.errors.generic'); + } +} /// Immutable view-state for an auth screen, mirroring the RN screens' /// `busy` / `error` / `notice` `useState` triplet — except `busy` is now an /// `AuthAction?` so the email and Google buttons own independent spinners. @immutable class AuthFormState { - const AuthFormState({this.action, this.error, this.notice}); + const AuthFormState({ + this.action, + this.error, + this.notice, + this.pendingEmail, + }); /// The in-flight action (email or Google), or `null` when idle. final AuthAction? action; @@ -31,6 +61,10 @@ class AuthFormState { /// `notice` — success notice (sage), used by sign-up's confirm-email path. final String? notice; + /// The address a confirmation email was sent to. Non-null drives the + /// "Check your email" cross-fade state (replacing the old vanishing toast). + final String? pendingEmail; + /// Any request in flight — disables inputs and the tab toggle. bool get busy => action != null; @@ -40,18 +74,24 @@ class AuthFormState { /// Whether Google is the in-flight action (drives the Google button spinner). bool get googleBusy => action == AuthAction.google; + /// Whether Apple is the in-flight action (drives the Apple button state). + bool get appleBusy => action == AuthAction.apple; + AuthFormState copyWith({ AuthAction? action, String? error, String? notice, + String? pendingEmail, bool clearAction = false, bool clearError = false, bool clearNotice = false, + bool clearPendingEmail = false, }) { return AuthFormState( action: clearAction ? null : (action ?? this.action), error: clearError ? null : (error ?? this.error), notice: clearNotice ? null : (notice ?? this.notice), + pendingEmail: clearPendingEmail ? null : (pendingEmail ?? this.pendingEmail), ); } } @@ -73,49 +113,60 @@ class AuthFormController extends StateNotifier { }) async { state = state.copyWith(action: AuthAction.email, clearError: true); try { - await _ref.read(authControllerProvider).signInWithPassword( - email: email.trim(), - password: password, - ); + await _ref + .read(authControllerProvider) + .signInWithPassword(email: email.trim(), password: password); // Success: the sessionProvider stream fires and the router redirect // routes into the app (RN did `router.replace('/logging')`). state = state.copyWith(clearAction: true); } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); - } catch (e) { - state = state.copyWith(clearAction: true, error: e.toString()); + if (e.code == 'email_not_confirmed') { + // The account exists but was never confirmed — surface the real + // confirm-email state (with its resend affordance) instead of a + // dead-end error line. + state = state.copyWith(clearAction: true, pendingEmail: email.trim()); + return; + } + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); + } catch (_) { + state = state.copyWith( + clearAction: true, + error: tr('auth.errors.generic'), + ); } } /// RN `signUp`: trims email, calls `signUp`. If a session comes back the /// router routes in; otherwise show the "check your email" notice. - Future signUp({ - required String email, - required String password, - }) async { + Future signUp({required String email, required String password}) async { state = state.copyWith( action: AuthAction.email, clearError: true, clearNotice: true, ); try { - final res = await _ref.read(authControllerProvider).signUpWithPassword( - email: email.trim(), - password: password, - ); + final res = await _ref + .read(authControllerProvider) + .signUpWithPassword(email: email.trim(), password: password); if (res.session != null) { // Signed in immediately — router redirect takes over. state = state.copyWith(clearAction: true); return; } + // No session: a confirmation email is on its way. Hold the address so the + // UI can cross-fade to a real "Check your email" state (not a toast that + // vanishes before the user reads it). state = state.copyWith( clearAction: true, - notice: tr('auth.signUp.success'), + pendingEmail: email.trim(), ); } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); - } catch (e) { - state = state.copyWith(clearAction: true, error: e.toString()); + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); + } catch (_) { + state = state.copyWith( + clearAction: true, + error: tr('auth.errors.generic'), + ); } } @@ -138,12 +189,12 @@ class AuthFormController extends StateNotifier { ); // The browser hands off; supabase_flutter's deep-link observer completes // the PKCE exchange on the nham://auth-callback return, onAuthStateChange - // fires, and the router redirect routes in. Clear the spinner now — the - // app is backgrounded in the browser and shouldn't sit spinning if the - // user cancels and returns. - state = state.copyWith(clearAction: true); + // fires, and the router redirect routes in. Keep the `google` action set + // so the UI can hold a "Finishing sign-in…" state across the Safari + // app-switch; the UI's lifecycle listener clears it if the user cancels + // and resumes still signed-out. } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); } catch (_) { state = state.copyWith( clearAction: true, @@ -152,6 +203,111 @@ class AuthFormController extends StateNotifier { } } + /// Native Sign in with Apple. Required by App Store Guideline 4.8 whenever a + /// third-party social login (Google) is offered. Uses the native credential + /// sheet, then hands Supabase the identity token + raw nonce so it verifies + /// the Apple-signed hashed nonce embedded in the token. + Future signInWithApple() async { + state = state.copyWith( + action: AuthAction.apple, + clearError: true, + clearNotice: true, + ); + try { + final rawNonce = _generateRawNonce(); + final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); + final credential = await SignInWithApple.getAppleIDCredential( + scopes: const [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], + nonce: hashedNonce, + ); + final idToken = credential.identityToken; + if (idToken == null) { + state = state.copyWith( + clearAction: true, + error: tr('auth.dialog.appleError'), + ); + return; + } + await _auth.signInWithIdToken( + provider: OAuthProvider.apple, + idToken: idToken, + nonce: rawNonce, + ); + // Success: onAuthStateChange fires and the router redirect routes in. + state = state.copyWith(clearAction: true); + } on SignInWithAppleAuthorizationException catch (e) { + // User cancelled the sheet → no error surfaced, just clear the spinner. + if (e.code == AuthorizationErrorCode.canceled) { + state = state.copyWith(clearAction: true); + return; + } + state = state.copyWith( + clearAction: true, + error: tr('auth.dialog.appleError'), + ); + } on AuthException catch (e) { + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); + } catch (_) { + state = state.copyWith( + clearAction: true, + error: tr('auth.dialog.appleError'), + ); + } + } + + /// A cryptographically-random nonce. Its SHA-256 is sent to Apple; the raw + /// value is sent to Supabase, which checks the two match to prevent replay. + String _generateRawNonce([int length = 32]) { + const charset = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._'; + final random = Random.secure(); + return List.generate( + length, + (_) => charset[random.nextInt(charset.length)], + ).join(); + } + + /// Re-send the sign-up confirmation email to the pending address. Drives the + /// "Resend email" affordance on the confirm-email state; the UI owns the + /// cooldown so a tap can't spam Supabase's own rate limit. + Future resendConfirmation() async { + final email = state.pendingEmail; + if (email == null) return; + state = state.copyWith(action: AuthAction.email, clearError: true); + try { + await _auth.resend(type: OtpType.signup, email: email); + state = state.copyWith( + clearAction: true, + notice: tr('auth.confirm.resent'), + ); + } on AuthException catch (e) { + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); + } catch (e) { + state = state.copyWith( + clearAction: true, + error: tr('auth.errors.generic'), + ); + } + } + + /// Drop the in-flight action (e.g. the OAuth browser round-trip was + /// cancelled and the app resumed still signed-out). + void resetAction() { + if (state.action != null) state = state.copyWith(clearAction: true); + } + + /// Leave the confirm-email state (the "Back" affordance). + void clearPendingEmail() { + state = state.copyWith( + clearPendingEmail: true, + clearError: true, + clearNotice: true, + ); + } + /// Clear any surfaced error/notice (e.g. when the user edits a field). void clearMessages() { if (state.error != null || state.notice != null) { @@ -160,15 +316,10 @@ class AuthFormController extends StateNotifier { } } -/// Sign-in screen controller. +/// The auth controller. The welcome → email → confirm-email path is one flow, +/// so a single instance backs the whole surface (the old split sign-in/sign-up +/// providers collapsed with the split UI). final signInControllerProvider = StateNotifierProvider.autoDispose( - AuthFormController.new, -); - -/// Sign-up screen controller (separate instance so its `notice` state is -/// independent of sign-in). -final signUpControllerProvider = - StateNotifierProvider.autoDispose( - AuthFormController.new, -); + AuthFormController.new, + ); diff --git a/apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart b/apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart new file mode 100644 index 00000000..e45b753f --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart @@ -0,0 +1,225 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/env.dart'; +import '../../../services/supabase_service.dart'; +import '../../../shared/widgets/nham_primitives.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_typography.dart'; +import '../widgets/auth_submit_button.dart'; +import '../widgets/auth_text_field.dart'; + +/// Forgot-password flow. There was no password recovery at all before this. +/// +/// Sends a reset email whose link routes through the web `/auth/callback` to the +/// web `/reset-password` page (the same flow the web app uses), then holds on a +/// persistent "check your email" state. Mobile reuses the web reset surface so +/// no in-app deep-link recovery handling is needed. +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _email = TextEditingController(); + String? _emailError; + bool _busy = false; + bool _sent = false; + + @override + void dispose() { + _email.dispose(); + super.dispose(); + } + + Future _submit() async { + final email = _email.text.trim(); + final ok = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); + if (!ok) { + setState(() => _emailError = tr('auth.forgot.emailError')); + return; + } + setState(() { + _emailError = null; + _busy = true; + }); + final locale = context.locale.languageCode; + final redirect = + '${Env.apiBaseUrl}/auth/callback?next=${Uri.encodeComponent('/$locale/reset-password')}'; + try { + // Anti-enumeration: Supabase returns success regardless, so we always + // advance to the "check your email" state. + await SupabaseService.client.auth.resetPasswordForEmail( + email, + redirectTo: redirect, + ); + } catch (_) { + // Swallow — still show the neutral confirmation. + } + if (!mounted) return; + setState(() { + _busy = false; + _sent = true; + }); + } + + @override + Widget build(BuildContext context) { + return Screen( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _BackHeader(onBack: () => Navigator.of(context).maybePop()), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: _sent ? _sentBody() : _formBody(), + ), + ), + ), + ], + ), + ); + } + + Widget _formBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('auth.forgot.title'), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 8), + Text( + tr('auth.forgot.description'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted, height: 1.5), + ), + const SizedBox(height: 20), + AuthTextField( + controller: _email, + label: tr('auth.forgot.email'), + placeholder: tr('auth.forgot.emailPlaceholder'), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.email], + enabled: !_busy, + errorText: _emailError, + onSubmitted: (_) => _submit(), + onChanged: (_) { + if (_emailError != null) setState(() => _emailError = null); + }, + ), + const SizedBox(height: 16), + AuthSubmitButton( + label: tr('auth.forgot.submit'), + busy: _busy, + loading: _busy, + onPressed: _submit, + ), + ], + ); + } + + Widget _sentBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Color(0x26C9A87C), // accent @ 15% + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.mailCheck, + size: 20, + color: Color(0xFFA88B63), + ), + ), + ), + const SizedBox(height: 16), + Text( + tr('auth.forgot.sentTitle'), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 8), + Text( + tr('auth.forgot.sentDescription'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted, height: 1.5), + ), + const SizedBox(height: 4), + Text( + _email.text.trim(), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: 16, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 24), + AuthSubmitButton( + label: tr('auth.forgot.backToSignIn'), + busy: false, + loading: false, + onPressed: () => Navigator.of(context).maybePop(), + ), + ], + ); + } +} + +class _BackHeader extends StatelessWidget { + const _BackHeader({required this.onBack}); + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onBack, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.textMuted, + ), + const SizedBox(width: 6), + Text( + tr('auth.forgot.back'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart b/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart index 63dc8945..e7f74b2a 100644 --- a/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart +++ b/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart @@ -5,9 +5,9 @@ import '../widgets/auth_page.dart'; /// The sign-in route. /// -/// A flat full-page auth surface (header, in-place Sign in / Sign up toggle, -/// Google button, divider, form, footer) on the cream surface. Opens on the -/// sign-in tab; the toggle/footer switches to sign-up in place (no route change). +/// Lands on the pre-auth welcome screen (wordmark, typing demo, three social/ +/// email options). "Continue with email" cross-fades to a single email path; +/// sign-up cross-fades to a "Check your email" state. No top tab split. class SignInScreen extends StatelessWidget { const SignInScreen({super.key}); @@ -15,7 +15,7 @@ class SignInScreen extends StatelessWidget { Widget build(BuildContext context) { return const Scaffold( backgroundColor: NhamColors.surface, - body: AuthPage(initialSignIn: true), + body: AuthPage(), ); } } diff --git a/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart b/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart index a64767c7..38a2cef4 100644 --- a/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart +++ b/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart @@ -5,9 +5,8 @@ import '../widgets/auth_page.dart'; /// The sign-up route. /// -/// A flat full-page auth surface (header, in-place Sign in / Sign up toggle, -/// Google button, divider, form, footer) on the cream surface. Opens on the -/// sign-up tab; the toggle/footer switches to sign-in in place (no route change). +/// Renders the same pre-auth welcome surface as `/sign-in` — the sign-in/sign-up +/// split is collapsed into one path, so both routes land on the welcome screen. class SignUpScreen extends StatelessWidget { const SignUpScreen({super.key}); @@ -15,7 +14,7 @@ class SignUpScreen extends StatelessWidget { Widget build(BuildContext context) { return const Scaffold( backgroundColor: NhamColors.surface, - body: AuthPage(initialSignIn: false), + body: AuthPage(), ); } } diff --git a/apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart b/apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart new file mode 100644 index 00000000..cabf1fe1 --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart @@ -0,0 +1,83 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// "Continue with Apple" — a custom button matching the Google / email buttons. +/// +/// Built to be identical in font, size and shape to the other auth buttons; the +/// ONLY thing it keeps from Apple's styling is the colour (black fill, white +/// logo + label). The mark is Apple's genuine logo glyph (U+F8FF) rendered from +/// the iOS system font — not a hand-traced path — so it stays authentic. +/// +/// Note: this is a hand-rolled button rather than the system +/// `SignInWithAppleButton`, so it intentionally diverges from Apple's HIG +/// "use the provided button" guidance in favour of a unified button stack. +class AppleButton extends StatefulWidget { + const AppleButton({super.key, required this.onPressed, required this.busy}); + + final VoidCallback onPressed; + + /// Any auth request is in flight — dims + blocks the button. + final bool busy; + + @override + State createState() => _AppleButtonState(); +} + +class _AppleButtonState extends State { + bool _pressed = false; + + // Apple's button black, and a slightly lighter shade for the pressed state. + static const Color _black = Color(0xFF000000); + static const Color _blackPressed = Color(0xFF1C1C1E); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: widget.busy ? 0.6 : 1.0, + child: GestureDetector( + onTapDown: + widget.busy ? null : (_) => setState(() => _pressed = true), + onTapUp: widget.busy ? null : (_) => setState(() => _pressed = false), + onTapCancel: + widget.busy ? null : () => setState(() => _pressed = false), + onTap: widget.busy ? null : widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: _pressed ? _blackPressed : _black, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Apple's genuine logo glyph from the iOS system font. + const Text( + '\u{F8FF}', + style: TextStyle( + fontFamily: '.SF Pro Text', + fontSize: 19, + height: 1, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + Text( + tr('auth.dialog.continueWithApple'), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.md) + .copyWith(color: NhamColors.elev, letterSpacing: -0.2), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart index 2841b5bb..a091bf64 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart @@ -1,242 +1,335 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../services/supabase_service.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../providers/auth_form_controller.dart'; +import 'apple_button.dart'; import 'auth_divider.dart'; +import 'confirm_email_view.dart'; +import '../../../shared/widgets/top_toast.dart'; +import 'email_auth_form.dart'; import 'google_button.dart'; -import 'sign_in_form.dart'; -import 'sign_up_form.dart'; +import 'welcome_demo.dart'; -/// The auth surface as a full-bleed page. +/// Which face of the auth surface is showing. +enum _AuthMode { welcome, email } + +/// The auth surface as a full-bleed page on the cream surface. /// -/// Ported from web `components/auth/auth-dialog.tsx`, but rendered as a flat -/// page on the cream surface rather than a modal: no dimmed/blurred backdrop and -/// no floating card. It keeps the same content (header, Google button + -/// divider, form, footer) and cross-fades between the Sign in / Sign up forms; -/// the footer link is the only tab switch (the top toggle was removed). +/// Replaces the old "login wall titled Welcome back". It now lands on a real +/// pre-auth **welcome screen**: the Lora wordmark, a typing demo resolving into +/// a point result, then three stacked social/email options. "Continue with +/// email" cross-fades to a single email path (no sign-in/sign-up tab split). A +/// successful sign-up cross-fades again to a real "Check your email" state with +/// a resend-cooldown, instead of a SnackBar that vanishes before it's read. class AuthPage extends ConsumerStatefulWidget { - const AuthPage({super.key, this.initialSignIn = true}); - - /// Which tab opens first. `/sign-in` → true, `/sign-up` → false. - final bool initialSignIn; + const AuthPage({super.key}); @override ConsumerState createState() => _AuthPageState(); } class _AuthPageState extends ConsumerState { - late bool _signIn = widget.initialSignIn; + _AuthMode _mode = _AuthMode.welcome; + + /// Drives the face-switch slide direction: true = advancing (welcome → email), + /// false = going back. Lets the transition read as forward/back navigation. + bool _forward = true; + + // The welcome + email surfaces share one controller (single path). + static final _provider = signInControllerProvider; + + AuthFormController get _controller => ref.read(_provider.notifier); + + AppLifecycleListener? _lifecycle; + + @override + void initState() { + super.initState(); + // OAuth hands off to Safari; on return the deep-link observer completes the + // PKCE exchange. If the app resumes still signed-out, the user cancelled — + // drop the "Finishing sign-in…" pending state so it doesn't hang. + _lifecycle = AppLifecycleListener( + onResume: () { + final state = ref.read(_provider); + if (state.googleBusy && + SupabaseService.client.auth.currentSession == null) { + _controller.clearMessages(); + // Clear the in-flight action so the overlay drops. + ref.read(_provider.notifier).resetAction(); + } + }, + ); + } - void _setTab(bool signIn) { - if (_signIn == signIn) return; - setState(() => _signIn = signIn); + @override + void dispose() { + _lifecycle?.dispose(); + super.dispose(); } void _toast(String message) { - final messenger = ScaffoldMessenger.maybeOf(context); - if (messenger == null) return; - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - backgroundColor: NhamColors.cardCream, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - side: const BorderSide(color: NhamColors.border), - ), - content: Text( - message, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), - ), - ), - ); + showTopToast(context, message, variant: TopToastVariant.error); } @override Widget build(BuildContext context) { - final provider = - _signIn ? signInControllerProvider : signUpControllerProvider; - final state = ref.watch(provider); - - return SafeArea( - child: Center( - child: SingleChildScrollView( - // Page gutter (32) + maxWidth 420 → a 356px content column, matching - // the old card's px-8 inner width so spacing/line lengths are 1:1. - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _header(), - _googleBlock(state, ref.read(provider.notifier).signInWithGoogle), - _formBlock(state), - ], - ), - ), - ), - ), - ); - } + final state = ref.watch(_provider); + final showConfirm = state.pendingEmail != null; - // pb-2, centered. - Widget _header() { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - children: [ - Text( - _signIn - ? tr('auth.dialog.signInTitle') - : tr('auth.dialog.signUpTitle'), - textAlign: TextAlign.center, - // Lora w400, 24px (text-2xl), #2C2416. - style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.h3) - .copyWith(color: NhamColors.text), - ), - const SizedBox(height: 4), // mb-1 - Text( - _signIn - ? tr('auth.dialog.signInSubtitle') - : tr('auth.dialog.signUpSubtitle'), - textAlign: TextAlign.center, - // text-sm #8B7355 DM Sans. - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.textMuted), - ), - ], - ), - ); - } + // Errors raised while the email form is on screen render inline there. + // Anything else (OAuth on the welcome face, a failed resend on the + // confirm face) would otherwise be invisible — toast those. + ref.listen(_provider, (prev, next) { + final err = next.error; + final emailFormShowing = + _mode == _AuthMode.email && next.pendingEmail == null; + if (err != null && err != prev?.error && !emailFormShowing) { + _toast(err); + _controller.clearMessages(); + } + }); + + // Which face: confirm-email > email form > welcome. The key also tells the + // switcher's transitionBuilder which child is incoming vs outgoing. + final Key currentKey = showConfirm + ? const ValueKey('confirm') + : _mode == _AuthMode.email + ? const ValueKey('email') + : const ValueKey('welcome'); - // space-y-3, pt-4. - Widget _googleBlock(AuthFormState state, VoidCallback onGoogle) { - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - children: [ - GoogleButton( - busy: state.busy, - loading: state.googleBusy, - onPressed: onGoogle, + final Widget face; + if (showConfirm) { + face = ConfirmEmailView(provider: _provider, onNotice: _toast); + } else if (_mode == _AuthMode.email) { + face = EmailAuthForm( + provider: _provider, + onBack: () => setState(() { + _forward = false; + _mode = _AuthMode.welcome; + }), + ); + } else { + face = _welcome(state); + } + + // Each face is a full-screen, opaque page so switching reads as an + // iOS-style full-page push (not a content cross-fade). The opaque fill lets + // the incoming page cover the outgoing one as it slides across. + final Widget page = ColoredBox( + key: currentKey, + color: NhamColors.surface, + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: face, + ), ), - const SizedBox(height: 12), // space-y-3 - const AuthDivider(), - ], + ), ), ); - } - // pt-4. - Widget _formBlock(AuthFormState state) { - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Tab-switch cross-fade + 10px horizontal slide, 150ms, mode 'wait'. - AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, + return Stack( + children: [ + Positioned.fill( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 340), transitionBuilder: (child, animation) { - final incomingSignIn = (child.key == const ValueKey('sign-in')); - // sign-in enters from x:-10, sign-up from x:+10. - final beginDx = incomingSignIn ? -10.0 : 10.0; - return FadeTransition( - opacity: animation, - child: AnimatedBuilder( - animation: animation, - builder: (context, c) => Transform.translate( - offset: Offset(beginDx * (1 - animation.value), 0), - child: c, - ), - child: child, - ), + // Incoming slides a full width in from the lead side; outgoing + // parallax-slides a little the opposite way, beneath it. Direction + // flips on "back" via [_forward]. + final incoming = child.key == currentKey; + final dir = _forward ? 1.0 : -1.0; + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + final begin = incoming ? Offset(dir, 0) : Offset(-dir * 0.25, 0); + return SlideTransition( + position: Tween(begin: begin, end: Offset.zero) + .animate(curved), + child: child, ); }, - child: _signIn - ? SignInForm(key: const ValueKey('sign-in'), onError: _toast) - : SignUpForm( - key: const ValueKey('sign-up'), - onError: _toast, - onNotice: _toast, - ), + layoutBuilder: (currentChild, previousChildren) => Stack( + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: page, ), - const SizedBox(height: 20), // mt-5 - _footer(state), - ], - ), + ), + // OAuth app-switch: a calm "Finishing sign-in…" hold so the surface + // never sits blank or spins indefinitely after Safari returns. + if (state.googleBusy) const _FinishingOverlay(), + ], ); } - Widget _footer(AuthFormState state) { - final prompt = _signIn - ? tr('auth.signIn.noAccount') - : tr('auth.signUp.hasAccount'); - final action = - _signIn ? tr('auth.signIn.signUpLink') : tr('auth.signUp.signInLink'); - return Row( - mainAxisAlignment: MainAxisAlignment.center, + Widget _welcome(AuthFormState state) { + final showApple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + return Column( + key: const ValueKey('welcome'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - '$prompt ', - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.textMuted), + // Wordmark. + Center( + child: Text( + 'Nhẩm', + style: NhamTextStyles.serifRegular( + fontSize: 28, + ).copyWith(color: NhamColors.text), + ), ), - // Inert while a request is in flight — same guard as the tab toggle. - Opacity( - opacity: state.busy ? 0.6 : 1.0, - child: IgnorePointer( - ignoring: state.busy, - child: _FooterLink(label: action, onTap: () => _setTab(!_signIn)), + const SizedBox(height: 14), + // Tagline — sentence, with the second clause italic-tan. + Text.rich( + TextSpan( + children: [ + TextSpan(text: '${tr('auth.welcome.tagline')} '), + TextSpan( + text: tr('auth.welcome.taglineHighlight'), + style: NhamTextStyles.serifItalic( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.accent), + ), + ], ), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + height: NhamLeading.snug, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 28), + const WelcomeDemo(), + const SizedBox(height: 28), + if (showApple) ...[ + AppleButton(busy: state.busy, onPressed: _controller.signInWithApple), + const SizedBox(height: 12), + ], + GoogleButton( + busy: state.busy, + loading: state.googleBusy, + onPressed: _controller.signInWithGoogle, + ), + const SizedBox(height: 12), + const AuthDivider(), + const SizedBox(height: 12), + _EmailEntryButton( + busy: state.busy, + onPressed: () => setState(() { + _forward = true; + _mode = _AuthMode.email; + }), + ), + const SizedBox(height: 18), + Text( + tr('auth.welcome.terms'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs, + height: NhamLeading.normal, + ).copyWith(color: NhamColors.textMuted), ), ], ); } } -/// Inline tab-switch link: `font-semibold text-[#C9A87C] -/// hover:text-[#A88B63] transition-colors`. -class _FooterLink extends StatefulWidget { - const _FooterLink({required this.label, required this.onTap}); +/// "Continue with email" — a ghost button matching the Google button shape but +/// without a logo, so the three options read as one stack. +class _EmailEntryButton extends StatefulWidget { + const _EmailEntryButton({required this.onPressed, required this.busy}); - final String label; - final VoidCallback onTap; + final VoidCallback onPressed; + final bool busy; @override - State<_FooterLink> createState() => _FooterLinkState(); + State<_EmailEntryButton> createState() => _EmailEntryButtonState(); } -class _FooterLinkState extends State<_FooterLink> { +class _EmailEntryButtonState extends State<_EmailEntryButton> { bool _pressed = false; - // Web hover color is #A88B63 (distinct from the accentDark token #B89968). - static const Color _hover = Color(0xFFA88B63); + @override + Widget build(BuildContext context) { + final fill = _pressed ? NhamColors.cardCream : NhamColors.elev; + return Opacity( + opacity: widget.busy ? 0.6 : 1.0, + child: GestureDetector( + onTapDown: widget.busy ? null : (_) => setState(() => _pressed = true), + onTapUp: widget.busy ? null : (_) => setState(() => _pressed = false), + onTapCancel: + widget.busy ? null : () => setState(() => _pressed = false), + onTap: widget.busy ? null : widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + border: Border.all(color: NhamColors.border), + ), + alignment: Alignment.center, + child: Text( + tr('auth.welcome.continueWithEmail'), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.md) + .copyWith(color: NhamColors.text, letterSpacing: -0.2), + ), + ), + ), + ); + } +} + +/// A calm full-bleed "Finishing sign-in…" hold shown while the OAuth browser +/// round-trip completes after Safari returns. +class _FinishingOverlay extends StatelessWidget { + const _FinishingOverlay(); @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), // transition-colors - style: NhamTextStyles.sansSemiBold(fontSize: NhamFontSize.sm) - .copyWith(color: _pressed ? _hover : NhamColors.accent), - child: Text(widget.label), + return Positioned.fill( + child: ColoredBox( + color: NhamColors.surface80, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NhamColors.accent, + ), + ), + const SizedBox(height: 16), + Text( + tr('auth.pending.finishing'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), ), ); } diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_submit_button.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_submit_button.dart index 270b0c34..c88aaaff 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_submit_button.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_submit_button.dart @@ -79,7 +79,7 @@ class _AuthSubmitButtonState extends State { ], Text( widget.label, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.md) .copyWith(color: NhamColors.elev, letterSpacing: -0.2), ), ], diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart index 3999bfdf..080d36d3 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -104,7 +105,7 @@ class _AuthTextFieldState extends State { // Label. Text( widget.label, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.detail) .copyWith(color: NhamColors.text, letterSpacing: NhamTracking.wide), ), const SizedBox(height: 6), // space-y-1.5 @@ -138,13 +139,13 @@ class _AuthTextFieldState extends State { autocorrect: false, enableSuggestions: !isPassword, cursorColor: NhamColors.text, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.md) .copyWith(color: NhamColors.text), decoration: InputDecoration( isDense: true, hintText: widget.placeholder, hintStyle: - NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) + NhamTextStyles.sansRegular(fontSize: NhamFontSize.md) .copyWith(color: _placeholder), filled: false, border: InputBorder.none, @@ -172,7 +173,7 @@ class _AuthTextFieldState extends State { const SizedBox(height: 6), // space-y-1.5 Text( widget.errorText!, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) .copyWith(color: _red500), ), ], @@ -209,7 +210,7 @@ class _RevealToggleState extends State<_RevealToggle> { onTapCancel: () => setState(() => _pressed = false), onTap: widget.onTap, child: Icon( - widget.revealed ? Icons.visibility_off_outlined : Icons.visibility_outlined, + widget.revealed ? LucideIcons.eyeOff : LucideIcons.eye, size: 16, color: color, ), diff --git a/apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart b/apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart new file mode 100644 index 00000000..d308cbc6 --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import '../providers/auth_form_controller.dart'; + +/// The post-sign-up "Check your email" state. +/// +/// Replaces the SnackBar that vanished before the user could read it: a real +/// surface that names the address the confirmation went to, with a resend +/// affordance gated by a 30s cooldown so the tap can't spam Supabase's rate +/// limit. "Back" returns to the welcome screen. +class ConfirmEmailView extends ConsumerStatefulWidget { + const ConfirmEmailView({ + super.key, + required this.provider, + required this.onNotice, + }); + + final AutoDisposeStateNotifierProvider + provider; + final void Function(String message) onNotice; + + @override + ConsumerState createState() => _ConfirmEmailViewState(); +} + +class _ConfirmEmailViewState extends ConsumerState { + static const _cooldownSeconds = 30; + int _remaining = _cooldownSeconds; + Timer? _timer; + bool _wasBusy = false; + + @override + void initState() { + super.initState(); + _startCooldown(); + } + + void _startCooldown() { + _remaining = _cooldownSeconds; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) return; + if (_remaining <= 1) { + t.cancel(); + setState(() => _remaining = 0); + } else { + setState(() => _remaining--); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + AuthFormController get _controller => ref.read(widget.provider.notifier); + + void _resend() { + if (_remaining > 0) return; + _controller.resendConfirmation(); + _startCooldown(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(widget.provider); + final busy = state.busy; + final email = state.pendingEmail ?? ''; + + if (_wasBusy && !busy && state.notice != null) { + final msg = state.notice!; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onNotice(msg); + _controller.clearMessages(); + }); + } + _wasBusy = busy; + + final canResend = _remaining == 0 && !busy; + + return Column( + key: const ValueKey('confirm-view'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: NhamColors.accentSelectedFill, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.mail, + size: 24, + color: NhamColors.accent, + ), + ), + ), + const SizedBox(height: 20), + Text( + tr('auth.confirm.title'), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 8), + Text( + tr('auth.confirm.description'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + const SizedBox(height: 2), + Text( + email, + textAlign: TextAlign.center, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 12), + Text( + tr('auth.confirm.hint'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs, + height: NhamLeading.normal, + ).copyWith(color: NhamColors.textMuted), + ), + const SizedBox(height: 24), + // Resend (cooldown-gated). + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: canResend ? _resend : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + border: Border.all(color: NhamColors.border), + ), + alignment: Alignment.center, + child: Text( + canResend + ? tr('auth.confirm.resend') + : tr( + 'auth.confirm.resendIn', + namedArgs: {'seconds': '$_remaining'}, + ), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.md) + .copyWith( + color: canResend + ? NhamColors.text + : NhamColors.textMuted, + ), + ), + ), + ), + const SizedBox(height: 12), + Center( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: busy ? null : _controller.clearPendingEmail, + child: Text( + tr('auth.confirm.back'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart new file mode 100644 index 00000000..f1c0114b --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart @@ -0,0 +1,228 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_typography.dart'; +import '../providers/auth_form_controller.dart'; +import '../screens/forgot_password_screen.dart'; +import 'auth_submit_button.dart'; +import 'auth_text_field.dart'; + +/// The single email path, reached from the welcome screen's "Continue with +/// email". One form, no top tabs: a primary action signs the user in, and a +/// quiet toggle below flips it to account-creation in place — collapsing the +/// old sign-in/sign-up split into one screen. +class EmailAuthForm extends ConsumerStatefulWidget { + const EmailAuthForm({ + super.key, + required this.provider, + required this.onBack, + }); + + final AutoDisposeStateNotifierProvider + provider; + final VoidCallback onBack; + + @override + ConsumerState createState() => _EmailAuthFormState(); +} + +class _EmailAuthFormState extends ConsumerState { + final _email = TextEditingController(); + final _password = TextEditingController(); + String? _emailError; + String? _passwordError; + bool _createMode = false; + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + AuthFormController get _controller => ref.read(widget.provider.notifier); + + bool _validate() { + final email = _email.text.trim(); + final emailOk = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); + final passOk = _password.text.length >= 6; + setState(() { + _emailError = emailOk ? null : tr('auth.signIn.emailError'); + _passwordError = passOk ? null : tr('auth.signIn.passwordError'); + }); + return emailOk && passOk; + } + + void _submit() { + if (!_validate()) return; + if (_createMode) { + _controller.signUp(email: _email.text, password: _password.text); + } else { + _controller.signInWithEmail(email: _email.text, password: _password.text); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(widget.provider); + final busy = state.busy; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Back to the welcome screen. + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: busy ? null : widget.onBack, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.textMuted, + ), + const SizedBox(width: 6), + Text( + tr('auth.confirm.back'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + ), + ), + Text( + _createMode + ? tr('auth.dialog.signUpTitle') + : tr('auth.dialog.signInTitle'), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 16), + AuthTextField( + controller: _email, + label: tr('auth.signIn.email'), + placeholder: tr('auth.signIn.emailPlaceholder'), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + enabled: !busy, + errorText: _emailError, + onChanged: (_) { + if (_emailError != null) setState(() => _emailError = null); + _controller.clearMessages(); + }, + ), + const SizedBox(height: 16), + AuthTextField( + controller: _password, + label: tr('auth.signIn.password'), + placeholder: _createMode + ? tr('auth.signUp.passwordPlaceholder') + : tr('auth.signIn.passwordPlaceholder'), + obscureText: true, + textInputAction: TextInputAction.done, + autofillHints: _createMode + ? const [AutofillHints.newPassword] + : const [AutofillHints.password], + onSubmitted: (_) => _submit(), + enabled: !busy, + errorText: _passwordError, + onChanged: (_) { + if (_passwordError != null) setState(() => _passwordError = null); + _controller.clearMessages(); + }, + ), + if (!_createMode) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: busy + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ForgotPasswordScreen(), + ), + ), + child: Text( + tr('auth.signIn.forgotPassword'), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.textMuted), + ), + ), + ), + ], + // Inline auth error — stays put (unlike a toast) and the fields keep + // their values so the user can correct and retry in place. + if (state.error != null) ...[ + const SizedBox(height: 12), + Text( + state.error!, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + height: NhamLeading.normal, + ).copyWith(color: NhamColors.danger), + ), + ], + const SizedBox(height: 16), + AuthSubmitButton( + label: _createMode + ? tr('auth.signUp.submit') + : tr('auth.signIn.submit'), + busy: busy, + loading: state.emailBusy, + onPressed: _submit, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _createMode + ? '${tr('auth.signUp.hasAccount')} ' + : '${tr('auth.signIn.noAccount')} ', + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + Opacity( + opacity: busy ? 0.6 : 1.0, + child: IgnorePointer( + ignoring: busy, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + _controller.clearMessages(); + setState(() => _createMode = !_createMode); + }, + child: Text( + _createMode + ? tr('auth.signUp.signInLink') + : tr('auth.signIn.signUpLink'), + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.accent), + ), + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/google_button.dart b/apps/mobile-flutter/lib/features/auth/widgets/google_button.dart index f187beb0..dbf1a25f 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/google_button.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/google_button.dart @@ -79,7 +79,7 @@ class _GoogleButtonState extends State { const SizedBox(width: 10), // gap-2.5 Text( tr('auth.dialog.continueWithGoogle'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.md) .copyWith(color: NhamColors.text, letterSpacing: -0.2), ), ], diff --git a/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart deleted file mode 100644 index 471c580c..00000000 --- a/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/auth_form_controller.dart'; -import 'auth_submit_button.dart'; -import 'auth_text_field.dart'; - -/// In-dialog sign-in form. -/// -/// Matches web `components/auth/sign-in-form.tsx`: a `space-y-4` (16px) stack of -/// email + password fields and the espresso submit button. Validation is -/// per-field (zod: email format, password min 6) surfacing inline red messages; -/// Supabase errors arrive via a transient toast (handled by the controller's -/// caller). -class SignInForm extends ConsumerStatefulWidget { - const SignInForm({super.key, required this.onError}); - - /// Surfaces a Supabase error message as a transient toast. - final void Function(String message) onError; - - @override - ConsumerState createState() => _SignInFormState(); -} - -class _SignInFormState extends ConsumerState { - final _email = TextEditingController(); - final _password = TextEditingController(); - String? _emailError; - String? _passwordError; - bool _wasBusy = false; - - @override - void dispose() { - _email.dispose(); - _password.dispose(); - super.dispose(); - } - - AuthFormController get _controller => - ref.read(signInControllerProvider.notifier); - - // zod: z.email() + z.string().min(6). - bool _validate() { - final email = _email.text.trim(); - final emailOk = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); - final passOk = _password.text.length >= 6; - setState(() { - _emailError = emailOk ? null : tr('auth.signIn.emailError'); - _passwordError = passOk ? null : tr('auth.signIn.passwordError'); - }); - return emailOk && passOk; - } - - void _submit() { - if (!_validate()) return; - _controller.signInWithEmail( - email: _email.text, - password: _password.text, - ); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(signInControllerProvider); - final busy = state.busy; - - // Surface a Supabase error as a toast once the request settles. - if (_wasBusy && !busy && state.error != null) { - final msg = state.error!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onError(msg); - _controller.clearMessages(); - }); - } - _wasBusy = busy; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthTextField( - controller: _email, - label: tr('auth.signIn.email'), - placeholder: tr('auth.signIn.emailPlaceholder'), - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.email], - enabled: !busy, - errorText: _emailError, - onChanged: (_) { - if (_emailError != null) setState(() => _emailError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthTextField( - controller: _password, - label: tr('auth.signIn.password'), - placeholder: tr('auth.signIn.passwordPlaceholder'), - obscureText: true, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.password], - onSubmitted: (_) => _submit(), - enabled: !busy, - errorText: _passwordError, - onChanged: (_) { - if (_passwordError != null) setState(() => _passwordError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthSubmitButton( - label: tr('auth.signIn.submit'), - busy: busy, - loading: state.emailBusy, - onPressed: _submit, - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart deleted file mode 100644 index fbb67734..00000000 --- a/apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/auth_form_controller.dart'; -import 'auth_submit_button.dart'; -import 'auth_text_field.dart'; - -/// In-dialog sign-up form. -/// -/// Matches web `components/auth/sign-up-form.tsx`: a `space-y-4` (16px) stack of -/// email + password fields and the espresso submit button. Validation is -/// per-field (zod: email format, password min 6). Both the Supabase error and -/// the "check your email" success are delivered as transient toasts (web uses -/// sonner) rather than inline paragraphs. -class SignUpForm extends ConsumerStatefulWidget { - const SignUpForm({super.key, required this.onError, required this.onNotice}); - - final void Function(String message) onError; - final void Function(String message) onNotice; - - @override - ConsumerState createState() => _SignUpFormState(); -} - -class _SignUpFormState extends ConsumerState { - final _email = TextEditingController(); - final _password = TextEditingController(); - String? _emailError; - String? _passwordError; - bool _wasBusy = false; - - @override - void dispose() { - _email.dispose(); - _password.dispose(); - super.dispose(); - } - - AuthFormController get _controller => - ref.read(signUpControllerProvider.notifier); - - bool _validate() { - final email = _email.text.trim(); - final emailOk = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); - final passOk = _password.text.length >= 6; - setState(() { - _emailError = emailOk ? null : tr('auth.signUp.emailError'); - _passwordError = passOk ? null : tr('auth.signUp.passwordError'); - }); - return emailOk && passOk; - } - - void _submit() { - if (!_validate()) return; - _controller.signUp(email: _email.text, password: _password.text); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(signUpControllerProvider); - final busy = state.busy; - - if (_wasBusy && !busy) { - if (state.error != null) { - final msg = state.error!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onError(msg); - _controller.clearMessages(); - }); - } else if (state.notice != null) { - final msg = state.notice!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onNotice(msg); - _controller.clearMessages(); - }); - } - } - _wasBusy = busy; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthTextField( - controller: _email, - label: tr('auth.signUp.email'), - placeholder: tr('auth.signUp.emailPlaceholder'), - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.email], - enabled: !busy, - errorText: _emailError, - onChanged: (_) { - if (_emailError != null) setState(() => _emailError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthTextField( - controller: _password, - label: tr('auth.signUp.password'), - placeholder: tr('auth.signUp.passwordPlaceholder'), - obscureText: true, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.newPassword], - onSubmitted: (_) => _submit(), - enabled: !busy, - errorText: _passwordError, - onChanged: (_) { - if (_passwordError != null) setState(() => _passwordError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthSubmitButton( - label: tr('auth.signUp.submit'), - busy: busy, - loading: state.emailBusy, - onPressed: _submit, - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart b/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart new file mode 100644 index 00000000..77a3d943 --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// The pre-auth typing demo: a Vietnamese meal string types itself in, then a +/// small result chip springs in with a point calorie value. +/// +/// Mirrors the landing hero's real typing animation (50ms/char, then an 800ms +/// pause before the AI response springs in) — the one decorative flourish on the +/// welcome screen, and the product's pitch in a single glance. Per the founder +/// direction the result is a single point value (no range, no confidence label). +/// Respects reduced-motion by showing the resolved end-state immediately. +class WelcomeDemo extends StatefulWidget { + const WelcomeDemo({super.key}); + + @override + State createState() => _WelcomeDemoState(); +} + +class _WelcomeDemoState extends State + with SingleTickerProviderStateMixin { + String _full = ''; + int _typed = 0; + bool _resolved = false; + Timer? _timer; + + late final AnimationController _chip; + + bool get _reducedMotion => + WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations; + + @override + void initState() { + super.initState(); + _chip = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final full = tr('auth.welcome.demoMeal'); + if (full == _full) return; + + _timer?.cancel(); + _full = full; + if (_reducedMotion) { + _typed = _full.length; + _resolved = true; + _chip.value = 1; + return; + } + _typed = 0; + _resolved = false; + _chip.reset(); + _startTyping(); + } + + void _startTyping() { + _timer = Timer.periodic(const Duration(milliseconds: 50), (t) { + if (!mounted) return; + if (_typed >= _full.length) { + t.cancel(); + // 800ms pause, then the result springs in. + _timer = Timer(const Duration(milliseconds: 800), () { + if (!mounted) return; + setState(() => _resolved = true); + _chip.forward(); + }); + return; + } + setState(() => _typed++); + }); + } + + @override + void dispose() { + _timer?.cancel(); + _chip.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + decoration: BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.xxl), + border: Border.all(color: NhamColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The user's words, in Lora — the loudest thing on the screen. + Text( + _full.substring(0, _typed), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.lg, + height: NhamLeading.snug, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 14), + // The point result chip springs in once typing resolves. + AnimatedSize( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOut, + alignment: Alignment.centerLeft, + child: + _resolved + ? FadeTransition( + opacity: _chip, + child: ScaleTransition( + scale: Tween(begin: 0.92, end: 1).animate( + CurvedAnimation(parent: _chip, curve: Curves.easeOut), + ), + alignment: Alignment.centerLeft, + child: _resultChip(), + ), + ) + : const SizedBox(height: 0, width: double.infinity), + ), + ], + ), + ); + } + + Widget _resultChip() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: NhamColors.accentSelectedFill, + borderRadius: BorderRadius.circular(NhamRadii.xl), + border: Border.all(color: NhamColors.accentSelectedBorder), + ), + child: Text( + tr('auth.welcome.demoResult', namedArgs: {'kcal': '620'}), + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart b/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart index 588e9ca0..bd6bda9a 100644 --- a/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart +++ b/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart @@ -88,6 +88,33 @@ final loggingDayProvider = return bundle.day; }); +/// Per-day meal slice for the dashboard's paged day-viewer. +/// +/// Browsing a past day must NOT refetch the whole 90d heatmap + profile + weight +/// bundle just to swap the meal list. This hits the same lightweight +/// `GET /api/v1/logging/day` endpoint the logging surface uses and decodes only +/// the [LoggingDayData] (persistedMeals) slice the Today card renders. The +/// *anchor* day (today) still reads off the already-warm bundle so the first +/// page costs no extra round-trip; only swiping to another day fetches. +/// +/// Deliberately NOT autoDispose: the pager keeps day slices cached while the +/// user swipes back and forth (autoDispose would refetch on every page turn). +/// The trade-off is that mutations must invalidate the affected date +/// explicitly — meal confirm ([ConfirmMealNotifier]) and meal delete +/// (the feed's swipe-remove) both do. +final dashboardDayProvider = + FutureProvider.family((ref, args) async { + final api = ref.watch(apiClientProvider); + final tz = localTimezoneOffsetMinutes(); + final date = Uri.encodeComponent(args.date); + return runWithRetry(() async { + final json = await api.get>( + '/api/v1/logging/day?date=$date&tz=$tz', + ); + return LoggingDayData.fromJson(json); + }); +}); + /// `useWeightSummary('30d')` → the 30-day weight summary slice. The mobile /// chart is fixed at 30d (the section header shows the passive "30 days" /// label), so this reads `bundle.weightSummary` directly. diff --git a/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart b/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart index fb62d198..b7a4603a 100644 --- a/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart +++ b/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart @@ -4,9 +4,16 @@ /// `todayDateString` helper used by the dashboard screen. library; +import 'package:intl/intl.dart'; + /// Rounds to a whole number, mapping null to 0. Mirrors web `round0`. int round0(num? n) => n == null ? 0 : n.round(); +/// Locale-aware thousands grouping (en → "2,000", vi → "2.000"). Mirrors the +/// web's `toLocaleString()` instead of the hardcoded comma grouping. +String formatCount(int n, String locale) => + NumberFormat.decimalPattern(locale).format(n); + /// Local `YYYY-MM-DD` for [date] (defaults to now). Matches the web/RN /// `todayDateString` — uses LOCAL date components, not UTC. String todayDateString([DateTime? date]) { diff --git a/apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart b/apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart deleted file mode 100644 index 76de393b..00000000 --- a/apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart +++ /dev/null @@ -1,69 +0,0 @@ -/// Vendored VERBATIM from the RN `lib/dashboard/logic/weight-chart-utils.ts` — -/// keep in sync. -/// -/// Provides the X-axis ticks + a label formatter: W1/W2/…/Now for 30d, -/// month-short/Now for 90d. -library; - -import 'package:intl/intl.dart'; - -import '../../../models/dashboard.dart'; - -/// Result of [buildXTicks]: the tick indices plus a label formatter that takes -/// `(value, idx)` and returns the displayed string. -class XTicks { - const XTicks(this.ticks, this.formatter); - - final List ticks; - final String Function(int value, int idx) formatter; -} - -List _uniqueTicks(List values) { - final seen = {}; - final out = []; - for (final v in values) { - if (seen.add(v)) out.add(v); - } - return out; -} - -/// Builds X-axis ticks + formatter for the weight chart. [count] is the number -/// of data points; [range] selects the labeling strategy. -XTicks buildXTicks( - int count, - WeightRange range, - String locale, - String nowLabel, - String weekPrefix, -) { - if (count < 2) { - return XTicks([0], (_, __) => nowLabel); - } - - if (range == WeightRange.d30) { - final step = count ~/ 4; - final ticks = _uniqueTicks([0, step, step * 2, step * 3, count - 1]); - return XTicks( - ticks, - (_, idx) => idx == ticks.length - 1 ? nowLabel : '$weekPrefix${idx + 1}', - ); - } - - // 90d — show months. - final today = DateTime.now(); - final monthFormatter = DateFormat.MMM(locale); - final ticks = []; - final labels = []; - for (var i = 2; i > 0; i--) { - final d = DateTime(today.year, today.month - i, today.day); - final dayIndex = count - 1 - i * 30; - ticks.add(dayIndex < 0 ? 0 : dayIndex); - labels.add(monthFormatter.format(d)); - } - ticks.add(count - 1); - labels.add(nowLabel); - return XTicks( - _uniqueTicks(ticks), - (_, idx) => idx < labels.length ? labels[idx] : '', - ); -} diff --git a/apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart b/apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart deleted file mode 100644 index 282a759f..00000000 --- a/apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart +++ /dev/null @@ -1,119 +0,0 @@ -/// Vendored VERBATIM from the RN `lib/dashboard/logic/weight-trend.ts` — keep -/// in sync. Produces the weight-chart callout (status / delta / projection). -library; - -import '../../../models/dashboard.dart'; -import '../../../models/weight.dart'; - -/// Pace/trend status — drives the callout label, detail copy, and color. -/// String values match the `dashboard.progressStatus.*` i18n keys. -enum WeightTrendStatus { - insufficient, - onPace, - ahead, - behind, - stable; - - /// i18n key segment (e.g. `on_pace`) under `dashboard.progressStatus`. - String get key => switch (this) { - WeightTrendStatus.insufficient => 'insufficient', - WeightTrendStatus.onPace => 'on_pace', - WeightTrendStatus.ahead => 'ahead', - WeightTrendStatus.behind => 'behind', - WeightTrendStatus.stable => 'stable', - }; -} - -/// The computed trend summary nested in the progress card. -class WeightTrendSummary { - const WeightTrendSummary({ - required this.status, - required this.startWeight, - required this.currentWeight, - required this.expectedEndWeight, - required this.projectedEndWeight, - required this.actualWeeklyRate, - required this.expectedWeeklyRate, - required this.rangeDays, - required this.canProject, - }); - - final WeightTrendStatus status; - final double startWeight; - final double currentWeight; - final double expectedEndWeight; - final double projectedEndWeight; - final double actualWeeklyRate; - final double expectedWeeklyRate; - final int rangeDays; - final bool canProject; -} - -const Map _rangeDays = { - WeightRange.d30: 30, - WeightRange.d90: 90, -}; - -bool _isMovingWithGoal( - double actualWeeklyRate, - double expectedWeeklyRate, - double tolerance, -) => - (actualWeeklyRate - expectedWeeklyRate).abs() <= tolerance; - -/// Builds the weight-chart callout summary. Mirrors web -/// `buildWeightTrendSummary` exactly. -WeightTrendSummary buildWeightTrendSummary({ - required List weights, - required double periodStartWeight, - required double expectedEndWeight, - required WeightGoalDirection goalDirection, - required WeightRange range, - int? elapsedDays, -}) { - final rangeDays = _rangeDays[range]!; - final startWeight = weights.isNotEmpty ? weights.first : periodStartWeight; - final currentWeight = weights.isNotEmpty ? weights.last : startWeight; - final expectedWeeklyRate = - ((expectedEndWeight - periodStartWeight) / rangeDays) * 7; - final hasElapsedDays = elapsedDays != null && elapsedDays > 0; - final actualWeeklyRate = ((currentWeight - startWeight) / - (hasElapsedDays ? elapsedDays : rangeDays)) * - 7; - final canProject = - weights.length >= 3 && hasElapsedDays && elapsedDays < rangeDays; - final projectedEndWeight = canProject - ? currentWeight + actualWeeklyRate * ((rangeDays - elapsedDays) / 7) - : currentWeight; - final tolerance = (expectedWeeklyRate.abs() * 0.2).clamp(0.1, double.infinity); - - final WeightTrendStatus status; - if (weights.length < 2) { - status = WeightTrendStatus.insufficient; - } else if (goalDirection == WeightGoalDirection.flat) { - status = actualWeeklyRate.abs() <= 0.1 - ? WeightTrendStatus.stable - : WeightTrendStatus.behind; - } else if (_isMovingWithGoal(actualWeeklyRate, expectedWeeklyRate, tolerance)) { - status = WeightTrendStatus.onPace; - } else if ((goalDirection == WeightGoalDirection.down && - actualWeeklyRate < expectedWeeklyRate) || - (goalDirection == WeightGoalDirection.up && - actualWeeklyRate > expectedWeeklyRate)) { - status = WeightTrendStatus.ahead; - } else { - status = WeightTrendStatus.behind; - } - - return WeightTrendSummary( - status: status, - startWeight: startWeight, - currentWeight: currentWeight, - expectedEndWeight: expectedEndWeight, - projectedEndWeight: projectedEndWeight, - actualWeeklyRate: actualWeeklyRate, - expectedWeeklyRate: expectedWeeklyRate, - rangeDays: rangeDays, - canProject: canProject, - ); -} diff --git a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart index 3e604cbc..1b4a8ffd 100644 --- a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart +++ b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart @@ -13,18 +13,24 @@ library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../models/dashboard.dart'; import '../../../shared/widgets/widgets.dart'; import '../../../data/session_provider.dart'; import '../../../shell/app_header.dart'; import '../../../theme/nham_theme.dart'; +import '../../logging/logic/timeline_utils.dart' hide WeekStrip; import '../data/dashboard_providers.dart'; import '../logic/dashboard_format.dart'; import '../widgets/adherence_heatmap.dart'; import '../widgets/dashboard_tokens.dart'; import '../widgets/floating_meal_trigger.dart'; import '../widgets/section_header.dart'; +import '../widgets/skeleton.dart'; import '../widgets/today_section.dart'; import '../widgets/week_strip.dart'; import '../widgets/weight_chart.dart'; @@ -66,20 +72,20 @@ class DashboardScreen extends ConsumerWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader(child: Text('Hello', style: dashHeadline())), + child: AppHeader( + child: Text(_greeting().tr(), style: dashHeadline()), + ), ), Expanded( child: bundle.when( - loading: () => Center( - child: Padding( - padding: const EdgeInsets.all(NhamSpacing.sp6), - child: SectionState(message: tr('dashboard.todayLoading')), - ), - ), + // Skeleton of the real layout (not a centered spinner) so the + // load previews its own shape. + loading: () => const _DashboardSkeleton(), error: (_, __) => Center( child: Padding( padding: const EdgeInsets.all(NhamSpacing.sp6), child: SectionState( + icon: LucideIcons.cloudOff, message: tr('dashboard.todayLoadError'), actionLabel: tr('dashboard.retry'), onAction: () => @@ -91,6 +97,7 @@ class DashboardScreen extends ConsumerWidget { args: args, todayDate: todayDate, targets: _targetsFor(data), + isFirstRun: _isFirstRun(data), ), ), ), @@ -99,6 +106,27 @@ class DashboardScreen extends ConsumerWidget { ); } + /// True when the user looks brand-new — so the dashboard can collapse to + /// the one first-run card and suppress the "% on track" framing. + /// + /// There is no explicit "has-logged-before" flag on the bundle, so this gates + /// on "zero meals today AND zero logged/partial heatmap cells in the 90d + /// window". Known limit: a returning user whose last meal is older than 90 + /// days has no cells in the window and IS treated as first-run — acceptable, + /// since after that long a gentle restart reads better than stale framing. + bool _isFirstRun(DashboardBundle data) { + if (data.day.persistedMeals.isNotEmpty) return false; + for (final row in data.heatmap.cells) { + for (final cell in row) { + if (cell.status == HeatmapCellStatus.logged || + cell.status == HeatmapCellStatus.partial) { + return false; + } + } + } + return true; + } + DockTargets _targetsFor(DashboardBundle data) { final p = data.profile; return DockTargets( @@ -110,11 +138,22 @@ class DashboardScreen extends ConsumerWidget { } } +/// The l10n key for a time-of-day greeting, driven by the device clock. A +/// greeting, not an interpretation — the only Lora moment on the screen. +String _greeting() { + final hour = DateTime.now().hour; + if (hour < 12) return 'dashboard.greeting.morning'; + if (hour < 17) return 'dashboard.greeting.afternoon'; + if (hour < 21) return 'dashboard.greeting.evening'; + return 'dashboard.greeting.night'; +} + class _Content extends StatefulWidget { const _Content({ required this.args, required this.todayDate, required this.targets, + required this.isFirstRun, }); /// Today-anchored args — Progress (30d), Consistency (90d) and the week @@ -122,21 +161,67 @@ class _Content extends StatefulWidget { final DashboardArgs args; final String todayDate; final DockTargets targets; + final bool isFirstRun; @override State<_Content> createState() => _ContentState(); } class _ContentState extends State<_Content> { - // The day whose summary the Today card shows; tapping a strip day changes it - // (browse day-card only). Defaults to today. - late String _selectedDate = widget.todayDate; + /// The browsable days, oldest → today (future days aren't pageable). Today is + /// always the last page; the strip centers today at index 3, so the past half + /// (indices 0..3) is exactly today and the three days before it. + late final List _days = + buildCenteredStripFromAnchor(widget.todayDate).days.sublist(0, 4); + late final int _todayPage = _days.length - 1; + + // The day whose summary the Today card shows; tapping a strip day or swiping + // the card changes it. Defaults to today (the last page). + late int _page = _todayPage; + late final PageController _pageController = + PageController(initialPage: _todayPage); + + String get _selectedDate => _days[_page]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + /// Strip tap → animate the day card to that page (selection haptic fires in + /// the strip's own GestureDetector). Future / out-of-window days are ignored. + void _onSelectDay(String date) { + final idx = _days.indexOf(date); + if (idx < 0 || idx == _page) return; + _pageController.animateToPage( + idx, + duration: const Duration(milliseconds: 280), + curve: const Cubic(0.16, 1, 0.3, 1), + ); + } + + /// Swipe → update selection + fire the same selection haptic the strip uses. + void _onPageChanged(int page) { + if (page == _page) return; + HapticFeedback.selectionClick(); + setState(() => _page = page); + } + + /// A human date line for the card: "Today" / "Yesterday" / a localized + /// weekday-month-day (e.g. "Mon, Jun 9"). + String _dateLabel(String date, String locale) { + if (date == widget.todayDate) return tr('dashboard.today'); + if (date == addDays(widget.todayDate, -1)) { + return tr('dashboard.yesterday'); + } + return DateFormat('EEE, MMM d', locale).format(dateStringToDate(date)); + } @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).padding.bottom; - // Today card follows the selected day; everything else stays on today. - final selectedArgs = (userId: widget.args.userId, date: _selectedDate); + final locale = context.locale.toString(); return Stack( children: [ @@ -149,17 +234,32 @@ class _ContentState extends State<_Content> { bottom: bottomInset + 96, ), children: [ - // SECTION 1 — week strip + today summary (greeting now lives in the - // header row, beside the hamburger). + // SECTION 1 — week strip + the paged day-viewer (greeting now lives + // in the header row, beside the hamburger). WeekStrip( args: widget.args, todayDate: widget.todayDate, selectedDate: _selectedDate, - onSelectDay: (d) => setState(() => _selectedDate = d), + onSelectDay: _onSelectDay, ), Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), - child: TodaySection(args: selectedArgs, targets: widget.targets), + child: widget.isFirstRun + ? TodaySection( + args: widget.args, + targets: widget.targets, + dateLabel: _dateLabel(widget.todayDate, locale), + isFirstRun: true, + ) + : _DayPager( + controller: _pageController, + days: _days, + todayPage: _todayPage, + userId: widget.args.userId, + targets: widget.targets, + onPageChanged: _onPageChanged, + dateLabel: (d) => _dateLabel(d, locale), + ), ), // SECTION 2 — Progress. _Section( @@ -190,6 +290,194 @@ class _ContentState extends State<_Content> { } } +/// The paged day-viewer: a PageView of [TodaySection]s, one per browsable day, +/// synced to the week strip. Swiping or tapping a strip day moves the page. +/// +/// A PageView needs a bounded height, but each day's card differs in height +/// (different meal counts). Each page reports its measured height; the pager +/// animates its own height to the active page so the surrounding ListView +/// reflows smoothly instead of the card being clipped or over-tall. +class _DayPager extends StatefulWidget { + const _DayPager({ + required this.controller, + required this.days, + required this.todayPage, + required this.userId, + required this.targets, + required this.onPageChanged, + required this.dateLabel, + }); + + final PageController controller; + final List days; + final int todayPage; + final String userId; + final DockTargets targets; + final ValueChanged onPageChanged; + final String Function(String date) dateLabel; + + @override + State<_DayPager> createState() => _DayPagerState(); +} + +class _DayPagerState extends State<_DayPager> { + late final List _heights = + List.filled(widget.days.length, null); + late int _active = widget.todayPage; + + void _report(int index, double height) { + if (_heights[index] == height) return; + // Defer the setState out of the layout/build phase. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _heights[index] = height); + }); + } + + @override + Widget build(BuildContext context) { + // While a page is unmeasured, fall back to the tallest known height (or a + // sensible minimum) so the first frame isn't zero-height. + final known = _heights.whereType(); + final fallback = known.isEmpty + ? 280.0 + : known.reduce((a, b) => a > b ? a : b); + final height = _heights[_active] ?? fallback; + + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: const Cubic(0.16, 1, 0.3, 1), + alignment: Alignment.topCenter, + child: SizedBox( + height: height, + child: PageView.builder( + controller: widget.controller, + itemCount: widget.days.length, + onPageChanged: (p) { + setState(() => _active = p); + widget.onPageChanged(p); + }, + itemBuilder: (context, index) { + final date = widget.days[index]; + return _MeasuredPage( + onHeight: (h) => _report(index, h), + child: TodaySection( + args: (userId: widget.userId, date: date), + targets: widget.targets, + dateLabel: widget.dateLabel(date), + isToday: index == widget.todayPage, + ), + ); + }, + ), + ), + ); + } +} + +/// Reports its child's laid-out height once per layout pass, top-aligned so the +/// card sits at the top of the (taller) page viewport. +class _MeasuredPage extends StatelessWidget { + const _MeasuredPage({required this.child, required this.onHeight}); + final Widget child; + final ValueChanged onHeight; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: _SizeReporter( + onHeight: onHeight, + child: child, + ), + ); + } +} + +class _SizeReporter extends SingleChildRenderObjectWidget { + const _SizeReporter({required this.onHeight, required super.child}); + final ValueChanged onHeight; + + @override + _SizeReporterRender createRenderObject(BuildContext context) => + _SizeReporterRender(onHeight); + + @override + void updateRenderObject( + BuildContext context, _SizeReporterRender renderObject) { + renderObject.onHeight = onHeight; + } +} + +class _SizeReporterRender extends RenderProxyBox { + _SizeReporterRender(this.onHeight); + ValueChanged onHeight; + double? _last; + + @override + void performLayout() { + super.performLayout(); + final h = size.height; + if (h != _last) { + _last = h; + onHeight(h); + } + } +} + +/// The full-page loading skeleton — a week-strip row, the Today card, and the +/// two lower section cards, all under one shimmer sweep. Mirrors [_Content]'s +/// padding so the swap to real data doesn't jump. +class _DashboardSkeleton extends StatelessWidget { + const _DashboardSkeleton(); + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).padding.bottom; + // Each card / header shimmers its own bars internally (so the white card + // surfaces stay white); only the bare week-strip pills need a wrapper. + return ListView( + padding: EdgeInsets.only( + left: NhamSpacing.sp3, + right: NhamSpacing.sp3, + top: NhamSpacing.sp3, + bottom: bottomInset + 96, + ), + children: const [ + // Week-strip row — four day pills. + Shimmer( + child: Padding( + padding: EdgeInsets.symmetric(vertical: NhamSpacing.sp2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SkeletonBar(width: 64, height: 76, radius: 16), + SkeletonBar(width: 64, height: 76, radius: 16), + SkeletonBar(width: 64, height: 76, radius: 16), + SkeletonBar(width: 64, height: 76, radius: 16), + ], + ), + ), + ), + SizedBox(height: NhamSpacing.sp3), + // Section 1 — Today. + SkeletonHeader(), + TodayCardSkeleton(), + SizedBox(height: NhamSpacing.sp4), + // Section 2 — Progress. + SkeletonHeader(), + WeightCardSkeleton(), + SizedBox(height: NhamSpacing.sp4), + // Section 3 — Consistency. + SkeletonHeader(), + SkeletonCard(children: [ + SkeletonBar(height: 120, radius: 10), + ]), + ], + ); + } +} + /// A dashboard section: `mb-4 gap-1.5` (16px bottom margin, 6px inner gap). class _Section extends StatelessWidget { const _Section({required this.children}); diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart index b93fa68f..55062840 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart @@ -19,7 +19,18 @@ import '../data/dashboard_providers.dart'; import '../logic/heatmap_colors.dart'; import 'dashboard_tokens.dart'; -const List _dayLabels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; +/// Monday-first narrow weekday initials for [locale] (en → M T W T F S S; vi → +/// the localized initials). Anchored on a known Monday so DST/locale offsets +/// can't shift the order. +List _weekdayInitials(String locale) { + // 2024-01-01 is a Monday. + final monday = DateTime(2024, 1, 1); + final fmt = DateFormat('EEEEE', locale); // narrow weekday + return [ + for (var i = 0; i < 7; i++) fmt.format(monday.add(Duration(days: i))), + ]; +} + const double _gap90d = 2; // GAP['90d'] const double _dayLabelWidth = 16; const double _dayLabelGutter = NhamSpacing.sp1; // gap-1 (4px) @@ -42,35 +53,37 @@ class AdherenceHeatmap extends ConsumerWidget { // Empty/loaded both render the grid; the server always returns a full // grid for the range (unlogged days are the "not logged" track). data: (data) => _HeatmapBody(data: data), - error: (_, __) => Container( - constraints: const BoxConstraints(minHeight: 180), - alignment: Alignment.center, - padding: const EdgeInsets.all(NhamSpacing.sp4), - decoration: BoxDecoration( - color: kCardSurface, - borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: Text( - tr('dashboard.heatmapLoadError'), - textAlign: TextAlign.center, - style: dashMeta(color: kInkDisabled), - ), + error: + (_, __) => Container( + constraints: const BoxConstraints(minHeight: 180), + alignment: Alignment.center, + padding: const EdgeInsets.all(NhamSpacing.sp4), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: kCardShadows, ), - const SizedBox(height: NhamSpacing.sp3), - NhamButton( - title: tr('dashboard.retry'), - variant: NhamButtonVariant.ghost, - onPressed: () => ref.invalidate(dashboardBundleProvider(args)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + tr('dashboard.heatmapLoadError'), + textAlign: TextAlign.center, + style: dashMeta(color: kInkDisabled), + ), + ), + const SizedBox(height: NhamSpacing.sp3), + NhamButton( + title: tr('dashboard.retry'), + variant: NhamButtonVariant.ghost, + onPressed: + () => ref.invalidate(dashboardBundleProvider(args)), + ), + ], ), - ], - ), - ), + ), ); } } @@ -98,8 +111,10 @@ class _HeatmapBodyState extends State<_HeatmapBody> // 0.16s with a stagger delay of wi*0.01 + di*0.005. The controller spans the // full timeline (max delay + 0.16s); the painter derives each cell's local // progress from [_reveal].value. Total ≈ (numWeeks-1)*0.01 + 6*0.005 + 0.16s. - late final int _staggerMs = ((_numWeeks - 1) * 10 + 6 * 5 + 160) - .clamp(160, 5000); + late final int _staggerMs = ((_numWeeks - 1) * 10 + 6 * 5 + 160).clamp( + 160, + 5000, + ); late final AnimationController _reveal = AnimationController( vsync: this, duration: Duration(milliseconds: _staggerMs), @@ -117,9 +132,11 @@ class _HeatmapBodyState extends State<_HeatmapBody> return data.cells.isNotEmpty ? data.cells[0].length : 0; } - int get _adherenceRate { + /// (onTrackPercent, loggedDayCount). The percent is meaningless with zero + /// logged days — the count gates whether the "% on track" line renders. + ({int percent, int loggedDays}) get _adherence { final data = widget.data; - if (data == null) return 0; + if (data == null) return (percent: 0, loggedDays: 0); var onTarget = 0; var total = 0; for (final row in data.cells) { @@ -130,12 +147,14 @@ class _HeatmapBodyState extends State<_HeatmapBody> } } } - return total > 0 ? ((onTarget / total) * 100).round() : 0; + final percent = total > 0 ? ((onTarget / total) * 100).round() : 0; + return (percent: percent, loggedDays: total); } double _cellSize(double contentWidth, int numWeeks) { if (numWeeks <= 0) return 10; - final available = contentWidth - + final available = + contentWidth - _dayLabelWidth - _dayLabelGutter - (numWeeks - 1) * _gap90d; @@ -147,13 +166,14 @@ class _HeatmapBodyState extends State<_HeatmapBody> Widget build(BuildContext context) { final data = widget.data; final numWeeks = _numWeeks; + final dayLabels = _weekdayInitials(context.locale.toString()); return Container( padding: const EdgeInsets.all(NhamSpacing.sp4), decoration: BoxDecoration( color: kCardSurface, // solid white borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], // shadow only, no border + boxShadow: kCardShadows, // shadow only, no border ), child: LayoutBuilder( builder: (context, constraints) { @@ -163,17 +183,23 @@ class _HeatmapBodyState extends State<_HeatmapBody> final gridWidth = numWeeks > 0 ? numWeeks * sq + (numWeeks - 1) * _gap90d : 0.0; final gridHeight = 7 * sq + 6 * _gap90d; + final adherence = _adherence; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header: "{percent}% on track". + // Header: "{percent}% on track". Suppressed until at least 3 + // scored days (spec) — a percentage computed from one or two + // days reads as noise, and a new user shouldn't see "0% on + // track" over an empty grid. Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), child: Text( - data != null - ? tr('dashboard.adherenceHeatmap.onTrack', - namedArgs: {'percent': '$_adherenceRate'}) + (data != null && adherence.loggedDays >= 3) + ? tr( + 'dashboard.adherenceHeatmap.onTrack', + namedArgs: {'percent': '${adherence.percent}'}, + ) : ' ', style: dashMeta(color: kInk, tabular: true), ), @@ -189,21 +215,23 @@ class _HeatmapBodyState extends State<_HeatmapBody> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < _dayLabels.length; i++) + for (var i = 0; i < dayLabels.length; i++) Container( height: sq, margin: EdgeInsets.only( - top: i == 0 - ? _monthStripHeight + NhamSpacing.sp1 - : _gap90d, + top: + i == 0 + ? _monthStripHeight + NhamSpacing.sp1 + : _gap90d, ), padding: const EdgeInsets.only(right: 4), alignment: Alignment.centerRight, child: Text( - _dayLabels[i], + dayLabels[i], style: dashEyebrow( - color: kInkSecondary, - weight: FontWeight.w600), + color: kInkSecondary, + weight: FontWeight.w600, + ), ), ), ], @@ -220,20 +248,23 @@ class _HeatmapBodyState extends State<_HeatmapBody> width: gridWidth, child: Stack( children: [ - for (final h in data?.monthHeaders ?? - const []) + for (final h + in data?.monthHeaders ?? + const []) Positioned( top: 0, left: h.startColumn * step, - width: h.span * sq + + width: + h.span * sq + (h.span - 1 > 0 ? h.span - 1 : 0) * _gap90d, child: Text( h.month, maxLines: 1, overflow: TextOverflow.clip, style: dashEyebrow( - color: kInkSecondary, - weight: FontWeight.w600), + color: kInkSecondary, + weight: FontWeight.w600, + ), ), ), ], @@ -246,9 +277,10 @@ class _HeatmapBodyState extends State<_HeatmapBody> children: [ GestureDetector( behavior: HitTestBehavior.opaque, - onTapUp: data == null - ? null - : (details) => _handleTap( + onTapUp: + data == null + ? null + : (details) => _handleTap( details.localPosition, data, sq, @@ -257,37 +289,44 @@ class _HeatmapBodyState extends State<_HeatmapBody> ), child: AnimatedBuilder( animation: _reveal, - builder: (context, _) => CustomPaint( - size: Size(gridWidth, gridHeight), - painter: _GridPainter( - data: data, - numWeeks: numWeeks, - sq: sq, - step: step, - // Drive the per-cell wave reveal. - reveal: _reveal.value, - totalMs: _staggerMs, - ), - ), + builder: + (context, _) => CustomPaint( + size: Size(gridWidth, gridHeight), + painter: _GridPainter( + data: data, + numWeeks: numWeeks, + sq: sq, + step: step, + // Drive the per-cell wave reveal. + reveal: _reveal.value, + totalMs: _staggerMs, + ), + ), ), ), if (_bubble != null) Positioned( left: (_bubble!.x - _bubbleHalfW).clamp( 0, - (gridWidth - _bubbleHalfW * 2) - .clamp(0, double.infinity), + (gridWidth - _bubbleHalfW * 2).clamp( + 0, + double.infinity, + ), ), bottom: gridHeight - _bubble!.y + NhamSpacing.sp1, child: Container( constraints: const BoxConstraints( - maxWidth: _bubbleHalfW * 2), + maxWidth: _bubbleHalfW * 2, + ), padding: const EdgeInsets.symmetric( - vertical: NhamSpacing.sp1, horizontal: 6), + vertical: NhamSpacing.sp1, + horizontal: 6, + ), decoration: BoxDecoration( color: NhamColors.text, - borderRadius: - BorderRadius.circular(NhamRadii.sm), + borderRadius: BorderRadius.circular( + NhamRadii.sm, + ), ), child: Text( _bubble!.text, @@ -315,8 +354,9 @@ class _HeatmapBodyState extends State<_HeatmapBody> const SizedBox(width: NhamSpacing.sp2), Expanded( child: ClipRRect( - borderRadius: - BorderRadius.circular(_legendBarHeight / 2), + borderRadius: BorderRadius.circular( + _legendBarHeight / 2, + ), child: Container( height: _legendBarHeight, decoration: const BoxDecoration( @@ -361,15 +401,17 @@ class _HeatmapBodyState extends State<_HeatmapBody> final wi = (pos.dx / step).floor(); final di = (pos.dy / step).floor(); if (wi < 0 || wi >= numWeeks || di < 0 || di >= 7) return; - final cell = (di < data.cells.length && wi < data.cells[di].length) - ? data.cells[di][wi] - : null; + final cell = + (di < data.cells.length && wi < data.cells[di].length) + ? data.cells[di][wi] + : null; if (cell == null) return; final isLogged = cell.status == HeatmapCellStatus.logged && cell.ratio != null; final isPartial = cell.status == HeatmapCellStatus.partial; - final isMuted = cell.status == HeatmapCellStatus.future || + final isMuted = + cell.status == HeatmapCellStatus.future || cell.status == HeatmapCellStatus.outside; if (!((isLogged || isPartial) && !isMuted)) return; @@ -415,7 +457,8 @@ class _HeatmapBodyState extends State<_HeatmapBody> if (isLogged) { return (fill: getHeatmapColor(ratio).bg ?? kTrack, stroke: null); } - final isMuted = cell?.status == HeatmapCellStatus.future || + final isMuted = + cell?.status == HeatmapCellStatus.future || cell?.status == HeatmapCellStatus.outside; if (isMuted) { return (fill: kPage, stroke: null); // out-of-range @@ -451,9 +494,10 @@ class _GridPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final fillPaint = Paint()..style = PaintingStyle.fill; - final strokePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 1; + final strokePaint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1; final elapsed = reveal * totalMs; @@ -467,20 +511,28 @@ class _GridPainter extends CustomPainter { final scale = 0.6 + 0.4 * p; // scale 0.6 → 1 final cellAlpha = p; // opacity 0 → 1 - final cell = (data != null && - di < data!.cells.length && - wi < data!.cells[di].length) - ? data!.cells[di][wi] - : null; + final cell = + (data != null && + di < data!.cells.length && + wi < data!.cells[di].length) + ? data!.cells[di][wi] + : null; final props = _cellRectProps(cell); // Scale about the cell center. final cx = wi * step + sq / 2; final cy = di * step + sq / 2; final half = (sq * scale) / 2; - final rect = Rect.fromLTWH(cx - half, cy - half, sq * scale, sq * scale); - final rrect = - RRect.fromRectAndRadius(rect, const Radius.circular(_cellRadius)); + final rect = Rect.fromLTWH( + cx - half, + cy - half, + sq * scale, + sq * scale, + ); + final rrect = RRect.fromRectAndRadius( + rect, + const Radius.circular(_cellRadius), + ); // Solid fill; cellAlpha is only the per-cell reveal fade (→ 1). fillPaint.color = props.fill.withValues(alpha: cellAlpha); diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart index ddebdaad..f1f73a0b 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart @@ -1,15 +1,25 @@ -/// CalorieRing — RN port of `components/logging/calorie-ring.tsx`. +/// CalorieRing — the dashboard/logging calorie ring. /// -/// Shows REMAINING calories as the fill fraction. The progress arc animates its -/// sweep (the RN `strokeDashoffset` tween) over 1000ms with the signature -/// `cubic-bezier(0.16, 1, 0.3, 1)` ease. The dashboard passes a [center] node -/// (a flame icon) to override the default in-ring remaining/“left” content. +/// The arc fills with CONSUMED calories (`current / target`) — it fills up as +/// you eat, matching the week-strip rings and the heatmap framing. The progress +/// arc animates its sweep over 1000ms with the signature +/// `cubic-bezier(0.16, 1, 0.3, 1)` ease. +/// +/// Over target: the base arc completes a full circle in tan, then an overflow +/// arc continues past 12 o'clock in ~40%-alpha terracotta for the overshoot +/// fraction — an honest, judgment-free visual. Never red, never a pill, never +/// an icon. +/// +/// The dashboard passes a [center] node (a flame icon) to override the default +/// in-ring remaining/over content. library; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; +import '../logic/dashboard_format.dart'; class CalorieRing extends StatefulWidget { const CalorieRing({ @@ -42,16 +52,13 @@ class _CalorieRingState extends State // cubic-bezier(0.16, 1, 0.3, 1) — the signature "ease-out-expo-ish" curve. static const Curve _ease = Cubic(0.16, 1, 0.3, 1); - double get _pct { - final remaining = (widget.target - widget.current).clamp(0, double.infinity); - return widget.target > 0 - ? (remaining / widget.target).clamp(0, 1).toDouble() - : 0; - } + /// Consumed fraction of the target — can exceed 1 when over target. The + /// painter splits the base (0..1, tan) from the overflow (>1, terracotta). + double get _consumedRatio => + widget.target > 0 ? (widget.current / widget.target) : 0; - Animation _build() => Tween(begin: 0, end: _pct).animate( - CurvedAnimation(parent: _controller, curve: _ease), - ); + Animation _build() => Tween(begin: 0, end: _consumedRatio) + .animate(CurvedAnimation(parent: _controller, curve: _ease)); @override void initState() { @@ -79,7 +86,7 @@ class _CalorieRingState extends State @override Widget build(BuildContext context) { - final remaining = (widget.target - widget.current).clamp(0, double.infinity); + final remaining = widget.target - widget.current; return SizedBox( width: widget.size, height: widget.size, @@ -91,12 +98,12 @@ class _CalorieRingState extends State builder: (context, _) => CustomPaint( size: Size(widget.size, widget.size), painter: _RingPainter( - fraction: _fill.value, + ratio: _fill.value, strokeWidth: widget.strokeWidth, ), ), ), - widget.center ?? _DefaultCenter(remaining: remaining.toDouble()), + widget.center ?? _DefaultCenter(remaining: remaining), ], ), ); @@ -106,21 +113,25 @@ class _CalorieRingState extends State class _DefaultCenter extends StatelessWidget { const _DefaultCenter({required this.remaining}); + /// target − consumed: positive = calories left, negative = over target. final double remaining; @override Widget build(BuildContext context) { + final locale = context.locale.toString(); + final over = remaining < 0; + final value = remaining.abs().round(); return Column( mainAxisSize: MainAxisSize.min, children: [ Text( - remaining.round().toString(), - style: NhamTextStyles.serifSemiBold(fontSize: 17, height: 1) + formatCount(value, locale), + style: NhamTextStyles.serifRegular(fontSize: 17, height: 1) .copyWith(color: NhamColors.text, letterSpacing: 0), ), const SizedBox(height: 2), Text( - 'LEFT', + (over ? tr('dashboard.over') : tr('dashboard.left')).toUpperCase(), style: NhamTextStyles.sansBold(fontSize: 8) .copyWith(letterSpacing: 1.2, color: NhamColors.stone), ), @@ -129,20 +140,24 @@ class _DefaultCenter extends StatelessWidget { } } -/// Draws the track ring + a rounded progress arc starting at 12 o'clock -/// (`rotate(-90)`), sweeping clockwise for [fraction] of the circle. Stroke -/// width is constant in pixels (RN `vectorEffect="non-scaling-stroke"`). +/// Draws the track ring, the consumed arc (tan, 0..1 of the circle, from 12 +/// o'clock clockwise) and — when [ratio] > 1 — an overflow arc continuing past +/// 12 o'clock in ~40%-alpha terracotta for the overshoot. class _RingPainter extends CustomPainter { - _RingPainter({required this.fraction, required this.strokeWidth}); + _RingPainter({required this.ratio, required this.strokeWidth}); - final double fraction; + final double ratio; final double strokeWidth; + static const double _tau = 2 * 3.141592653589793; + static const double _start = -3.141592653589793 / 2; // -90° (12 o'clock). + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); // RN viewBox 0 0 100 100, r=46, center 50 → radius scales with size. final radius = (size.width / 2) * (46 / 50); + final rect = Rect.fromCircle(center: center, radius: radius); final track = Paint() ..style = PaintingStyle.stroke @@ -150,26 +165,28 @@ class _RingPainter extends CustomPainter { ..color = NhamColors.track; canvas.drawCircle(center, radius, track); - if (fraction <= 0) return; + if (ratio <= 0) return; - final arc = Paint() + final base = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = NhamColors.accent; - - const start = -3.141592653589793 / 2; // -90° (12 o'clock). - final sweep = 2 * 3.141592653589793 * fraction; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - start, - sweep, - false, - arc, - ); + canvas.drawArc(rect, _start, _tau * ratio.clamp(0, 1).toDouble(), false, base); + + // Over target — the overflow arc continues in terracotta @ ~40% alpha. + if (ratio > 1) { + final overflow = (ratio - 1).clamp(0, 1).toDouble(); + final over = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..color = NhamColors.danger.withValues(alpha: 0.4); + canvas.drawArc(rect, _start, _tau * overflow, false, over); + } } @override bool shouldRepaint(_RingPainter old) => - old.fraction != fraction || old.strokeWidth != strokeWidth; + old.ratio != ratio || old.strokeWidth != strokeWidth; } diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart index 22f29ff6..2445af89 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart @@ -141,6 +141,7 @@ class _CompactWeightLogState extends ConsumerState { _dirty = false; _pending = false; }); + HapticFeedback.mediumImpact(); // success cue on save _showFeedback( _Feedback(_FeedbackKind.success, tr('dashboard.weightCard.saved')), ); @@ -166,8 +167,7 @@ class _CompactWeightLogState extends ConsumerState { final showEditHint = _hasTodayWeight && _validationError == null && _feedback == null; - final borderColor = - _validationError != null ? NhamColors.danger : kHairline; + final hasError = _validationError != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -188,11 +188,16 @@ class _CompactWeightLogState extends ConsumerState { ], ), ), - Row( - children: [ - Expanded( - child: SizedBox( - height: 44, + // IntrinsicHeight + stretch so the field's filled area and the button + // are the exact same height. (A fixed height on the field alone doesn't + // work: isDense sizes the InputDecorator fill to its content and centers + // it, leaving transparent gaps — so the cream fill read shorter than the + // solid button.) + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( child: TextField( controller: _controller, onChanged: _onChanged, @@ -204,40 +209,48 @@ class _CompactWeightLogState extends ConsumerState { ], autocorrect: false, cursorColor: NhamColors.accent, - style: dashBody(tabular: true), + // Substantial value type — a number entry reads as data, not + // body copy. + style: dashValue(color: kInk), decoration: InputDecoration( isDense: true, filled: true, - fillColor: kCardSurface, + // Soft warm fill instead of a hairline outline — the field + // reads as a tappable surface, the way native mobile inputs + // do, and the border only appears on focus / error. + fillColor: hasError + ? NhamColors.danger.withValues(alpha: 0.06) + : kFieldFill, + // Vertical padding sets the field's intrinsic height (≈ the + // control height); the button stretches to match it. contentPadding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp3, + horizontal: NhamSpacing.sp4, + vertical: 15, ), // Suffix in-flow (no Positioned overlay → no overlap). suffixText: tr('dashboard.units.kg'), - suffixStyle: dashMeta(color: kInkDisabled), - border: _border(borderColor), - enabledBorder: _border(borderColor), - // Web Input focus-visible ring → accent border. - focusedBorder: _border(_validationError != null - ? NhamColors.danger - : NhamColors.accent), - disabledBorder: _border(kHairline), + suffixStyle: dashMeta(color: kInkSecondary), + border: _border(Colors.transparent), + enabledBorder: _border( + hasError ? NhamColors.danger : Colors.transparent), + focusedBorder: _border( + hasError ? NhamColors.danger : NhamColors.accent), + disabledBorder: _border(Colors.transparent), ), ), ), - ), - const SizedBox(width: NhamSpacing.sp2), - _SubmitButton( - label: submitLabel, - pending: _pending, - pressed: _pressed, - onTapDown: () => setState(() => _pressed = true), - onTapUp: () => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: _pending ? null : _onSubmit, - ), - ], + const SizedBox(width: NhamSpacing.sp2), + _SubmitButton( + label: submitLabel, + pending: _pending, + pressed: _pressed, + onTapDown: () => setState(() => _pressed = true), + onTapUp: () => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: _pending ? null : _onSubmit, + ), + ], + ), ), if (_feedback != null) Padding( @@ -272,8 +285,11 @@ class _CompactWeightLogState extends ConsumerState { } OutlineInputBorder _border(Color color) => OutlineInputBorder( - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - borderSide: BorderSide(color: color), + borderRadius: BorderRadius.circular(NhamRadii.xl), + borderSide: BorderSide( + color: color, + width: color == Colors.transparent ? 0 : 1.5, + ), ); } @@ -306,12 +322,12 @@ class _SubmitButton extends StatelessWidget { child: Opacity( opacity: pending ? 0.55 : 1, child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp4), + // No fixed height — the parent Row stretches it to the field's height. + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp5), alignment: Alignment.center, decoration: BoxDecoration( color: pressed && !pending ? NhamColors.btnHover : NhamColors.btn, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + borderRadius: BorderRadius.circular(NhamRadii.xl), ), child: pending ? const SizedBox( @@ -322,7 +338,9 @@ class _SubmitButton extends StatelessWidget { ) : Text( label, - style: dashEyebrow(color: Colors.white, weight: FontWeight.w600), + // Sentence-case, body-sized label — a native button reads as + // a word, not a techy 11px all-caps eyebrow. + style: dashBody(color: Colors.white, weight: FontWeight.w600), ), ), ), diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/dashboard_tokens.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/dashboard_tokens.dart index 4abfdca0..ca296506 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/dashboard_tokens.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/dashboard_tokens.dart @@ -3,7 +3,8 @@ /// The dashboard deliberately diverges from the web's "Apple Notes on cream /// paper" port. The palette is unchanged (cream / espresso / tan); what changed /// is the APPLICATION: solid surfaces (no stacked translucency), ONE sans -/// family (DM Sans) on a 5-size scale, exactly 3 text colors, one card radius, +/// family (Be Vietnam Pro) on a 5-size scale, exactly 3 text colors, one card +/// radius, /// and serif reserved for a single editorial headline per viewport. /// /// Reference: getdesign.md (Anthropic/Notion/Linear) + CalAI. The throughline — @@ -13,11 +14,14 @@ library; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../../theme/nham_typography.dart'; + // ── Surfaces (all solid, 100% opacity) ─────────────────────────────────── const Color kPage = Color(0xFFFEFBF6); // app page — paper cream const Color kCardSurface = Color(0xFFFFFFFF); // cards — solid white -const Color kTrack = Color(0xFFF5F4F0); // ring/bar tracks — the ONLY low-contrast surface +const Color kTrack = Color(0xFFF1EFE9); // ring/bar tracks — the ONLY low-contrast surface const Color kHairline = Color(0xFFE8D5B5); // the one border (biscotti, solid) +const Color kFieldFill = Color(0xFFF6F1E8); // soft warm fill for inputs // ── Text — exactly 3 colors, all 100% opacity ──────────────────────────── const Color kInk = Color(0xFF2C2416); // espresso — primary data @@ -25,21 +29,38 @@ const Color kInkSecondary = Color(0xFF8B7355); // warm taupe — labels/units/ca const Color kInkDisabled = Color(0xFFA8A29E); // stone — disabled metadata ≥13px ONLY // ── Shape ──────────────────────────────────────────────────────────────── -const double kCardRadius = 16; // one card radius (was a 24/20/16 mix) +const double kCardRadius = 22; // one card radius — modern iOS grouped-card feel + +/// Layered espresso card shadow — a tight contact shadow plus a soft ambient +/// one. Two stacked shadows read like a real iOS card lifting off the warm page, +/// where one flat blur read as a smudge. Cards use shadow, not border. +const List kCardShadows = [ + BoxShadow( + color: Color(0x142C2416), // ambient (~8%) + blurRadius: 24, + offset: Offset(0, 10), + ), + BoxShadow( + color: Color(0x0F2C2416), // contact (~6%) + blurRadius: 3, + offset: Offset(0, 1), + ), +]; -/// The soft espresso card shadow (espresso @5%). Cards use shadow, not border. +/// Back-compat single shadow (some surfaces still reference it). const BoxShadow kCardShadow = BoxShadow( - color: Color(0x0D2C2416), - blurRadius: 32, - offset: Offset(0, 10), + color: Color(0x0F2C2416), + blurRadius: 28, + offset: Offset(0, 12), ); const List _tnum = [FontFeature.tabularFigures()]; -// ── Type — DM Sans only, 5 sizes (40 / 20 / 15 / 13 / 11) ───────────────── +// ── Type — Be Vietnam Pro only, 5 sizes (40 / 20 / 15 / 13 / 11) ────────── /// 40 / 600 — the ONE hero number per card (calories remaining, weight). -TextStyle dashHero({Color color = kInk}) => GoogleFonts.dmSans( +TextStyle dashHero({Color color = kInk}) => TextStyle( + fontFamily: NhamTextStyles.sansFamily, fontSize: 40, fontWeight: FontWeight.w600, height: 1.0, @@ -49,7 +70,8 @@ TextStyle dashHero({Color color = kInk}) => GoogleFonts.dmSans( ); /// 20 / 600 — ring-center number, macro gram values, metric values. -TextStyle dashValue({Color color = kInk}) => GoogleFonts.dmSans( +TextStyle dashValue({Color color = kInk}) => TextStyle( + fontFamily: NhamTextStyles.sansFamily, fontSize: 20, fontWeight: FontWeight.w600, height: 1.1, @@ -63,7 +85,8 @@ TextStyle dashBody({ FontWeight weight = FontWeight.w400, bool tabular = false, }) => - GoogleFonts.dmSans( + TextStyle( + fontFamily: NhamTextStyles.sansFamily, fontSize: 15, fontWeight: weight, height: 1.4, @@ -73,7 +96,8 @@ TextStyle dashBody({ /// 13 / 500 — secondary captions, stat values. TextStyle dashMeta({Color color = kInkSecondary, bool tabular = false}) => - GoogleFonts.dmSans( + TextStyle( + fontFamily: NhamTextStyles.sansFamily, fontSize: 13, fontWeight: FontWeight.w500, height: 1.35, @@ -86,7 +110,8 @@ TextStyle dashEyebrow({ Color color = kInkSecondary, FontWeight weight = FontWeight.w700, }) => - GoogleFonts.dmSans( + TextStyle( + fontFamily: NhamTextStyles.sansFamily, fontSize: 11, fontWeight: weight, height: 1.3, diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart index 18096961..3f55e94b 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart @@ -9,6 +9,7 @@ library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:go_router/go_router.dart'; import '../../../theme/nham_colors.dart'; @@ -123,7 +124,7 @@ class _FloatingMealTriggerState extends State { ], ), child: Icon( - _expanded ? Icons.close : Icons.restaurant_outlined, + _expanded ? LucideIcons.x : LucideIcons.utensilsCrossed, size: 20, // h-5 w-5 color: Colors.white, ), @@ -236,7 +237,7 @@ class _MealInputBarState extends State<_MealInputBar> { borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // 12 ), child: Icon( - Icons.arrow_upward, + LucideIcons.arrowUp, size: 16, // h-4 w-4 color: hasText ? Colors.white : NhamColors.stone, ), diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/section_header.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/section_header.dart index 0502f5e2..545b38c0 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/section_header.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/section_header.dart @@ -119,12 +119,16 @@ class SectionState extends StatefulWidget { required this.message, this.actionLabel, this.onAction, + this.icon, }); final String message; final String? actionLabel; final VoidCallback? onAction; + /// Optional glyph shown above the message (e.g. a cloud-off for errors). + final IconData? icon; + @override State createState() => _SectionStateState(); } @@ -141,12 +145,25 @@ class _SectionStateState extends State { decoration: BoxDecoration( color: kCardSurface, borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], // shadow only, no border + boxShadow: kCardShadows, // shadow only, no border ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ + if (widget.icon != null) ...[ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: kTrack, + shape: BoxShape.circle, + ), + child: Icon(widget.icon, size: 22, color: NhamColors.stone), + ), + const SizedBox(height: NhamSpacing.sp3), + ], Text( widget.message, textAlign: TextAlign.center, @@ -172,7 +189,9 @@ class _SectionStateState extends State { ), child: Text( widget.actionLabel!, - style: dashEyebrow(color: Colors.white, weight: FontWeight.w600), + // Sentence-case body label — unified with the weight-card + // submit button (not an 11px caps eyebrow). + style: dashBody(color: Colors.white, weight: FontWeight.w600), ), ), ), diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/skeleton.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/skeleton.dart new file mode 100644 index 00000000..109f83ce --- /dev/null +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/skeleton.dart @@ -0,0 +1,240 @@ +/// Skeleton loading primitives — a warm shimmer sweep over placeholder blocks. +/// +/// Modern apps show the *shape* of the content while it loads (a skeleton), +/// not a centered spinner. These primitives draw warm-grey placeholder bars and +/// run one shared shimmer animation across them, so the dashboard's loading +/// state previews its own layout instead of a blank wait. +library; + +import 'package:flutter/material.dart'; + +import 'dashboard_tokens.dart'; + +const Color _skelBase = Color(0xFFE0D6C2); // warm placeholder grey +const Color _skelHi = Color(0xFFF1EADC); // shimmer highlight + +/// Wraps [child] in a left-to-right shimmer sweep. Put a tree of [SkeletonBar] / +/// [SkeletonCircle] underneath and they all light up from the one controller. +class Shimmer extends StatefulWidget { + const Shimmer({super.key, required this.child}); + final Widget child; + + @override + State createState() => _ShimmerState(); +} + +class _ShimmerState extends State with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1300), + )..repeat(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Honor reduced-motion: hold a static base tint, no sweep. + if (MediaQuery.disableAnimationsOf(context)) return widget.child; + return AnimatedBuilder( + animation: _c, + builder: (context, child) { + final t = _c.value; // 0 → 1 + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) { + final dx = bounds.width * (t * 2 - 1); // sweep -w → +w + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [_skelBase, _skelHi, _skelBase], + stops: const [0.35, 0.5, 0.65], + transform: _SlideGradient(dx), + ).createShader(bounds); + }, + child: child, + ); + }, + child: widget.child, + ); + } +} + +/// Shifts a gradient horizontally by [dx] pixels (for the shimmer sweep). +class _SlideGradient extends GradientTransform { + const _SlideGradient(this.dx); + final double dx; + @override + Matrix4 transform(Rect bounds, {TextDirection? textDirection}) => + Matrix4.translationValues(dx, 0, 0); +} + +/// A single placeholder bar. [widthFactor] (0–1) sizes it relative to its parent +/// when [width] is null. +class SkeletonBar extends StatelessWidget { + const SkeletonBar({ + super.key, + this.width, + this.widthFactor, + required this.height, + this.radius = 6, + }); + + final double? width; + final double? widthFactor; + final double height; + final double radius; + + @override + Widget build(BuildContext context) { + final bar = Container( + width: width, + height: height, + decoration: BoxDecoration( + color: _skelBase, + borderRadius: BorderRadius.circular(radius), + ), + ); + if (width == null && widthFactor != null) { + return FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: widthFactor, + child: bar, + ); + } + return bar; + } +} + +class SkeletonCircle extends StatelessWidget { + const SkeletonCircle({super.key, required this.size}); + final double size; + + @override + Widget build(BuildContext context) => Container( + width: size, + height: size, + decoration: const BoxDecoration( + color: _skelBase, + shape: BoxShape.circle, + ), + ); +} + +/// A white card matching the real dashboard cards, holding skeleton [children]. +/// The shimmer wraps only the bars (not the card surface) so the white gaps +/// stay white and the bars read as distinct placeholders. +class SkeletonCard extends StatelessWidget { + const SkeletonCard({super.key, required this.children}); + final List children; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: kCardShadows, + ), + child: Shimmer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ); +} + +/// An out-of-card eyebrow placeholder (matches the section headers). +class SkeletonHeader extends StatelessWidget { + const SkeletonHeader({super.key}); + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Shimmer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SkeletonBar(width: 120, height: 11, radius: 4), + SkeletonBar(width: 52, height: 11, radius: 4), + ], + ), + ), + ); +} + +/// Inner placeholder rows for the Today card — hero + ring, three macro rows, a +/// divider gap, two meal rows. Mirrors the real card so the swap is calm. Used +/// inside a [SkeletonCard] (dashboard load) or directly under the card during a +/// per-day refetch. +List todayCardSkeletonChildren() => [ + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBar(width: 140, height: 34, radius: 8), + SizedBox(height: 8), + SkeletonBar(width: 90, height: 12, radius: 4), + ], + ), + ), + SkeletonCircle(size: 84), + ], + ), + const SizedBox(height: 22), + for (var i = 0; i < 3; i++) ...[ + if (i > 0) const SizedBox(height: 12), + const Row( + children: [ + SkeletonBar(width: 56, height: 11, radius: 4), + SizedBox(width: 12), + Expanded(child: SkeletonBar(height: 8, radius: 4)), + SizedBox(width: 12), + SkeletonBar(width: 48, height: 11, radius: 4), + ], + ), + ], + const SizedBox(height: 22), + const SkeletonBar(widthFactor: 0.7, height: 13, radius: 4), + const SizedBox(height: 12), + const SkeletonBar(widthFactor: 0.55, height: 13, radius: 4), + ]; + +/// Inner placeholder rows for the Weight card — hero + stat, input field, +/// chart band. +List weightCardSkeletonChildren() => const [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBar(width: 110, height: 34, radius: 8), + SkeletonBar(width: 56, height: 22, radius: 6), + ], + ), + SizedBox(height: 16), + SkeletonBar(height: 52, radius: 14), + SizedBox(height: 16), + SkeletonBar(height: 96, radius: 10), + ]; + +/// The Today card placeholder (header + card), under one shimmer. +class TodayCardSkeleton extends StatelessWidget { + const TodayCardSkeleton({super.key}); + @override + Widget build(BuildContext context) => + SkeletonCard(children: todayCardSkeletonChildren()); +} + +/// The Weight card placeholder (header + card), under one shimmer. +class WeightCardSkeleton extends StatelessWidget { + const WeightCardSkeleton({super.key}); + @override + Widget build(BuildContext context) => + SkeletonCard(children: weightCardSkeletonChildren()); +} diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart index da7a628c..a1d0137c 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart @@ -9,16 +9,19 @@ library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; +import '../../logging/widgets/count_up.dart'; import '../data/dashboard_providers.dart'; import '../data/logging_day.dart'; import '../logic/dashboard_format.dart'; import 'calorie_ring.dart'; import 'dashboard_tokens.dart'; import 'section_header.dart'; +import 'skeleton.dart'; /// Per-screen dock targets (profile values, with the web DEFAULT_PROFILE /// fallbacks applied by the screen). @@ -41,22 +44,151 @@ class DockTargets { const double _valueColumnWidth = 68; class TodaySection extends ConsumerWidget { - const TodaySection({super.key, required this.args, required this.targets}); + const TodaySection({ + super.key, + required this.args, + required this.targets, + required this.dateLabel, + this.isToday = true, + this.isFirstRun = false, + }); final DashboardArgs args; final DockTargets targets; + /// A human date line shown at the top of the card ("Today", "Yesterday", or a + /// localized "Mon, Jun 9"). The card always says which day it is showing. + final String dateLabel; + + /// Whether [args] is today. Today reads off the already-warm dashboard bundle + /// (no extra round-trip); other days fetch their own light per-day slice. + final bool isToday; + + /// The user has never logged anything, ever — collapse to the first-run card. + final bool isFirstRun; + @override Widget build(BuildContext context, WidgetRef ref) { - final async = ref.watch(loggingDayProvider(args)); + if (isFirstRun) return const _FirstRunCard(); + + // Today reads the bundle's day slice (warm cache, seam parity with the rest + // of the dashboard); any other day fetches its own slice so browsing never + // refetches the 90d heatmap + profile + weight bundle. + final provider = + isToday ? loggingDayProvider(args) : dashboardDayProvider(args); + final async = ref.watch(provider); return async.when( - loading: () => SectionState(message: tr('dashboard.todayLoading')), + loading: () => const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SkeletonHeader(), + TodayCardSkeleton(), + ], + ), error: (_, __) => SectionState( + icon: LucideIcons.cloudOff, message: tr('dashboard.todayLoadError'), actionLabel: tr('dashboard.retry'), - onAction: () => ref.invalidate(dashboardBundleProvider(args)), + onAction: () => ref.invalidate(provider), + ), + data: (day) => _Dock(day: day, targets: targets, dateLabel: dateLabel), + ); + } +} + +/// First-run collapse: a single Lora question, no ring, no "% on track", plus +/// three time-of-day-aware suggestion chips that open the meal composer +/// prefilled. Shown only when the user has never logged a meal (zero today +/// AND zero history). +class _FirstRunCard extends StatelessWidget { + const _FirstRunCard(); + + /// Which suggestion set fits the device clock (morning / midday / evening). + static String _chipBucket() { + final hour = DateTime.now().hour; + if (hour < 11) return 'morning'; + if (hour < 16) return 'midday'; + return 'evening'; + } + + @override + Widget build(BuildContext context) { + final bucket = _chipBucket(); + final suggestions = [ + for (var i = 1; i <= 3; i++) tr('dashboard.firstRunChips.$bucket$i'), + ]; + return _FadeInDown( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: NhamSpacing.sp6, horizontal: NhamSpacing.sp4), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: kCardShadows, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr('dashboard.firstRunQuestion'), style: dashHeadline()), + const SizedBox(height: NhamSpacing.sp2), + Text( + tr('dashboard.firstRunHint'), + style: dashBody(color: kInkSecondary), + ), + const SizedBox(height: NhamSpacing.sp4), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final s in suggestions) + _FirstRunChip( + label: s, + // Same prefill handoff the meal FAB uses. + onTap: () => context + .go('/logging?meal=${Uri.encodeComponent(s)}'), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// A suggestion pill matching the logging empty-state chips: border hairline, +/// pill radius, white fill; pressed → accent-tinged border. +class _FirstRunChip extends StatefulWidget { + const _FirstRunChip({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + State<_FirstRunChip> createState() => _FirstRunChipState(); +} + +class _FirstRunChipState extends State<_FirstRunChip> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _pressed ? const Color(0x26E8D5B5) : NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all( + color: _pressed ? NhamColors.accent50 : NhamColors.borderSoft, + ), + ), + child: Text(widget.label, style: dashMeta(color: kInk)), ), - data: (day) => _Dock(day: day, targets: targets), ); } } @@ -70,13 +202,19 @@ class _MacroBarData { } class _Dock extends StatelessWidget { - const _Dock({required this.day, required this.targets}); + const _Dock({ + required this.day, + required this.targets, + required this.dateLabel, + }); final LoggingDayData day; final DockTargets targets; + final String dateLabel; @override Widget build(BuildContext context) { + final locale = context.locale.toString(); final meals = [...day.persistedMeals] ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); @@ -91,9 +229,9 @@ class _Dock extends StatelessWidget { totalFat += m.nutrition.fatG ?? 0; } final calories = round0(totalCalories); - final remaining = (targets.calorieTarget - calories) - .clamp(0, double.infinity) - .round(); + // Honest signed remaining — negative when over target (no censoring clamp). + final remaining = (targets.calorieTarget - calories).round(); + final overTarget = remaining < 0; final macroBars = <_MacroBarData>[ _MacroBarData(tr('dashboard.protein'), round0(totalProtein), @@ -105,91 +243,117 @@ class _Dock extends StatelessWidget { ]; return _FadeInDown( - child: Container( - padding: const EdgeInsets.all(NhamSpacing.sp4), - decoration: BoxDecoration( - color: kCardSurface, // solid white - borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], // shadow only, no border - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // (a) Hero: big calories number on the left, ring on the right. - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Section header OUTSIDE the card — label left, day right. Same + // eyebrow treatment + flush alignment as the Progress / Consistency + // headers so all three sections share one rhythm. + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr('dashboard.caloriesRemaining').toUpperCase(), - style: dashEyebrow(), - ), - const SizedBox(height: NhamSpacing.sp1), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, + Flexible( + child: Text( + (overTarget + ? tr('dashboard.caloriesOverTarget') + : tr('dashboard.caloriesRemaining')) + .toUpperCase(), + style: dashEyebrow(color: kInk), + ), + ), + Text(dateLabel.toUpperCase(), + style: dashEyebrow(weight: FontWeight.w500)), + ], + ), + ), + Container( + padding: const EdgeInsets.all(NhamSpacing.sp4), + decoration: BoxDecoration( + color: kCardSurface, // solid white + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: kCardShadows, // shadow only, no border + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // (a) Hero: big calories number on the left, ring on the right. + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: Text(_fmt(remaining), - style: dashHero(), maxLines: 1), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Flexible( + // Counts the remaining figure up on day-swap + // (~300ms) so paging settles in place. + child: CountUpText( + value: remaining.abs().toDouble(), + enabled: !MediaQuery.disableAnimationsOf( + context), + duration: const Duration(milliseconds: 300), + style: dashHero(), + format: (v) => _fmt(v.round(), locale), + ), + ), + const SizedBox(width: 6), + Text( + overTarget + ? tr('dashboard.over') + : '/ ${_fmt(targets.calorieTarget.round(), locale)}', + style: dashBody(color: kInkSecondary), + ), + ], ), - const SizedBox(width: 6), + const SizedBox(height: NhamSpacing.sp1), Text( - '/ ${_fmt(targets.calorieTarget.round())}', - style: dashBody(color: kInkSecondary), + '${_fmt(calories, locale)} ${tr('dashboard.caloriesLogged')}', + style: dashMeta(color: kInkDisabled), ), ], ), - const SizedBox(height: NhamSpacing.sp1), - Text( - '${_fmt(calories)} ${tr('dashboard.caloriesLogged')}', - style: dashMeta(color: kInkDisabled), - ), - ], - ), - ), - const SizedBox(width: NhamSpacing.sp4), - CalorieRing( - current: calories.toDouble(), - target: targets.calorieTarget, - size: 84, - strokeWidth: 6, - center: const Icon(LucideIcons.flame, size: 22, color: kInk), + ), + const SizedBox(width: NhamSpacing.sp4), + CalorieRing( + current: calories.toDouble(), + target: targets.calorieTarget, + size: 84, + strokeWidth: 6, + center: + const Icon(LucideIcons.flame, size: 22, color: kInk), + ), + ], ), - ], - ), - const SizedBox(height: NhamSpacing.sp5), + const SizedBox(height: NhamSpacing.sp5), - // (b) Macro bars — full width. - for (var i = 0; i < macroBars.length; i++) ...[ - if (i > 0) const SizedBox(height: NhamSpacing.sp3), - _MacroRow(bar: macroBars[i], idx: i), - ], + // (b) Macro bars — full width. + for (var i = 0; i < macroBars.length; i++) ...[ + if (i > 0) const SizedBox(height: NhamSpacing.sp3), + _MacroRow(bar: macroBars[i], idx: i), + ], - const _Separator(), + const _Separator(), - // (c) Meal list — plain, on the card surface (no nested fill). - meals.isEmpty ? _EmptyMeals() : _MealList(meals: meals), - ], - ), + // (c) Meal list — plain, on the card surface (no nested fill). + meals.isEmpty ? _EmptyMeals() : _MealList(meals: meals), + ], + ), + ), + ], ), ); } - static String _fmt(int n) { - // toLocaleString() → group thousands with commas (en default). - final s = n.abs().toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return n < 0 ? '-$buf' : buf.toString(); - } + // Locale-aware thousands grouping (en → "2,000", vi → "2.000"). + static String _fmt(int n, String locale) => formatCount(n, locale); } /// A hairline divider between the card's zones (hero · macros · meals). @@ -300,6 +464,8 @@ class _MacroBarState extends State<_MacroBar> @override Widget build(BuildContext context) { + // Reduced motion: render the fill at its resting width, no sweep. + final reduceMotion = MediaQuery.disableAnimationsOf(context); return ClipRRect( borderRadius: BorderRadius.circular(NhamRadii.pill), child: Container( @@ -311,7 +477,8 @@ class _MacroBarState extends State<_MacroBar> builder: (context, _) => Align( alignment: Alignment.centerLeft, child: Container( - width: constraints.maxWidth * (_fill.value / 100), + width: constraints.maxWidth * + ((reduceMotion ? widget.pct : _fill.value) / 100), height: 8, decoration: BoxDecoration( color: widget.color, @@ -425,7 +592,7 @@ class _MealRow extends StatelessWidget { SizedBox( width: _valueColumnWidth, child: Text( - '${round0(meal.nutrition.caloriesKcal)}', + '${round0(meal.nutrition.caloriesKcal)} kcal', textAlign: TextAlign.right, style: dashMeta(color: kInkSecondary, tabular: true), ), diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart index 1754deda..6ca3e7f7 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart @@ -13,6 +13,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/dashboard.dart'; @@ -175,7 +176,10 @@ class _DayCell extends StatelessWidget { if (isFuture) return cell0; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => onSelectDay(date), + onTap: () { + HapticFeedback.selectionClick(); + onSelectDay(date); + }, child: cell0, ); } diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/weight_chart.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/weight_chart.dart index a7198b47..5d65dd2f 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/weight_chart.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/weight_chart.dart @@ -11,6 +11,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../models/weight.dart'; import '../../../theme/nham_colors.dart'; @@ -18,6 +19,7 @@ import '../../../theme/nham_theme.dart'; import '../data/dashboard_providers.dart'; import 'compact_weight_log.dart'; import 'dashboard_tokens.dart'; +import 'skeleton.dart'; const double _chartAspect = 2.2; // canvas width : height (minimal band) @@ -36,31 +38,31 @@ class WeightChart extends ConsumerWidget { decoration: BoxDecoration( color: kCardSurface, // solid white borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], // shadow only, no border + boxShadow: kCardShadows, // shadow only, no border ), child: async.when( - loading: () => _MinHeight( + // Skeleton of the card body (no spinner) — the card is already drawn, + // so only its inner rows shimmer. + loading: () => Shimmer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: weightCardSkeletonChildren(), + ), + ), + error: (_, __) => _MinHeight( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator( - color: NhamColors.accent, strokeWidth: 3), + const Icon(LucideIcons.cloudOff, size: 22, color: NhamColors.stone), const SizedBox(height: NhamSpacing.sp2), Text( - tr('dashboard.loadingWeightTrend'), + tr('dashboard.progressLoadError'), textAlign: TextAlign.center, - style: dashMeta(color: kInkDisabled), + style: dashMeta(color: kInkSecondary), ), ], ), ), - error: (_, __) => _MinHeight( - child: Text( - tr('dashboard.progressLoadError'), - textAlign: TextAlign.center, - style: dashMeta(color: kInkDisabled), - ), - ), data: (data) => _Body(data: data, todayDate: todayDate, args: args), ), ); @@ -93,17 +95,32 @@ class _Body extends StatelessWidget { Widget build(BuildContext context) { final kg = tr('dashboard.units.kg'); + // Net change over the window (current − period start). The top-right stat + // that fills what used to be an empty corner — a SaaS-style metric pair. + final delta = data.currentWeight - data.periodStartWeight; + final hasTrend = data.weights.length > 1 && delta.abs() >= 0.05; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Hero — current weight only (no trend pill / detail / projection). + // Hero — current weight (left) + net-change stat (right) so the corner + // never reads empty. Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(data.currentWeight.toStringAsFixed(1), style: dashHero()), - const SizedBox(width: 6), - Text(kg, style: dashBody(color: kInkSecondary)), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(data.currentWeight.toStringAsFixed(1), + style: dashHero()), + const SizedBox(width: 6), + Text(kg, style: dashBody(color: kInkSecondary)), + ], + ), + ), + _TrendStat(delta: delta, hasTrend: hasTrend, kg: kg), ], ), const SizedBox(height: NhamSpacing.sp4), @@ -128,6 +145,53 @@ class _Body extends StatelessWidget { } } +/// The top-right net-change metric. A signed delta + kg with a small eyebrow, +/// turning the empty corner into a SaaS-style metric pair. No trend-arrow icon +/// (a SaaS trope per the brand drift-watchlist) — the sign carries direction. +class _TrendStat extends StatelessWidget { + const _TrendStat({ + required this.delta, + required this.hasTrend, + required this.kg, + }); + + final double delta; + final bool hasTrend; + final String kg; + + @override + Widget build(BuildContext context) { + // No history yet → a plain "steady" word (no number, no label). + if (!hasTrend) { + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + tr('dashboard.weightCard.steady'), + style: dashMeta(color: kInkSecondary), + ), + ); + } + + // The net-change stat — arrow + value + kg, no caption label. + final value = delta.abs().toStringAsFixed(1); + final arrow = delta > 0 ? LucideIcons.arrowUp : LucideIcons.arrowDown; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(arrow, size: 18, color: kInk), + const SizedBox(width: 1), + Text(value, style: dashValue(color: kInk)), + const SizedBox(width: 3), + Padding( + padding: const EdgeInsets.only(bottom: 1), + child: Text(kg, style: dashMeta(color: kInkDisabled)), + ), + ], + ); + } +} + /// A frameless area line that bleeds to the card edges, with a dotted goal line /// and tiny start/now end-labels — no axes, no band, no interpretation. class _ChartCanvas extends StatelessWidget { @@ -244,13 +308,12 @@ class _ChartCanvas extends StatelessWidget { lineBarsData: [ LineChartBarData( spots: spots, - isCurved: !isSinglePoint, - curveSmoothness: 0.35, - preventCurveOverShooting: true, + // Sharp, angular line — no smoothing (was a rounded curve). + isCurved: false, color: NhamColors.accent, barWidth: 2, - isStrokeCapRound: true, - isStrokeJoinRound: true, + isStrokeCapRound: false, + isStrokeJoinRound: false, dotData: FlDotData( show: isSinglePoint, getDotPainter: (spot, pct, bar, idx) => FlDotCirclePainter( diff --git a/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart b/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart index b301b9a5..a58a72b0 100644 --- a/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart +++ b/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart @@ -8,21 +8,26 @@ library; abstract final class LoggingDayKeys { static const List all = ['logging-day']; - static List byUserDate(String userId, String date) => - ['logging-day', userId, date]; + static List byUserDate(String userId, String date) => [ + 'logging-day', + userId, + date, + ]; static List byUserDateOffset( String userId, String date, int timezoneOffset, - ) => - ['logging-day', userId, date, timezoneOffset]; + ) => ['logging-day', userId, date, timezoneOffset]; } /// `['meal-dates', userId, timezoneOffset]`. Invalidations elsewhere use the /// `['meal-dates']` prefix — never exact. -List mealDatesKey(String userId, int timezoneOffset) => - ['meal-dates', userId, timezoneOffset]; +List mealDatesKey(String userId, int timezoneOffset) => [ + 'meal-dates', + userId, + timezoneOffset, +]; /// Profile cache key — mirrors RN `onboardingKeys.profile`. const List onboardingProfileKey = ['onboarding', 'profile']; @@ -35,6 +40,17 @@ String todayDateString([DateTime? date]) { return '${d.year}-$m-$day'; } +/// Local YYYY-MM-DD `delta` days from [date] (matches the web's `addDays`). +String addDays(String date, int delta) { + final parts = date.split('-'); + final base = DateTime( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + ); + return todayDateString(DateTime(base.year, base.month, base.day + delta)); +} + /// Local timezone offset in MINUTES, matching JS `Date.getTimezoneOffset()` /// (minutes BEHIND UTC, i.e. `-(localOffsetMinutes)`). int timezoneOffsetMinutes([DateTime? date]) { diff --git a/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart b/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart index 2402e14b..25d56f43 100644 --- a/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart +++ b/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart @@ -67,18 +67,6 @@ class LoggingDayNotifier ); } - /// Optimistically remove a persisted meal (on delete). - void removeMeal(String mealId) { - final current = state.valueOrNull; - if (current == null) return; - state = AsyncData( - current.copyWith( - persistedMeals: - current.persistedMeals.where((m) => m.id != mealId).toList(), - ), - ); - } - /// Roll back to a snapshot (on mutation error). void restore(LoggingDayData snapshot) { state = AsyncData(snapshot); @@ -119,6 +107,14 @@ final mealDatesProvider = FutureProvider.autoDispose.family< .then((list) => list.cast()); }); +/// Session-scoped dismiss state for the once-daily "yesterday looks +/// under-logged" nudge, keyed by the yesterday date so a fresh day re-prompts. +/// Mirrors the web's in-memory `yesterdayPromptDismissed` useState — it resets +/// on app relaunch (not persisted), so the prompt is at most once per session. +final yesterdayPromptDismissedProvider = StateProvider.family( + (ref, date) => false, +); + /// The onboarding profile row the logging screen needs (calorie + macro /// targets). Mirrors RN `useQuery({ queryKey: onboardingKeys.profile, ... })`. final loggingProfileProvider = @@ -143,6 +139,7 @@ void invalidateMealSurfaces( } ref.invalidate(mealDatesProvider(userId)); ref.invalidate(dash.dashboardBundleProvider((userId: userId, date: date))); + ref.invalidate(dash.dashboardDayProvider((userId: userId, date: date))); } /// Confirm a pending analysis into a saved meal, with the RN hook's optimistic diff --git a/apps/mobile-flutter/lib/features/logging/data/manual_log_providers.dart b/apps/mobile-flutter/lib/features/logging/data/manual_log_providers.dart index 622affaa..52dce695 100644 --- a/apps/mobile-flutter/lib/features/logging/data/manual_log_providers.dart +++ b/apps/mobile-flutter/lib/features/logging/data/manual_log_providers.dart @@ -85,7 +85,7 @@ class ManualLogController extends AutoDisposeNotifier { } void updateGrams(String itemId, double? grams) { - final clamped = grams == null ? null : grams.clamp(0, 5000).toDouble(); + final clamped = grams?.clamp(0, 5000).toDouble(); state = state.copyWith( items: [ for (final item in state.items) diff --git a/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart b/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart index 1889875a..c51e38a3 100644 --- a/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart +++ b/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart @@ -31,6 +31,11 @@ class StreamAnalysisState { final String? error; final bool isAnalyzing; + /// The day this run logs into (`StreamAnalyzeInput.loggedDate`). Lets the + /// feed pin the streaming/reveal cards to their origin date, so switching + /// the selected day doesn't render them on the wrong day's feed. + final String? loggedDate; + const StreamAnalysisState({ this.status = StreamStatus.idle, this.items = const [], @@ -39,6 +44,7 @@ class StreamAnalysisState { this.analysisId, this.error, this.isAnalyzing = false, + this.loggedDate, }); StreamAnalysisState copyWith({ @@ -49,6 +55,7 @@ class StreamAnalysisState { String? analysisId, String? error, bool? isAnalyzing, + String? loggedDate, }) => StreamAnalysisState( status: status ?? this.status, items: items ?? this.items, @@ -57,6 +64,7 @@ class StreamAnalysisState { analysisId: analysisId ?? this.analysisId, error: error ?? this.error, isAnalyzing: isAnalyzing ?? this.isAnalyzing, + loggedDate: loggedDate ?? this.loggedDate, ); static const StreamAnalysisState initial = StreamAnalysisState(); @@ -149,9 +157,10 @@ class StreamAnalysisController extends Notifier { Future analyze(StreamAnalyzeInput input) async { _closeStream(); final reqId = ++_requestId; - state = const StreamAnalysisState( + state = StreamAnalysisState( status: StreamStatus.connecting, isAnalyzing: true, + loggedDate: input.loggedDate, ); final api = ref.read(apiClientProvider); diff --git a/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart b/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart index 3be89343..57ec8ad9 100644 --- a/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart +++ b/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart @@ -8,6 +8,18 @@ import '../../../models/meal.dart'; /// Minimum allowed cooked weight for a grams/ml dish. const double minDishGrams = 10; +/// A day reads as under-logged when its calories fall below this fraction of +/// the target (ported from `lib/nutrition/pattern/completeness.ts`). +const double partialDayFraction = 0.5; + +/// True when a past day's logged calories are positive but below half the +/// target — likely under-logged, so the trends set it aside. +bool isLikelyPartialDay(double calories, int? calorieTarget) { + if (calorieTarget == null || calorieTarget <= 0) return false; + if (calories <= 0) return false; + return calories < partialDayFraction * calorieTarget; +} + /// Sum each item's macros into a single [MacroBreakdown]. MacroBreakdown recalculateTotals(List items) { return items.fold( diff --git a/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart b/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart index b5710b8f..260c233f 100644 --- a/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart +++ b/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart @@ -4,13 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../data/session_provider.dart'; import '../../../shared/widgets/nham_text.dart'; -import '../../../shell/app_header.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../data/logging_keys.dart'; import '../data/logging_models.dart'; import '../data/logging_providers.dart'; import '../widgets/feed_area.dart'; +import '../widgets/partial_yesterday_prompt.dart'; import '../widgets/timeline_picker.dart'; /// The logging tab. Owns the selected date + picker-expanded state so the date @@ -26,8 +26,7 @@ class LoggingScreen extends ConsumerStatefulWidget { } class _LoggingScreenState extends ConsumerState { - final String _today = todayDateString(); - late String _selectedDate = _today; + late String _selectedDate = todayDateString(); bool _pickerExpanded = false; @override @@ -61,11 +60,12 @@ class _LoggingScreenState extends ConsumerState { carbsTargetG: (data?['carbsTargetG'] as num?)?.toInt() ?? 250, fatTargetG: (data?['fatTargetG'] as num?)?.toInt() ?? 65, ); + final today = todayDateString(); + final yesterday = addDays(today, -1); - // Header in normal flow at the top; the collapse scrim overlays ONLY the - // feed region below it (so the strip's cells/chevrons stay tappable while - // the scrim catches taps everywhere else — the RN z-20 header / z-10 scrim - // contract). Tapping outside the strip collapses it (web parity). + // The date chip MORPHS in place into the week strip (fixed height, so the + // feed never shifts) — a buttery cross-dissolve, not a panel that opens + // below. The scrim catches outside-taps to collapse back to the chip. return ColoredBox( color: NhamColors.surface, child: SafeArea( @@ -73,22 +73,32 @@ class _LoggingScreenState extends ConsumerState { child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp3, + 0, + NhamSpacing.sp3, + NhamSpacing.sp1, + ), + child: DateMorph( + dates: mealDates, + today: today, + selectedDate: _selectedDate, expanded: _pickerExpanded, - child: TimelinePicker( - dates: mealDates, - today: _today, - selectedDate: _selectedDate, - expanded: _pickerExpanded, - onSelectDate: (date) => - setState(() => _selectedDate = date), - onExpandedChange: (v) => - setState(() => _pickerExpanded = v), - ), + onSelectDate: (date) => setState(() => _selectedDate = date), + onExpand: () => setState(() => _pickerExpanded = true), + onCollapse: () => setState(() => _pickerExpanded = false), ), ), + // A once-daily nudge when *yesterday* was under-logged — only while + // viewing today, and only until dismissed this session. + if (_selectedDate == today && + !ref.watch(yesterdayPromptDismissedProvider(yesterday))) + PartialYesterdayPrompt( + userId: userId, + yesterday: yesterday, + calorieTarget: profile.calorieTarget, + onOpenDay: (date) => setState(() => _selectedDate = date), + ), Expanded( child: Stack( children: [ @@ -99,8 +109,7 @@ class _LoggingScreenState extends ConsumerState { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, - onTap: () => - setState(() => _pickerExpanded = false), + onTap: () => setState(() => _pickerExpanded = false), ), ), ], diff --git a/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart b/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart index d9f2f5d7..544f7f09 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart @@ -1,18 +1,21 @@ import 'dart:math' as math; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import '../../../features/dashboard/logic/dashboard_format.dart' show formatCount; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; -/// The calorie ring shows REMAINING calories as the fill fraction (matches -/// web's shared/calorie-ring). The progress arc animates its -/// `strokeDashoffset` over 1000ms with `cubic-bezier(0.16, 1, 0.3, 1)`. +/// The calorie ring fills with CONSUMED calories (`current / target`) — it fills +/// up as you eat, matching the dashboard ring, the week strip, and the heatmap +/// framing. The progress arc animates its sweep over 1000ms with +/// `cubic-bezier(0.16, 1, 0.3, 1)`. /// -/// RN geometry: a 100-unit viewbox, radius 46, centered, with a -/// `non-scaling-stroke` of [strokeWidth] px at the rendered [size]. [center] -/// overrides the default in-ring content (the remaining number + "left" -/// eyebrow), mirroring the RN `center` prop. +/// Over target: the base arc completes in tan, then an overflow arc continues +/// past 12 o'clock in ~40%-alpha terracotta — never red, never a pill. The +/// default in-ring content shows the calories `left`, flipping to `over` when +/// past target. [center] overrides that content. class CalorieRing extends StatefulWidget { const CalorieRing({ super.key, @@ -43,19 +46,19 @@ class _CalorieRingState extends State duration: const Duration(milliseconds: 1000), ); late Animation _animation; - double _fromPct = 0; - late double _pct; + double _fromRatio = 0; + late double _ratio; - double get _remaining => - math.max(0, widget.target - widget.current); - double _computePct() => - widget.target > 0 ? math.min(_remaining / widget.target, 1) : 0; + double get _remaining => widget.target - widget.current; + + /// Consumed fraction — may exceed 1 (the painter splits base vs. overflow). + double _computeRatio() => widget.target > 0 ? widget.current / widget.target : 0; @override void initState() { super.initState(); - _pct = _computePct(); - _animation = Tween(begin: 0, end: _pct) + _ratio = _computeRatio(); + _animation = Tween(begin: 0, end: _ratio) .chain(CurveTween(curve: _curve)) .animate(_controller); _controller.forward(); @@ -64,11 +67,11 @@ class _CalorieRingState extends State @override void didUpdateWidget(CalorieRing oldWidget) { super.didUpdateWidget(oldWidget); - final nextPct = _computePct(); - if (nextPct != _pct) { - _fromPct = _animation.value; - _pct = nextPct; - _animation = Tween(begin: _fromPct, end: _pct) + final nextRatio = _computeRatio(); + if (nextRatio != _ratio) { + _fromRatio = _animation.value; + _ratio = nextRatio; + _animation = Tween(begin: _fromRatio, end: _ratio) .chain(CurveTween(curve: _curve)) .animate(_controller); _controller @@ -85,7 +88,6 @@ class _CalorieRingState extends State @override Widget build(BuildContext context) { - final remainingLabel = _remaining.round().toString(); return SizedBox( width: widget.size, height: widget.size, @@ -97,13 +99,12 @@ class _CalorieRingState extends State builder: (context, _) => CustomPaint( size: Size(widget.size, widget.size), painter: _RingPainter( - pct: _animation.value, + ratio: _animation.value, strokeWidth: widget.strokeWidth, ), ), ), - widget.center ?? - _DefaultCenter(remainingLabel: remainingLabel), + widget.center ?? _DefaultCenter(remaining: _remaining), ], ), ); @@ -111,16 +112,21 @@ class _CalorieRingState extends State } class _DefaultCenter extends StatelessWidget { - const _DefaultCenter({required this.remainingLabel}); - final String remainingLabel; + const _DefaultCenter({required this.remaining}); + + /// target − consumed: positive = calories left, negative = over target. + final double remaining; @override Widget build(BuildContext context) { + final locale = context.locale.toString(); + final over = remaining < 0; + final value = remaining.abs().round(); return Column( mainAxisSize: MainAxisSize.min, children: [ NhamText( - remainingLabel, + formatCount(value, locale), variant: NhamTextVariant.numDisplay, style: const TextStyle( fontSize: 17, @@ -129,10 +135,10 @@ class _DefaultCenter extends StatelessWidget { ), ), const SizedBox(height: 2), - const NhamText( - 'left', + NhamText( + over ? tr('dashboard.over') : tr('dashboard.left'), variant: NhamTextVariant.eyebrow, - style: TextStyle(fontSize: 8, letterSpacing: 1.2), + style: const TextStyle(fontSize: 8, letterSpacing: 1.2), ), ], ); @@ -140,16 +146,19 @@ class _DefaultCenter extends StatelessWidget { } class _RingPainter extends CustomPainter { - _RingPainter({required this.pct, required this.strokeWidth}); + _RingPainter({required this.ratio, required this.strokeWidth}); - final double pct; + final double ratio; final double strokeWidth; + static const double _start = -math.pi / 2; // 12 o'clock. + @override void paint(Canvas canvas, Size size) { // RN: radius 46 in a 100-unit viewbox → scale to the rendered size. final center = Offset(size.width / 2, size.height / 2); final radius = (46 / 100) * size.width; + final rect = Rect.fromCircle(center: center, radius: radius); final track = Paint() ..style = PaintingStyle.stroke @@ -157,27 +166,34 @@ class _RingPainter extends CustomPainter { ..color = NhamColors.track; canvas.drawCircle(center, radius, track); - if (pct <= 0) return; + if (ratio <= 0) return; - final arc = Paint() + final base = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = NhamColors.accent; - - // Start at -90° (12 o'clock), sweep clockwise by pct of the full circle. - const startAngle = -math.pi / 2; - final sweepAngle = 2 * math.pi * pct; canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - startAngle, - sweepAngle, + rect, + _start, + 2 * math.pi * ratio.clamp(0, 1).toDouble(), false, - arc, + base, ); + + // Over target — the overflow arc continues in terracotta @ ~40% alpha. + if (ratio > 1) { + final overflow = (ratio - 1).clamp(0, 1).toDouble(); + final over = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..color = NhamColors.danger.withValues(alpha: 0.4); + canvas.drawArc(rect, _start, 2 * math.pi * overflow, false, over); + } } @override bool shouldRepaint(_RingPainter old) => - old.pct != pct || old.strokeWidth != strokeWidth; + old.ratio != ratio || old.strokeWidth != strokeWidth; } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart b/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart new file mode 100644 index 00000000..d143f552 --- /dev/null +++ b/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/nham_text.dart'; + +/// Counts a number up from [from] to [value] over [duration] on first build +/// (and re-counts whenever [value] changes), formatting each frame with [format]. +/// +/// Used by the streaming-reveal morph so the totals row settles into place +/// instead of popping — the emotional peak of the analysis landing. +class CountUpText extends StatefulWidget { + const CountUpText({ + super.key, + required this.value, + required this.format, + this.from = 0, + this.duration = const Duration(milliseconds: 600), + this.curve = Curves.easeOut, + this.variant = NhamTextVariant.body, + this.style, + this.enabled = true, + }); + + final double value; + final String Function(double) format; + final double from; + final Duration duration; + final Curve curve; + final NhamTextVariant variant; + final TextStyle? style; + + /// When false the value renders directly with no animation (reduced motion / + /// non-reveal contexts). + final bool enabled; + + @override + State createState() => _CountUpTextState(); +} + +class _CountUpTextState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: widget.duration, + ); + late Animation _anim = _build(widget.from, widget.value); + + Animation _build(double from, double to) => Tween( + begin: from, + end: to, + ).chain(CurveTween(curve: widget.curve)).animate(_c); + + @override + void initState() { + super.initState(); + if (widget.enabled) { + _c.forward(); + } else { + _c.value = 1; + } + } + + @override + void didUpdateWidget(CountUpText old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + _anim = _build(_anim.value, widget.value); + if (widget.enabled) { + _c + ..reset() + ..forward(); + } else { + _c.value = 1; + } + } else if (old.enabled != widget.enabled && !widget.enabled) { + _c.value = 1; + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) { + return NhamText( + widget.format(widget.value), + variant: widget.variant, + style: widget.style, + ); + } + return AnimatedBuilder( + animation: _anim, + builder: + (context, _) => NhamText( + widget.format(_anim.value), + variant: widget.variant, + style: widget.style, + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/dashed_divider.dart b/apps/mobile-flutter/lib/features/logging/widgets/dashed_divider.dart deleted file mode 100644 index 8828134f..00000000 --- a/apps/mobile-flutter/lib/features/logging/widgets/dashed_divider.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; - -/// A 1px horizontal dashed rule — the RN `borderTop` rows use -/// `borderStyle: 'dashed'`, which Flutter's [Border] cannot express, so this -/// paints the dashes directly. -class DashedDivider extends StatelessWidget { - const DashedDivider({ - super.key, - required this.color, - this.dashWidth = 3, - this.dashGap = 3, - this.thickness = 1, - }); - - final Color color; - final double dashWidth; - final double dashGap; - final double thickness; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: thickness, - width: double.infinity, - child: CustomPaint( - painter: _DashedLinePainter( - color: color, - dashWidth: dashWidth, - dashGap: dashGap, - thickness: thickness, - ), - ), - ); - } -} - -class _DashedLinePainter extends CustomPainter { - _DashedLinePainter({ - required this.color, - required this.dashWidth, - required this.dashGap, - required this.thickness, - }); - - final Color color; - final double dashWidth; - final double dashGap; - final double thickness; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = thickness; - double x = 0; - final y = size.height / 2; - while (x < size.width) { - canvas.drawLine(Offset(x, y), Offset(x + dashWidth, y), paint); - x += dashWidth + dashGap; - } - } - - @override - bool shouldRepaint(_DashedLinePainter old) => - old.color != color || - old.dashWidth != dashWidth || - old.dashGap != dashGap || - old.thickness != thickness; -} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart b/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart index be94cf07..c06da12a 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart @@ -1,5 +1,8 @@ +import 'dart:math'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; @@ -7,15 +10,28 @@ import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import 'entrances.dart'; -// Hardcoded Vietnamese suggestions (matches web — these are NOT localized). -const _suggestions = ['2 mực kho + cơm', 'Phở bò tái', 'Bún chả Hà Nội']; +// The "what did you eat?" prompt has several phrasings; one is chosen at random +// per visit so the empty feed feels alive instead of canned. +const _titleKeys = [ + 'logging.emptyState.title', + 'logging.emptyState.title2', + 'logging.emptyState.title3', + 'logging.emptyState.title4', + 'logging.emptyState.title5', +]; + +/// The empty feed: an icon tile and a single rotating "What did you eat?" +/// prompt. Staggered icon→title entrance (mirrors the web: icon scale delay 50, +/// title 100). +class EmptyState extends StatefulWidget { + const EmptyState({super.key}); -/// The "what did you eat?" empty feed. Staggered entrances mirror the web: -/// icon scale (delay 50), title (100), subtitle (200), chips (300). -class EmptyState extends StatelessWidget { - const EmptyState({super.key, required this.onSuggestion}); + @override + State createState() => _EmptyStateState(); +} - final ValueChanged onSuggestion; +class _EmptyStateState extends State { + late final String _titleKey = _titleKeys[Random().nextInt(_titleKeys.length)]; @override Widget build(BuildContext context) { @@ -36,50 +52,20 @@ class EmptyState extends StatelessWidget { borderRadius: BorderRadius.circular(NhamRadii.xl), // rounded-xl ), child: const Icon( - Icons.restaurant, // lucide UtensilsCrossed → Icons.restaurant + LucideIcons.utensilsCrossed, size: 20, color: NhamColors.textMuted, ), ), ), const SizedBox(height: NhamSpacing.sp4), // gap-4 - Column( - mainAxisSize: MainAxisSize.min, - children: [ - FadeInDown( - delay: const Duration(milliseconds: 100), - child: NhamText( - 'logging.emptyState.title'.tr(), - variant: NhamTextVariant.h4, - textAlign: TextAlign.center, - style: const TextStyle(letterSpacing: NhamTracking.tight), - ), - ), - const SizedBox(height: 6), // gap-1.5 - FadeInDown( - delay: const Duration(milliseconds: 200), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: NhamText( - 'logging.emptyState.subtitle'.tr(), - variant: NhamTextVariant.small, - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - const SizedBox(height: NhamSpacing.sp4), // gap-4 FadeInDown( - delay: const Duration(milliseconds: 300), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 6, // gap-1.5 - runSpacing: 6, - children: [ - for (final s in _suggestions) - _SuggestionChip(label: s, onTap: () => onSuggestion(s)), - ], + delay: const Duration(milliseconds: 100), + child: NhamText( + _titleKey.tr(), + variant: NhamTextVariant.h4, + textAlign: TextAlign.center, + style: const TextStyle(letterSpacing: NhamTracking.tight), ), ), ], @@ -87,43 +73,3 @@ class EmptyState extends StatelessWidget { ); } } - -/// A suggestion pill: border/60 rounded-full px-3 py-1 bg-white/80; pressed → -/// accent/50 border + border/15 fill (web `hover:` touch affordance). -class _SuggestionChip extends StatefulWidget { - const _SuggestionChip({required this.label, required this.onTap}); - final String label; - final VoidCallback onTap; - - @override - State<_SuggestionChip> createState() => _SuggestionChipState(); -} - -class _SuggestionChipState extends State<_SuggestionChip> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), // px-3 py-1 - decoration: BoxDecoration( - // Pressed fill is bg-nham-border/15 (border @15%), not accent. - color: _pressed - ? const Color(0x26E8D5B5) - : NhamColors.elevTranslucent, - borderRadius: BorderRadius.circular(NhamRadii.pill), - border: Border.all( - color: _pressed ? NhamColors.accent50 : NhamColors.borderSoft, - ), - ), - child: NhamText(widget.label, variant: NhamTextVariant.chipText), - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index c9e708e4..7708f7ef 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -1,7 +1,10 @@ +import 'dart:async' show unawaited; import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; @@ -15,18 +18,21 @@ import '../../../theme/nham_typography.dart'; import '../data/logging_keys.dart'; import '../data/logging_models.dart'; import '../data/logging_providers.dart'; +import '../../dashboard/data/dashboard_providers.dart' as dash; +import '../../dashboard/logic/dashboard_format.dart' show formatCount; import '../data/stream_analysis_controller.dart'; import '../logic/format.dart'; +import '../logic/meal_utils.dart' show isLikelyPartialDay; import 'calorie_ring.dart'; -import 'dashed_divider.dart'; +import '../../../shared/widgets/top_toast.dart'; import 'empty_state.dart'; import 'entrances.dart'; import 'manual_log_sheet.dart'; import 'meal_entry.dart'; import 'meal_input.dart'; +import 'meal_mode_sheet.dart'; import 'persisted_meal_card.dart'; import 'streaming_entry.dart'; -import 'timeline_rail.dart'; const _uuid = Uuid(); @@ -46,14 +52,83 @@ class FeedArea extends ConsumerStatefulWidget { class _FeedAreaState extends ConsumerState { final MealInputController _inputController = MealInputController(); + + /// The selected composer mode (the pill on the input bar). Cheat meal ports + /// from web later; manual is a one-shot sheet, so the persistent mode is + /// effectively Normal for now. + MealLogMode _mode = MealLogMode.normal; + + /// Scrolls the freshly-revealed answer into view (nothing scrolled it before). + final ScrollController _scrollController = ScrollController(); + + /// The text of the run currently in flight — restored to the composer if it + /// fails, so a failed analysis never destroys what the user typed. + String? _inFlightText; + + /// A failed attempt, rendered as a feed card with "Try again" (terracotta). + String? _failedText; + + /// The raw text of the just-revealed answer — shown as the morph card's Lora + /// quote so the confirmable card carries the user's own words, not a derived + /// meal name (the streaming→reveal→persisted object stays continuous). + String? _revealRawInput; + + /// Inline error for a failed confirm (saving a meal) — not analysis errors, + /// which surface as the failed-attempt card. String? _errorText; + /// Meals swiped away but still inside the undo window. They are filtered out + /// of the rendered feed (so a mid-window refetch can't resurrect the card) + /// without ever mutating the day cache — Undo just removes the id, and a + /// failed DELETE removes it too (the data was never locally changed). + final Set _pendingRemovalIds = {}; + LoggingDayArgs get _dayArgs => LoggingDayArgs(widget.profile.userId, widget.date); + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// Bring the footer (streaming card / revealed answer) into view. + void _scrollToAnswer() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 400), + curve: const Cubic(0.16, 1, 0.3, 1), + ); + }); + } + void _submit(String text) { - setState(() => _errorText = null); + // A second submit while an unconfirmed reveal is showing must not + // vaporize the first answer: that analysis is already stored server-side + // as pending, so refresh its origin day — it resurfaces as a + // pending-confirmation card. + final prior = ref.read(streamAnalysisProvider); + if (prior.status == StreamStatus.done && prior.analysisId != null) { + final originDate = prior.loggedDate ?? widget.date; + unawaited( + ref + .read( + loggingDayProvider( + LoggingDayArgs(widget.profile.userId, originDate), + ).notifier, + ) + .refresh(), + ); + } + setState(() { + _failedText = null; + _revealRawInput = null; + _inFlightText = text; + }); _inputController.clear(); + _scrollToAnswer(); ref .read(streamAnalysisProvider.notifier) .analyze( @@ -65,22 +140,147 @@ class _FeedAreaState extends ConsumerState { ); } - void _handleSuggestion(String s) { - _inputController.setText(s); - _inputController.focus(); + void _retry() { + final text = _failedText; + if (text == null) return; + _submit(text); + } + + /// The first step: choose how to log. Normal focuses the composer; Manual + /// opens the search-and-grams sheet (a one-shot, not a persistent mode); Cheat + /// meal ports from web later, so it just acknowledges for now. + Future _openModeSheet() async { + final picked = await showMealModeSheet(context, current: _mode); + if (picked == null || !mounted) return; + switch (picked) { + case MealLogMode.normal: + setState(() => _mode = MealLogMode.normal); + _inputController.focus(); + case MealLogMode.manual: + showManualLogSheet( + context, + userId: widget.profile.userId, + date: widget.date, + ); + case MealLogMode.cheat: + showTopToast(context, 'logging.modeSelector.comingSoon'.tr()); + } + } + + /// Trailing-swipe removal of a saved meal: the day visibly heals (the meal + /// drops out of the totals immediately) with a 5-second undo. The DELETE only + /// fires if the undo window closes. The day cache is never locally mutated — + /// the meal id sits in [_pendingRemovalIds] and is filtered out of the + /// rendered feed, so undo, refetch races, and failed deletes all resolve by + /// just adding/removing the id. No confirm modal; nothing is destroyed + /// within the grace window. + void _removeMeal(PersistedMeal meal) { + setState(() => _pendingRemovalIds.add(meal.id)); + + var undone = false; + // Top-anchored undo toast (every toast lives at the top now). Its future + // resolves on dismissal — by Undo, tap, or timeout — mirroring the old + // SnackBar.closed, so the delete still finalizes only after the window. + showTopToast( + context, + 'logging.mealRemoved'.tr(), + actionLabel: 'logging.undo'.tr(), + duration: const Duration(seconds: 5), + onAction: () { + undone = true; + if (mounted) { + setState(() => _pendingRemovalIds.remove(meal.id)); + } + }, + ).then((_) async { + if (undone || !mounted) return; + try { + await ref + .read(apiClientProvider) + .delete('/api/v1/meals/${Uri.encodeComponent(meal.id)}'); + } catch (_) { + // The server rejected the delete — releasing the id makes the card + // reappear (the cache was never mutated), keeping the feed truthful. + ref.invalidate(loggingDayProvider(_dayArgs)); + ref.invalidate( + dash.dashboardBundleProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + ref.invalidate( + dash.dashboardDayProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + if (mounted) { + setState(() { + _pendingRemovalIds.remove(meal.id); + _errorText = 'errors.internal'.tr(); + }); + } + return; + } + if (!mounted) return; + // The delete landed — heal every cache that carries this date before + // releasing the id, so the refetched day (sans meal) is what renders. + ref.invalidate(mealDatesProvider(widget.profile.userId)); + ref.invalidate( + dash.dashboardBundleProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + ref.invalidate( + dash.dashboardDayProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + try { + await ref.read(loggingDayProvider(_dayArgs).notifier).refresh(); + } catch (_) { + // The refetch failing doesn't un-delete the meal — keep the id + // filtered (a harmless no-op once a later fetch succeeds). + return; + } + if (mounted) setState(() => _pendingRemovalIds.remove(meal.id)); + }); + } + + /// Pull-to-refresh: refetch the day + the meal-dates strip. Awaited so the + /// platform refresh control holds its spinner until the data settles. + Future _refresh() async { + ref.invalidate(mealDatesProvider(widget.profile.userId)); + await ref.read(loggingDayProvider(_dayArgs).notifier).refresh(); } void _onStreamChange(StreamAnalysisState? prev, StreamAnalysisState next) { - // On completion: refetch the day + meal-dates so the stored analysis shows - // as a confirmable card, then clear the local stream. - if (next.status == StreamStatus.done && next.analysisId != null) { - ref.invalidate(loggingDayProvider(_dayArgs)); - ref.invalidate(mealDatesProvider(widget.profile.userId)); - ref.read(streamAnalysisProvider.notifier).reset(); + // On completion: hold the stream alive and let the streaming card morph in + // place into a confirmable answer (the reveal — per-row macros already real, + // totals count up, spinner row swaps for Edit/Confirm). One light impact + // marks the moment the answer lands; nothing unmounts. The refetch + reset + // happens only once the user confirms (_confirmReveal). + if (next.status == StreamStatus.done && + next.analysisId != null && + prev?.status != StreamStatus.done) { + _revealRawInput = _inFlightText; + _inFlightText = null; + HapticFeedback.lightImpact(); + _scrollToAnswer(); } - // On error: surface the message then reset. + // On error: never destroy the typed meal. Restore the raw text into the + // composer AND render the failed attempt as a feed card (Try again). if (next.status == StreamStatus.error) { - setState(() => _errorText = next.error ?? 'errors.internal'.tr()); + final text = _inFlightText; + setState(() { + _failedText = text; + _inFlightText = null; + }); + if (text != null && _inputController.getText().trim().isEmpty) { + _inputController.setText(text); + } ref.read(streamAnalysisProvider.notifier).reset(); } } @@ -95,18 +295,51 @@ class _FeedAreaState extends ConsumerState { final confirmPending = ref.watch(confirmMealProvider(profile.userId)); final day = dayAsync.valueOrNull; - final isLoading = dayAsync.isLoading; - - final persistedMeals = [...(day?.persistedMeals ?? const [])] - ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); + // Only show the skeleton on the FIRST load (no data yet). A refetch after + // confirm/delete keeps the previous data on screen (react-query's + // keepPreviousData), so saving slots the meal in instead of reloading the + // whole feed. + final isLoading = dayAsync.isLoading && day == null; + + // Swiped-away meals inside the undo window are filtered out here (not + // removed from the cache), so totals heal immediately and a mid-window + // refetch cannot resurrect the card. + final persistedMeals = + (day?.persistedMeals ?? const []) + .where((m) => !_pendingRemovalIds.contains(m.id)) + .toList() + ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); final pendingConfirmations = day?.pendingConfirmations ?? const []; + // Legacy meals can carry unknown macros — when any do, the daily summary + // can't be totalled honestly, so we show a quiet note instead of the ring. + final hasUnknownDailyMacros = persistedMeals.any( + (m) => + m.nutrition.caloriesKcal == null || + m.nutrition.proteinG == null || + m.nutrition.carbohydrateG == null || + m.nutrition.fatG == null, + ); + + // Pin the live stream/reveal cards to the day they were submitted on, so + // switching the selected date doesn't render them on the wrong day's feed. + final streamIsForThisDay = stream.loggedDate == widget.date; + final isStreaming = + streamIsForThisDay && stream.status != StreamStatus.idle && stream.status != StreamStatus.done && stream.status != StreamStatus.error; + // The completed-but-not-yet-confirmed answer, held in place as a morph of + // the streaming card (built from the locally-held stream.result). + final isRevealing = + streamIsForThisDay && + stream.status == StreamStatus.done && + stream.result != null && + stream.analysisId != null; + final dailyCalories = round0( persistedMeals.fold( 0, @@ -126,13 +359,36 @@ class _FeedAreaState extends ConsumerState { persistedMeals.fold(0, (s, m) => s + (m.nutrition.fatG ?? 0)), ); + final hasFailedAttempt = _failedText != null; + final isEmpty = !isLoading && persistedMeals.isEmpty && pendingConfirmations.isEmpty && - !isStreaming; - - final hasFooterItems = pendingConfirmations.isNotEmpty || isStreaming; + !isStreaming && + !isRevealing && + !hasFailedAttempt; + + final hasFooterItems = + pendingConfirmations.isNotEmpty || + isStreaming || + isRevealing || + hasFailedAttempt; + + // A past day with real meals but under half the target reads as + // under-logged; the trends set it aside, so we say so (and offer to fold it + // back in by adding what was missed). Only when nothing is mid-flight. + final isPastDay = widget.date.compareTo(todayDateString()) < 0; + final showPartialDayNotice = + isPastDay && + !isLoading && + !dayAsync.hasError && + !hasUnknownDailyMacros && + persistedMeals.isNotEmpty && + pendingConfirmations.isEmpty && + !isStreaming && + !isRevealing && + isLikelyPartialDay(dailyCalories.toDouble(), profile.calorieTarget); final macroBars = [ _MacroBarData( @@ -173,6 +429,19 @@ class _FeedAreaState extends ConsumerState { child: isLoading ? const _MacroSummarySkeleton() + : hasUnknownDailyMacros + // Some legacy meals have unknown macros — the day can't be + // totalled, so say so plainly instead of showing a wrong ring. + ? Align( + alignment: Alignment.centerLeft, + child: NhamText( + 'logging.feedArea.legacyMacroWarning'.tr(), + variant: NhamTextVariant.small, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.eyebrow + 1, + ).copyWith(color: NhamColors.textMuted80), + ), + ) : Row( children: [ Column( @@ -184,7 +453,7 @@ class _FeedAreaState extends ConsumerState { ), const SizedBox(height: 4), // gap-1 NhamText( - '$dailyCalories / ${profile.calorieTarget} kcal', + '${formatCount(dailyCalories, context.locale.toString())} / ${formatCount(profile.calorieTarget, context.locale.toString())} kcal', variant: NhamTextVariant.numCaption, ), ], @@ -208,6 +477,21 @@ class _FeedAreaState extends ConsumerState { ), ), + // Under-logged past day: a quiet note that it's set aside from trends. + if (showPartialDayNotice) + Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp3, + 0, + NhamSpacing.sp3, + NhamSpacing.sp2, + ), + child: _PartialDayNotice( + calories: dailyCalories, + target: profile.calorieTarget, + ), + ), + // The card list. Expanded( child: _buildList( @@ -217,9 +501,13 @@ class _FeedAreaState extends ConsumerState { persistedMeals: persistedMeals, pendingConfirmations: pendingConfirmations, isStreaming: isStreaming, + isRevealing: isRevealing, stream: stream, hasFooterItems: hasFooterItems, confirmPending: confirmPending, + failedText: _failedText, + onRetry: _retry, + onDiscardFailed: () => setState(() => _failedText = null), ), ), @@ -241,7 +529,7 @@ class _FeedAreaState extends ConsumerState { Padding( padding: EdgeInsets.fromLTRB( NhamSpacing.sp3, - NhamSpacing.sp2, + NhamSpacing.sp3, NhamSpacing.sp3, bottomInset + NhamSpacing.sp2, ), @@ -249,13 +537,10 @@ class _FeedAreaState extends ConsumerState { controller: _inputController, onSubmit: _submit, onCancel: () => ref.read(streamAnalysisProvider.notifier).cancel(), - onManualTap: - () => showManualLogSheet( - context, - userId: widget.profile.userId, - date: widget.date, - ), - disabled: stream.isAnalyzing, + analyzing: stream.isAnalyzing, + modeLabel: mealModeLabel(_mode), + modeIcon: mealModeIcon(_mode), + onModePressed: _openModeSheet, ), ), ], @@ -269,9 +554,13 @@ class _FeedAreaState extends ConsumerState { required List persistedMeals, required List pendingConfirmations, required bool isStreaming, + required bool isRevealing, required StreamAnalysisState stream, required bool hasFooterItems, required bool confirmPending, + required String? failedText, + required VoidCallback onRetry, + required VoidCallback onDiscardFailed, }) { // Day fetch error → red alert card with retry (LoggingDayErrorState). if (hasError && persistedMeals.isEmpty && !hasFooterItems) { @@ -285,7 +574,7 @@ class _FeedAreaState extends ConsumerState { if (persistedMeals.isEmpty) { final Widget body; if (isEmpty) { - body = EmptyState(onSuggestion: _handleSuggestion); + body = const EmptyState(); } else if (isLoading) { // 2-item card skeleton with the timeline rail (LoggingDaySkeleton). body = const _LoggingDaySkeleton(); @@ -297,19 +586,27 @@ class _FeedAreaState extends ConsumerState { // the footer still renders with the gutter padding. if (hasFooterItems) { return SingleChildScrollView( + controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only( top: NhamSpacing.sp3, bottom: NhamSpacing.sp3, - left: NhamSpacing.sp3 + NhamSpacing.sp6, + left: NhamSpacing.sp3, right: NhamSpacing.sp3, ), child: _Footer( pendingConfirmations: pendingConfirmations, isStreaming: isStreaming, + isRevealing: isRevealing, stream: stream, + streamingRawInput: _inFlightText, confirmPending: confirmPending, onConfirm: _confirm, + onConfirmReveal: _confirmReveal, + revealRawInput: _revealRawInput, + failedText: failedText, + onRetry: onRetry, + onDiscardFailed: onDiscardFailed, ), ); } @@ -322,7 +619,7 @@ class _FeedAreaState extends ConsumerState { padding: const EdgeInsets.only( top: NhamSpacing.sp3, bottom: NhamSpacing.sp3, - left: NhamSpacing.sp3 + NhamSpacing.sp6, + left: NhamSpacing.sp3, right: NhamSpacing.sp3, ), child: body, @@ -338,35 +635,49 @@ class _FeedAreaState extends ConsumerState { ); } - return ListView.separated( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.only( - top: NhamSpacing.sp3, - bottom: NhamSpacing.sp3, - left: NhamSpacing.sp3 + NhamSpacing.sp6, // padding + timeline gutter - right: NhamSpacing.sp3, - ), - itemCount: persistedMeals.length + (hasFooterItems ? 1 : 0), - separatorBuilder: (_, __) => const SizedBox(height: NhamSpacing.sp2), - itemBuilder: (context, index) { - if (index < persistedMeals.length) { - final meal = persistedMeals[index]; - return FadeIn( - key: ValueKey(meal.id), - child: PersistedMealCard( - meal: meal, - isLast: !hasFooterItems && index == persistedMeals.length - 1, - ), + return RefreshIndicator.adaptive( + onRefresh: _refresh, + color: NhamColors.accent, + child: ListView.separated( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.only( + top: NhamSpacing.sp3, + bottom: NhamSpacing.sp3, + left: NhamSpacing.sp3, + right: NhamSpacing.sp3, + ), + itemCount: persistedMeals.length + (hasFooterItems ? 1 : 0), + separatorBuilder: (_, __) => const SizedBox(height: NhamSpacing.sp2), + itemBuilder: (context, index) { + if (index < persistedMeals.length) { + final meal = persistedMeals[index]; + return FadeIn( + key: ValueKey(meal.id), + child: PersistedMealCard( + meal: meal, + isLast: !hasFooterItems && index == persistedMeals.length - 1, + onRemove: () => _removeMeal(meal), + ), + ); + } + return _Footer( + pendingConfirmations: pendingConfirmations, + isStreaming: isStreaming, + isRevealing: isRevealing, + stream: stream, + streamingRawInput: _inFlightText, + confirmPending: confirmPending, + onConfirm: _confirm, + onConfirmReveal: _confirmReveal, + revealRawInput: _revealRawInput, + failedText: failedText, + onRetry: onRetry, + onDiscardFailed: onDiscardFailed, ); - } - return _Footer( - pendingConfirmations: pendingConfirmations, - isStreaming: isStreaming, - stream: stream, - confirmPending: confirmPending, - onConfirm: _confirm, - ); - }, + }, + ), ); } @@ -389,32 +700,94 @@ class _FeedAreaState extends ConsumerState { }, ], ); + // Saved — a success haptic + a top toast confirm the meal landed. + HapticFeedback.mediumImpact(); + if (mounted) showTopToast(context, 'logging.feedArea.savedMeal'.tr()); } catch (_) { // confirm() rolls the optimistic removal back on failure; surface the // error too so it isn't silently swallowed. if (mounted) setState(() => _errorText = 'errors.internal'.tr()); } } + + /// Confirm straight from the revealed answer (the morph card). The analysis is + /// already stored server-side (analysis_complete); confirming persists it. On + /// success we tear down the local stream so the revealed card hands off to the + /// refetched persisted card — one continuous object from typed words to saved + /// meal. On failure the stream stays so the user can retry the confirm. + Future _confirmReveal( + String analysisId, + List edits, + ) async { + // Confirm against the day the analysis was submitted on (the reveal is + // date-guarded to that day), so the ORIGIN date's caches are updated. + final originDate = + ref.read(streamAnalysisProvider).loggedDate ?? widget.date; + try { + await ref + .read(confirmMealProvider(widget.profile.userId).notifier) + .confirm( + analysisId: analysisId, + mealId: _uuid.v4(), + originDate: originDate, + edits: + edits.isEmpty + ? null + : [ + for (final e in edits) + { + 'mealItemOrder': e.mealItemOrder, + 'newGrams': e.newGrams, + }, + ], + ); + HapticFeedback.mediumImpact(); + if (mounted) showTopToast(context, 'logging.feedArea.savedMeal'.tr()); + _revealRawInput = null; + ref.read(streamAnalysisProvider.notifier).reset(); + } catch (_) { + if (mounted) setState(() => _errorText = 'errors.internal'.tr()); + } + } } class _Footer extends StatelessWidget { const _Footer({ required this.pendingConfirmations, required this.isStreaming, + required this.isRevealing, required this.stream, + required this.streamingRawInput, required this.confirmPending, required this.onConfirm, + required this.onConfirmReveal, + required this.revealRawInput, + required this.failedText, + required this.onRetry, + required this.onDiscardFailed, }); final List pendingConfirmations; final bool isStreaming; + final bool isRevealing; + final String? revealRawInput; + + /// The just-typed text of the in-flight analysis, shown on the streaming card + /// immediately so the card carries the user's words while it analyzes. + final String? streamingRawInput; final StreamAnalysisState stream; final bool confirmPending; final void Function(String analysisId, List edits) onConfirm; + final void Function(String analysisId, List edits) + onConfirmReveal; + final String? failedText; + final VoidCallback onRetry; + final VoidCallback onDiscardFailed; @override Widget build(BuildContext context) { + final hasFailed = failedText != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -424,7 +797,11 @@ class _Footer extends StatelessWidget { rawInput: pendingConfirmations[i].rawInput, parsedMeal: pendingConfirmations[i].parsedMeal, busy: confirmPending, - isLast: !isStreaming && i == pendingConfirmations.length - 1, + isLast: + !isStreaming && + !isRevealing && + !hasFailed && + i == pendingConfirmations.length - 1, onConfirm: (edits) => onConfirm(pendingConfirmations[i].id, edits), ), if (isStreaming) @@ -432,13 +809,230 @@ class _Footer extends StatelessWidget { status: stream.status, items: stream.items, completedItems: stream.completedItems, - isLast: true, + rawInput: streamingRawInput, + isLast: !hasFailed, + ), + // The completed answer in the streaming card's slot: per-row macros + // already real, totals count up, the spinner row swapped for + // Edit/Confirm. This IS a remount (StreamingEntry and MealEntry are + // different widgets) — the `revealing` flag softens the seam: the card + // background matches the streaming card's and the item rows crossfade + // in place instead of re-entering. + if (isRevealing) + MealEntry( + key: ValueKey('reveal-${stream.analysisId}'), + rawInput: revealRawInput ?? '', + parsedMeal: stream.result!, + busy: confirmPending, + revealing: true, + isLast: !hasFailed, + onConfirm: (edits) => onConfirmReveal(stream.analysisId!, edits), + ), + if (hasFailed) + _FailedAttemptCard( + rawInput: failedText!, + onRetry: onRetry, + onDiscard: onDiscardFailed, ), ], ); } } +/// A failed analysis, rendered as a feed card so the attempt is never lost: the +/// raw input as a Lora quote, a terracotta one-liner, and "Try again" as the +/// primary action (with a quiet Discard). The raw text is also restored to the +/// composer — this card is the visible record of what happened. +class _FailedAttemptCard extends StatelessWidget { + const _FailedAttemptCard({ + required this.rawInput, + required this.onRetry, + required this.onDiscard, + }); + + final String rawInput; + final VoidCallback onRetry; + final VoidCallback onDiscard; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), + child: Container( + padding: const EdgeInsets.all(NhamSpacing.sp4), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.borderSoft), + boxShadow: const [NhamShadows.sm], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhamText( + rawInput, + variant: NhamTextVariant.mealQuote, + style: const TextStyle(fontSize: 17, height: 28 / 17), + ), + const SizedBox(height: NhamSpacing.sp3), + NhamText( + 'logging.failedAttempt.message'.tr(), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.danger), + ), + const SizedBox(height: NhamSpacing.sp4), + Row( + children: [ + Expanded(child: _RetryButton(onTap: onRetry)), + const SizedBox(width: NhamSpacing.sp2), + _DiscardButton(onTap: onDiscard), + ], + ), + ], + ), + ), + ); + } +} + +/// Primary "Try again" — solid umber, mirroring the confirm button's resting +/// look (an honest re-run of the same meal). +class _RetryButton extends StatefulWidget { + const _RetryButton({required this.onTap}); + final VoidCallback onTap; + + @override + State<_RetryButton> createState() => _RetryButtonState(); +} + +class _RetryButtonState extends State<_RetryButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'logging.failedAttempt.tryAgain'.tr(), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: _pressed ? NhamColors.btnHover : NhamColors.btn, + borderRadius: BorderRadius.circular(NhamRadii.xl), + boxShadow: [_pressed ? NhamShadows.md : NhamShadows.sm], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.refreshCw, size: 14, color: Colors.white), + const SizedBox(width: 6), + NhamText( + 'logging.failedAttempt.tryAgain'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.xs, + ).copyWith(color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} + +/// Quiet "Discard" — wires the previously-unused logging.discard string. +class _DiscardButton extends StatefulWidget { + const _DiscardButton({required this.onTap}); + final VoidCallback onTap; + + @override + State<_DiscardButton> createState() => _DiscardButtonState(); +} + +class _DiscardButtonState extends State<_DiscardButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'logging.discard'.tr(), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.xl), + ), + child: NhamText( + 'logging.discard'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.xs, + ).copyWith(color: NhamColors.textMuted), + ), + ), + ), + ); + } +} + +/// A past-day under-logged note: Lora-italic terracotta title + a DM Sans body +/// that names the gap and invites folding the day back in. Ported from the web +/// `PartialDayNotice` (the strings shipped translated but were never rendered). +class _PartialDayNotice extends StatelessWidget { + const _PartialDayNotice({required this.calories, required this.target}); + + final int calories; + final int target; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(NhamSpacing.sp3), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhamText( + 'logging.feedArea.partialDayNotice.title'.tr(), + variant: NhamTextVariant.italicAccent, + style: const TextStyle(color: NhamColors.danger), + ), + const SizedBox(height: 4), // mt-1 + NhamText( + 'logging.feedArea.partialDayNotice.body'.tr( + namedArgs: { + 'calories': formatCount(calories, locale), + 'target': formatCount(target, locale), + }, + ), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + ], + ), + ); + } +} + class _MacroBarData { const _MacroBarData(this.label, this.current, this.target, this.color); final String label; @@ -462,10 +1056,13 @@ class _MacroRow extends StatelessWidget { return Row( children: [ SizedBox( - width: 48, // w-12 + width: 76, child: NhamText( data.label, variant: NhamTextVariant.macroLabel, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.clip, style: const TextStyle(color: NhamColors.textMuted70), ), ), @@ -534,6 +1131,8 @@ class _MacroBarState extends State<_MacroBar> @override Widget build(BuildContext context) { + // Reduced motion: render the fill at its resting width, no sweep. + final reduceMotion = MediaQuery.disableAnimationsOf(context); return ClipRRect( borderRadius: BorderRadius.circular(NhamRadii.pill), child: Container( @@ -544,7 +1143,8 @@ class _MacroBarState extends State<_MacroBar> builder: (context, _) => FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: (_anim.value / 100).clamp(0, 1), + widthFactor: ((reduceMotion ? widget.pct : _anim.value) / 100) + .clamp(0, 1), child: Container( decoration: BoxDecoration( color: widget.color, @@ -580,6 +1180,19 @@ class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin { end: 1, ).animate(CurvedAnimation(parent: _c, curve: const Cubic(0.4, 0, 0.6, 1))); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Reduced motion: hold the skeleton fully visible instead of looping. + if (MediaQuery.disableAnimationsOf(context)) { + _c + ..stop() + ..value = 1; + } else if (!_c.isAnimating) { + _c.repeat(reverse: true); + } + } + @override void dispose() { _c.dispose(); @@ -657,20 +1270,9 @@ class _LoggingDaySkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - Widget ghostCard(bool isLast) => TimelineRail( - isLast: isLast, - dotChild: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.surface, - border: Border.all(color: NhamColors.accent60, width: 2), - ), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp8), // gap-8 - child: Column( + Widget ghostCard(bool isLast) => Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp8), // gap-8 + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _bar(64, 12, const Color(0xB3E8D5B5)), // border/70 time bar @@ -717,7 +1319,7 @@ class _LoggingDaySkeleton extends StatelessWidget { ), ), const SizedBox(height: NhamSpacing.sp5), // mt-5 - const DashedDivider(color: NhamColors.borderHalf), + const Divider(height: 1, thickness: 1, color:NhamColors.borderHalf), const SizedBox(height: NhamSpacing.sp3), // pt-3 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -731,25 +1333,20 @@ class _LoggingDaySkeleton extends StatelessWidget { ), ], ), - ), - ); + ); return _Pulse(child: Column(children: [ghostCard(false), ghostCard(true)])); } } -/// Day fetch error: a red alert card with an AlertCircle, title/desc, and a -/// retry pill whose icon spins while refetching (LoggingDayErrorState). +/// Day fetch error: a warm alert card — terracotta `nham-danger` accents on +/// the cream surface (never literal reds, which break the palette on sight) — +/// with a CircleAlert, title/desc, and a retry pill (LoggingDayErrorState). class _LoggingDayErrorState extends StatelessWidget { const _LoggingDayErrorState({required this.onRetry}); final VoidCallback onRetry; - static const _red50 = Color(0xCCFEF2F2); // bg-red-50/80 - static const _red200 = Color(0xB3FECACA); // border-red-200/70 - static const _red600 = Color(0xFFDC2626); - static const _red950 = Color(0xFF450A0A); - static const _red900 = Color(0xCC7F1D1D); // red-900/80 - static const _red100 = Color(0xFFFEE2E2); + static const _dangerFill = Color(0x1AD37B69); // nham-danger @ 10% @override Widget build(BuildContext context) { @@ -760,9 +1357,9 @@ class _LoggingDayErrorState extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 448), // max-w-md padding: const EdgeInsets.all(NhamSpacing.sp4), // p-4 decoration: BoxDecoration( - color: _red50, + color: NhamColors.surface, borderRadius: BorderRadius.circular(NhamRadii.containerLg), // 2xl - border: Border.all(color: _red200), + border: Border.all(color: NhamColors.borderSoft), boxShadow: const [NhamShadows.sm], ), child: Row( @@ -771,9 +1368,9 @@ class _LoggingDayErrorState extends StatelessWidget { const Padding( padding: EdgeInsets.only(top: 2), // mt-0.5 child: Icon( - Icons.error_outline, // lucide AlertCircle + LucideIcons.circleAlert, // lucide AlertCircle size: 20, - color: _red600, + color: NhamColors.danger, ), ), const SizedBox(width: NhamSpacing.sp3), // gap-3 @@ -786,13 +1383,13 @@ class _LoggingDayErrorState extends StatelessWidget { variant: NhamTextVariant.small, style: NhamTextStyles.sansSemiBold( fontSize: NhamFontSize.sm, - ).copyWith(color: _red950), + ).copyWith(color: NhamColors.text), ), const SizedBox(height: 4), // mt-1 NhamText( 'logging.feedArea.loadErrorDescription'.tr(), variant: NhamTextVariant.small, - style: const TextStyle(color: _red900), + style: const TextStyle(color: NhamColors.textMuted), ), const SizedBox(height: NhamSpacing.sp3), // mt-3 _RetryPill(onRetry: onRetry), @@ -822,16 +1419,16 @@ class _RetryPill extends StatelessWidget { vertical: 8, ), // px-3.5 py-2 decoration: BoxDecoration( - color: _LoggingDayErrorState._red100, + color: _LoggingDayErrorState._dangerFill, borderRadius: BorderRadius.circular(NhamRadii.pill), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( - Icons.refresh, // lucide RefreshCw + LucideIcons.refreshCw, // lucide RefreshCw size: 16, - color: _LoggingDayErrorState._red950, + color: NhamColors.danger, ), const SizedBox(width: NhamSpacing.sp2), // gap-2 NhamText( @@ -839,7 +1436,7 @@ class _RetryPill extends StatelessWidget { variant: NhamTextVariant.small, style: NhamTextStyles.sansMedium( fontSize: NhamFontSize.sm, - ).copyWith(color: _LoggingDayErrorState._red950), + ).copyWith(color: NhamColors.danger), ), ], ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/manual_log_sheet.dart b/apps/mobile-flutter/lib/features/logging/widgets/manual_log_sheet.dart index 0d73bce6..b67a6233 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/manual_log_sheet.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/manual_log_sheet.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../models/ingredient.dart'; import '../../../shared/widgets/decimal_input.dart'; @@ -141,7 +143,7 @@ class _ManualLogSheetState extends ConsumerState { ), IconButton( onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, size: 20), + icon: const Icon(LucideIcons.x, size: 20), color: NhamColors.textMuted, tooltip: 'common.cancel'.tr(), ), @@ -162,7 +164,7 @@ class _ManualLogSheetState extends ConsumerState { decoration: InputDecoration( isDense: true, prefixIcon: const Icon( - Icons.search, + LucideIcons.search, size: 18, color: NhamColors.textMuted, ), @@ -394,7 +396,7 @@ class _SelectedItemRow extends StatelessWidget { ), IconButton( onPressed: disabled ? null : onRemove, - icon: const Icon(Icons.close, size: 16), + icon: const Icon(LucideIcons.x, size: 16), color: NhamColors.textMuted50, tooltip: 'logging.manualLogging.removeItem'.tr(), visualDensity: VisualDensity.compact, @@ -453,9 +455,20 @@ class _ResultsSection extends StatelessWidget { ), data: (results) { if (results.isEmpty) { - // An empty recents list just means a new user — show nothing - // rather than an alarming "no results". - if (isRecents) return const SizedBox.shrink(); + // An empty recents list just means a new user — nudge them to + // search rather than show an alarming "no results". + if (isRecents) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: NhamSpacing.sp6), + child: Center( + child: NhamText( + 'logging.manualLogging.recentsHint'.tr(), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + ), + ); + } return Padding( padding: const EdgeInsets.symmetric(vertical: NhamSpacing.sp4), child: NhamText( @@ -478,26 +491,45 @@ class _ResultsSection extends StatelessWidget { } } -class _ResultTile extends StatelessWidget { +class _ResultTile extends StatefulWidget { const _ResultTile({required this.result, required this.onTap}); final IngredientSearchResult result; final VoidCallback onTap; + @override + State<_ResultTile> createState() => _ResultTileState(); +} + +class _ResultTileState extends State<_ResultTile> { + bool _pressed = false; + + void _onTap() { + HapticFeedback.selectionClick(); + widget.onTap(); + } + @override Widget build(BuildContext context) { + final result = widget.result; final kcal = result.per100g.caloriesKcal; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(NhamRadii.md), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp1, - vertical: NhamSpacing.sp2, - ), - child: Row( + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: _onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover40 : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.md), + ), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp2, + vertical: NhamSpacing.sp2, + ), + child: Row( children: [ Expanded( child: Column( @@ -547,15 +579,14 @@ class _ResultTile extends StatelessWidget { ), const SizedBox(width: NhamSpacing.sp1), const Icon( - Icons.add_circle_outline, + LucideIcons.circlePlus, size: 18, color: NhamColors.accentDark, ), ], ), ), - ), - ); + ); } } @@ -565,6 +596,7 @@ class _TotalsSummary extends StatelessWidget { final IngredientMacrosPer100g totals; String _fmt(double? value) => value == null ? '—' : '${value.round()}'; + String _fmtG(double? value) => value == null ? '—' : '${value.round()}g'; @override Widget build(BuildContext context) { @@ -577,7 +609,7 @@ class _TotalsSummary extends StatelessWidget { ), const SizedBox(height: 2), NhamText( - 'P ${_fmt(totals.proteinG)} · C ${_fmt(totals.carbohydrateG)} · F ${_fmt(totals.fatG)}', + 'P: ${_fmtG(totals.proteinG)} · C: ${_fmtG(totals.carbohydrateG)} · F: ${_fmtG(totals.fatG)}', variant: NhamTextVariant.numCaption, style: const TextStyle(color: NhamColors.textMuted), ), @@ -586,7 +618,7 @@ class _TotalsSummary extends StatelessWidget { } } -class _SaveButton extends StatelessWidget { +class _SaveButton extends StatefulWidget { const _SaveButton({ required this.enabled, required this.saving, @@ -597,28 +629,46 @@ class _SaveButton extends StatelessWidget { final bool saving; final VoidCallback onTap; + @override + State<_SaveButton> createState() => _SaveButtonState(); +} + +class _SaveButtonState extends State<_SaveButton> { + bool _pressed = false; + + void _onTap() { + HapticFeedback.lightImpact(); + widget.onTap(); + } + @override Widget build(BuildContext context) { + final enabled = widget.enabled; return Semantics( button: true, enabled: enabled, label: 'logging.manualLogging.save'.tr(), child: GestureDetector( - onTap: enabled ? onTap : null, - child: Opacity( - opacity: enabled ? 1 : 0.4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp2, - ), - decoration: BoxDecoration( - color: NhamColors.btn, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - ), - child: - saving - ? const SizedBox( + onTapDown: enabled ? (_) => setState(() => _pressed = true) : null, + onTapUp: enabled ? (_) => setState(() => _pressed = false) : null, + onTapCancel: enabled ? () => setState(() => _pressed = false) : null, + onTap: enabled ? _onTap : null, + child: AnimatedScale( + scale: _pressed ? 0.96 : 1, + duration: const Duration(milliseconds: 150), + child: Opacity( + opacity: enabled ? 1 : 0.4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: _pressed ? NhamColors.btnHover : NhamColors.btn, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: widget.saving + ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( @@ -626,12 +676,13 @@ class _SaveButton extends StatelessWidget { color: Colors.white, ), ) - : Text( + : Text( 'logging.manualLogging.save'.tr(), - style: NhamTextStyles.sansMedium( + style: NhamTextStyles.sansSemiBold( fontSize: NhamFontSize.sm, ).copyWith(color: Colors.white), ), + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index f62e82ae..a0269290 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import '../../../models/meal.dart'; import '../../../shared/widgets/nham_text.dart'; @@ -10,9 +12,8 @@ import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../logic/format.dart'; import '../logic/meal_utils.dart'; -import 'dashed_divider.dart'; +import 'count_up.dart'; import 'entrances.dart'; -import 'timeline_rail.dart'; // Briefly block Confirm after a quantity tap so a fast double-tap on a stepper // can't slip through and save before the user is done adjusting. @@ -28,6 +29,7 @@ class MealEntry extends StatefulWidget { required this.onConfirm, this.busy = false, this.isLast = false, + this.revealing = false, }); final ParsedMeal parsedMeal; @@ -36,6 +38,11 @@ class MealEntry extends StatefulWidget { final bool busy; final bool isLast; + /// True for the streaming-reveal morph's first mount: the totals row counts + /// up and the confirm CTA slides in as the spinner row slides out — the + /// continuation of the streaming card, not a fresh pop. + final bool revealing; + @override State createState() => _MealEntryState(); } @@ -46,6 +53,9 @@ class _MealEntryState extends State { bool _editing = false; bool _confirmCoolingDown = false; Timer? _confirmTimer; + // After the first totals count-up, edits should jump rather than re-animate + // from zero — only the reveal's opening frame counts up. + late bool _countUp = widget.revealing; @override void dispose() { @@ -54,7 +64,9 @@ class _MealEntryState extends State { } void _change(String itemId, double delta) { + HapticFeedback.selectionClick(); setState(() { + _countUp = false; // a manual edit snaps; only the reveal counts up _items = applyQuantityChange(_items, _original, itemId, delta); _confirmCoolingDown = true; }); @@ -64,21 +76,26 @@ class _MealEntryState extends State { }); } - bool get _confirmDisabled => - widget.busy || (_editing && _confirmCoolingDown); + bool get _confirmDisabled => widget.busy || (_editing && _confirmCoolingDown); + + /// Wrap the confirm CTA in a slide-up entrance only on the reveal morph's + /// opening frame (the spinner row has just slid out of the same slot). + Widget _maybeReveal(Widget child) => + widget.revealing ? FadeInUp(offset: 12, child: child) : child; @override Widget build(BuildContext context) { final totals = recalculateTotals(_items); - return TimelineRail( - isLast: widget.isLast, - child: Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), // mb-3 - child: Column( + return Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), // mb-3 + child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Card( + // The reveal replaces the streaming card in place — matching its + // surface background removes the background flip at the swap. + color: widget.revealing ? NhamColors.surface : NhamColors.elev, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -106,7 +123,7 @@ class _MealEntryState extends State { ], ), const SizedBox(height: NhamSpacing.sp5), // mt-5 - const DashedDivider(color: NhamColors.border), + const Divider(height: 1, thickness: 1, color: NhamColors.borderFaint), const SizedBox(height: NhamSpacing.sp4), // pt-4 Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), @@ -114,21 +131,34 @@ class _MealEntryState extends State { children: [ for (final (index, item) in _items.indexed) // Web: each item enters opacity 0→1, x:-8→0, staggered - // delay index*0.05s (meal-entry-item.tsx:32-35). - FadeInLeft( - key: ValueKey(item.id), - offset: 8, - delay: Duration(milliseconds: index * 50), - child: _ItemRow( - item: item, - editing: _editing, - onChange: _change, + // delay index*0.05s (meal-entry-item.tsx:32-35). On + // the reveal the rows were already on screen in the + // streaming card — crossfade in place, don't re-enter. + if (widget.revealing) + FadeIn( + key: ValueKey(item.id), + duration: const Duration(milliseconds: 150), + child: _ItemRow( + item: item, + editing: _editing, + onChange: _change, + ), + ) + else + FadeInLeft( + key: ValueKey(item.id), + offset: 8, + delay: Duration(milliseconds: index * 50), + child: _ItemRow( + item: item, + editing: _editing, + onChange: _change, + ), ), - ), ], ), ), - const DashedDivider(color: NhamColors.borderHalf), + const Divider(height: 1, thickness: 1, color: NhamColors.borderFaint), const SizedBox(height: NhamSpacing.sp3), // pt-3 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -148,8 +178,15 @@ class _MealEntryState extends State { style: const TextStyle(color: NhamColors.textMuted), ), const SizedBox(width: NhamSpacing.sp4), // gap-4 - NhamText(fmtKcal(totals.calories), - variant: NhamTextVariant.numStrong), + CountUpText( + value: totals.calories, + // Reduced motion: the reveal total lands in place. + enabled: + _countUp && + !MediaQuery.disableAnimationsOf(context), + format: (v) => fmtKcal(v), + variant: NhamTextVariant.numStrong, + ), ], ), ], @@ -158,19 +195,22 @@ class _MealEntryState extends State { ), ), const SizedBox(height: NhamSpacing.sp3), // mt-3 - _ConfirmButton( - editing: _editing, - disabled: _confirmDisabled, - onTap: _confirmDisabled - ? null - : () => widget.onConfirm( - deriveQuantityEdits(_items, _original), - ), + // On reveal the CTA slides up into the slot the spinner row vacated. + _maybeReveal( + _ConfirmButton( + editing: _editing, + disabled: _confirmDisabled, + onTap: + _confirmDisabled + ? null + : () => widget.onConfirm( + deriveQuantityEdits(_items, _original), + ), + ), ), ], ), - ), - ); + ); } } @@ -191,18 +231,27 @@ class _ItemRow extends StatelessWidget { final step = isGrams ? 10.0 : 1.0; final minusDisabled = isGrams ? item.quantity <= minDishGrams : item.quantity <= 0; + // Stepping a count-unit item to 0 strikes the row — a clear "this one's + // out" cue before confirm drops it. Grams floor at minDishGrams, so only + // count units can reach 0. + final struck = !isGrams && item.quantity <= 0; return AnimatedContainer( duration: const Duration(milliseconds: 150), - padding: editing - ? const EdgeInsets.symmetric(vertical: 10, horizontal: 8) // py-2.5 px-2 - : const EdgeInsets.symmetric(vertical: 10), - decoration: editing - ? BoxDecoration( - color: NhamColors.surface80, // surface/80 - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-md - ) - : null, + padding: + editing + ? const EdgeInsets.symmetric( + vertical: 10, + horizontal: 8, + ) // py-2.5 px-2 + : const EdgeInsets.symmetric(vertical: 10), + decoration: + editing + ? BoxDecoration( + color: NhamColors.surface80, // surface/80 + borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-md + ) + : null, child: Row( children: [ Expanded( @@ -214,11 +263,12 @@ class _ItemRow extends StatelessWidget { child: Row( children: [ _Stepper( - icon: Icons.remove, // lucide Minus + icon: LucideIcons.minus, // lucide Minus disabled: minusDisabled, - onTap: minusDisabled - ? null - : () => onChange(item.id, -step), + onTap: + minusDisabled + ? null + : () => onChange(item.id, -step), ), const SizedBox(width: 2), // gap-0.5 SizedBox( @@ -227,13 +277,14 @@ class _ItemRow extends StatelessWidget { item.quantity.round().toString(), variant: NhamTextVariant.numStrong, textAlign: TextAlign.center, - style: NhamTextStyles.sansSemiBold(fontSize: 11) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansSemiBold( + fontSize: 11, + ).copyWith(color: NhamColors.text), ), ), const SizedBox(width: 2), _Stepper( - icon: Icons.add, // lucide Plus + icon: LucideIcons.plus, // lucide Plus onTap: () => onChange(item.id, step), ), const SizedBox(width: NhamSpacing.sp2), // gap-2 @@ -246,26 +297,49 @@ class _ItemRow extends StatelessWidget { variant: NhamTextVariant.itemName, maxLines: 1, overflow: TextOverflow.ellipsis, + style: + struck + ? const TextStyle( + decoration: TextDecoration.lineThrough, + decorationColor: NhamColors.textMuted, + color: NhamColors.textMuted, + ) + : null, ), ), ], ), ), const SizedBox(width: NhamSpacing.sp3), // gap-3 - Row( - children: [ - NhamText('P:${fmtG(item.macros.protein)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), - const SizedBox(width: NhamSpacing.sp2), - NhamText('C:${fmtG(item.macros.carbs)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), - const SizedBox(width: NhamSpacing.sp2), - NhamText('F:${fmtG(item.macros.fat)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), - const SizedBox(width: NhamSpacing.sp3), // gap-3 - NhamText(fmtKcal(item.macros.calories), - variant: NhamTextVariant.itemCalories, maxLines: 1), - ], + Opacity( + opacity: struck ? 0.4 : 1, + child: Row( + children: [ + NhamText( + 'P: ${fmtG(item.macros.protein)}', + variant: NhamTextVariant.itemMacro, + maxLines: 1, + ), + const SizedBox(width: NhamSpacing.sp2), + NhamText( + 'C: ${fmtG(item.macros.carbs)}', + variant: NhamTextVariant.itemMacro, + maxLines: 1, + ), + const SizedBox(width: NhamSpacing.sp2), + NhamText( + 'F: ${fmtG(item.macros.fat)}', + variant: NhamTextVariant.itemMacro, + maxLines: 1, + ), + const SizedBox(width: NhamSpacing.sp3), // gap-3 + NhamText( + fmtKcal(item.macros.calories), + variant: NhamTextVariant.itemCalories, + maxLines: 1, + ), + ], + ), ), ], ), @@ -297,19 +371,27 @@ class _StepperState extends State<_Stepper> { onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, onTapCancel: tappable ? () => setState(() => _pressed = false) : null, onTap: widget.onTap, - child: Opacity( - opacity: widget.disabled ? 0.4 : 1, // opacity-40 - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-colors - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _pressed ? NhamColors.hover : NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - border: Border.all(color: NhamColors.borderSoft), + // 40pt tap target around the 28pt visual stepper (kept under 44 so two + // steppers + the count value still fit a narrow row without overflow). + child: SizedBox( + width: 40, + height: 40, + child: Center( + child: Opacity( + opacity: widget.disabled ? 0.4 : 1, // opacity-40 + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-colors + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Icon(widget.icon, size: 10, color: NhamColors.textMuted), + ), ), - child: Icon(widget.icon, size: 10, color: NhamColors.textMuted), ), ), ); @@ -343,7 +425,9 @@ class _EditPill extends StatelessWidget { child: Container( key: ValueKey(editing ? 'done' : 'edit'), padding: const EdgeInsets.symmetric( - vertical: 4, horizontal: 10), // py-1 px-2.5 + vertical: 4, + horizontal: 10, + ), // py-1 px-2.5 decoration: BoxDecoration( color: editing ? NhamColors.accent10 : Colors.transparent, borderRadius: BorderRadius.circular(NhamRadii.pill), @@ -355,7 +439,9 @@ class _EditPill extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - editing ? Icons.check : Icons.edit_outlined, // Check / Pencil + editing + ? LucideIcons.check + : LucideIcons.pencil, // Check / Pencil size: 12, color: editing ? NhamColors.accent : NhamColors.textMuted, ), @@ -413,44 +499,56 @@ class _ConfirmButtonState extends State<_ConfirmButton> { } else { bg = active ? NhamColors.btnHover : NhamColors.btn; } - final BoxBorder? border = editing - ? Border.all( - color: active ? NhamColors.btn : NhamColors.btnBorderGhost, - ) - : null; - final List? shadow = editing - ? null - : [active ? NhamShadows.md : NhamShadows.sm]; - - return Opacity( - opacity: widget.disabled ? 0.5 : 1, // opacity-50 - child: GestureDetector( - onTapDown: tappable ? (_) => setState(() => _pressed = true) : null, - onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, - onTapCancel: tappable ? () => setState(() => _pressed = false) : null, - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), // transition-all duration-200 - padding: - const EdgeInsets.symmetric(vertical: 10, horizontal: 12), // py-2.5 px-3 - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(NhamRadii.xl), // rounded-xl - border: border, - boxShadow: shadow, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check, size: 14, color: fg), - const SizedBox(width: 6), // gap-1.5 - NhamText( - 'logging.confirm'.tr(), - variant: NhamTextVariant.body, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) - .copyWith(color: fg), - ), - ], + final BoxBorder? border = + editing + ? Border.all( + color: active ? NhamColors.btn : NhamColors.btnBorderGhost, + ) + : null; + final List? shadow = + editing ? null : [active ? NhamShadows.md : NhamShadows.sm]; + + return Semantics( + button: true, + enabled: tappable, + excludeSemantics: true, + label: 'logging.confirm'.tr(), + onTap: widget.onTap, + child: Opacity( + opacity: widget.disabled ? 0.5 : 1, // opacity-50 + child: GestureDetector( + onTapDown: tappable ? (_) => setState(() => _pressed = true) : null, + onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, + onTapCancel: tappable ? () => setState(() => _pressed = false) : null, + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration( + milliseconds: 200, + ), // transition-all duration-200 + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 12, + ), // py-2.5 px-3 + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(NhamRadii.xl), // rounded-xl + border: border, + boxShadow: shadow, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.check, size: 14, color: fg), + const SizedBox(width: 6), // gap-1.5 + NhamText( + 'logging.confirm'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.xs, + ).copyWith(color: fg), + ), + ], + ), ), ), ), @@ -459,16 +557,18 @@ class _ConfirmButtonState extends State<_ConfirmButton> { } /// Card: rounded-2xl (16px), border/60 hairline, shadow.sm, padding 16. +/// [color] lets the reveal match the streaming card's surface background. class _Card extends StatelessWidget { - const _Card({required this.child}); + const _Card({required this.child, this.color = NhamColors.elev}); final Widget child; + final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(NhamSpacing.sp4), decoration: BoxDecoration( - color: NhamColors.elev, + color: color, borderRadius: BorderRadius.circular(NhamRadii.containerLg), border: Border.all(color: NhamColors.borderSoft), boxShadow: const [NhamShadows.sm], diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart index 8ad8ac58..deea98bf 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart @@ -1,5 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -26,18 +28,28 @@ class MealInput extends StatefulWidget { required this.controller, required this.onSubmit, this.onCancel, - this.onManualTap, - this.disabled = false, + this.onModePressed, + this.modeLabel, + this.modeIcon, + this.analyzing = false, }); final MealInputController controller; final ValueChanged onSubmit; final VoidCallback? onCancel; - /// Opens the manual-log sheet (search foods + exact grams, no AI). Rendered - /// as a ghost button beside submit when provided. - final VoidCallback? onManualTap; - final bool disabled; + /// While true the action button shows Stop (the run can be cancelled) — but + /// the field stays editable so a new meal can be typed mid-analysis (the + /// requestId-supersede mechanism handles overlap). + final bool analyzing; + + /// Opens the mode selector (Normal / Cheat meal / Manual). Rendered as an + /// icon + label on the input bar's second line. + final VoidCallback? onModePressed; + + /// Label + icon of the currently selected mode, shown on the mode control. + final String? modeLabel; + final IconData? modeIcon; @override State createState() => _MealInputState(); @@ -100,10 +112,13 @@ class _MealInputState extends State ); } - bool get _canSubmit => _controller.text.trim().isNotEmpty && !widget.disabled; + bool get _canSubmit => _controller.text.trim().isNotEmpty; void _submit() { - if (_canSubmit) widget.onSubmit(_controller.text); + if (_canSubmit) { + HapticFeedback.lightImpact(); + widget.onSubmit(_controller.text); + } } @override @@ -135,83 +150,84 @@ class _MealInputState extends State child: child, ); }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: _minHeight, - maxHeight: _maxHeight, - ), - child: Opacity( - opacity: widget.disabled ? 0.5 : 1, - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: !widget.disabled, - maxLines: null, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.sm, - height: 20 / 14, // leading-5 (20px) at text-sm (14px) - ).copyWith(color: NhamColors.text), - cursorColor: NhamColors.accent, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - vertical: 6, - ), // py-1.5 - hintText: 'logging.placeholder'.tr(), - hintStyle: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.sm, - height: 20 / 14, - ).copyWith(color: NhamColors.placeholderMuted40), - ), - ), + // Line 1 — the composer field, full width. + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _minHeight, + maxHeight: _maxHeight, + ), + child: TextField( + controller: _controller, + focusNode: _focusNode, + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + height: 20 / 14, // leading-5 (20px) at text-sm (14px) + ).copyWith(color: NhamColors.text), + cursorColor: NhamColors.accent, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 6, + ), // py-1.5 + hintText: 'logging.composerPlaceholder'.tr(), + hintStyle: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + height: 20 / 14, + ).copyWith(color: NhamColors.placeholderMuted40), ), ), ), - const SizedBox(width: NhamSpacing.sp3), - if (widget.onManualTap != null && !widget.disabled) ...[ - _GhostButton( - icon: Icons.edit_note, - label: 'logging.manualLogging.openSheet'.tr(), - onTap: widget.onManualTap!, - ), - const SizedBox(width: NhamSpacing.sp2), - ], - if (widget.disabled && widget.onCancel != null) - _ActionButton( - icon: Icons.stop, // lucide Square (filled) → Icons.stop - iconSize: 14, - label: 'common.cancel'.tr(), - onTap: widget.onCancel, - ) - else - _ActionButton( - icon: Icons.arrow_upward, // lucide ArrowUp → Icons.arrow_upward - iconSize: 16, - label: 'logging.submit'.tr(), - enabled: _canSubmit, - onTap: _canSubmit ? _submit : null, - ), + const SizedBox(height: NhamSpacing.sp2), + // Line 2 — mode selector on the left, send/stop on the right. + Row( + children: [ + if (widget.onModePressed != null && !widget.analyzing) + _ModeButton( + icon: widget.modeIcon ?? LucideIcons.zap, + label: widget.modeLabel ?? + 'logging.modeSelector.button'.tr(), + onTap: widget.onModePressed!, + ), + const Spacer(), + if (!_canSubmit && widget.analyzing && widget.onCancel != null) + _ActionButton( + icon: LucideIcons.square, // lucide Square (filled) + iconSize: 14, + label: 'common.cancel'.tr(), + onTap: widget.onCancel, + ) + else + _ActionButton( + icon: LucideIcons.arrowUp, // lucide ArrowUp + iconSize: 16, + label: 'logging.submit'.tr(), + enabled: _canSubmit, + onTap: _canSubmit ? _submit : null, + ), + ], + ), ], ), ); } } -/// 32x32 ghost button (umber outline, transparent bg) — the manual-log entry -/// point sitting beside the submit button, mirroring the web's mode picker -/// placement next to send. -class _GhostButton extends StatelessWidget { - const _GhostButton({ +/// The mode control on the input bar's second line — a minimal icon + label +/// (no border, no chevron), like the Claude composer's "Auto". Tapping opens the +/// mode chooser. 44pt tap target, scales 0.96 on press. +class _ModeButton extends StatefulWidget { + const _ModeButton({ required this.icon, required this.label, required this.onTap, @@ -221,23 +237,47 @@ class _GhostButton extends StatelessWidget { final String label; final VoidCallback onTap; + @override + State<_ModeButton> createState() => _ModeButtonState(); +} + +class _ModeButtonState extends State<_ModeButton> { + bool _pressed = false; + @override Widget build(BuildContext context) { return Semantics( button: true, - label: label, + label: widget.label, child: GestureDetector( - onTap: onTap, - child: Container( - width: 32, - height: 32, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.md), - border: Border.all(color: NhamColors.btnBorderGhost), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: SizedBox( + height: 44, // HIG tap target + child: Center( + child: AnimatedScale( + scale: _pressed ? 0.96 : 1, + duration: const Duration(milliseconds: 200), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 18, color: NhamColors.btn), + const SizedBox(width: 6), + Text( + widget.label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.btn), + ), + ], + ), + ), + ), ), - child: Icon(icon, size: 18, color: NhamColors.btn), ), ), ); @@ -280,25 +320,32 @@ class _ActionButtonState extends State<_ActionButton> { onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, onTapCancel: tappable ? () => setState(() => _pressed = false) : null, onTap: widget.onTap, - child: AnimatedScale( - scale: _pressed ? 0.95 : 1, - duration: const Duration( - milliseconds: 200, - ), // transition-all duration-200 - child: Opacity( - opacity: widget.enabled ? 1 : 0.3, - child: Container( - width: 32, - height: 32, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _pressed ? NhamColors.btnHover : NhamColors.btn, - borderRadius: BorderRadius.circular(NhamRadii.md), - ), - child: Icon( - widget.icon, - size: widget.iconSize, - color: Colors.white, + // 44pt minimum tap target (HIG) around the 32pt visual button. + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: AnimatedScale( + scale: _pressed ? 0.95 : 1, + duration: const Duration( + milliseconds: 200, + ), // transition-all duration-200 + child: Opacity( + opacity: widget.enabled ? 1 : 0.3, + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.btnHover : NhamColors.btn, + borderRadius: BorderRadius.circular(NhamRadii.md), + ), + child: Icon( + widget.icon, + size: widget.iconSize, + color: Colors.white, + ), + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_mode_sheet.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_mode_sheet.dart new file mode 100644 index 00000000..3295a7c2 --- /dev/null +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_mode_sheet.dart @@ -0,0 +1,240 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../shared/widgets/nham_text.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// How a meal gets logged. `normal` = describe it in words (AI); `cheat` = the +/// cheat-meal flow (ported from web later); `manual` = search foods + grams. +enum MealLogMode { normal, cheat, manual } + +IconData mealModeIcon(MealLogMode mode) => switch (mode) { + MealLogMode.normal => LucideIcons.zap, // lightning + MealLogMode.cheat => LucideIcons.pizza, + MealLogMode.manual => LucideIcons.pencil, + }; + +String mealModeLabel(MealLogMode mode) => switch (mode) { + MealLogMode.normal => 'logging.modeSelector.normal'.tr(), + MealLogMode.cheat => 'logging.modeSelector.cheat'.tr(), + MealLogMode.manual => 'logging.modeSelector.manual'.tr(), + }; + +/// Opens the "select mode" chooser — the first step before the composer. Minimal +/// list (bare colored icon · title · description · check), mirroring the Claude +/// Code mobile mode selector. Returns the picked mode (or null if dismissed). +Future showMealModeSheet( + BuildContext context, { + required MealLogMode current, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => _MealModeSheet(current: current), + ); +} + +class _MealModeSheet extends StatelessWidget { + const _MealModeSheet({required this.current}); + + final MealLogMode current; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).padding.bottom; + return Container( + decoration: const BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(NhamRadii.xxl)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header — X (close) on the left, centered title. + Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp2, + NhamSpacing.sp2, + NhamSpacing.sp2, + NhamSpacing.sp1, + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.x, size: 22), + color: NhamColors.textMuted, + tooltip: 'common.cancel'.tr(), + ), + Expanded( + child: Center( + child: NhamText( + 'logging.modeSelector.title'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.md, + ).copyWith(color: NhamColors.text), + ), + ), + ), + // Mirror the X so the title stays optically centered. + const SizedBox(width: 48, height: 48), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + NhamSpacing.sp3, + NhamSpacing.sp2, + NhamSpacing.sp3, + bottomInset + NhamSpacing.sp4, + ), + child: Column( + children: [ + _ModeRow( + icon: mealModeIcon(MealLogMode.normal), + iconColor: NhamColors.accentDark, + title: 'logging.modeSelector.normal'.tr(), + desc: 'logging.modeSelector.normalDesc'.tr(), + selected: current == MealLogMode.normal, + onTap: () => Navigator.of(context).pop(MealLogMode.normal), + ), + _ModeRow( + icon: mealModeIcon(MealLogMode.cheat), + iconColor: NhamColors.danger, + title: 'logging.modeSelector.cheat'.tr(), + desc: 'logging.modeSelector.cheatDesc'.tr(), + selected: current == MealLogMode.cheat, + soon: true, + onTap: () => Navigator.of(context).pop(MealLogMode.cheat), + ), + _ModeRow( + icon: mealModeIcon(MealLogMode.manual), + iconColor: NhamColors.text, + title: 'logging.modeSelector.manual'.tr(), + desc: 'logging.modeSelector.manualDesc'.tr(), + selected: current == MealLogMode.manual, + onTap: () => Navigator.of(context).pop(MealLogMode.manual), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ModeRow extends StatefulWidget { + const _ModeRow({ + required this.icon, + required this.iconColor, + required this.title, + required this.desc, + required this.selected, + required this.onTap, + this.soon = false, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String desc; + final bool selected; + final bool soon; + final VoidCallback onTap; + + @override + State<_ModeRow> createState() => _ModeRowState(); +} + +class _ModeRowState extends State<_ModeRow> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: () { + HapticFeedback.selectionClick(); + widget.onTap(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp2, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover40 : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.container20), + ), + child: Row( + children: [ + Icon(widget.icon, size: 24, color: widget.iconColor), + const SizedBox(width: NhamSpacing.sp3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + NhamText( + widget.title, + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.lg, + ).copyWith(color: NhamColors.text), + ), + if (widget.soon) ...[ + const SizedBox(width: NhamSpacing.sp2), + const _SoonBadge(), + ], + ], + ), + const SizedBox(height: 1), + NhamText( + widget.desc, + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + ], + ), + ), + if (widget.selected) + const Icon(LucideIcons.check, size: 20, color: NhamColors.accent), + ], + ), + ), + ); + } +} + +class _SoonBadge extends StatelessWidget { + const _SoonBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: NhamColors.hover, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'logging.modeSelector.soon'.tr(), + style: NhamTextStyles.sansMedium(fontSize: 10).copyWith( + color: NhamColors.textMuted, + letterSpacing: 0.4, + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart b/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart new file mode 100644 index 00000000..7883c14d --- /dev/null +++ b/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart @@ -0,0 +1,235 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../shared/widgets/nham_text.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import '../../dashboard/logic/dashboard_format.dart' show formatCount; +import '../data/logging_models.dart'; +import '../data/logging_providers.dart'; +import '../logic/format.dart'; +import '../logic/meal_utils.dart' show isLikelyPartialDay; + +/// A once-daily nudge shown above today's feed when *yesterday* looks +/// under-logged: it opens yesterday so the user can fold in whatever they +/// missed. Ported from the web `partial-yesterday-prompt.tsx`. +/// +/// Renders nothing when yesterday has no meals, has any meal with unknown +/// calories (the calorie-based check can't be trusted), or is above the +/// partial-day floor. Dismissal is session-scoped (see +/// [yesterdayPromptDismissedProvider]). +class PartialYesterdayPrompt extends ConsumerWidget { + const PartialYesterdayPrompt({ + super.key, + required this.userId, + required this.yesterday, + required this.calorieTarget, + required this.onOpenDay, + }); + + final String userId; + final String yesterday; + final int calorieTarget; + final ValueChanged onOpenDay; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dismissed = ref.watch(yesterdayPromptDismissedProvider(yesterday)); + final dayAsync = ref.watch( + loggingDayProvider(LoggingDayArgs(userId, yesterday)), + ); + final meals = + dayAsync.valueOrNull?.persistedMeals ?? const []; + + final hasMeals = meals.isNotEmpty; + // A meal with unknown calories makes the day's total untrustworthy, so the + // partial-day check would be misleading — suppress the prompt then. + final hasUnknownCalories = meals.any( + (m) => m.nutrition.caloriesKcal == null, + ); + final calories = round0( + meals.fold(0, (s, m) => s + (m.nutrition.caloriesKcal ?? 0)), + ); + + if (dismissed || + !hasMeals || + hasUnknownCalories || + !isLikelyPartialDay(calories.toDouble(), calorieTarget)) { + return const SizedBox.shrink(); + } + + final locale = context.locale.toString(); + final t = 'logging.feedArea.partialYesterdayPrompt'; + + return Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp3, + NhamSpacing.sp3, + NhamSpacing.sp3, + 0, + ), + child: Container( + padding: const EdgeInsets.all(NhamSpacing.sp3), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhamText( + '$t.title'.tr(), + variant: NhamTextVariant.italicAccent, + style: const TextStyle(color: NhamColors.danger), + ), + const SizedBox(height: 4), // mt-1 + NhamText( + '$t.body'.tr( + namedArgs: { + 'calories': formatCount(calories, locale), + 'target': formatCount(calorieTarget, locale), + }, + ), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + const SizedBox(height: NhamSpacing.sp3), // mt-3 + _OpenButton( + label: '$t.open'.tr(), + onTap: () => onOpenDay(yesterday), + ), + ], + ), + ), + const SizedBox(width: NhamSpacing.sp3), + _DismissButton( + label: '$t.dismiss'.tr(), + onTap: + () => + ref + .read( + yesterdayPromptDismissedProvider( + yesterday, + ).notifier, + ) + .state = true, + ), + ], + ), + ), + ); + } +} + +/// "Open yesterday" — a ghost pill (ArrowLeft + label) whose border lightens to +/// accent/50 on press, mirroring the web's hover treatment. +class _OpenButton extends StatefulWidget { + const _OpenButton({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + State<_OpenButton> createState() => _OpenButtonState(); +} + +class _OpenButtonState extends State<_OpenButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: widget.label, + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + constraints: const BoxConstraints(minHeight: 32), // min-h-8 + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 6, // py-1.5 + ), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all( + color: _pressed ? NhamColors.accent50 : NhamColors.borderSoft, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.text, + ), + const SizedBox(width: 8), // gap-2 + NhamText( + widget.label, + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ], + ), + ), + ), + ); + } +} + +/// The dismiss affordance — a 32×32 X target whose fill tints to hover on press. +class _DismissButton extends StatefulWidget { + const _DismissButton({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + State<_DismissButton> createState() => _DismissButtonState(); +} + +class _DismissButtonState extends State<_DismissButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: widget.label, + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : Colors.transparent, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.x, + size: 16, + color: _pressed ? NhamColors.text : NhamColors.textMuted, + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart index 48f58130..a97f178c 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart @@ -1,13 +1,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; import '../data/logging_models.dart'; import '../logic/format.dart'; -import 'dashed_divider.dart'; -import 'timeline_rail.dart'; /// A saved meal in the day's feed — collapsed by default, expandable. /// @@ -20,11 +21,16 @@ class PersistedMealCard extends StatefulWidget { super.key, required this.meal, this.isLast = false, + this.onRemove, }); final PersistedMeal meal; final bool isLast; + /// iOS trailing-swipe removal (terracotta, never red) — fired when the card is + /// dismissed. Null disables the swipe. + final VoidCallback? onRemove; + @override State createState() => _PersistedMealCardState(); } @@ -54,6 +60,43 @@ class _PersistedMealCardState extends State super.dispose(); } + /// Wrap the card in a trailing-swipe-to-remove Dismissible (terracotta, never + /// red) when [onRemove] is set; otherwise return the card untouched. + Widget _maybeDismissible(Widget card) { + final onRemove = widget.onRemove; + if (onRemove == null) return card; + return Dismissible( + key: ValueKey('dismiss-${widget.meal.id}'), + direction: DismissDirection.endToStart, + onDismissed: (_) { + HapticFeedback.mediumImpact(); + onRemove(); + }, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp5), + decoration: BoxDecoration( + color: NhamColors.danger, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.trash2, size: 18, color: Colors.white), + const SizedBox(width: 6), + NhamText( + 'logging.remove'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) + .copyWith(color: Colors.white), + ), + ], + ), + ), + child: card, + ); + } + @override Widget build(BuildContext context) { final meal = widget.meal; @@ -67,43 +110,45 @@ class _PersistedMealCardState extends State curve: Curves.easeInOut, ); - return TimelineRail( - isLast: widget.isLast, - child: Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), // mb-3 - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), // mb-3 + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Time label sits ABOVE the card (sibling, mb-2) — not inside it. - NhamText( - time, - variant: NhamTextVariant.timeLabel, - ), + // Time as a centered divider on top of the card (── 1:04 AM ──) — + // no left timeline gutter, so the card gets the full row width. + _TimeDivider(time: time), const SizedBox(height: NhamSpacing.sp2), // mb-2 + _maybeDismissible( _Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header — only the chevron is the toggle target. - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: NhamText( - meal.rawInput, - variant: NhamTextVariant.mealQuote, - style: const TextStyle( - fontSize: 17, - height: 28 / 17, // leading-7 (28px) + // Header — the whole row is the toggle target (not just the + // ~24px chevron), so the comfortable tap area spans the quote. + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggle, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: NhamText( + meal.rawInput, + variant: NhamTextVariant.mealQuote, + style: const TextStyle( + fontSize: 17, + height: 28 / 17, // leading-7 (28px) + ), ), ), - ), - const SizedBox(width: NhamSpacing.sp3), // gap-3 - _ChevronToggle( - expand: _expand, - onTap: _toggle, - ), - ], + const SizedBox(width: NhamSpacing.sp3), // gap-3 + _ChevronToggle( + expand: _expand, + onTap: _toggle, + ), + ], + ), ), // Collapsed summary — fades + collapses height as it expands. @@ -154,10 +199,39 @@ class _PersistedMealCardState extends State ], ), ), + ), ], ), + ); + } +} + +/// Centered time divider that sits on top of a meal card: a hairline, the time +/// (── 1:04 AM ──), and a hairline — replacing the old left-rail time label. +class _TimeDivider extends StatelessWidget { + const _TimeDivider({required this.time}); + + final String time; + + @override + Widget build(BuildContext context) { + const line = Expanded( + child: Divider( + color: NhamColors.borderFaint, + height: 1, + thickness: 1, ), ); + return Row( + children: [ + line, + Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), + child: NhamText(time, variant: NhamTextVariant.timeLabel), + ), + line, + ], + ); } } @@ -194,7 +268,7 @@ class _ChevronToggleState extends State<_ChevronToggle> { child: RotationTransition( turns: Tween(begin: 0, end: 0.5).animate(widget.expand), child: Icon( - Icons.keyboard_arrow_down, // lucide ChevronDown + LucideIcons.chevronDown, // lucide ChevronDown size: 16, color: _pressed ? NhamColors.text : NhamColors.textMuted60, ), @@ -218,7 +292,7 @@ class _ExpandedDetails extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const DashedDivider(color: NhamColors.border), + const Divider(height: 1, thickness: 1, color: NhamColors.borderFaint), const SizedBox(height: NhamSpacing.sp4), // pt-4 Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), // mb-4 @@ -240,13 +314,14 @@ class _ExpandedDetails extends StatelessWidget { const SizedBox(width: NhamSpacing.sp3), // gap-3 Row( children: [ - NhamText('P:${fmtG(group.nutrition.proteinG)}', + NhamText('P: ${fmtG(group.nutrition.proteinG)}', variant: NhamTextVariant.macroTiny), const SizedBox(width: NhamSpacing.sp2), - NhamText('C:${fmtG(group.nutrition.carbohydrateG)}', + NhamText( + 'C: ${fmtG(group.nutrition.carbohydrateG)}', variant: NhamTextVariant.macroTiny), const SizedBox(width: NhamSpacing.sp2), - NhamText('F:${fmtG(group.nutrition.fatG)}', + NhamText('F: ${fmtG(group.nutrition.fatG)}', variant: NhamTextVariant.macroTiny), const SizedBox(width: NhamSpacing.sp3), // gap-3 NhamText( @@ -261,7 +336,7 @@ class _ExpandedDetails extends StatelessWidget { ], ), ), - const DashedDivider(color: NhamColors.borderHalf), + const Divider(height: 1, thickness: 1, color: NhamColors.borderFaint), const SizedBox(height: NhamSpacing.sp3), // pt-3 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart index e5cc2db4..bdf9fb0f 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart @@ -6,11 +6,8 @@ import '../../../models/streaming.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; -import '../../../theme/nham_typography.dart'; import '../logic/format.dart'; -import 'dashed_divider.dart'; import 'entrances.dart'; -import 'timeline_rail.dart'; // Statuses that map to a localized phase label; others fall back to "Analyzing". const _phaseKeys = { @@ -30,21 +27,28 @@ String _phaseKey(StreamStatus status) => switch (status) { _ => 'logging.streaming.analyzing', }; -/// Live preview while the SSE analysis streams: completed items, then names -/// still awaiting macros (the waterfall). Ported 1:1 from -/// `apps/mobile/src/components/logging/feed/streaming-entry.tsx`. +/// The analyzing card while the SSE analysis streams. The items still stream out +/// like the original waterfall — names appear as they're detected, then carry +/// real macros once resolved — but there is a SINGLE loading state: the current +/// step (spinner + phase label) at the bottom. No skeleton placeholder bars. class StreamingEntry extends StatefulWidget { const StreamingEntry({ super.key, required this.status, required this.items, required this.completedItems, + this.rawInput, this.isLast = false, }); final StreamStatus status; final List items; final List completedItems; + + /// The user's just-typed text, shown as a Lora quote at the top of the card + /// the instant the meal is sent — so the card carries their words while it + /// analyzes (matches the web). + final String? rawInput; final bool isLast; @override @@ -52,30 +56,29 @@ class StreamingEntry extends StatefulWidget { } class _StreamingEntryState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin { // Continuous 360° spin (CSS animate-spin equivalent), 1000ms linear. late final AnimationController _spin = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), )..repeat(); - // Pulse 0.5 → 1 → 0.5 for skeleton bars / the timeline dot, 2000ms. - late final AnimationController _pulse = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2000), - )..repeat(reverse: true); - // Tailwind animate-pulse: opacity 1→.5→1 over 2s cubic-bezier(0.4,0,0.6,1). - late final Animation _pulseOpacity = Tween( - begin: 0.5, - end: 1, - ).animate( - CurvedAnimation(parent: _pulse, curve: const Cubic(0.4, 0, 0.6, 1)), - ); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Reduced motion: rest the arc instead of spinning. + if (MediaQuery.disableAnimationsOf(context)) { + _spin + ..stop() + ..value = 0; + } else if (!_spin.isAnimating) { + _spin.repeat(); + } + } @override void dispose() { _spin.dispose(); - _pulse.dispose(); super.dispose(); } @@ -83,122 +86,68 @@ class _StreamingEntryState extends State Widget build(BuildContext context) { final completedNames = widget.completedItems.map((i) => i.name.toLowerCase()).toSet(); + // Names detected but not yet carrying macros — the streaming waterfall. final pendingNames = widget.items .where((n) => !completedNames.contains(n.toLowerCase())) .toList(); + final phaseLabel = _phaseKeys.contains(widget.status) ? _phaseKey(widget.status).tr() : 'logging.streaming.analyzing'.tr(); - // Anonymous skeleton rows pad the list up to DEFAULT_SKELETON_COUNT=3 while - // the phase is still early (streaming-meal-entry.tsx:72-75). - const defaultSkeletonCount = 3; - final totalKnown = widget.completedItems.length + pendingNames.length; - final anonymousCount = (defaultSkeletonCount - totalKnown).clamp(0, 3); - final showAnonymous = widget.status != StreamStatus.assembling && - widget.status != StreamStatus.done; - - return TimelineRail( - isLast: widget.isLast, - dotChild: FadeTransition( - opacity: _pulseOpacity, - child: const _StreamingDot(), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), // mb-3 - child: _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Items block — mb-4 with 4px row gaps. - Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), - child: Column( - children: [ - for (var i = 0; i < widget.completedItems.length; i++) - FadeInLeft( - key: ValueKey(widget.completedItems[i].id), - offset: 8, - delay: Duration(milliseconds: i * 40), - child: _CompletedRow(item: widget.completedItems[i]), - ), - for (var i = 0; i < pendingNames.length; i++) - FadeInLeft( - key: ValueKey('${pendingNames[i]}-$i'), - offset: 8, - delay: Duration( - milliseconds: - (widget.completedItems.length + i) * 80), - child: _PendingRow( - name: pendingNames[i], - pulse: _pulseOpacity, - ), - ), - if (showAnonymous) - for (var i = 0; i < anonymousCount; i++) - FadeInLeft( - key: ValueKey('skel-$i'), - offset: 8, - delay: Duration( - milliseconds: (totalKnown + i) * 80), - child: _AnonymousRow( - // name-bar width 80 + index*20px. - nameWidth: 80 + (totalKnown + i) * 20, - pulse: _pulseOpacity, - ), - ), - ], - ), - ), + final hasQuote = + widget.rawInput != null && widget.rawInput!.trim().isNotEmpty; + final hasItems = + widget.completedItems.isNotEmpty || pendingNames.isNotEmpty; - // Totals skeleton. - const DashedDivider(color: NhamColors.borderHalf), - const SizedBox(height: NhamSpacing.sp3), // pt-3 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - NhamText( - 'logging.mealEntry.total'.tr(), - variant: NhamTextVariant.itemName, - style: NhamTextStyles.sansBold(fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.text), - ), - Row( - children: [ - _Skeleton(width: 40, height: 14, pulse: _pulseOpacity), - const SizedBox(width: NhamSpacing.sp2), - _Skeleton(width: 40, height: 14, pulse: _pulseOpacity), - const SizedBox(width: NhamSpacing.sp2), - _Skeleton(width: 40, height: 14, pulse: _pulseOpacity), - const SizedBox(width: NhamSpacing.sp4), // gap-4 - _Skeleton( - width: 56, - height: 16, - pulse: _pulseOpacity, - strong: true, - ), - ], - ), - ], - ), - - // Stage indicator — spinner + phase label. - const SizedBox(height: NhamSpacing.sp3), // mt-3 - Row( - children: [ - RotationTransition(turns: _spin, child: const _Spinner()), - const SizedBox(width: NhamSpacing.sp2), // gap-2 - NhamText(phaseLabel, variant: NhamTextVariant.phaseLabel), - ], + return Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), // mb-3 + child: _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The typed text as a Lora quote — shown instantly. + if (hasQuote) ...[ + NhamText( + widget.rawInput!, + variant: NhamTextVariant.mealQuote, + style: const TextStyle(fontSize: 17, height: 28 / 17), ), + const SizedBox(height: NhamSpacing.sp2), ], - ), + // Items stream out: resolved rows first, then detected names. + if (hasItems) ...[ + for (var i = 0; i < widget.completedItems.length; i++) + FadeInLeft( + key: ValueKey(widget.completedItems[i].id), + offset: 8, + delay: Duration(milliseconds: i * 40), + child: _CompletedRow(item: widget.completedItems[i]), + ), + for (var i = 0; i < pendingNames.length; i++) + FadeInLeft( + key: ValueKey('${pendingNames[i]}-$i'), + offset: 8, + child: _PendingNameRow(name: pendingNames[i]), + ), + const SizedBox(height: NhamSpacing.sp1), + ], + // The ONE loading state: the current step. + Row( + children: [ + RotationTransition(turns: _spin, child: const _Spinner()), + const SizedBox(width: NhamSpacing.sp2), // gap-2 + NhamText(phaseLabel, variant: NhamTextVariant.phaseLabel), + ], + ), + ], ), ), ); } } +/// A resolved item: name + real P/C/F + calories. class _CompletedRow extends StatelessWidget { const _CompletedRow({required this.item}); final MealItem item; @@ -206,7 +155,7 @@ class _CompletedRow extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), // py-2.5 + padding: const EdgeInsets.symmetric(vertical: 8), // py-2 child: Row( children: [ Expanded( @@ -235,103 +184,21 @@ class _CompletedRow extends StatelessWidget { } } -class _PendingRow extends StatelessWidget { - const _PendingRow({required this.name, required this.pulse}); +/// A detected-but-unresolved item: just the name, muted (its macros are still +/// streaming). No skeleton bars — the bottom step line is the only loading cue. +class _PendingNameRow extends StatelessWidget { + const _PendingNameRow({required this.name}); final String name; - final Animation pulse; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), // py-2.5 - child: Row( - children: [ - Expanded( - child: NhamText(name, - variant: NhamTextVariant.itemName, maxLines: 1), - ), - const SizedBox(width: NhamSpacing.sp3), - Row( - children: [ - _Skeleton(width: 36, height: 12, pulse: pulse), - const SizedBox(width: NhamSpacing.sp2), - _Skeleton(width: 36, height: 12, pulse: pulse), - const SizedBox(width: NhamSpacing.sp2), - _Skeleton(width: 36, height: 12, pulse: pulse), - const SizedBox(width: NhamSpacing.sp3), // gap-3 - _Skeleton(width: 48, height: 14, pulse: pulse, strong: true), - ], - ), - ], - ), - ); - } -} - -/// An anonymous (un-named) skeleton row: a name-placeholder bar instead of a -/// real name, plus the macro/calorie skeleton bars (meal-entry-item-skeleton). -class _AnonymousRow extends StatelessWidget { - const _AnonymousRow({required this.nameWidth, required this.pulse}); - final double nameWidth; - final Animation pulse; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), // py-2.5 - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Name placeholder bar: h-3.5 border/40, width 80+index*20. - _Skeleton( - width: nameWidth, - height: 14, - pulse: pulse, - strong: true, - ), - const SizedBox(width: NhamSpacing.sp3), - Row( - children: [ - _Skeleton(width: 24, height: 12, pulse: pulse), // w-6 border/30 - const SizedBox(width: NhamSpacing.sp2), - _Skeleton(width: 24, height: 12, pulse: pulse), - const SizedBox(width: NhamSpacing.sp2), - _Skeleton(width: 24, height: 12, pulse: pulse), - const SizedBox(width: NhamSpacing.sp3), // gap-3 - _Skeleton(width: 48, height: 14, pulse: pulse, strong: true), - ], - ), - ], - ), - ); - } -} - -/// A pulsing skeleton bar. `strong` uses the border/40 fill, else border/30. -class _Skeleton extends StatelessWidget { - const _Skeleton({ - required this.width, - required this.height, - required this.pulse, - this.strong = false, - }); - - final double width; - final double height; - final Animation pulse; - final bool strong; - - @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: pulse, - child: Container( - width: width, - height: height, - decoration: BoxDecoration( - color: strong ? NhamColors.borderBiscotti40 : NhamColors.borderFaint, - borderRadius: BorderRadius.circular(NhamRadii.sm), // rounded-sm - ), + padding: const EdgeInsets.symmetric(vertical: 8), // py-2 + child: NhamText( + name, + variant: NhamTextVariant.itemName, + maxLines: 1, + style: const TextStyle(color: NhamColors.textMuted), ), ); } @@ -390,23 +257,3 @@ class _Card extends StatelessWidget { ); } } - -/// The streaming timeline dot: h-2 w-2, 2px accent border, accent/30 FILL, -/// animate-pulse (streaming-meal-entry.tsx:93). Differs from the white-fill -/// persisted dot. -class _StreamingDot extends StatelessWidget { - const _StreamingDot(); - - @override - Widget build(BuildContext context) { - return Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.accent30, // accent @30% fill - border: Border.all(color: NhamColors.accent, width: 2), - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart index dc9b9cad..6df5fa43 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart @@ -1,99 +1,304 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; import '../../../shared/widgets/nham_text.dart'; +import '../../../shell/app_header.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../logic/timeline_utils.dart'; -/// Chip (collapsed) vs. week-strip (expanded) date picker. The expanded/chip -/// state is lifted to the parent so the header can hand the strip the full row. -/// -/// Ported 1:1 from -/// `apps/mobile/src/components/logging/input/timeline-picker.tsx`. -class TimelinePicker extends StatefulWidget { - const TimelinePicker({ +/// The collapsed date pill that lives in the app header. Tapping it asks the +/// parent to open the [TimelineStrip] as a dropdown overlay — the chip itself +/// never changes size, so the feed below never shifts. +class TimelineChip extends StatefulWidget { + const TimelineChip({ + super.key, + required this.dates, + required this.selectedDate, + required this.onTap, + }); + + final List dates; + final String selectedDate; + final VoidCallback onTap; + + @override + State createState() => _TimelineChipState(); +} + +class _TimelineChipState extends State { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + final hasMeal = widget.dates.contains(widget.selectedDate); + final formatted = formatTimelineDayLabel(widget.selectedDate, locale); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.96 : 1, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Container( + height: 44, + constraints: const BoxConstraints(maxWidth: 288), // max-w-72 + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp4), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all(color: NhamColors.borderHalf), // border/50 + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.calendar, + size: 14, color: NhamColors.accent), + const SizedBox(width: NhamSpacing.sp2), // gap-2 + Flexible( + child: NhamText( + formatted, + variant: NhamTextVariant.chipText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: NhamColors.text), + ), + ), + if (hasMeal) ...[ + const SizedBox(width: NhamSpacing.sp2), + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: NhamColors.accent, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Morphs the date chip IN PLACE into the week strip (and back), at a fixed +/// height so the feed below never shifts. One controller drives a buttery +/// cross-dissolve (chip fades/shrinks out as the strip fades/scales in) on a +/// single easeOutCubic curve. Tapping the chip expands; the parent collapses on +/// any outside tap (the scrim) or a day selection. +class DateMorph extends StatefulWidget { + const DateMorph({ super.key, required this.dates, required this.today, required this.selectedDate, - required this.onSelectDate, required this.expanded, - required this.onExpandedChange, + required this.onSelectDate, + required this.onExpand, + required this.onCollapse, }); final List dates; final String today; final String selectedDate; - final ValueChanged onSelectDate; final bool expanded; - final ValueChanged onExpandedChange; + final ValueChanged onSelectDate; + final VoidCallback onExpand; + final VoidCallback onCollapse; @override - State createState() => _TimelinePickerState(); + State createState() => _DateMorphState(); } -// Swipe cooldown to mirror the web (mobile-timeline-picker.tsx:32). The 40px -// threshold is satisfied by Flutter's drag-end velocity gesture. -const int _kSwipeCooldownMs = 250; +class _DateMorphState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 340), + value: widget.expanded ? 1 : 0, + ); + + @override + void didUpdateWidget(DateMorph old) { + super.didUpdateWidget(old); + if (widget.expanded != old.expanded) { + if (widget.expanded) { + _c.forward(); + } else { + _c.reverse(); + } + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } -class _TimelinePickerState extends State { + @override + Widget build(BuildContext context) { + // Fixed height = the expanded strip's height, so morphing never pushes the + // feed; the collapsed chip just centers in it. + return SizedBox( + height: 56, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_c.value); + final chipOpacity = (1 - t * 1.6).clamp(0.0, 1.0); + final stripOpacity = ((t - 0.25) / 0.75).clamp(0.0, 1.0); + return Stack( + alignment: Alignment.center, + children: [ + // Collapsed layer: hamburger + centered chip + mirror spacer. + // The hamburger lives here, so it fades out as the strip expands. + if (t < 1) + Opacity( + opacity: chipOpacity, + child: IgnorePointer( + ignoring: widget.expanded, + child: Row( + children: [ + const AppMenuButton(), + Expanded( + child: Center( + child: Transform.scale( + scale: 1 - t * 0.04, // shrink as it hands off + child: TimelineChip( + dates: widget.dates, + selectedDate: widget.selectedDate, + onTap: widget.onExpand, + ), + ), + ), + ), + const SizedBox(width: 44, height: 44), + ], + ), + ), + ), + // Expanded layer: the week strip, FULL header width. + if (t > 0) + Opacity( + opacity: stripOpacity, + child: IgnorePointer( + ignoring: !widget.expanded, + child: Transform.scale( + scale: 0.96 + 0.04 * t, // settle in from 0.96 → 1 + child: TimelineStrip( + dates: widget.dates, + today: widget.today, + selectedDate: widget.selectedDate, + onSelectDate: widget.onSelectDate, + onClose: widget.onCollapse, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +// A large midpoint so the PageView can page many weeks into the past while the +// current week sits at a known index (weeks are unbounded backwards). +const int _kWeekPageBase = 5000; + +/// The expanded week strip, shown as a dropdown overlay below the header. Real +/// week paging via [PageView] (swipe + chevrons). Selecting a day reports it and +/// asks the parent to close. +class TimelineStrip extends StatefulWidget { + const TimelineStrip({ + super.key, + required this.dates, + required this.today, + required this.selectedDate, + required this.onSelectDate, + required this.onClose, + }); + + final List dates; + final String today; + final String selectedDate; + final ValueChanged onSelectDate; + final VoidCallback onClose; + + @override + State createState() => _TimelineStripState(); +} + +class _TimelineStripState extends State { late String _visibleAnchor = _selectedAnchor; - // Suppresses the day-tap that immediately follows a horizontal swipe - // (mirrors didSwipeRef in mobile-timeline-picker.tsx). - bool _didSwipe = false; - int _lastSwipeAtMs = 0; + late final PageController _pageController = + PageController(initialPage: _pageForAnchor(_selectedAnchor)); String get _currentAnchor => widget.today; - String get _selectedAnchor => widget.selectedDate.compareTo(_currentAnchor) > 0 - ? _currentAnchor - : widget.selectedDate; + String get _selectedAnchor => + widget.selectedDate.compareTo(_currentAnchor) > 0 + ? _currentAnchor + : widget.selectedDate; bool get _canNavigateNext => _visibleAnchor.compareTo(_currentAnchor) < 0; - void _openStrip() { - setState(() => _visibleAnchor = _selectedAnchor); - widget.onExpandedChange(true); + String _anchorForPage(int page) => + addDays(_currentAnchor, (page - _kWeekPageBase) * 7); + + int _pageForAnchor(String anchor) { + final base = dateStringToDate(_currentAnchor); + final target = dateStringToDate(anchor); + final weeks = (target.difference(base).inDays / 7).round(); + return _kWeekPageBase + weeks; + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); } void _selectDay(String date) { - if (_didSwipe) { - _didSwipe = false; - return; + if (date != widget.selectedDate) { + HapticFeedback.selectionClick(); + widget.onSelectDate(date); } - if (date != widget.selectedDate) widget.onSelectDate(date); - widget.onExpandedChange(false); + widget.onClose(); } - void _navigateToAnchor(String anchor) { - final next = - anchor.compareTo(_currentAnchor) > 0 ? _currentAnchor : anchor; - if (next != _visibleAnchor) setState(() => _visibleAnchor = next); + void _onPageChanged(int page) { + final anchor = _anchorForPage(page); + if (anchor != _visibleAnchor) { + HapticFeedback.selectionClick(); + setState(() => _visibleAnchor = anchor); + } } - void _scrollPrev() => _navigateToAnchor(addDays(_visibleAnchor, -7)); + void _scrollPrev() { + _pageController.previousPage( + duration: const Duration(milliseconds: 280), + curve: const Cubic(0.16, 1, 0.3, 1), + ); + } void _scrollNext() { if (!_canNavigateNext) return; - _navigateToAnchor(addDays(_visibleAnchor, 7)); - } - - // Horizontal-swipe paging with a 40px threshold + 250ms cooldown, mirroring - // handlePointerUp in mobile-timeline-picker.tsx:294-316. A swipe also - // suppresses the subsequent day-tap via [_didSwipe]. - void _onSwipeEnd(DragEndDetails details) { - final dx = details.primaryVelocity ?? 0; - if (dx == 0) return; - final now = DateTime.now().millisecondsSinceEpoch; - if (now - _lastSwipeAtMs < _kSwipeCooldownMs) return; - _lastSwipeAtMs = now; - _didSwipe = true; - if (dx > 0) { - _scrollPrev(); - } else { - _scrollNext(); - } + _pageController.nextPage( + duration: const Duration(milliseconds: 280), + curve: const Cubic(0.16, 1, 0.3, 1), + ); } @override @@ -101,121 +306,44 @@ class _TimelinePickerState extends State { final locale = context.locale.toString(); final mealDates = widget.dates.toSet(); - // The container morphs between the chip pill and the full-width strip, - // mirroring the framer `layout` morph (motion.div, duration 0.28s, - // cubic-bezier(0.16,1,0.3,1)) in mobile-timeline-picker.tsx:347-356. - return LayoutBuilder( - builder: (context, constraints) { - final expanded = widget.expanded; - return AnimatedContainer( - duration: const Duration(milliseconds: 280), - curve: const Cubic(0.16, 1, 0.3, 1), - // Chip is a single row (44px); the expanded strip stacks - // weekday + number + dot per cell and needs more height, else the - // day cells overflow the 44px clamp (BOTTOM OVERFLOWED stripe). - height: expanded ? 56 : 44, - // chip: max-w-72 pill; strip: full width. - width: expanded ? constraints.maxWidth : null, - constraints: expanded - ? null - : const BoxConstraints(maxWidth: 288), // max-w-72 - padding: expanded - ? EdgeInsets.zero - : const EdgeInsets.symmetric(horizontal: NhamSpacing.sp4), - decoration: BoxDecoration( - color: expanded ? Colors.transparent : NhamColors.surface, - borderRadius: - BorderRadius.circular(expanded ? 0 : NhamRadii.pill), - border: expanded - ? const Border.fromBorderSide(BorderSide.none) - : Border.all(color: NhamColors.borderHalf), // border/50 - ), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - child: expanded - ? _buildStrip(locale, mealDates) - : _buildChipContent(locale, mealDates), - ), - ); - }, - ); - } - - Widget _buildChipContent(String locale, Set mealDates) { - final hasMeal = mealDates.contains(widget.selectedDate); - final formatted = formatTimelineDayLabel(widget.selectedDate, locale); - return _ChipButton( - key: const ValueKey('chip-content'), - label: formatted, - hasMeal: hasMeal, - onTap: _openStrip, - ); - } - - Widget _buildStrip(String locale, Set mealDates) { - final weekStrips = [ - addDays(_visibleAnchor, -7), - _visibleAnchor, - addDays(_visibleAnchor, 7), - ].map(buildCenteredStripFromAnchor).toList(); - - return Row( - key: const ValueKey('strip-content'), - children: [ - _NavButton( - icon: Icons.chevron_left, // lucide ChevronLeft - onTap: _scrollPrev, - color: NhamColors.textMuted, - ), - const SizedBox(width: 4), // gap-1 - // Three-week carousel: prev/visible/next rendered in a row offset - // translateX(-100%), paging slides 200ms ease-out - // (mobile-timeline-picker.tsx:410-452). - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragEnd: _onSwipeEnd, - child: ClipRect( - child: LayoutBuilder( - builder: (context, c) { - final weekWidth = c.maxWidth; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - // Base offset translateX(-100%): visible week centered. - transform: Matrix4.translationValues(-weekWidth, 0, 0), - width: weekWidth * 3, - child: Row( - children: [ - for (final week in weekStrips) - SizedBox( - width: weekWidth, - child: _WeekRow( - week: week, - today: widget.today, - selectedDate: widget.selectedDate, - mealDates: mealDates, - locale: locale, - onSelect: _selectDay, - ), - ), - ], - ), - ); - }, - ), + return SizedBox( + height: 56, + child: Row( + children: [ + _NavButton( + icon: LucideIcons.chevronLeft, + onTap: _scrollPrev, + color: NhamColors.textMuted, + ), + const SizedBox(width: 4), // gap-1 + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _kWeekPageBase + 1, + itemBuilder: (context, page) { + final week = buildCenteredStripFromAnchor(_anchorForPage(page)); + return _WeekRow( + week: week, + today: widget.today, + selectedDate: widget.selectedDate, + mealDates: mealDates, + locale: locale, + onSelect: _selectDay, + ); + }, ), ), - ), - const SizedBox(width: 4), - _NavButton( - icon: Icons.chevron_right, // lucide ChevronRight - onTap: _canNavigateNext ? _scrollNext : null, - color: _canNavigateNext - ? NhamColors.textMuted - : const Color(0x4D8B7355), // text-nham-text-muted/30 - ), - ], + const SizedBox(width: 4), + _NavButton( + icon: LucideIcons.chevronRight, + onTap: _canNavigateNext ? _scrollNext : null, + color: _canNavigateNext + ? NhamColors.textMuted + : const Color(0x4D8B7355), // text-nham-text-muted/30 + ), + ], + ), ); } } @@ -259,66 +387,6 @@ class _WeekRow extends StatelessWidget { } } -class _ChipButton extends StatefulWidget { - const _ChipButton({ - super.key, - required this.label, - required this.hasMeal, - required this.onTap, - }); - - final String label; - final bool hasMeal; - final VoidCallback onTap; - - @override - State<_ChipButton> createState() => _ChipButtonState(); -} - -class _ChipButtonState extends State<_ChipButton> { - @override - Widget build(BuildContext context) { - // The pill chrome (bg/border/radius/padding/maxWidth) is owned by the - // morphing AnimatedContainer in the parent; this is only the chip's - // content + tap target. - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onTap, - child: SizedBox( - height: 44, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.calendar_today_outlined, - size: 14, color: NhamColors.accent), // lucide Calendar - const SizedBox(width: NhamSpacing.sp2), // gap-2 - Flexible( - child: NhamText( - widget.label, - variant: NhamTextVariant.chipText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: NhamColors.text), - ), - ), - if (widget.hasMeal) ...[ - const SizedBox(width: NhamSpacing.sp2), - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.accent, - ), - ), - ], - ], - ), - ), - ); - } -} - class _NavButton extends StatefulWidget { const _NavButton({required this.icon, required this.color, this.onTap}); @@ -428,8 +496,6 @@ class _DayCellState extends State<_DayCell> { ); } - // Web: future days are NOT dimmed/disabled — only the meal dot becomes an - // empty 6x6 spacer (handled above). The cell stays full-opacity + tappable. return GestureDetector( onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), @@ -449,31 +515,31 @@ class _DayCellState extends State<_DayCell> { color: bg, borderRadius: BorderRadius.circular(14.4), // rounded-[0.9rem] ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - NhamText( - dayName, - variant: NhamTextVariant.macroLabel, - style: NhamTextStyles.sansSemiBold( - fontSize: NhamFontSize.eyebrow, - ).copyWith( - letterSpacing: NhamTracking.tight, - color: labelColor, - ), - ), - const SizedBox(height: 2), // gap-0.5 - NhamText( - dayNum, - variant: NhamTextVariant.numInline, - style: TextStyle(fontSize: 13, color: labelColor), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NhamText( + dayName, + variant: NhamTextVariant.macroLabel, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.eyebrow, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: labelColor, ), - const SizedBox(height: 2), - dot, - ], - ), + ), + const SizedBox(height: 2), // gap-0.5 + NhamText( + dayNum, + variant: NhamTextVariant.numInline, + style: TextStyle(fontSize: 13, color: labelColor), + ), + const SizedBox(height: 2), + dot, + ], ), ), - ); + ), + ); } } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/timeline_rail.dart b/apps/mobile-flutter/lib/features/logging/widgets/timeline_rail.dart deleted file mode 100644 index 22d3da8b..00000000 --- a/apps/mobile-flutter/lib/features/logging/widgets/timeline_rail.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme/nham_colors.dart'; - -/// The shared feed timeline decoration drawn in the left gutter beside each -/// card: a vertical hairline (hidden on the last item) and an accent ring dot. -/// -/// On the web mobile view the rail sits at `-left-4` (16px, line) and -/// `-left-5` (20px, dot) relative to the card, inside the feed's `pl-6` (24px) -/// left gutter (the `-left-10`/`-left-[43px]` values are the sm: desktop -/// positions). We reproduce the mobile pixel offsets with [Positioned] children -/// in a [Stack] that the card sits inside. -class TimelineRail extends StatelessWidget { - const TimelineRail({ - super.key, - required this.child, - this.isLast = false, - this.dotChild, - }); - - final Widget child; - final bool isLast; - - /// Optional override for the dot (the streaming entry pulses it). - final Widget? dotChild; - - @override - Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - // Vertical line: top 8 (top-2), bottom 0, width 1px, border/60. - if (!isLast) - const Positioned( - top: 8, - bottom: 0, - left: -16, // mobile -left-4 - child: SizedBox( - width: 1, - child: ColoredBox(color: NhamColors.borderSoft), - ), - ), - // Accent ring dot: top 8, h-2 w-2, 2px accent border, white fill. - Positioned( - top: 8, - left: -20, // mobile -left-5 - child: dotChild ?? const _Dot(), - ), - child, - ], - ); - } -} - -class _Dot extends StatelessWidget { - const _Dot(); - - @override - Widget build(BuildContext context) { - return Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.elev, - border: Border.all(color: NhamColors.accent, width: 2), - ), - ); - } -} - -/// The accent dot styled for re-use (e.g. the streaming entry's pulsing dot). -class TimelineDot extends StatelessWidget { - const TimelineDot({super.key}); - - @override - Widget build(BuildContext context) => const _Dot(); -} diff --git a/apps/mobile-flutter/lib/features/nutrition/logic/candidate_nutrients.dart b/apps/mobile-flutter/lib/features/nutrition/logic/candidate_nutrients.dart deleted file mode 100644 index a0d49026..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/logic/candidate_nutrients.dart +++ /dev/null @@ -1,29 +0,0 @@ -/// The supported food-source-candidate nutrients, vendored as consts from web -/// `lib/nutrition/catalog/nutrients.ts` (keep in sync). Only the candidate -/// gating is needed on mobile. -/// -/// Ported from `apps/mobile/src/lib/nutrition/logic/candidate-nutrients.ts`. -library; - -import '../../../models/nutrition.dart'; - -/// The supported candidate nutrient enum names, as raw strings (matching the -/// REST contract's `SupportedCandidateNutrient`). -const List kSupportedCandidateNutrients = [ - 'calciumMg', - 'ironMg', - 'vitaminCMg', - 'phosphorusMg', - 'vitaminB1Mg', - 'vitaminB2Mg', - 'vitaminPpMg', - 'vitaminAMcg', -]; - -final Set _supportedSet = kSupportedCandidateNutrients.toSet(); - -/// Narrows a nutrient key to a supported candidate nutrient string, or null. -String? asSupported(NutritionNutrientKey nutrient) { - final name = nutrient.name; - return _supportedSet.contains(name) ? name : null; -} diff --git a/apps/mobile-flutter/lib/features/nutrition/logic/status.dart b/apps/mobile-flutter/lib/features/nutrition/logic/status.dart index 031fd5b4..f9fa219d 100644 --- a/apps/mobile-flutter/lib/features/nutrition/logic/status.dart +++ b/apps/mobile-flutter/lib/features/nutrition/logic/status.dart @@ -50,11 +50,11 @@ StatusKey statusKeyFor(NutrientCardData card) { return StatusKey.far; } -/// Whether the steady-detail panel shows food-source chips: supported nutrient, -/// decent confidence, and meaningfully below target (<90%). +/// Whether to surface food-source chips for a nutrient: decent confidence and +/// meaningfully below target (<90%). Every card nutrient has DB-derived +/// suggestions, so there's no per-nutrient support gate. bool showChips(NutrientCardData card) { - return card.supportsCandidates && - card.confidence >= 40 && + return card.confidence >= 40 && card.percentOfTarget != null && card.percentOfTarget! < 90; } diff --git a/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart b/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart index 00c781f3..058c7311 100644 --- a/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart +++ b/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart @@ -39,13 +39,3 @@ String joinNames(List names, String locale) { final head = names.sublist(0, names.length - 1).join(', '); return '$head, $conj ${names.last}'; } - -double? getAverageConfidence(NutritionOverview overview) { - final items = [ - ...overview.summary.mostConsistent, - ...overview.summary.needsAttention, - ]; - if (items.isEmpty) return null; - return items.fold(0, (total, item) => total + item.confidence) / - items.length; -} diff --git a/apps/mobile-flutter/lib/features/nutrition/providers/candidates_response.dart b/apps/mobile-flutter/lib/features/nutrition/providers/candidates_response.dart index 4565a1b7..48f482bb 100644 --- a/apps/mobile-flutter/lib/features/nutrition/providers/candidates_response.dart +++ b/apps/mobile-flutter/lib/features/nutrition/providers/candidates_response.dart @@ -1,49 +1,44 @@ -/// Render-ready food-source candidate models for the nutrition surface. +/// Food suggestions for a nutrient, derived from the food-composition table. /// -/// Ported from `lib/api/contracts/nutrition.ts` (`CandidatesResponse`, -/// `FoodSourceCandidate`). i18n-key-only — every user-facing string is a -/// translation key resolved at render time. +/// Ported from the web `CandidatesResponse` contract: each food carries its +/// Vietnamese + English name and per-100g amount of the requested nutrient. library; -class FoodSourceCandidate { - final String nutrient; +class FoodCandidate { final String id; - final String nameKey; - final String servingKey; - final String rationaleKey; - final String? cautionKey; + final String name; // Vietnamese (namePrimary) + final String nameEn; + final double amount; // per-100g of the nutrient + final String unit; - const FoodSourceCandidate({ - required this.nutrient, + const FoodCandidate({ required this.id, - required this.nameKey, - required this.servingKey, - required this.rationaleKey, - this.cautionKey, + required this.name, + required this.nameEn, + required this.amount, + required this.unit, }); - factory FoodSourceCandidate.fromJson(Map json) => - FoodSourceCandidate( - nutrient: json['nutrient'] as String, + factory FoodCandidate.fromJson(Map json) => FoodCandidate( id: json['id'] as String, - nameKey: json['nameKey'] as String, - servingKey: json['servingKey'] as String, - rationaleKey: json['rationaleKey'] as String, - cautionKey: json['cautionKey'] as String?, + name: json['name'] as String, + nameEn: json['nameEn'] as String, + amount: (json['amount'] as num?)?.toDouble() ?? 0, + unit: json['unit'] as String, ); } class CandidatesResponse { final String nutrient; - final List candidates; + final List foods; - const CandidatesResponse({required this.nutrient, required this.candidates}); + const CandidatesResponse({required this.nutrient, required this.foods}); factory CandidatesResponse.fromJson(Map json) => CandidatesResponse( nutrient: json['nutrient'] as String, - candidates: (json['candidates'] as List) - .map((e) => FoodSourceCandidate.fromJson(e as Map)) + foods: (json['foods'] as List) + .map((e) => FoodCandidate.fromJson(e as Map)) .toList(), ); } diff --git a/apps/mobile-flutter/lib/features/nutrition/providers/food_candidates_provider.dart b/apps/mobile-flutter/lib/features/nutrition/providers/food_candidates_provider.dart index ac22bcb2..7b5a2c57 100644 --- a/apps/mobile-flutter/lib/features/nutrition/providers/food_candidates_provider.dart +++ b/apps/mobile-flutter/lib/features/nutrition/providers/food_candidates_provider.dart @@ -1,41 +1,27 @@ -/// Riverpod port of the RN `useFoodCandidates` hook -/// (`apps/mobile/src/lib/nutrition/hooks/use-food-candidates.ts`). +/// Food suggestions for a nutrient, keyed by the nutrient. /// -/// Mirrors the web `FoodChipRow` query: key `['nutrition','candidates', -/// supported]`, `retry:false`, disabled when the nutrient is unsupported. -/// -/// The catalog is STATIC per nutrient but the screen fans out one query per -/// spotlight/detail card, so each nutrient is fetched at most once per session -/// (`staleTime`/`gcTime: Infinity`). We replicate that with `ref.keepAlive()` -/// so the family entry never auto-disposes and is never refetched. +/// Now DB-derived (top foods by per-100g density) and available for ANY card +/// nutrient — no fixed supported set. Each nutrient is fetched at most once per +/// session (`ref.keepAlive()`), returning a pool the sheet pages through, so the +/// "don't have these?" refresh is instant (no extra round-trip). library; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../data/api_client.dart'; import '../../../models/nutrition.dart'; -import '../logic/candidate_nutrients.dart'; import 'candidates_response.dart'; -/// Family keyed by the nutrient. Returns `null` when the nutrient is not a -/// supported candidate (parity with the RN `enabled: supported !== null` gate — -/// the widget renders nothing in that case). -final foodCandidatesProvider = FutureProvider.family((ref, nutrient) async { - final supported = asSupported(nutrient); - if (supported == null) { - // Disabled query — never fetches (RN `enabled: false`). - return null; - } - - // gcTime: Infinity — keep the resolved catalog for the whole session. +final foodCandidatesProvider = + FutureProvider.family( + (ref, nutrient) async { + // Keep the resolved pool for the whole session (gcTime: Infinity). ref.keepAlive(); final api = ref.read(apiClientProvider); - // retry:false in RN — surface failures immediately. final json = await api.post>( '/api/v1/nutrition/candidates', - {'nutrient': supported}, + {'nutrient': nutrient.name}, ); return CandidatesResponse.fromJson(json); }); diff --git a/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart b/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart index e7baf4c3..ca19464f 100644 --- a/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart +++ b/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart @@ -11,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../data/api_client.dart'; import '../../../data/query.dart'; +import '../../../data/session_provider.dart'; import '../../../models/nutrition.dart'; /// Raw `getTimezoneOffset()` parity: JS returns minutes POSITIVE west of UTC. @@ -24,28 +25,34 @@ int nutritionTimezoneOffset() => -DateTime.now().timeZoneOffset.inMinutes; /// the new provider instance keeps the previously-resolved overview as its /// initial value so the layout doesn't collapse to a skeleton mid-refetch. final nutritionOverviewProvider = AsyncNotifierProvider.family< - NutritionOverviewNotifier, NutritionOverview, NutritionRangeInput>( - NutritionOverviewNotifier.new, -); + NutritionOverviewNotifier, + NutritionOverview, + NutritionRangeInput +>(NutritionOverviewNotifier.new); /// Holds the last successful overview across the family so a range switch can -/// seed the next instance (the RN `keepPreviousData` behavior). Riverpod -/// rebuilds a fresh notifier per family key, so we stash the previous value -/// here at the Provider scope. -NutritionOverview? _lastOverview; +/// seed the next instance (the RN `keepPreviousData` behavior). It is keyed by +/// user + timezone so an account switch can never seed another user's nutrition +/// pattern into the placeholder state. +final Map _lastOverviewByAccount = {}; + +String _overviewCacheKey(String? userId) => + '${userId ?? 'signed-out'}:${nutritionTimezoneOffset()}'; class NutritionOverviewNotifier extends FamilyAsyncNotifier { @override Future build(NutritionRangeInput arg) async { - final previous = _lastOverview; + final userId = ref.watch(currentSessionProvider)?.user.id; + final cacheKey = _overviewCacheKey(userId); + final previous = _lastOverviewByAccount[cacheKey]; if (previous != null) { // Seed with the prior overview so consumers keep rendering the editorial // stack while the new range loads (placeholderData: keepPreviousData). state = AsyncData(previous); } final overview = await _fetch(arg); - _lastOverview = overview; + _lastOverviewByAccount[cacheKey] = overview; return overview; } @@ -60,15 +67,34 @@ class NutritionOverviewNotifier } /// Refetch the current range — mirrors `query.refetch()`. Keeps the existing - /// data visible (sets `isFetching` semantics via `AsyncValue.isLoading` on a - /// guarded refresh that preserves the previous value). - Future refetch() async { + /// data visible while loading, and — critically — on FAILURE retains the + /// previous overview underneath the error (`copyWithPrevious`) so a flaky + /// refetch doesn't blank out the editorial stack the user is reading. The + /// screen reads `hasValue` to keep rendering content and surfaces the failure + /// as a toast instead. + /// + /// Returns true on success, false on failure (so the caller can toast). + Future refetch() async { final range = arg; - state = await AsyncValue.guard(() async { + final previous = state; + // Show the in-flight (isLoading) state over the current value. + state = const AsyncValue.loading().copyWithPrevious( + previous, + ); + try { final overview = await _fetch(range); - _lastOverview = overview; - return overview; - }); + final userId = ref.read(currentSessionProvider)?.user.id; + _lastOverviewByAccount[_overviewCacheKey(userId)] = overview; + state = AsyncData(overview); + return true; + } catch (error, stack) { + // Retain the previous data under the error. + state = AsyncError( + error, + stack, + ).copyWithPrevious(previous); + return false; + } } } diff --git a/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart b/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart index 3047738b..8747cedb 100644 --- a/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart +++ b/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart @@ -1,32 +1,33 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../data/session_provider.dart'; +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; import '../../../models/nutrition.dart'; import '../../../shared/widgets/nham_primitives.dart'; import '../../../shared/widgets/nham_text.dart'; +import '../../../shared/widgets/top_toast.dart'; import '../../../shell/app_header.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; import '../providers/nutrition_overview_provider.dart'; -import '../widgets/background_section.dart'; -import '../widgets/daily_rhythm.dart'; -import '../widgets/editorial_header.dart'; +import '../widgets/day_summary.dart'; import '../widgets/empty_state.dart'; -import '../widgets/focus_section.dart'; import '../widgets/inline_error.dart'; +import '../widgets/nutrient_grid_card.dart'; import '../widgets/nutrition_skeleton.dart'; -import '../widgets/pull_quote.dart'; -import '../widgets/steady_section.dart'; -import '../widgets/verdict_hero.dart'; - -/// Nutrition screen — mobile port of the web `NutritionShell` -/// (`apps/mobile/src/app/(app)/(tabs)/nutrition.tsx`). A purely client-query -/// editorial stack: header (with interactive 7d/30d/90d range toggle + verdict) -/// → calorie rhythm card → focus spotlights → steady nutrient list → "other -/// nutrients" toggle → vitamin-D pull-quote. +import '../widgets/range_selector.dart'; +import '../widgets/source_attribution.dart'; +import '../widgets/suggested_foods_sheet.dart'; + +/// Nutrition screen — a dense, single-view micronutrient overview. A compact +/// calorie/macro summary, then every tracked nutrient in a 2-column grid grouped +/// by Vitamins / Minerals (progress shown inline, met nutrients greened), a +/// FAO/WHO source line, and one CTA: suggested foods for the timeline's gaps. /// -/// Single column throughout (the web's lg:two-up + sm:2-col grids collapse on -/// phone); the AnimatePresence height-collapse becomes an `AnimatedSize` fade. +/// The timeframe toggle (Today / 7d / 30d) lives in the header beside the menu. class NutritionScreen extends ConsumerStatefulWidget { const NutritionScreen({super.key}); @@ -43,16 +44,35 @@ class _NutritionScreenState extends ConsumerState { final userId = session?.user.id; if (userId == null) { - return const Screen( + return Screen( child: Center( child: Padding( - padding: EdgeInsets.all(24), - child: NhamText('Not signed in.', variant: NhamTextVariant.small), + padding: const EdgeInsets.all(24), + child: NhamText( + tr('common.notSignedIn'), + variant: NhamTextVariant.small, + ), ), ), ); } + // A refetch that fails while we still hold a prior overview keeps the + // content on screen (copyWithPrevious) and surfaces the failure as a top + // toast instead of nuking what the user is reading. + ref.listen>( + nutritionOverviewProvider(_range), + (prev, next) { + if (next.hasError && next.hasValue) { + showTopToast( + context, + tr('nutrition.errors.overviewToast'), + variant: TopToastVariant.error, + ); + } + }, + ); + final async = ref.watch(nutritionOverviewProvider(_range)); // `isFetching`: a refetch in flight while previous data is shown. final isFetching = async.isLoading && async.hasValue; @@ -61,15 +81,29 @@ class _NutritionScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), - child: AppHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: AppHeader( + trailing: NutritionRangeSelector( + resolvedRange: async.valueOrNull?.resolvedRange ?? '7d', + onRangeChange: (range) => setState(() => _range = range), + disabled: isFetching, + ), + ), ), Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 80), - physics: const ClampingScrollPhysics(), - child: _buildBody(async, isFetching), + child: RefreshIndicator( + onRefresh: () => ref + .read(nutritionOverviewProvider(_range).notifier) + .refetch(), + color: NhamColors.accent, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + child: _buildBody(async, isFetching), + ), ), ), ], @@ -78,70 +112,171 @@ class _NutritionScreenState extends ConsumerState { } Widget _buildBody(AsyncValue async, bool isFetching) { - // isLoading with no prior value → skeleton (RN query.isLoading). if (async.isLoading && !async.hasValue) { return const NutritionSkeleton(); } final overview = async.valueOrNull; - if (async.hasError && overview == null) { + if (overview == null) { return InlineError( isRetrying: isFetching, message: tr('nutrition.errors.overview'), retryLabel: tr('nutrition.errors.retry'), onRetry: () { - ref - .read(nutritionOverviewProvider(_range).notifier) - .refetch(); + ref.read(nutritionOverviewProvider(_range).notifier).refetch(); }, ); } - if (overview == null) { - // Error path with no data fallback (mirrors RN `|| !overview`). - return InlineError( - isRetrying: isFetching, - message: tr('nutrition.errors.overview'), - retryLabel: tr('nutrition.errors.retry'), - onRetry: () { - ref - .read(nutritionOverviewProvider(_range).notifier) - .refetch(); - }, + if (overview.loggedDays == 0) { + return const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState(), ); } - final isEmpty = overview.loggedDays == 0; + final all = [...overview.micronutrients, ...overview.moreNutrients]; + final vitamins = + all.where((c) => c.group == NutrientGroup.vitamin).toList(); + final minerals = + all.where((c) => c.group != NutrientGroup.vitamin).toList(); + final foodNutrients = suggestedFoodNutrients(overview); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - EditorialHeader( + DaySummary( + macros: overview.macros, resolvedRange: overview.resolvedRange, - onRangeChange: (range) => setState(() => _range = range), - startDate: overview.period.startDate, - endDate: overview.period.endDate, - disabled: isFetching, - verdict: isEmpty ? null : VerdictHero(overview: overview), + daySeries: overview.daySeries, ), - if (isEmpty) ...[ - const SizedBox(height: 48), - const EmptyState(), - ] else ...[ - const SizedBox(height: 48), - DailyRhythm(macros: overview.macros), - const SizedBox(height: 48), - FocusSection(cards: overview.spotlight), - const SizedBox(height: 48), - SteadySection(cards: overview.steady), - const SizedBox(height: 48), - BackgroundSection(cards: overview.moreNutrients), - for (final card in overview.educationCards) ...[ - const SizedBox(height: 48), - PullQuote(card: card), - ], + // The single CTA sits right under the summary so it's visible on load, + // not buried below the full nutrient grid. + if (foodNutrients.isNotEmpty) ...[ + const SizedBox(height: 12), + _SuggestedFoodsButton( + onTap: () => + showSuggestedFoodsSheet(context, nutrients: foodNutrients), + ), + ], + if (vitamins.isNotEmpty) ...[ + const SizedBox(height: 28), + _GroupHeader(label: tr('nutrition.nutrientGroups.vitamins')), + const SizedBox(height: 12), + _NutrientGrid(cards: vitamins), + ], + if (minerals.isNotEmpty) ...[ + const SizedBox(height: 28), + _GroupHeader(label: tr('nutrition.nutrientGroups.minerals')), + const SizedBox(height: 12), + _NutrientGrid(cards: minerals), ], + const SizedBox(height: 24), + const SourceAttribution(), ], ); } } + +class _GroupHeader extends StatelessWidget { + const _GroupHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Text(label.toUpperCase(), style: dashEyebrow()); + } +} + +/// A 2-column grid of [NutrientGridCard]s — bars stagger in left-to-right, +/// top-to-bottom for a gentle fill. +class _NutrientGrid extends StatelessWidget { + const _NutrientGrid({required this.cards}); + + final List cards; + + @override + Widget build(BuildContext context) { + const gap = 12.0; + final rows = []; + for (var i = 0; i < cards.length; i += 2) { + final left = cards[i]; + final right = i + 1 < cards.length ? cards[i + 1] : null; + rows.add( + Padding( + padding: EdgeInsets.only(top: i == 0 ? 0 : gap), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: NutrientGridCard( + card: left, + barDelay: Duration(milliseconds: 60 * i), + ), + ), + const SizedBox(width: gap), + Expanded( + child: right == null + ? const SizedBox.shrink() + : NutrientGridCard( + card: right, + barDelay: Duration(milliseconds: 60 * (i + 1)), + ), + ), + ], + ), + ), + ); + } + return Column(children: rows); + } +} + +/// The single nutrition CTA — opens the suggested-foods sheet. +class _SuggestedFoodsButton extends StatefulWidget { + const _SuggestedFoodsButton({required this.onTap}); + + final VoidCallback onTap; + + @override + State<_SuggestedFoodsButton> createState() => _SuggestedFoodsButtonState(); +} + +class _SuggestedFoodsButtonState extends State<_SuggestedFoodsButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.96 : 1, + duration: const Duration(milliseconds: 120), + child: Container( + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: NhamColors.btn, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.sparkles, size: 18, color: NhamColors.surface), + const SizedBox(width: NhamSpacing.sp2), + Text( + tr('nutrition.suggestedFoods.button'), + style: dashBody(color: NhamColors.surface, weight: FontWeight.w600), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart deleted file mode 100644 index c2c97827..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import 'nutrient_row.dart'; - -/// RN port of -/// `apps/mobile/src/components/nutrition/sections/background-section.tsx`. The -/// web AnimatePresence collapses height + opacity together (duration 0.3, -/// ease:[0.22,1,0.36,1]) on BOTH enter and exit, so a single controller drives -/// SizeTransition + FadeTransition forward (open) / reverse (close). -class BackgroundSection extends StatefulWidget { - const BackgroundSection({super.key, required this.cards}); - - final List cards; - - @override - State createState() => _BackgroundSectionState(); -} - -class _BackgroundSectionState extends State - with SingleTickerProviderStateMixin { - bool _open = false; - - late final AnimationController _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - ); - - // Web ease:[0.22, 1, 0.36, 1] — expo-out style. - late final Animation _curve = CurvedAnimation( - parent: _controller, - curve: const Cubic(0.22, 1.0, 0.36, 1.0), - reverseCurve: const Cubic(0.22, 1.0, 0.36, 1.0), - ); - - void _toggle() { - setState(() => _open = !_open); - if (_open) { - _controller.forward(); - } else { - _controller.reverse(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cards = widget.cards; - if (cards.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - SectionEyebrow( - label: tr('nutrition.background.eyebrow'), - delay: const Duration(milliseconds: 200), - ), - const Spacer(), - const SizedBox(width: 16), - _ToggleButton( - label: _open - ? tr('nutrition.background.hide') - : tr('nutrition.background.show', - namedArgs: {'count': cards.length.toString()}), - onTap: _toggle, - ), - ], - ), - // Web AnimatePresence opacity + height collapse, duration 0.3, on both - // enter and exit. - SizeTransition( - alignment: AlignmentDirectional.topStart, - sizeFactor: _curve, - child: FadeTransition( - opacity: _curve, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr('nutrition.background.hint'), - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith(color: NhamColors.textMuted), - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: NhamColors.borderHalf), - color: NhamColors.cardWhite30, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final card in cards) NutrientRow(card: card), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ], - ); - } -} - -/// Web: `font-medium text-xs text-nham-text` with `hover:underline` / -/// `focus-visible:underline` (underline-offset-4). On touch the affordance is a -/// pressed underline. -class _ToggleButton extends StatefulWidget { - const _ToggleButton({required this.label, required this.onTap}); - - final String label; - final VoidCallback onTap; - - @override - State<_ToggleButton> createState() => _ToggleButtonState(); -} - -class _ToggleButtonState extends State<_ToggleButton> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: Text( - widget.label, - style: NhamTextStyles.sansMedium(fontSize: 12).copyWith( - color: NhamColors.text, - decoration: _pressed ? TextDecoration.underline : null, - decorationColor: NhamColors.text, - decorationThickness: 1, - ), - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart deleted file mode 100644 index ecf5a11e..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; -import '../../../shared/widgets/target_progress_bar.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/helpers.dart'; -import '../logic/rhythm_logic.dart'; -import 'fade_in_down.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/sections/daily-rhythm.tsx`. -class DailyRhythm extends StatelessWidget { - const DailyRhythm({super.key, required this.macros}); - - final List macros; - - @override - Widget build(BuildContext context) { - final locale = context.locale.languageCode; - final calories = macros.where((m) => m.key == 'calories').firstOrNull; - final composition = buildComposition(macros); - final macroRows = orderedMacroRows(macros); - - return FadeInDown( - duration: const Duration(milliseconds: 500), - delay: const Duration(milliseconds: 100), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionEyebrow( - label: tr('nutrition.rhythm.eyebrow'), - delay: const Duration(milliseconds: 100), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - border: Border.all(color: NhamColors.borderSoft), - color: NhamColors.cardWhite55, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Calorie hero. - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: calories != null - ? formatLocalizedNumber( - calories.averagePerDay, locale) - : '—', - style: - NhamTextStyles.serifMedium(fontSize: 36).copyWith( - height: 40 / 36, - letterSpacing: -0.7, - color: NhamColors.text, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const WidgetSpan(child: SizedBox(width: 8)), - TextSpan( - text: tr('nutrition.rhythm.calories'), - style: NhamTextStyles.sansRegular(fontSize: 16) - .copyWith(color: NhamColors.textMuted), - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - tr('nutrition.rhythm.avgPerLoggedDay').toUpperCase(), - style: NhamTextStyles.sansRegular(fontSize: 12).copyWith( - letterSpacing: 2.2, - color: NhamColors.textMuted, - ), - ), - ], - ), - if (composition.totalKcal > 0) ...[ - const SizedBox(height: 24), - _CompositionPill( - segments: composition.segments, - ariaLabel: tr('nutrition.rhythm.macroCompositionAria'), - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 4, - children: [ - for (final segment in composition.segments) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: kCompositionColors[segment.key], - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - '${kCompositionShort[segment.key]} ${segment.pct.round()}%', - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith( - color: NhamColors.textMuted, - fontFeatures: const [ - FontFeature.tabularFigures() - ], - ), - ), - ], - ), - ], - ), - ], - const SizedBox(height: 24), - // Dashed top divider + macro rows. - _DashedTopDivider( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < macroRows.length; i++) ...[ - if (i > 0) const SizedBox(height: 12), - _MacroRow( - macro: macroRows[i], - locale: locale, - label: tr(macroRows[i].labelKey), - consistencyLabel: tr( - 'nutrition.${consistencyLabelKey(macroRows[i].consistencyPct)}', - ), - barAriaLabel: tr('nutrition.rhythm.barAria', - namedArgs: {'macro': tr(macroRows[i].labelKey)}), - ), - ], - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// Dashed top border + 20px top padding wrapper (RN `border-t pt-5` dashed). -class _DashedTopDivider extends StatelessWidget { - const _DashedTopDivider({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox( - height: 1, - child: CustomPaint(painter: _DashedLinePainter()), - ), - Padding( - padding: const EdgeInsets.only(top: 20), - child: child, - ), - ], - ); - } -} - -class _DashedLinePainter extends CustomPainter { - const _DashedLinePainter(); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = NhamColors.borderBiscotti40 - ..strokeWidth = 1; - const dash = 4.0; - const gap = 3.0; - var x = 0.0; - while (x < size.width) { - canvas.drawLine(Offset(x, 0), Offset(x + dash, 0), paint); - x += dash + gap; - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -/// The macro composition bar. Web animates each segment `scaleX 0→1` from the -/// left (transformOrigin:left, duration 0.6 delay 0.15 easeOut). Since segments -/// share the same left origin, scaling the whole group reproduces the "draw -/// out" effect while preserving each segment's relative width. -class _CompositionPill extends StatefulWidget { - const _CompositionPill({required this.segments, required this.ariaLabel}); - - final List segments; - final String ariaLabel; - - @override - State<_CompositionPill> createState() => _CompositionPillState(); -} - -class _CompositionPillState extends State<_CompositionPill> - with SingleTickerProviderStateMixin { - late final AnimationController _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 600), - ); - - late final Animation _scale = CurvedAnimation( - parent: _controller, - // Web framer-motion ease:'easeOut' = cubic-bezier(0, 0, 0.58, 1). - curve: const Cubic(0.0, 0.0, 0.58, 1.0), - ); - - @override - void initState() { - super.initState(); - Future.delayed(const Duration(milliseconds: 150), () { - if (mounted) _controller.forward(); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Semantics( - label: widget.ariaLabel, - image: true, - child: ClipRRect( - borderRadius: BorderRadius.circular(9999), - child: Container( - height: 8, - width: double.infinity, - color: NhamColors.track, - child: AnimatedBuilder( - animation: _scale, - builder: (context, child) => Align( - alignment: Alignment.centerLeft, - child: Transform.scale( - scaleX: _scale.value, - alignment: Alignment.centerLeft, - child: child, - ), - ), - // Proportional flex so the segments always fill the bar exactly — - // fixed pixel widths summed to ~0.8% over (float rounding) and - // overflowed the Row by a couple px. - child: Row( - children: [ - for (final segment in widget.segments) - if (segment.pct > 0) - Expanded( - flex: (segment.pct * 1000).round(), - child: Container(color: kCompositionColors[segment.key]), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _MacroRow extends StatelessWidget { - const _MacroRow({ - required this.macro, - required this.locale, - required this.label, - required this.consistencyLabel, - required this.barAriaLabel, - }); - - final MacroPattern macro; - final String locale; - final String label; - final String consistencyLabel; - final String barAriaLabel; - - @override - Widget build(BuildContext context) { - final ratio = macro.target != null && macro.target! > 0 - ? macro.averagePerDay / macro.target! - : null; - final pctOfTarget = ratio == null ? null : ratio * 100; - final showExceed = shouldShowExceed(macro.nutrientType, pctOfTarget); - - String figure; - if (macro.target == null || macro.target! <= 0) { - figure = '${formatLocalizedNumber(macro.averagePerDay, locale)} ${macro.unit}'; - } else { - final pct = (macro.averagePerDay / macro.target! * 100).round(); - figure = showExceed && pct > 100 ? '+${pct - 100}%' : '$pct%'; - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 88, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.text), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TargetProgressBar( - percentOfTarget: pctOfTarget, - showExceed: showExceed, - duration: const Duration(milliseconds: 600), - semanticLabel: barAriaLabel, - ), - ), - const SizedBox(width: 12), - Text( - macro.target == null - ? '' - : '${formatLocalizedNumber(macro.target!, locale)} ${macro.unit}', - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 88, - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - consistencyLabel, - style: NhamTextStyles.sansRegular(fontSize: 12) - .copyWith(color: NhamColors.text), - ), - const SizedBox(width: 6), - Text( - figure, - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - color: showExceed ? NhamColors.danger : NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/day_summary.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/day_summary.dart new file mode 100644 index 00000000..7be6c7c2 --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/day_summary.dart @@ -0,0 +1,245 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../models/nutrition.dart'; +import '../../../theme/nham_theme.dart'; +import '../logic/helpers.dart'; +import '../logic/rhythm_logic.dart'; +import 'macro_trend_chart.dart'; + +/// Compact calorie + macro summary at the top of the nutrition view — the lean +/// replacement for the tall "daily rhythm" card. The calorie figure is the one +/// serif (Lora) moment per viewport; everything else is the dashboard sans +/// scale. A thin P/C/F composition bar + gram legend sits underneath. +class DaySummary extends StatelessWidget { + const DaySummary({ + super.key, + required this.macros, + required this.resolvedRange, + required this.daySeries, + }); + + final List macros; + final String resolvedRange; + final NutritionDaySeries daySeries; + + @override + Widget build(BuildContext context) { + final locale = context.locale.languageCode; + final calories = macros.where((m) => m.key == 'calories').firstOrNull; + final composition = buildComposition(macros); + + // For multi-day ranges with ≥2 buckets, show the macro-calorie trend chart; + // a single day has no trend, so it keeps the composition bar. + final macroSeries = daySeries.series + .where((s) => + s.metric == 'protein' || + s.metric == 'carbohydrate' || + s.metric == 'fat') + .toList(); + final bucketCount = + macroSeries.isEmpty ? 0 : macroSeries.first.buckets.length; + final showTrend = resolvedRange != '1d' && bucketCount >= 2; + + return Container( + padding: const EdgeInsets.all(NhamSpacing.sp5), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: kCardShadows, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + calories != null + ? formatLocalizedNumber(calories.averagePerDay, locale) + : '—', + style: dashHero(), + ), + const SizedBox(width: NhamSpacing.sp2), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + tr('nutrition.rhythm.calories'), + style: dashMeta(color: kInkSecondary), + ), + ), + ], + ), + ), + // Top-right: target calories with an over/under arrow. Empty when + // no calorie goal is set. + if (calories?.target != null && calories!.target! > 0) + _CalorieTarget( + avg: calories.averagePerDay, + target: calories.target!, + locale: locale, + ), + ], + ), + const SizedBox(height: NhamSpacing.sp3), + Text( + tr('nutrition.rhythm.avgPerLoggedDay').toUpperCase(), + style: dashEyebrow(color: kInkDisabled), + ), + if (composition.totalKcal > 0) ...[ + const SizedBox(height: NhamSpacing.sp3), + if (showTrend) + MacroTrendChart(daySeries: daySeries) + else + _CompositionBar(segments: composition.segments), + const SizedBox(height: NhamSpacing.sp3), + // Centered color key: which band is which macro (+ avg grams). + SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.center, + spacing: NhamSpacing.sp4, + runSpacing: NhamSpacing.sp1, + children: [ + for (final key in kCompositionKeys) + _MacroLegend( + label: kCompositionShort[key]!, + grams: macros.where((m) => m.key == key).firstOrNull, + color: kCompositionColors[key]!, + locale: locale, + ), + ], + ), + ), + ], + ], + ), + ); + } +} + +/// 8px rounded P/C/F kcal-share bar (static — the cells fill on the grid below). +class _CompositionBar extends StatelessWidget { + const _CompositionBar({required this.segments}); + + final List segments; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(NhamRadii.pill), + child: SizedBox( + height: 8, + child: Row( + children: [ + for (final segment in segments) + if (segment.pct > 0) + Expanded( + flex: (segment.pct * 1000).round(), + child: ColoredBox(color: kCompositionColors[segment.key]!), + ), + ], + ), + ), + ); + } +} + +class _MacroLegend extends StatelessWidget { + const _MacroLegend({ + required this.label, + required this.grams, + required this.color, + required this.locale, + }); + + final String label; + final MacroPattern? grams; + final Color color; + final String locale; + + @override + Widget build(BuildContext context) { + final m = grams; + final value = + m == null ? '—' : '${formatLocalizedNumber(m.averagePerDay, locale)}g'; + + // Deviation from this macro's target (+over / −under), shown with an arrow. + int? dev; + if (m?.target != null && m!.target! > 0) { + dev = (m.averagePerDay / m.target! * 100 - 100).round(); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Longer rounded swatch (a short bar, not a tiny dot). + Container( + width: 16, + height: 6, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + ), + const SizedBox(width: NhamSpacing.sp1_5), + Text('$label $value', style: dashMeta(tabular: true)), + if (dev != null) ...[ + const SizedBox(width: NhamSpacing.sp1), + Icon( + dev >= 0 ? LucideIcons.arrowUp : LucideIcons.arrowDown, + size: 12, + color: kInkDisabled, + ), + Text( + '${dev.abs()}%', + style: dashMeta(color: kInkDisabled, tabular: true), + ), + ], + ], + ); + } +} + +/// Top-right calorie-goal chip: an over/under arrow + the target calories. +class _CalorieTarget extends StatelessWidget { + const _CalorieTarget({ + required this.avg, + required this.target, + required this.locale, + }); + + final double avg; + final double target; + final String locale; + + @override + Widget build(BuildContext context) { + final over = avg >= target; + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + over ? LucideIcons.arrowUp : LucideIcons.arrowDown, + size: 15, + color: kInkSecondary, + ), + const SizedBox(width: 2), + Text( + '${formatLocalizedNumber(target, locale)} ${tr('nutrition.rhythm.calories')}', + style: dashMeta(color: kInkSecondary, tabular: true), + ), + ], + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart deleted file mode 100644 index f7602459..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/format_date.dart'; -import 'fade_in_down.dart'; - -const List _ranges = ['7d', '30d', '90d']; - -/// RN port of `apps/mobile/src/components/nutrition/sections/editorial-header.tsx`. -class EditorialHeader extends StatelessWidget { - const EditorialHeader({ - super.key, - required this.resolvedRange, - required this.onRangeChange, - required this.startDate, - required this.endDate, - this.disabled = false, - this.verdict, - }); - - /// Raw '7d' | '30d' | '90d'. - final String resolvedRange; - final ValueChanged onRangeChange; - final String startDate; - final String endDate; - final bool disabled; - - /// The verdict line — rendered full-width below the title row on phone. - final Widget? verdict; - - NutritionRangeInput _inputFor(String range) => switch (range) { - '7d' => NutritionRangeInput.d7, - '30d' => NutritionRangeInput.d30, - '90d' => NutritionRangeInput.d90, - _ => NutritionRangeInput.auto, - }; - - @override - Widget build(BuildContext context) { - final locale = context.locale.languageCode; - final dateRange = tr('nutrition.editorial.dateRange', namedArgs: { - 'start': formatDate(startDate, locale), - 'end': formatDate(endDate, locale), - }); - - return Container( - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: NhamColors.borderHalf)), - ), - padding: const EdgeInsets.only(bottom: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Title block: web opacity/y:6 duration 0.5 (no delay). - Flexible( - child: FadeInDown( - offset: 6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionEyebrow( - label: tr('nutrition.editorial.eyebrow'), - trailing: tr('nutrition.range.$resolvedRange'), - ), - const SizedBox(height: 8), - Text( - dateRange, - style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ), - ), - const SizedBox(width: 16), - // Range toggle: web opacity/y:6 duration 0.5 delay 0.05. - FadeInDown( - offset: 6, - delay: const Duration(milliseconds: 50), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9999), - border: Border.all(color: NhamColors.borderSoft), - color: NhamColors.cardWhite40, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < _ranges.length; i++) ...[ - if (i > 0) const SizedBox(width: 1), - _RangePill( - label: tr('nutrition.range.${_ranges[i]}'), - active: resolvedRange == _ranges[i], - disabled: disabled, - onTap: () => onRangeChange(_inputFor(_ranges[i])), - ), - ], - ], - ), - ), - ), - ], - ), - if (verdict != null) ...[ - const SizedBox(height: 12), - // Verdict: web opacity/y:4 duration 0.5 delay 0.1. - FadeInDown( - offset: 4, - delay: const Duration(milliseconds: 100), - child: SizedBox(width: double.infinity, child: verdict), - ), - ], - ], - ), - ); - } -} - -class _RangePill extends StatefulWidget { - const _RangePill({ - required this.label, - required this.active, - required this.disabled, - required this.onTap, - }); - - final String label; - final bool active; - final bool disabled; - final VoidCallback onTap; - - @override - State<_RangePill> createState() => _RangePillState(); -} - -class _RangePillState extends State<_RangePill> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - // Inactive pills: web `text-nham-text-muted hover:text-nham-text - // transition-colors` — the touch affordance shifts the text toward - // nham-text on press-down. - final textColor = widget.active - ? NhamColors.surface - : _pressed - ? NhamColors.text - : NhamColors.textMuted; - - final pill = AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9999), - color: widget.active ? NhamColors.text : null, - ), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 150), - style: NhamTextStyles.sansMedium(fontSize: 12).copyWith( - color: textColor, - fontFeatures: const [FontFeature.tabularFigures()], - ), - child: Text(widget.label), - ), - ); - - return Opacity( - opacity: widget.disabled ? 0.6 : 1, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: - widget.disabled ? null : (_) => setState(() => _pressed = true), - onTapUp: - widget.disabled ? null : (_) => setState(() => _pressed = false), - onTapCancel: - widget.disabled ? null : () => setState(() => _pressed = false), - onTap: widget.disabled ? null : widget.onTap, - child: pill, - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart deleted file mode 100644 index 90fef661..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; -import '../../../theme/nham_colors.dart'; -import 'spotlight_row.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/sections/focus-section.tsx`. -class FocusSection extends StatelessWidget { - const FocusSection({super.key, required this.cards}); - - final List cards; - - @override - Widget build(BuildContext context) { - if (cards.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionEyebrow( - label: tr('nutrition.focus.eyebrow'), - delay: const Duration(milliseconds: 150), - ), - const SizedBox(height: 20), - for (var i = 0; i < cards.length; i++) ...[ - if (i > 0) const SizedBox(height: 24), - if (i < cards.length - 1) - Container( - padding: const EdgeInsets.only(bottom: 24), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: NhamColors.borderBiscotti40), - ), - ), - child: SpotlightRow(card: cards[i], delay: 100 + i * 60), - ) - else - SpotlightRow(card: cards[i], delay: 100 + i * 60), - ], - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/food_chip_row.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/food_chip_row.dart deleted file mode 100644 index 6fa8642b..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/food_chip_row.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../models/nutrition.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/candidate_nutrients.dart'; -import '../providers/food_candidates_provider.dart'; - -/// Spotlight (warmer) vs steady chip treatment. -enum FoodChipVariant { spotlight, steady } - -/// RN port of `apps/mobile/src/components/nutrition/rows/food-chip-row.tsx`. -class FoodChipRow extends ConsumerWidget { - const FoodChipRow({ - super.key, - required this.nutrient, - this.enabled = true, - this.variant = FoodChipVariant.spotlight, - this.limit = 5, - }); - - final NutritionNutrientKey nutrient; - final bool enabled; - final FoodChipVariant variant; - final int limit; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final supported = asSupported(nutrient); - if (supported == null) return const SizedBox.shrink(); - - if (!enabled) return const SizedBox.shrink(); - - final async = ref.watch(foodCandidatesProvider(nutrient)); - - return async.when( - loading: () => _ChipRowSkeleton(variant: variant), - error: (_, __) => Text( - tr('nutrition.errors.candidatesError'), - style: NhamTextStyles.sansRegular(fontSize: 12) - .copyWith(color: NhamColors.textMuted), - ), - data: (response) { - if (response == null) return const SizedBox.shrink(); - if (response.candidates.isEmpty) { - return Text( - tr('nutrition.focus.noFoodIdeas'), - style: NhamTextStyles.sansRegular(fontSize: 12) - .copyWith(color: NhamColors.textMuted), - ); - } - - final chips = response.candidates.take(limit).toList(); - final isSpotlight = variant == FoodChipVariant.spotlight; - - return Wrap( - spacing: 6, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - tr('nutrition.focus.tryLabel').toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 10).copyWith( - letterSpacing: 2, - color: NhamColors.stone, - ), - ), - for (final candidate in chips) - Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 2), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9999), - border: Border.all( - color: isSpotlight - ? NhamColors.accent40 - : NhamColors.borderSoft, - ), - color: isSpotlight - ? NhamColors.accent10 - : NhamColors.cardWhite40, - ), - child: Text( - tr(candidate.nameKey), - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - height: 20 / 11, - color: variant == FoodChipVariant.steady - ? NhamColors.textMuted - : NhamColors.text, - ), - ), - ), - ], - ); - }, - ); - } -} - -class _ChipRowSkeleton extends StatelessWidget { - const _ChipRowSkeleton({required this.variant}); - - final FoodChipVariant variant; - - @override - Widget build(BuildContext context) { - const widths = [60.0, 80.0, 70.0, 90.0]; - final fill = variant == FoodChipVariant.spotlight - ? NhamColors.accent15 - : NhamColors.track; - return Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - for (final w in widths) - Container( - width: w, - height: 28, - decoration: BoxDecoration( - color: fill, - borderRadius: BorderRadius.circular(9999), - ), - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart index 016b4343..350ebd7a 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart @@ -42,37 +42,49 @@ class _InlineErrorState extends State { const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: widget.isRetrying - ? null - : (_) => setState(() => _pressed = true), - onTapUp: widget.isRetrying - ? null - : (_) => setState(() => _pressed = false), - onTapCancel: widget.isRetrying - ? null - : () => setState(() => _pressed = false), + child: Semantics( + button: true, + enabled: !widget.isRetrying, + excludeSemantics: true, + label: widget.retryLabel, onTap: widget.isRetrying ? null : widget.onRetry, - child: Opacity( - opacity: widget.isRetrying ? 0.5 : 1, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: NhamColors.borderSoft), - color: (_pressed && !widget.isRetrying) - ? NhamColors.hover - : null, - ), - child: NhamText( - widget.retryLabel, - variant: NhamTextVariant.small, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: + widget.isRetrying + ? null + : (_) => setState(() => _pressed = true), + onTapUp: + widget.isRetrying + ? null + : (_) => setState(() => _pressed = false), + onTapCancel: + widget.isRetrying + ? null + : () => setState(() => _pressed = false), + onTap: widget.isRetrying ? null : widget.onRetry, + child: Opacity( + opacity: widget.isRetrying ? 0.5 : 1, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: NhamColors.borderSoft), + color: + (_pressed && !widget.isRetrying) + ? NhamColors.hover + : null, + ), + child: NhamText( + widget.retryLabel, + variant: NhamTextVariant.small, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/macro_trend_chart.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/macro_trend_chart.dart new file mode 100644 index 00000000..caa6c163 --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/macro_trend_chart.dart @@ -0,0 +1,178 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../models/nutrition.dart'; +import '../../../theme/nham_colors.dart'; +import '../logic/rhythm_logic.dart'; + +/// A stacked line-area chart of macro **calories** per bucket (day for 7d, week +/// for 30d): total height = that bucket's calories, each band's thickness = the +/// energy from protein / carbs / fat. Reads the overview `daySeries` directly. +/// +/// Curved (line-area, never columns), filled with the warm macro tokens that +/// match the `DaySummary` legend. +class MacroTrendChart extends StatelessWidget { + const MacroTrendChart({super.key, required this.daySeries}); + + final NutritionDaySeries daySeries; + + NutrientDaySeries? _seriesFor(String metric) => + daySeries.series.where((s) => s.metric == metric).firstOrNull; + + @override + Widget build(BuildContext context) { + final p = _seriesFor('protein'); + final c = _seriesFor('carbohydrate'); + final f = _seriesFor('fat'); + + // All metrics share the same bucket axis; fall back to whichever exists. + final buckets = (p ?? c ?? f)?.buckets ?? const []; + if (buckets.length < 2) return const SizedBox.shrink(); + + double g(NutrientDaySeries? s, int i) => + (s != null && i < s.buckets.length ? s.buckets[i].value : null) ?? 0; + + final zero = []; + final cum1 = []; // protein kcal + final cum2 = []; // + carbs + final cum3 = []; // + fat (total) + var maxY = 0.0; + for (var i = 0; i < buckets.length; i++) { + final pk = g(p, i) * kKcalPerGram['protein']!; + final ck = g(c, i) * kKcalPerGram['carbohydrate']!; + final fk = g(f, i) * kKcalPerGram['fat']!; + final x = i.toDouble(); + zero.add(FlSpot(x, 0)); + cum1.add(FlSpot(x, pk)); + cum2.add(FlSpot(x, pk + ck)); + cum3.add(FlSpot(x, pk + ck + fk)); + if (pk + ck + fk > maxY) maxY = pk + ck + fk; + } + if (maxY <= 0) return const SizedBox.shrink(); + + LineChartBarData band(List spots, {Color? stroke}) => + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.28, + preventCurveOverShooting: true, + barWidth: stroke == null ? 0 : 1.5, + color: stroke ?? Colors.transparent, + dotData: const FlDotData(show: false), + ); + + final protein = kCompositionColors['protein']!; + final carbs = kCompositionColors['carbohydrate']!; + final fat = kCompositionColors['fat']!; + + // Axis always reaches at least 3000 kcal so the 2500 / 3000 guides show, + // and grows past that if intake exceeds it. + final axisTarget = maxY > 3000 ? maxY : 3000.0; + final step = _niceStep(axisTarget); + final maxLabel = (axisTarget / step).ceil() * step; + final topY = maxLabel + step * 0.35; // headroom so the top label isn't clipped + + return SizedBox( + height: 248, + child: LineChart( + LineChartData( + minX: 0, + maxX: (buckets.length - 1).toDouble(), + minY: 0, + maxY: topY, + lineTouchData: const LineTouchData(enabled: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: step, + getDrawingHorizontalLine: (value) => const FlLine( + color: NhamColors.borderBiscotti40, + strokeWidth: 1, + dashArray: [4, 4], + ), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: step, + reservedSize: 36, + getTitlesWidget: (value, meta) { + if (value <= 0 || value > maxLabel) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(right: 6), + child: Text( + value.round().toString(), + textAlign: TextAlign.right, + style: dashMeta(color: kInkDisabled).copyWith(fontSize: 10), + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + interval: 1, + getTitlesWidget: (value, meta) { + final i = value.round(); + if (i < 0 || i >= buckets.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _label(buckets[i].startDate, daySeries.unit, + context.locale.toString()), + style: dashEyebrow(color: kInkDisabled) + .copyWith(letterSpacing: 0.4), + ), + ); + }, + ), + ), + ), + // Fills between the stacked cumulative lines. + betweenBarsData: [ + BetweenBarsData(fromIndex: 0, toIndex: 1, color: protein.withValues(alpha: 0.85)), + BetweenBarsData(fromIndex: 1, toIndex: 2, color: carbs.withValues(alpha: 0.85)), + BetweenBarsData(fromIndex: 2, toIndex: 3, color: fat.withValues(alpha: 0.85)), + ], + lineBarsData: [ + band(zero), + band(cum1), + band(cum2), + band(cum3, stroke: NhamColors.text.withValues(alpha: 0.28)), + ], + ), + ), + ); + } + + /// A round kcal gridline step giving ~3–5 lines across the data range. + static double _niceStep(double maxV) { + const steps = [250.0, 500.0, 1000.0, 1500.0, 2000.0]; + // <= 6 so a ~3000 axis keeps a 500 step (shows 2500 and 3000), not 1000. + for (final s in steps) { + if (maxV / s <= 6) return s; + } + return 2500; + } + + /// Day buckets → weekday initial; week buckets → "d/M" of the week start. + static String _label(String startDate, String unit, String locale) { + final d = DateTime.tryParse(startDate); + if (d == null) return ''; + if (unit == 'week') return DateFormat('d/M', locale).format(d); + return DateFormat('E', locale).format(d).characters.first; + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart deleted file mode 100644 index 3cc10412..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/target_progress_bar.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/helpers.dart'; -import '../logic/status.dart'; -import 'food_chip_row.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/rows/nutrient-detail.tsx`. -class NutrientDetail extends StatelessWidget { - const NutrientDetail({super.key, required this.card}); - - final NutrientCardData card; - - @override - Widget build(BuildContext context) { - final locale = context.locale.languageCode; - final label = tr(card.labelKey); - - final hasTarget = card.percentOfTarget != null; - final percent = card.percentOfTarget ?? 0; - final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); - - final avgText = card.averagePerDay == null - ? null - : tr('nutrition.focus.averageLine', namedArgs: { - 'value': formatLocalizedNumber(card.averagePerDay!, locale), - 'unit': card.unit, - 'source': tr(card.targetSourceLabelKey), - }); - - final avgStyle = NhamTextStyles.sansMedium(fontSize: 14).copyWith( - color: NhamColors.text, - fontFeatures: const [FontFeature.tabularFigures()], - ); - - final children = []; - - if (hasTarget) { - children.add( - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: TargetProgressBar( - percentOfTarget: card.percentOfTarget, - showExceed: showExceed, - duration: const Duration(milliseconds: 550), - semanticLabel: tr('nutrition.focus.spotlightBarAria', - namedArgs: { - 'label': label, - 'pct': percent.round().toString(), - }), - ), - ), - if (card.target != null) ...[ - const SizedBox(width: 12), - Text( - '${formatLocalizedNumber(card.target!, locale)} ${card.unit}', - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ], - ), - ); - if (avgText != null) { - children.add(Text(avgText, style: avgStyle)); - } - } else if (avgText != null) { - children.add(Text(avgText, style: avgStyle)); - } else { - children.add( - Text( - tr('nutrition.card.noData'), - style: NhamTextStyles.sansRegular(fontSize: 14) - .copyWith(color: NhamColors.textMuted), - ), - ); - } - - if (showChips(card)) { - children.add( - FoodChipRow( - nutrient: card.nutrient, - variant: FoodChipVariant.spotlight, - limit: 5, - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: _withGap(children, 16), - ), - ); - } -} - -/// Inserts vertical [gap] between [children] (RN `gap-4` = 16px). -List _withGap(List children, double gap) { - if (children.isEmpty) return children; - final out = []; - for (var i = 0; i < children.length; i++) { - if (i > 0) out.add(SizedBox(height: gap)); - out.add(children[i]); - } - return out; -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_grid_card.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_grid_card.dart new file mode 100644 index 00000000..4ffaf5eb --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_grid_card.dart @@ -0,0 +1,135 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../models/nutrition.dart'; +import '../../../shared/widgets/target_progress_bar.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import '../logic/helpers.dart'; +import '../logic/status.dart'; + +/// A compact nutrient cell for the 2-column grid: name + % on top, an inline +/// progress bar below. When the nutrient is adequate (a met floor / an in-limit +/// ceiling) the whole card greens to signal success; everything else stays +/// neutral, and limited / no-target nutrients read muted. +class NutrientGridCard extends StatelessWidget { + const NutrientGridCard({super.key, required this.card, this.barDelay}); + + final NutrientCardData card; + + /// Optional stagger so a row of bars fills in sequence. + final Duration? barDelay; + + @override + Widget build(BuildContext context) { + final locale = context.locale.languageCode; + final label = tr(card.labelKey); + final pct = card.percentOfTarget; + final isLimited = + card.displayState == ConfidenceDisplayState.limitedData || + card.displayState == ConfidenceDisplayState.insufficientData; + final showExceed = shouldShowExceed(card.nutrientType, pct); + + // Success = a real, confident reading that's on target (floor met, or a + // ceiling held under its limit). An exceeded ceiling is never "success". + final adequate = pct != null && + !isLimited && + !showExceed && + statusKeyFor(card) == StatusKey.onTarget; + + String figure; + if (card.displayState == ConfidenceDisplayState.insufficientData) { + figure = tr('nutrition.cell.limited'); + } else if (pct == null) { + figure = tr('nutrition.cell.noTarget'); + } else if (showExceed && pct > 100) { + figure = '+${(pct - 100).round()}%'; + } else { + figure = tr( + 'nutrition.steady.percent', + namedArgs: {'value': pct.round().toString()}, + ); + } + + final Color figureColor = showExceed + ? NhamColors.danger + : isLimited || pct == null + ? kInkDisabled + : adequate + ? NhamColors.successDark + : kInk; + + // Absolute average / goal in the nutrient's own unit (g or mg/mcg). + final String goalText; + if (card.target != null) { + final avgStr = card.averagePerDay != null + ? formatLocalizedNumber(card.averagePerDay!, locale) + : '0'; + goalText = + '$avgStr / ${formatLocalizedNumber(card.target!, locale)} ${card.unit}'; + } else if (card.averagePerDay != null) { + goalText = '${formatLocalizedNumber(card.averagePerDay!, locale)} ${card.unit}'; + } else { + goalText = '—'; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: adequate ? NhamColors.successFaint : kCardSurface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all( + color: adequate ? NhamColors.successBorder : NhamColors.borderSoft, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: dashMeta(color: kInk), + ), + ), + const SizedBox(width: NhamSpacing.sp2), + Text( + figure, + style: dashMeta(color: figureColor, tabular: true), + ), + ], + ), + const SizedBox(height: NhamSpacing.sp2), + TargetProgressBar( + percentOfTarget: pct, + showExceed: showExceed, + delay: barDelay ?? Duration.zero, + semanticLabel: label, + fillColor: adequate ? NhamColors.successAccent : null, + ), + const SizedBox(height: NhamSpacing.sp2), + Text( + goalText, + maxLines: 1, + overflow: TextOverflow.clip, + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xxs).copyWith( + color: kInkDisabled, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart deleted file mode 100644 index efa7b6d1..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/helpers.dart'; -import '../logic/status.dart'; -import 'nutrient_detail.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/rows/nutrient-row.tsx` -/// (expandable). lucide `ChevronRight` → `Icons.chevron_right`. -class NutrientRow extends StatefulWidget { - const NutrientRow({super.key, required this.card}); - - final NutrientCardData card; - - @override - State createState() => _NutrientRowState(); -} - -class _NutrientRowState extends State { - bool _open = false; - bool _pressed = false; - - @override - Widget build(BuildContext context) { - final card = widget.card; - final label = tr(card.labelKey); - final isLimited = - card.displayState == ConfidenceDisplayState.limitedData || - card.displayState == ConfidenceDisplayState.insufficientData; - final hasNoTarget = card.percentOfTarget == null; - final dotColor = isLimited || hasNoTarget - ? NhamColors.stone - : kStatusColors[statusKeyFor(card)]!; - - final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); - - String figure; - if (card.displayState == ConfidenceDisplayState.insufficientData) { - figure = tr('nutrition.steady.limited'); - } else if (card.percentOfTarget == null) { - figure = tr('nutrition.steady.noTarget'); - } else if (showExceed && card.percentOfTarget! > 100) { - figure = '+${(card.percentOfTarget! - 100).round()}%'; - } else { - figure = tr('nutrition.steady.percent', - namedArgs: {'value': card.percentOfTarget!.round().toString()}); - } - - final figureColor = showExceed - ? NhamColors.danger - : isLimited - ? NhamColors.textMuted - : NhamColors.text; - - return DecoratedBox( - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: NhamColors.borderBiscotti40), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: () => setState(() => _open = !_open), - child: ColoredBox( - color: _pressed ? NhamColors.hover40 : Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansRegular(fontSize: 14) - .copyWith(color: NhamColors.text), - ), - ), - const SizedBox(width: 12), - Text( - figure, - style: NhamTextStyles.sansRegular(fontSize: 12).copyWith( - color: figureColor, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(width: 12), - AnimatedRotation( - turns: _open ? 0.25 : 0, - duration: const Duration(milliseconds: 200), - child: const Icon( - Icons.chevron_right, - size: 16, - color: NhamColors.stone, - ), - ), - ], - ), - ), - ), - ), - // Web height/opacity collapse, duration 0.28. AnimatedSize reflows the - // list as the detail mounts; the panel fades over the same window. - AnimatedSize( - duration: const Duration(milliseconds: 280), - // Web ease:[0.22, 1, 0.36, 1] — expo-out style. - curve: const Cubic(0.22, 1.0, 0.36, 1.0), - alignment: Alignment.topCenter, - child: _open - ? Padding( - padding: - const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: _FadeIn(child: NutrientDetail(card: card)), - ) - : const SizedBox(width: double.infinity), - ), - ], - ), - ); - } -} - -/// One-shot fade-in mirroring Reanimated `FadeIn.duration(280)`. -class _FadeIn extends StatefulWidget { - const _FadeIn({required this.child}); - - final Widget child; - - @override - State<_FadeIn> createState() => _FadeInState(); -} - -class _FadeInState extends State<_FadeIn> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 280), - )..forward(); - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => - FadeTransition(opacity: _c, child: widget.child); -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart deleted file mode 100644 index f10d07be..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import 'fade_in_down.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/sections/pull-quote.tsx`. -class PullQuote extends StatelessWidget { - const PullQuote({super.key, required this.card}); - - final EducationCardData card; - - @override - Widget build(BuildContext context) { - // Web motion.aside opacity/y:8 duration 0.55 delay 0.22. - return FadeInDown( - duration: const Duration(milliseconds: 550), - delay: const Duration(milliseconds: 220), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 3px dashed accent-60 left border. - const SizedBox( - width: 3, - child: CustomPaint(painter: _DashedVerticalPainter()), - ), - const SizedBox(width: 20), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr('nutrition.pullQuote.eyebrow').toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 10).copyWith( - letterSpacing: 2.2, - color: NhamColors.stone, - ), - ), - const SizedBox(height: 12), - Text( - tr(card.titleKey), - style: NhamTextStyles.serifItalic(fontSize: 18).copyWith( - height: 25 / 18, - letterSpacing: -0.09, - color: NhamColors.text, - ), - ), - const SizedBox(height: 12), - Text( - tr(card.bodyKey), - style: NhamTextStyles.serifItalic(fontSize: 15).copyWith( - height: 28 / 15, - color: NhamColors.textMuted, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _DashedVerticalPainter extends CustomPainter { - const _DashedVerticalPainter(); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = NhamColors.accent60 - ..strokeWidth = 3 - ..strokeCap = StrokeCap.square; - const dash = 5.0; - const gap = 4.0; - var y = 0.0; - final x = size.width / 2; - while (y < size.height) { - canvas.drawLine(Offset(x, y), Offset(x, (y + dash).clamp(0, size.height)), - paint); - y += dash + gap; - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart new file mode 100644 index 00000000..5b5a0338 --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart @@ -0,0 +1,138 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../models/nutrition.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +// Today / 7d / 30d — the header-anchored timeframe toggle (90d retired). +const List _ranges = ['1d', '7d', '30d']; + +/// A compact, equal-width segmented control (iOS style): a warm track with a +/// single white "thumb" sliding under the active segment. Lives in the header +/// beside the menu button. +class NutritionRangeSelector extends StatelessWidget { + const NutritionRangeSelector({ + super.key, + required this.resolvedRange, + required this.onRangeChange, + this.disabled = false, + }); + + final String resolvedRange; + final ValueChanged onRangeChange; + final bool disabled; + + NutritionRangeInput _inputFor(String range) => switch (range) { + '1d' => NutritionRangeInput.d1, + '7d' => NutritionRangeInput.d7, + '30d' => NutritionRangeInput.d30, + _ => NutritionRangeInput.auto, + }; + + @override + Widget build(BuildContext context) { + final activeIndex = + _ranges.indexOf(resolvedRange).clamp(0, _ranges.length - 1); + + return Opacity( + opacity: disabled ? 0.6 : 1, + child: SizedBox( + width: 168, + height: 34, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: kTrack, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final segWidth = constraints.maxWidth / _ranges.length; + return Stack( + children: [ + // Sliding white thumb under the active segment. + AnimatedAlign( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment( + _ranges.length == 1 + ? 0 + : -1 + 2 * (activeIndex / (_ranges.length - 1)), + 0, + ), + child: Container( + width: segWidth, + height: constraints.maxHeight, + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(NhamRadii.pill), + boxShadow: const [NhamShadows.xs], + ), + ), + ), + Row( + children: [ + for (var i = 0; i < _ranges.length; i++) + Expanded( + child: _Segment( + label: tr('nutrition.range.${_ranges[i]}'), + active: i == activeIndex, + onTap: disabled + ? null + : () { + HapticFeedback.selectionClick(); + onRangeChange(_inputFor(_ranges[i])); + }, + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _Segment extends StatelessWidget { + const _Segment({ + required this.label, + required this.active, + required this.onTap, + }); + + final String label; + final bool active; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: active, + excludeSemantics: true, + label: label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: NhamTextStyles.sansSemiBold(fontSize: NhamFontSize.xs) + .copyWith( + color: active ? kInk : kInkSecondary, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Text(label), + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/source_attribution.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/source_attribution.dart new file mode 100644 index 00000000..b05bfd5c --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/source_attribution.dart @@ -0,0 +1,137 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; + +/// A quiet line under the nutrient grid attributing the daily targets to +/// recognized reference intakes (WHO/FAO · Vietnam RDA · NASEM DRI). Tapping it +/// opens the full citations — the "these are reliable sources" affordance. +class SourceAttribution extends StatelessWidget { + const SourceAttribution({super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showCitations(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + LucideIcons.shieldCheck, + size: 13, + color: kInkDisabled, + ), + const SizedBox(width: NhamSpacing.sp1_5), + Flexible( + child: Text( + tr('nutrition.sources.caption'), + textAlign: TextAlign.center, + style: dashMeta(color: kInkDisabled), + ), + ), + const SizedBox(width: NhamSpacing.sp1), + const Icon(LucideIcons.info, size: 13, color: kInkDisabled), + ], + ), + ); + } +} + +void _showCitations(BuildContext context) { + final bottomInset = MediaQuery.of(context).padding.bottom; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: const BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(NhamRadii.xxl)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp2, + NhamSpacing.sp2, + NhamSpacing.sp2, + NhamSpacing.sp1, + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.x, size: 22), + color: NhamColors.textMuted, + tooltip: tr('common.cancel'), + ), + Expanded( + child: Center( + child: Text( + tr('nutrition.sources.title'), + style: dashBody(weight: FontWeight.w600), + ), + ), + ), + const SizedBox(width: 48, height: 48), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + NhamSpacing.sp5, + NhamSpacing.sp1, + NhamSpacing.sp5, + bottomInset + NhamSpacing.sp5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr('nutrition.sources.intro'), style: dashMeta()), + const SizedBox(height: NhamSpacing.sp4), + _Citation( + label: tr('nutrition.targetSources.whoFao'), + detail: tr('nutrition.sources.whoFao'), + ), + _Citation( + label: tr('nutrition.targetSources.vietnamRda'), + detail: tr('nutrition.sources.vietnamRda'), + ), + _Citation( + label: tr('nutrition.targetSources.nasem'), + detail: tr('nutrition.sources.nasem'), + ), + ], + ), + ), + ], + ), + ), + ); +} + +class _Citation extends StatelessWidget { + const _Citation({required this.label, required this.detail}); + + final String label; + final String detail; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: dashBody(weight: FontWeight.w600)), + const SizedBox(height: 2), + Text(detail, style: dashMeta(color: kInkSecondary)), + ], + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/spotlight_row.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/spotlight_row.dart deleted file mode 100644 index aaedb327..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/spotlight_row.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/target_progress_bar.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/helpers.dart'; -import 'fade_in_down.dart'; -import 'food_chip_row.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/rows/spotlight-row.tsx`. -class SpotlightRow extends StatelessWidget { - const SpotlightRow({super.key, required this.card, this.delay = 0}); - - final NutrientCardData card; - - /// Entrance delay, ms (mirrors the web per-card motion stagger). - final int delay; - - @override - Widget build(BuildContext context) { - final locale = context.locale.languageCode; - final label = tr(card.labelKey); - final percent = card.percentOfTarget ?? 0; - final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); - final figure = showExceed && percent > 100 - ? '+${(percent - 100).round()}%' - : tr('nutrition.focus.percentOfTarget', - namedArgs: {'value': percent.round().toString()}); - - return FadeInDown( - duration: const Duration(milliseconds: 500), - delay: Duration(milliseconds: delay), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title + % figure row. - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Flexible( - child: Text( - label, - style: NhamTextStyles.serifMedium(fontSize: 21).copyWith( - height: 27 / 21, - letterSpacing: -0.21, - color: NhamColors.text, - ), - ), - ), - const SizedBox(width: 12), - Text( - figure, - style: NhamTextStyles.sansMedium(fontSize: 14).copyWith( - color: showExceed ? NhamColors.danger : NhamColors.text, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - const SizedBox(height: 12), - // Bar + target row. - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: TargetProgressBar( - percentOfTarget: card.percentOfTarget, - showExceed: showExceed, - delay: Duration(milliseconds: delay + 100), - semanticLabel: tr('nutrition.focus.spotlightBarAria', - namedArgs: { - 'label': label, - 'pct': percent.round().toString(), - }), - ), - ), - if (card.target != null) ...[ - const SizedBox(width: 12), - Text( - '${formatLocalizedNumber(card.target!, locale)} ${card.unit}', - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ], - ), - const SizedBox(height: 12), - // Average line. - Text( - tr('nutrition.focus.averageLine', namedArgs: { - 'value': card.averagePerDay == null - ? '—' - : formatLocalizedNumber(card.averagePerDay!, locale), - 'unit': card.unit, - 'source': tr(card.targetSourceLabelKey), - }), - style: NhamTextStyles.sansMedium(fontSize: 14).copyWith( - color: NhamColors.text, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(height: 12), - FoodChipRow( - nutrient: card.nutrient, - variant: FoodChipVariant.spotlight, - limit: 5, - ), - ], - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart deleted file mode 100644 index f58559ed..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import 'nutrient_row.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/sections/steady-section.tsx`. -/// The web's `sm:grid-cols-2` split collapses to a single column on phone. -class SteadySection extends StatelessWidget { - const SteadySection({super.key, required this.cards}); - - final List cards; - - @override - Widget build(BuildContext context) { - if (cards.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - SectionEyebrow( - label: tr('nutrition.steady.eyebrow'), - delay: const Duration(milliseconds: 180), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - tr('nutrition.steady.hint'), - textAlign: TextAlign.right, - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith(color: NhamColors.textMuted), - ), - ), - ], - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: NhamColors.borderHalf), - color: NhamColors.cardWhite40, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final card in cards) NutrientRow(card: card), - ], - ), - ), - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/suggested_foods_sheet.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/suggested_foods_sheet.dart new file mode 100644 index 00000000..ccbef4ea --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/suggested_foods_sheet.dart @@ -0,0 +1,302 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../models/nutrition.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../logic/status.dart'; +import '../providers/candidates_response.dart'; +import '../providers/food_candidates_provider.dart'; + +/// The under-target nutrients from an overview, most in-need first — the input +/// to the suggested-foods CTA (confidence ≥ 40 · < 90% of target). +List suggestedFoodNutrients(NutritionOverview overview) { + final all = [...overview.micronutrients, ...overview.moreNutrients]; + final eligible = all.where(showChips).toList() + ..sort( + (a, b) => (a.percentOfTarget ?? 0).compareTo(b.percentOfTarget ?? 0), + ); + return eligible.take(5).toList(); +} + +/// The single nutrition CTA: which nutrients you're short on, by how much, and +/// foods that close the gap. Tap a food to keep it; "Don't have these?" swaps +/// the rest for other options from the same pool (no refetch). +Future showSuggestedFoodsSheet( + BuildContext context, { + required List nutrients, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => _SuggestedFoodsSheet(nutrients: nutrients), + ); +} + +class _SuggestedFoodsSheet extends StatelessWidget { + const _SuggestedFoodsSheet({required this.nutrients}); + + final List nutrients; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).padding.bottom; + final maxHeight = MediaQuery.of(context).size.height * 0.8; + + return Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: const BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(NhamRadii.xxl)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp2, + NhamSpacing.sp2, + NhamSpacing.sp2, + NhamSpacing.sp1, + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.x, size: 22), + color: NhamColors.textMuted, + tooltip: tr('common.cancel'), + ), + Expanded( + child: Center( + child: Text( + tr('nutrition.suggestedFoods.title'), + style: dashBody(weight: FontWeight.w600), + ), + ), + ), + const SizedBox(width: 48, height: 48), + ], + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.fromLTRB( + NhamSpacing.sp4, + NhamSpacing.sp1, + NhamSpacing.sp4, + bottomInset + NhamSpacing.sp5, + ), + itemCount: nutrients.length, + separatorBuilder: (_, __) => const Divider( + height: NhamSpacing.sp5, + thickness: 1, + color: NhamColors.borderFaint, + ), + itemBuilder: (_, i) => _NutrientGap(card: nutrients[i]), + ), + ), + ], + ), + ); + } +} + +class _NutrientGap extends ConsumerStatefulWidget { + const _NutrientGap({required this.card}); + + final NutrientCardData card; + + @override + ConsumerState<_NutrientGap> createState() => _NutrientGapState(); +} + +class _NutrientGapState extends ConsumerState<_NutrientGap> { + static const int _visibleCount = 3; + + final Set _reserved = {}; + int _cursor = 0; + + @override + Widget build(BuildContext context) { + final card = widget.card; + final async = ref.watch(foodCandidatesProvider(card.nutrient)); + final pct = card.percentOfTarget; + final shortBy = pct == null ? null : (100 - pct).round(); + final vi = context.locale.languageCode == 'vi'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Which nutrient + by how much you're short. + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text(tr(card.labelKey), + style: dashBody(weight: FontWeight.w600)), + ), + const SizedBox(width: NhamSpacing.sp2), + if (shortBy != null && shortBy > 0) + Text( + tr('nutrition.suggestedFoods.short', + namedArgs: {'value': shortBy.toString()}), + style: dashMeta(color: NhamColors.danger, tabular: true), + ), + ], + ), + const SizedBox(height: NhamSpacing.sp2_5), + async.when( + loading: () => Text(tr('nutrition.candidates.loading'), + style: dashMeta(color: kInkDisabled)), + error: (_, __) => Text(tr('nutrition.candidates.error'), + style: dashMeta(color: kInkDisabled)), + data: (response) => _foods(response, vi), + ), + ], + ); + } + + Widget _foods(CandidatesResponse response, bool vi) { + final pool = response.foods; + if (pool.isEmpty) { + return Text(tr('nutrition.candidates.empty'), + style: dashMeta(color: kInkDisabled)); + } + + final reserved = pool.where((f) => _reserved.contains(f.id)).toList(); + final rest = pool.where((f) => !_reserved.contains(f.id)).toList(); + final rotatingSlots = (_visibleCount - reserved.length).clamp(0, pool.length); + + final rotating = []; + for (var i = 0; i < rotatingSlots && i < rest.length; i++) { + rotating.add(rest[(_cursor + i) % rest.length]); + } + final visible = [...reserved, ...rotating]; + final canRefresh = rest.length > rotatingSlots; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: NhamSpacing.sp2, + runSpacing: NhamSpacing.sp2, + children: [ + for (final f in visible) + _FoodChip( + label: vi ? f.name : f.nameEn, + reserved: _reserved.contains(f.id), + onTap: () { + HapticFeedback.selectionClick(); + setState(() { + if (!_reserved.add(f.id)) _reserved.remove(f.id); + }); + }, + ), + ], + ), + if (canRefresh) ...[ + const SizedBox(height: NhamSpacing.sp2), + _RefreshButton( + onTap: () => setState( + () => _cursor = (_cursor + rotatingSlots) % rest.length, + ), + ), + ], + ], + ); + } +} + +class _FoodChip extends StatelessWidget { + const _FoodChip({ + required this.label, + required this.reserved, + required this.onTap, + }); + + final String label; + final bool reserved; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // A long food name can be wider than the row; Wrap can't break inside a + // child, so cap the chip and let the label ellipsize. + final maxWidth = MediaQuery.of(context).size.width * 0.62; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + constraints: BoxConstraints(maxWidth: maxWidth), + padding: EdgeInsets.fromLTRB( + reserved ? 8 : 10, + 6, + 10, + 6, + ), + decoration: BoxDecoration( + color: reserved ? NhamColors.accent10 : kFieldFill, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all( + color: reserved ? NhamColors.accent50 : NhamColors.borderSoft, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (reserved) ...[ + const Icon(LucideIcons.check, size: 13, color: NhamColors.accentDark), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: dashMeta(color: reserved ? NhamColors.text : kInk), + ), + ), + ], + ), + ), + ); + } +} + +class _RefreshButton extends StatelessWidget { + const _RefreshButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.refreshCw, size: 13, color: kInkSecondary), + const SizedBox(width: 5), + Text( + tr('nutrition.suggestedFoods.refresh'), + style: dashMeta(color: kInkSecondary), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart deleted file mode 100644 index 24c826ed..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/verdict_logic.dart'; -import 'fade_in_down.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/sections/verdict-hero.tsx`. -class VerdictHero extends StatelessWidget { - const VerdictHero({super.key, required this.overview}); - - final NutritionOverview overview; - - @override - Widget build(BuildContext context) { - final locale = context.locale.languageCode; - final dayFormatter = NumberFormat.decimalPattern(locale); - - final period = tr('nutrition.${kPeriodKeys[overview.resolvedRange]}'); - final consistent = joinNames( - takeNames(overview.summary.mostConsistent, tr), - locale, - ); - final attention = joinNames( - takeNames(overview.summary.needsAttention, tr), - locale, - ); - final tooFew = overview.trendStatus == 'too_few_logged_days'; - - String headline; - String muted; - - if (tooFew) { - headline = tr('nutrition.verdict.gathering'); - muted = tr('nutrition.verdict.gatheringSub', namedArgs: { - 'days': dayFormatter.format(overview.completeDays), - }); - } else { - if (consistent.isNotEmpty && attention.isNotEmpty) { - headline = tr('nutrition.verdict.both', namedArgs: { - 'period': period, - 'consistent': consistent, - 'attention': attention, - }); - } else if (consistent.isNotEmpty) { - headline = tr('nutrition.verdict.steadyOnly', namedArgs: { - 'period': period, - 'consistent': consistent, - }); - } else if (attention.isNotEmpty) { - headline = tr('nutrition.verdict.attentionOnly', namedArgs: { - 'period': period, - 'attention': attention, - }); - } else { - headline = tr('nutrition.verdict.quiet'); - } - - final avgConfidence = getAverageConfidence(overview); - muted = avgConfidence == null - ? tr('nutrition.verdict.trustLineNoConfidence', namedArgs: { - 'days': dayFormatter.format(overview.completeDays), - }) - : tr('nutrition.verdict.trustLine', namedArgs: { - 'days': dayFormatter.format(overview.completeDays), - 'confidence': (NumberFormat.decimalPattern(locale) - ..maximumFractionDigits = 0) - .format(avgConfidence), - }); - if (overview.partialDays > 0) { - // ICU plural → easy_localization plural object. The leading '· ' and the - // count live inside each form; '{}' is the count. - final note = - 'nutrition.verdict.partialNote'.plural(overview.partialDays); - muted += ' $note'; - } - } - - // Web motion.p: flex flex-wrap items-baseline gap-x-2.5 gap-y-1, leading-6. - // Web animates y:4→0 (offset 4), duration 0.5, delay 0.05. - return FadeInDown( - offset: 4, - delay: const Duration(milliseconds: 50), - child: Wrap( - spacing: 10, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 5), - child: Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: NhamColors.accent, - shape: BoxShape.circle, - ), - ), - ), - Text( - headline, - style: NhamTextStyles.serifMedium(fontSize: 14) - .copyWith(height: 24 / 14, color: NhamColors.text), - ), - Text( - muted, - style: NhamTextStyles.sansRegular(fontSize: 14) - .copyWith(height: 24 / 14, color: NhamColors.textMuted), - ), - ], - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart b/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart index 7fa9b502..1b5a61e2 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart @@ -6,9 +6,11 @@ import '../../../shared/widgets/decimal_input.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; +import '../../dashboard/logic/dashboard_format.dart' show formatCount; import '../logic/tdee.dart'; import '../widgets/aggression_slider.dart'; import '../widgets/custom_select.dart'; +import '../widgets/option_strip.dart'; /// Step-2 form values + computed targets, reported up when the body-metrics /// schema passes (mirrors RN `ScreenOneData`). Keys match the RN payload so the @@ -47,21 +49,21 @@ class ScreenTwoValues { }); Map toJson() => { - 'biologicalSex': biologicalSex, - 'weightKg': weightKg, - 'heightCm': heightCm, - 'age': age, - 'activityLevel': activityLevel, - 'goal': goal, - 'aggression': aggression, - 'carbSplit': carbSplit, - 'deficitOverride': deficitOverride, - 'tdeeKcal': tdeeKcal, - 'calorieTarget': calorieTarget, - 'proteinTargetG': proteinTargetG, - 'carbsTargetG': carbsTargetG, - 'fatTargetG': fatTargetG, - }; + 'biologicalSex': biologicalSex, + 'weightKg': weightKg, + 'heightCm': heightCm, + 'age': age, + 'activityLevel': activityLevel, + 'goal': goal, + 'aggression': aggression, + 'carbSplit': carbSplit, + 'deficitOverride': deficitOverride, + 'tdeeKcal': tdeeKcal, + 'calorieTarget': calorieTarget, + 'proteinTargetG': proteinTargetG, + 'carbsTargetG': carbsTargetG, + 'fatTargetG': fatTargetG, + }; } /// Partial seed for step 2 (from a saved profile / re-entered wizard state). @@ -128,13 +130,15 @@ class _ScreenBodyMetricsState extends State { int? get _tdee { if (!_allMetricsFilled) return null; - final bmr = calcBMR(BodyMetrics( - biologicalSex: BiologicalSex.values.byName(_sex!), - weightKg: _weight!, - heightCm: _height!, - age: _age!, - activityLevel: activityLevelFromString(_activity), - )); + final bmr = calcBMR( + BodyMetrics( + biologicalSex: BiologicalSex.values.byName(_sex!), + weightKg: _weight!, + heightCm: _height!, + age: _age!, + activityLevel: activityLevelFromString(_activity), + ), + ); return calcTDEE(bmr, activityLevelFromString(_activity)); } @@ -192,22 +196,24 @@ class _ScreenBodyMetricsState extends State { final targets = _finalTargets; if (tdee == null || targets == null) return; if (!_validate()) return; - widget.onChange(ScreenTwoValues( - biologicalSex: _sex!, - weightKg: _weight!, - heightCm: _height!, - age: _age!, - activityLevel: _activity, - goal: _goal, - aggression: _aggression, - carbSplit: _carbSplit, - deficitOverride: widget.defaultValues.deficitOverride, - tdeeKcal: tdee, - calorieTarget: targets.calories.round(), - proteinTargetG: targets.proteinG.round(), - carbsTargetG: targets.carbsG.round(), - fatTargetG: targets.fatG.round(), - )); + widget.onChange( + ScreenTwoValues( + biologicalSex: _sex!, + weightKg: _weight!, + heightCm: _height!, + age: _age!, + activityLevel: _activity, + goal: _goal, + aggression: _aggression, + carbSplit: _carbSplit, + deficitOverride: widget.defaultValues.deficitOverride, + tdeeKcal: tdee, + calorieTarget: targets.calories.round(), + proteinTargetG: targets.proteinG.round(), + carbsTargetG: targets.carbsG.round(), + fatTargetG: targets.fatG.round(), + ), + ); } @override @@ -219,17 +225,19 @@ class _ScreenBodyMetricsState extends State { Text( tr('onboarding.bodyMetrics.title'), // tracking-tight: -0.025em × 24px ≈ -0.6 - style: NhamTextStyles.serifMedium(fontSize: 24) - .copyWith(letterSpacing: -0.6, color: NhamColors.text), + style: NhamTextStyles.serifMedium( + fontSize: 24, + ).copyWith(letterSpacing: -0.6, color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp1), Text( tr('onboarding.bodyMetrics.subtitle'), - style: NhamTextStyles.sansRegular(fontSize: 14, height: 22 / 14) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 14, + height: 22 / 14, + ).copyWith(color: NhamColors.textHelp), ), const SizedBox(height: NhamSpacing.sp5), // space-y-5 - // Metrics card. Container( padding: const EdgeInsets.all(NhamSpacing.sp5), @@ -241,18 +249,20 @@ class _ScreenBodyMetricsState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // "About You" header block (mb-4 before the grid). + // "About you" header block (mb-4 before the grid). Text( - 'About You', - style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(letterSpacing: 1.5, color: NhamColors.stone), + tr('onboarding.bodyMetrics.aboutYou').toUpperCase(), + style: NhamTextStyles.sansBold( + fontSize: 11, + ).copyWith(letterSpacing: 1.5, color: NhamColors.stone), ), const SizedBox(height: NhamSpacing.sp1), // mt-1 Text( - 'These stay optional, but once you fill them in, Nhẩm can ' - 'compute more tailored targets locally.', - style: NhamTextStyles.sansRegular(fontSize: 13, height: 1.625) - .copyWith(color: NhamColors.textHelp), + tr('onboarding.bodyMetrics.aboutYouHint'), + style: NhamTextStyles.sansRegular( + fontSize: 13, + height: 1.625, + ).copyWith(color: NhamColors.textHelp), ), const SizedBox(height: NhamSpacing.sp4), // mb-4 _buildGrid(), @@ -260,12 +270,8 @@ class _ScreenBodyMetricsState extends State { ), ), const SizedBox(height: NhamSpacing.sp5), // space-y-5 - // Goal card (when TDEE known) or the dashed unlock placeholder. - if (tdee != null) - _buildGoalCard(tdee) - else - _buildUnlockPlaceholder(), + if (tdee != null) _buildGoalCard(tdee) else _buildUnlockPlaceholder(), ], ); } @@ -277,16 +283,18 @@ class _ScreenBodyMetricsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Fill the basics to unlock targets.', - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.text), + tr('onboarding.bodyMetrics.unlockTitle'), + style: NhamTextStyles.sansMedium( + fontSize: 14, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp1), // mt-1 Text( - 'Once sex, weight, height, age, and activity are filled, this side ' - 'turns into your live calorie target and macro planner.', - style: NhamTextStyles.sansRegular(fontSize: 13, height: 1.625) - .copyWith(color: NhamColors.textHelp), + tr('onboarding.bodyMetrics.unlockHint'), + style: NhamTextStyles.sansRegular( + fontSize: 13, + height: 1.625, + ).copyWith(color: NhamColors.textHelp), ), ], ), @@ -297,16 +305,21 @@ class _ScreenBodyMetricsState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // biological sex (full) + // biological sex (full) — two options, so a 2-segment strip rather than + // a select popover (a tap-to-open menu for a binary choice). _FieldLabel(tr('onboarding.bodyMetrics.biologicalSex')), const SizedBox(height: 6), - CustomSelect( + OptionStrip( value: _sex ?? '', options: [ - CustomSelectOption( - label: tr('onboarding.bodyMetrics.male'), value: 'male'), - CustomSelectOption( - label: tr('onboarding.bodyMetrics.female'), value: 'female'), + OptionStripItem( + label: tr('onboarding.bodyMetrics.male'), + value: 'male', + ), + OptionStripItem( + label: tr('onboarding.bodyMetrics.female'), + value: 'female', + ), ], onChange: (v) { setState(() => _sex = v); @@ -387,15 +400,21 @@ class _ScreenBodyMetricsState extends State { value: _activity, options: [ CustomSelectOption( - label: tr('onboarding.bodyMetrics.sedentary'), - value: 'sedentary'), + label: tr('onboarding.bodyMetrics.sedentary'), + value: 'sedentary', + ), CustomSelectOption( - label: tr('onboarding.bodyMetrics.light'), value: 'light'), + label: tr('onboarding.bodyMetrics.light'), + value: 'light', + ), CustomSelectOption( - label: tr('onboarding.bodyMetrics.moderate'), value: 'moderate'), + label: tr('onboarding.bodyMetrics.moderate'), + value: 'moderate', + ), CustomSelectOption( - label: tr('onboarding.bodyMetrics.veryActive'), - value: 'very_active'), + label: tr('onboarding.bodyMetrics.veryActive'), + value: 'very_active', + ), ], onChange: (v) { setState(() => _activity = v); @@ -411,19 +430,24 @@ class _ScreenBodyMetricsState extends State { // surface them live alongside the report so the hero stays in sync). setState(() { String tv(String k) => tr('validation.bodyMetrics.$k'); - _weightError = _weight == null - ? null - : (_weight! < 30 - ? tv('weightMin') - : (_weight! > 300 ? tv('weightMax') : null)); - _heightError = _height == null - ? null - : (_height! < 100 - ? tv('heightMin') - : (_height! > 250 ? tv('heightMax') : null)); - _ageError = _age == null - ? null - : (_age! < 13 ? tv('ageMin') : (_age! > 100 ? tv('ageMax') : null)); + _weightError = + _weight == null + ? null + : (_weight! < 30 + ? tv('weightMin') + : (_weight! > 300 ? tv('weightMax') : null)); + _heightError = + _height == null + ? null + : (_height! < 100 + ? tv('heightMin') + : (_height! > 250 ? tv('heightMax') : null)); + _ageError = + _age == null + ? null + : (_age! < 13 + ? tv('ageMin') + : (_age! > 100 ? tv('ageMax') : null)); }); _report(); } @@ -509,10 +533,10 @@ class _ScreenBodyMetricsState extends State { } } -/// Daily-target hero card — accent gradient surface showing the computed -/// calorie target, a "based on TDEE…" caption, and the selected split's macros. -/// Ported from the RN onboarding `hero` block (which used a flat accent tint); -/// here it's a soft accent gradient. +/// Daily-target card — a flat white surface showing the computed calorie +/// target, a "based on TDEE…" caption, and the selected split's macros. +/// Hierarchy comes from the hairline border, not an alpha gradient (matching +/// the dashboard token system: solid cards, one radius). class _DailyTargetCard extends StatelessWidget { const _DailyTargetCard({ required this.calorieTarget, @@ -526,31 +550,26 @@ class _DailyTargetCard extends StatelessWidget { final String goal; final MacroTargets? macros; - String _fmt(num n) { - final s = n.round().toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return buf.toString(); - } - @override Widget build(BuildContext context) { - final caption = StringBuffer() - ..write(tr('onboarding.bodyMetrics.basedOnTdee')) - ..write(' ~${_fmt(tdee)} ${tr('onboarding.bodyMetrics.kcal')}'); + final locale = context.locale.toString(); + final caption = + StringBuffer() + ..write(tr('onboarding.bodyMetrics.basedOnTdee')) + ..write( + ' ~${formatCount(tdee, locale)} ${tr('onboarding.bodyMetrics.kcal')}', + ); if (goal == 'maintaining') { caption.write(' · ${tr('onboarding.bodyMetrics.maintenance')}'); } else { final sign = goal == 'cutting' ? '−' : '+'; final delta = (tdee - calorieTarget).abs(); - final kind = goal == 'cutting' - ? tr('onboarding.bodyMetrics.aggressionDeficit') - : tr('onboarding.bodyMetrics.aggressionSurplus'); + final kind = + goal == 'cutting' + ? tr('onboarding.bodyMetrics.aggressionDeficit') + : tr('onboarding.bodyMetrics.aggressionSurplus'); caption.write( - ' · $sign${_fmt(delta)} ${tr('onboarding.bodyMetrics.perDay')} $kind', + ' · $sign${formatCount(delta.round(), locale)} ${tr('onboarding.bodyMetrics.perDay')} $kind', ); } @@ -559,43 +578,44 @@ class _DailyTargetCard extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(NhamSpacing.sp5), decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [NhamColors.accent15, NhamColors.accent05], - ), + color: NhamColors.elev, borderRadius: BorderRadius.circular(NhamRadii.containerLg), - border: Border.all(color: NhamColors.accent40), + border: Border.all(color: NhamColors.inputBorder), ), child: Column( children: [ Text( tr('onboarding.bodyMetrics.calorieTarget').toUpperCase(), textAlign: TextAlign.center, - style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(color: NhamColors.accentDark, letterSpacing: 1.5), + style: NhamTextStyles.sansBold( + fontSize: 11, + ).copyWith(color: NhamColors.stone, letterSpacing: 1.5), ), const SizedBox(height: NhamSpacing.sp2), Text.rich( TextSpan( children: [ - TextSpan(text: _fmt(calorieTarget)), + TextSpan(text: formatCount(calorieTarget.round(), locale)), TextSpan( text: ' ${tr('onboarding.bodyMetrics.kcal')}', - style: NhamTextStyles.sansRegular(fontSize: 18) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 18, + ).copyWith(color: NhamColors.textHelp), ), ], ), - style: NhamTextStyles.serifRegular(fontSize: 36) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: 36, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp2), Text( caption.toString(), textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular(fontSize: 12, height: 16 / 12) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 12, + height: 16 / 12, + ).copyWith(color: NhamColors.textHelp), ), if (m != null) ...[ const SizedBox(height: NhamSpacing.sp4), @@ -618,14 +638,16 @@ class _DailyTargetCard extends StatelessWidget { children: [ Text( label.toUpperCase(), - style: NhamTextStyles.sansMedium(fontSize: 10) - .copyWith(color: NhamColors.textMuted, letterSpacing: 0.5), + style: NhamTextStyles.sansMedium( + fontSize: 10, + ).copyWith(color: NhamColors.textMuted, letterSpacing: 0.5), ), const SizedBox(height: 2), Text( '${grams.round()}${tr('onboarding.bodyMetrics.grams')}', - style: NhamTextStyles.sansSemiBold(fontSize: 15) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansSemiBold( + fontSize: 15, + ).copyWith(color: NhamColors.text), ), ], ); @@ -637,18 +659,9 @@ class _TdeeHero extends StatelessWidget { const _TdeeHero({required this.tdee}); final int tdee; - String _fmt(num n) { - final s = n.round().toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return buf.toString(); - } - @override Widget build(BuildContext context) { + final locale = context.locale.toString(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -657,17 +670,19 @@ class _TdeeHero extends StatelessWidget { Text.rich( TextSpan( children: [ - TextSpan(text: '~${_fmt(tdee)} '), + TextSpan(text: '~${formatCount(tdee, locale)} '), TextSpan( text: tr('onboarding.bodyMetrics.kcal'), - style: NhamTextStyles.sansRegular(fontSize: 18) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 18, + ).copyWith(color: NhamColors.textHelp), ), ], ), // text-4xl (36px) tracking-tighter - style: NhamTextStyles.serifRegular(fontSize: 36) - .copyWith(letterSpacing: -1, color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: 36, + ).copyWith(letterSpacing: -1, color: NhamColors.text), ), ], ); @@ -679,8 +694,7 @@ class _Divider extends StatelessWidget { final Color color; @override - Widget build(BuildContext context) => - Container(height: 1, color: color); + Widget build(BuildContext context) => Container(height: 1, color: color); } /// A dashed (dotted) 1px border panel: rounded-[28px], bg #FFFCF8, p-5. @@ -691,10 +705,7 @@ class DottedBorderBox extends StatelessWidget { @override Widget build(BuildContext context) { return CustomPaint( - painter: _DashedBorderPainter( - color: NhamColors.inputBorder, - radius: 28, - ), + painter: _DashedBorderPainter(color: NhamColors.inputBorder, radius: 28), child: Container( padding: const EdgeInsets.all(NhamSpacing.sp5), decoration: BoxDecoration( @@ -714,10 +725,11 @@ class _DashedBorderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; + final paint = + Paint() + ..color = color + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; final rrect = RRect.fromRectAndRadius( Offset.zero & size, Radius.circular(radius), @@ -750,8 +762,9 @@ class _FieldLabel extends StatelessWidget { Widget build(BuildContext context) { return Text( text.toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(letterSpacing: 1.5, color: NhamColors.stone), + style: NhamTextStyles.sansBold( + fontSize: 11, + ).copyWith(letterSpacing: 1.5, color: NhamColors.stone), ); } } @@ -775,8 +788,9 @@ class _Cell extends StatelessWidget { const SizedBox(height: 6), Text( error!, - style: NhamTextStyles.sansRegular(fontSize: 12) - .copyWith(color: NhamColors.danger), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.danger), ), ], ], @@ -842,9 +856,10 @@ class _GoalButtonState extends State<_GoalButton> { @override Widget build(BuildContext context) { // active bg-white #2C2416 shadow-sm; inactive #8B8682 + hover:text-#2C2416. - final color = widget.active - ? NhamColors.text - : (_pressed ? NhamColors.text : NhamColors.textHelp); + final color = + widget.active + ? NhamColors.text + : (_pressed ? NhamColors.text : NhamColors.textHelp); return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _pressed = true), @@ -859,13 +874,14 @@ class _GoalButtonState extends State<_GoalButton> { vertical: NhamSpacing.sp1_5, // py-1.5 horizontal: NhamSpacing.sp3, // px-3 ), - decoration: widget.active - ? BoxDecoration( - color: NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - boxShadow: const [NhamShadows.sm], // shadow-sm - ) - : const BoxDecoration(), + decoration: + widget.active + ? BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + boxShadow: const [NhamShadows.sm], // shadow-sm + ) + : const BoxDecoration(), child: Text( widget.label, style: NhamTextStyles.sansMedium(fontSize: 14).copyWith(color: color), @@ -896,21 +912,23 @@ class _CarbCardState extends State<_CarbCard> { bool _pressed = false; String get _label => switch (widget.id) { - 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarb'), - 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarb'), - _ => tr('onboarding.bodyMetrics.higherCarb'), - }; + 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarb'), + 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarb'), + _ => tr('onboarding.bodyMetrics.higherCarb'), + }; String get _desc => switch (widget.id) { - 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarbDescription'), - 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarbDescription'), - _ => tr('onboarding.bodyMetrics.higherCarbDescription'), - }; + 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarbDescription'), + 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarbDescription'), + _ => tr('onboarding.bodyMetrics.higherCarbDescription'), + }; @override Widget build(BuildContext context) { - final macros = - calcMacroGrams(widget.targetCalories, carbSplitFromString(widget.id)); + final macros = calcMacroGrams( + widget.targetCalories, + carbSplitFromString(widget.id), + ); final active = widget.active; final grams = tr('onboarding.bodyMetrics.grams'); final rows = <(String, num)>[ @@ -919,9 +937,10 @@ class _CarbCardState extends State<_CarbCard> { (tr('onboarding.bodyMetrics.carbs'), macros.carbsG.round()), ]; - final borderColor = active - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + final borderColor = + active + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); return GestureDetector( behavior: HitTestBehavior.opaque, @@ -935,25 +954,28 @@ class _CarbCardState extends State<_CarbCard> { color: active ? NhamColors.selectedCard : NhamColors.elev, // #FFF8EF borderRadius: BorderRadius.circular(NhamRadii.xxxl), // rounded-[22px] border: Border.all(color: borderColor), - boxShadow: active - ? const [ - // shadow-[0_10px_24px_rgba(201,168,124,0.14)] - BoxShadow( - color: Color(0x24C9A87C), - blurRadius: 24, - offset: Offset(0, 10), - ), - ] - : null, + boxShadow: + active + ? const [ + // shadow-[0_10px_24px_rgba(201,168,124,0.14)] + BoxShadow( + color: Color(0x24C9A87C), + blurRadius: 24, + offset: Offset(0, 10), + ), + ] + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Header band (px-3.5 py-2.5). Container( - color: active - ? NhamColors.selectedSegment // #FBF2E6 - : NhamColors.track, // #F5F4F0 + color: + active + ? NhamColors + .selectedSegment // #FBF2E6 + : NhamColors.track, // #F5F4F0 padding: const EdgeInsets.symmetric( horizontal: NhamSpacing.sp3_5, // px-3.5 vertical: NhamSpacing.sp2_5, // py-2.5 @@ -963,16 +985,19 @@ class _CarbCardState extends State<_CarbCard> { children: [ Text( _label, - style: NhamTextStyles.sansMedium(fontSize: 13) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: 13, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: 2), // mt-0.5 Text( _desc, style: NhamTextStyles.sansRegular(fontSize: 10).copyWith( - color: active - ? NhamColors.textSelected // #6F6556 - : NhamColors.textHelp, // #8B8682 + color: + active + ? NhamColors + .textSelected // #6F6556 + : NhamColors.textHelp, // #8B8682 ), ), ], @@ -993,16 +1018,18 @@ class _CarbCardState extends State<_CarbCard> { children: [ Text( rows[i].$1.toUpperCase(), - style: NhamTextStyles.sansRegular(fontSize: 10) - .copyWith( + style: NhamTextStyles.sansRegular( + fontSize: 10, + ).copyWith( letterSpacing: 0.4, // tracking-wide color: NhamColors.textSelected, // #6F6556 ), ), Text( '${rows[i].$2}$grams', - style: NhamTextStyles.sansSemiBold(fontSize: 12) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansSemiBold( + fontSize: 12, + ).copyWith(color: NhamColors.text), ), ], ), diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart b/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart index a94318a9..bcd2d16f 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -14,7 +15,7 @@ import '../widgets/language_toggle.dart'; /// sets the persisted `preferredLocale` AND live-switches the app language via /// `context.setLocale` (matching web's `switchLocale`). lucide /// `Languages`/`Globe`/`MapPin` → -/// [Icons.translate]/[Icons.public]/[Icons.place_outlined]. +/// [LucideIcons.languages]/[LucideIcons.globe]/[LucideIcons.mapPin]. class ScreenOrigin extends StatefulWidget { const ScreenOrigin({ super.key, @@ -72,7 +73,7 @@ class _ScreenOriginState extends State { _Card( children: [ _LabelRow( - icon: Icons.translate, + icon: LucideIcons.languages, label: tr('onboarding.origin.preferredLanguage'), ), const SizedBox(height: NhamSpacing.sp4), @@ -93,7 +94,7 @@ class _ScreenOriginState extends State { _Card( children: [ _Field( - icon: Icons.public, + icon: LucideIcons.globe, label: tr('onboarding.origin.countryOfOrigin'), hint: tr('onboarding.origin.countryOfOriginHint'), child: CountrySelect( @@ -106,7 +107,7 @@ class _ScreenOriginState extends State { ), const SizedBox(height: NhamSpacing.sp4), _Field( - icon: Icons.place_outlined, + icon: LucideIcons.mapPin, label: tr('onboarding.origin.countryOfResidence'), hint: tr('onboarding.origin.countryOfResidenceHint'), child: CountrySelect( diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart b/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart index 74a56fd5..dc0a0f27 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart @@ -3,20 +3,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../data/session_provider.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; import '../../dashboard/data/dashboard_providers.dart'; +import '../../dashboard/logic/dashboard_format.dart'; import '../../logging/data/logging_providers.dart'; import '../providers/onboarding_providers.dart'; -/// "Setting up your account" — the celebratory finish shown after the wizard -/// completes, before landing on `/logging`. +/// The celebratory finish shown after the wizard completes, before landing on +/// `/logging`. /// -/// It does double duty: a lively beat so the transition feels deliberate, and a -/// cache-warm gate that drops the stale/null targets the dashboard + logging -/// screens may be holding (the per-step saves only refreshed the profile). It -/// invalidates the profile, dashboard bundle, and logging profile, warms the -/// profile fetch, holds a minimum window so it never flashes, then routes on. +/// It does double duty: a deliberate beat that pays off the wizard by counting +/// the freshly-computed daily calorie target up in Lora 40, and a cache-warm +/// gate that drops the stale/null targets the dashboard + logging screens may +/// be holding (the per-step saves only refreshed the profile). It invalidates +/// the profile, dashboard bundle, and logging profile, warms the dashboard +/// fetch (the authority on the computed target), holds a minimum window so it +/// never flashes, then routes on. class WelcomeSetupScreen extends ConsumerStatefulWidget { const WelcomeSetupScreen({super.key}); @@ -26,11 +30,13 @@ class WelcomeSetupScreen extends ConsumerStatefulWidget { class _WelcomeSetupScreenState extends ConsumerState with SingleTickerProviderStateMixin { - // Gentle radar pulse around the badge. - late final AnimationController _pulse = AnimationController( + // Drives the count-up from 0 → target. + late final AnimationController _count = AnimationController( vsync: this, - duration: const Duration(milliseconds: 1600), - )..repeat(); + duration: const Duration(milliseconds: 1100), + ); + + int? _target; @override void initState() { @@ -40,7 +46,7 @@ class _WelcomeSetupScreenState extends ConsumerState @override void dispose() { - _pulse.dispose(); + _count.dispose(); super.dispose(); } @@ -50,12 +56,33 @@ class _WelcomeSetupScreenState extends ConsumerState ref.invalidate(loggingProfileProvider); ref.invalidate(profileProvider); - // Warm the profile (targets), but never block the finish on a slow/failed - // fetch — the min window carries the UX either way. - final warm = ref - .read(profileProvider.future) - .then((_) {}, onError: (_, __) {}) - .timeout(const Duration(seconds: 6), onTimeout: () {}); + final userId = ref.read(currentSessionProvider)?.user.id; + + // Warm the dashboard bundle (the computed target lives here), but never + // block the finish on a slow/failed fetch — the min window carries the UX. + Future warm = Future.value(); + if (userId != null) { + final args = (userId: userId, date: todayDateString()); + warm = ref + .read(dashboardBundleProvider(args).future) + .then((bundle) { + final t = bundle.profile?.calorieTarget; + if (t != null && mounted) { + setState(() => _target = t.round()); + final reduced = WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations; + if (reduced) { + _count.value = 1; + } else { + _count.forward(); + } + } + }, onError: (_, __) {}) + .timeout(const Duration(seconds: 6), onTimeout: () {}); + } await Future.wait([ warm, @@ -81,8 +108,46 @@ class _WelcomeSetupScreenState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - _Badge(pulse: _pulse), - const SizedBox(height: 28), + if (_target != null) ...[ + Text( + tr('onboarding.setup.targetReadyLabel').toUpperCase(), + textAlign: TextAlign.center, + style: NhamTextStyles.eyebrow() + .copyWith(color: NhamColors.stone), + ), + const SizedBox(height: 10), + // The target counts up in Lora 40. + AnimatedBuilder( + animation: _count, + builder: (context, _) { + final shown = (_target! * _count.value).round(); + return Text.rich( + TextSpan( + children: [ + // Locale-aware grouping (en "2,000" / vi "2.000"). + TextSpan( + text: formatCount( + shown, + context.locale.toString(), + ), + ), + TextSpan( + text: ' ${tr('onboarding.setup.perDay')}', + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.md, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h1, + ).copyWith(color: NhamColors.text), + ); + }, + ), + const SizedBox(height: 24), + ], Text( tr('onboarding.setup.title'), textAlign: TextAlign.center, @@ -104,51 +169,3 @@ class _WelcomeSetupScreenState extends ConsumerState ); } } - -/// Accent badge with a check, wrapped by an expanding-and-fading radar ring. -class _Badge extends StatelessWidget { - const _Badge({required this.pulse}); - - final Animation pulse; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 120, - height: 120, - child: Stack( - alignment: Alignment.center, - children: [ - AnimatedBuilder( - animation: pulse, - builder: (context, _) { - final v = pulse.value; - final size = 76 + 44 * v; - return Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.accent.withValues(alpha: (1 - v) * 0.22), - ), - ); - }, - ), - Container( - width: 76, - height: 76, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.accent, - ), - child: const Icon( - Icons.check_rounded, - size: 38, - color: NhamColors.cream, - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart index d79f6361..621347b1 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart @@ -1,17 +1,32 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../data/countries.dart'; -/// Mirror of `components/onboarding/screen-origin.tsx` CountryPicker. +/// ISO values pinned above the alphabet — Việt Nam first, then the destinations +/// most Nhẩm users live in. Keeps Việt Nam one tap away instead of buried at "V". +const List _pinnedValues = [ + 'Vietnam', + 'United States', + 'Australia', + 'Japan', + 'South Korea', + 'Singapore', +]; + +/// Country picker for onboarding `screen-origin`. /// -/// An anchored popover dropdown glued to the trigger: portal-positioned at the -/// trigger bottom (+8px, flipped above when no room), same width as the trigger, -/// rounded-2xl, border #EAE7E0, shadow [0_20px_60px_rgba(44,36,22,0.18)], a -/// search input pinned at top, and a scroll list clamped to maxHeight 160..320. +/// Replaces the anchored popover (which opened alphabetically at Afghanistan, +/// auto-focused a search whose keyboard covered the list, and pinned nothing) +/// with a native modal bottom sheet: a grabber, a pinned search that stays above +/// the keyboard, a "Common" section (Việt Nam + frequent residences) above the +/// full alphabetical list, and a sheet that grows with the keyboard so the list +/// is never occluded. class CountrySelect extends StatefulWidget { const CountrySelect({super.key, required this.value, required this.onChange}); @@ -22,31 +37,7 @@ class CountrySelect extends StatefulWidget { State createState() => _CountrySelectState(); } -class _CountrySelectState extends State - with SingleTickerProviderStateMixin { - final OverlayPortalController _portal = OverlayPortalController(); - final LayerLink _link = LayerLink(); - final TextEditingController _search = TextEditingController(); - final GlobalKey _triggerKey = GlobalKey(); - - late final AnimationController _chevron = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - ); - - bool _open = false; - String _query = ''; - bool _flipAbove = false; - double _maxHeight = 320; - double _triggerWidth = 0; - - @override - void dispose() { - _chevron.dispose(); - _search.dispose(); - super.dispose(); - } - +class _CountrySelectState extends State { Country? get _selected { if (widget.value == null) return null; return kCountries.cast().firstWhere( @@ -55,265 +46,234 @@ class _CountrySelectState extends State ); } - void _toggle() { - if (_open) { - _close(); - } else { - _computePlacement(); - setState(() => _open = true); - _portal.show(); - _chevron.forward(); - } - } - - void _close() { - _chevron.reverse(); - _portal.hide(); - _search.clear(); - setState(() { - _open = false; - _query = ''; - }); - } - - void _computePlacement() { - final box = _triggerKey.currentContext?.findRenderObject() as RenderBox?; - if (box == null) return; - final screenH = MediaQuery.sizeOf(context).height; - final topLeft = box.localToGlobal(Offset.zero); - final spaceBelow = screenH - (topLeft.dy + box.size.height); - final spaceAbove = topLeft.dy; - _triggerWidth = box.size.width; - // Flip above when no room below (mirrors web: spaceBelow<180 && - // spaceAbove>spaceBelow+40). - _flipAbove = spaceBelow < 180 && spaceAbove > spaceBelow + 40; - final avail = (_flipAbove ? spaceAbove : spaceBelow) - 16; - _maxHeight = avail.clamp(160.0, 320.0); + Future _open() async { + HapticFeedback.selectionClick(); + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: NhamColors.text40, + builder: (_) => _CountrySheet(selectedValue: widget.value), + ); + if (picked != null) widget.onChange(picked); } @override Widget build(BuildContext context) { final hasValue = widget.value != null && widget.value!.isNotEmpty; final display = hasValue - ? (_selected != null ? '${widget.value} (${_selected!.vi})' : widget.value!) + ? (_selected != null + ? '${widget.value} (${_selected!.vi})' + : widget.value!) : tr('onboarding.origin.selectCountry'); - return CompositedTransformTarget( - link: _link, - child: OverlayPortal( - controller: _portal, - overlayChildBuilder: _buildOverlay, - child: GestureDetector( - key: _triggerKey, - behavior: HitTestBehavior.opaque, - onTap: _toggle, - child: Container( - // px-4 py-3, rounded-2xl - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp3, - ), - decoration: BoxDecoration( - // open: border #C9A87C + bg white + shadow-sm; closed: #EAE7E0 + cream - color: _open ? const Color(0xFFFFFFFF) : NhamColors.cream, - borderRadius: BorderRadius.circular(NhamRadii.containerLg), - border: Border.all( - color: _open ? NhamColors.accent : NhamColors.inputBorder, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _open, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: NhamColors.cream, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.inputBorder), + ), + child: Row( + children: [ + Expanded( + child: Text( + display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( + color: hasValue ? NhamColors.text : NhamColors.textHelp, + ), ), - boxShadow: _open ? const [NhamShadows.sm] : null, ), - child: Row( - children: [ - Expanded( - child: Text( - display, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( - color: hasValue ? NhamColors.text : NhamColors.textHelp, - ), - ), - ), - const SizedBox(width: NhamSpacing.sp2), - RotationTransition( - turns: Tween(begin: 0, end: 0.5).animate(_chevron), - child: const Icon( - Icons.keyboard_arrow_down, - size: 16, - color: NhamColors.textHelp, - ), - ), - ], + const SizedBox(width: NhamSpacing.sp2), + const Icon( + LucideIcons.chevronDown, + size: 16, + color: NhamColors.textHelp, ), - ), + ], ), ), ); } - - Widget _buildOverlay(BuildContext context) { - // Tap-outside scrim to dismiss. - return Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _close, - ), - ), - CompositedTransformFollower( - link: _link, - showWhenUnlinked: false, - targetAnchor: - _flipAbove ? Alignment.topLeft : Alignment.bottomLeft, - followerAnchor: - _flipAbove ? Alignment.bottomLeft : Alignment.topLeft, - offset: Offset(0, _flipAbove ? -8 : 8), - child: SizedBox( - width: _triggerWidth, - child: _DropdownPanel( - maxHeight: _maxHeight, - query: _query, - search: _search, - selectedValue: widget.value, - onQueryChange: (v) => setState(() => _query = v), - onPick: (v) { - widget.onChange(v); - _close(); - }, - ), - ), - ), - ], - ); - } } -class _DropdownPanel extends StatelessWidget { - const _DropdownPanel({ - required this.maxHeight, - required this.query, - required this.search, - required this.selectedValue, - required this.onQueryChange, - required this.onPick, - }); +class _CountrySheet extends StatefulWidget { + const _CountrySheet({required this.selectedValue}); - final double maxHeight; - final String query; - final TextEditingController search; final String? selectedValue; - final ValueChanged onQueryChange; - final ValueChanged onPick; + + @override + State<_CountrySheet> createState() => _CountrySheetState(); +} + +class _CountrySheetState extends State<_CountrySheet> { + final TextEditingController _search = TextEditingController(); + String _query = ''; + + static final List _pinned = [ + for (final v in _pinnedValues) + kCountries.firstWhere((c) => c.value == v), + ]; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final q = query.toLowerCase(); - final filtered = q.isEmpty + final q = _query.trim().toLowerCase(); + final searching = q.isNotEmpty; + final filtered = searching ? kCountries - : kCountries .where((c) => c.value.toLowerCase().contains(q) || c.vi.toLowerCase().contains(q)) - .toList(); + .toList() + : kCountries; - return Material( - type: MaterialType.transparency, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(NhamRadii.containerLg), // rounded-2xl - border: Border.all(color: NhamColors.inputBorder), - boxShadow: const [ - // shadow-[0_20px_60px_rgba(44,36,22,0.18)] - BoxShadow( - color: Color(0x2E2C2416), - blurRadius: 60, - offset: Offset(0, 20), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Search header (p-2 + bottom border). - Container( - padding: const EdgeInsets.all(NhamSpacing.sp2), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: NhamColors.inputBorder), + // Keyboard inset → the sheet lifts so the pinned search + list clear it. + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: FractionallySizedBox( + heightFactor: 0.85, + child: Container( + decoration: const BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + const SizedBox(height: 8), + // Grabber. + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: NhamColors.border, + borderRadius: BorderRadius.circular(NhamRadii.pill), ), ), - child: TextField( - controller: search, - autofocus: true, - onChanged: onQueryChange, - cursorColor: NhamColors.accent, - style: NhamTextStyles.sansRegular(fontSize: 13) - .copyWith(color: NhamColors.text), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: NhamColors.track, // bg-[#F5F4F0] - hintText: tr('onboarding.origin.searchCountry'), - hintStyle: NhamTextStyles.sansRegular(fontSize: 13) - .copyWith(color: NhamColors.textHelp), // #8B8682 - contentPadding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, + // Pinned search. + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: _search, + autofocus: false, + onChanged: (v) => setState(() => _query = v), + cursorColor: NhamColors.accent, + style: NhamTextStyles.sansRegular(fontSize: 14) + .copyWith(color: NhamColors.text), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: NhamColors.track, + prefixIcon: const Icon( + LucideIcons.search, + size: 16, + color: NhamColors.textHelp, + ), + hintText: tr('onboarding.origin.searchCountry'), + hintStyle: NhamTextStyles.sansRegular(fontSize: 14) + .copyWith(color: NhamColors.textHelp), + contentPadding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp3, + ), + border: _border(), + enabledBorder: _border(), + focusedBorder: _border(), ), - border: _border(), - enabledBorder: _border(), - focusedBorder: _border(), ), ), - ), - Flexible( - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), + const _SheetDivider(), + Expanded( child: filtered.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, - ), + ? Center( child: Text( tr('onboarding.origin.noCountries'), - textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular(fontSize: 13) + style: NhamTextStyles.sansRegular(fontSize: 14) .copyWith(color: NhamColors.textHelp), ), ) - : ListView.builder( - padding: const EdgeInsets.all(NhamSpacing.sp1), // p-1 - shrinkWrap: true, + : ListView( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - itemCount: filtered.length, - itemBuilder: (context, i) { - final item = filtered[i]; - return _OptionRow( - country: item, - selected: selectedValue == item.value, - onTap: () => onPick(item.value), - ); - }, + children: [ + if (!searching) ...[ + _SectionLabel( + tr('onboarding.origin.commonCountries'), + ), + for (final c in _pinned) + _OptionRow( + country: c, + selected: widget.selectedValue == c.value, + onTap: () => Navigator.of(context).pop(c.value), + ), + const SizedBox(height: 8), + const _SheetDivider(), + const SizedBox(height: 8), + ], + for (final c in filtered) + _OptionRow( + country: c, + selected: widget.selectedValue == c.value, + onTap: () => Navigator.of(context).pop(c.value), + ), + ], ), ), - ), - ], + ], + ), ), ), ); } OutlineInputBorder _border() => OutlineInputBorder( - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg + borderRadius: BorderRadius.circular(NhamRadii.md), borderSide: BorderSide.none, ); } +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 6), + child: Text( + text.toUpperCase(), + style: NhamTextStyles.sansBold(fontSize: 10) + .copyWith(letterSpacing: 1.5, color: NhamColors.stone), + ), + ); + } +} + +class _SheetDivider extends StatelessWidget { + const _SheetDivider(); + + @override + Widget build(BuildContext context) => + Container(height: 1, color: NhamColors.inputBorder); +} + class _OptionRow extends StatefulWidget { const _OptionRow({ required this.country, @@ -334,7 +294,6 @@ class _OptionRowState extends State<_OptionRow> { @override Widget build(BuildContext context) { - // selected = bg-[#C9A87C]/10 + medium #2C2416; hover/press = bg #F5F4F0. final Color fill = widget.selected ? NhamColors.accent10 : (_pressed ? NhamColors.track : Colors.transparent); @@ -347,32 +306,35 @@ class _OptionRowState extends State<_OptionRow> { child: Container( decoration: BoxDecoration( color: fill, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // rounded-xl + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), ), padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, // px-3 - vertical: NhamSpacing.sp2_5, // py-2.5 + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp3, ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( + Expanded( child: Text( widget.country.value, maxLines: 1, overflow: TextOverflow.ellipsis, style: (widget.selected - ? NhamTextStyles.sansMedium(fontSize: 13) - : NhamTextStyles.sansRegular(fontSize: 13)) + ? NhamTextStyles.sansMedium(fontSize: 14) + : NhamTextStyles.sansRegular(fontSize: 14)) .copyWith(color: NhamColors.text), ), ), const SizedBox(width: NhamSpacing.sp3), Text( widget.country.vi, - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith(color: NhamColors.textHelp), // #8B8682 + style: NhamTextStyles.sansRegular(fontSize: 12) + .copyWith(color: NhamColors.textHelp), ), + if (widget.selected) ...[ + const SizedBox(width: NhamSpacing.sp2), + const Icon(LucideIcons.check, size: 16, color: NhamColors.accent), + ], ], ), ), diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart index 943b1675..9589920e 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -150,7 +151,7 @@ class _CustomSelectState extends State RotationTransition( turns: Tween(begin: 0, end: 0.5).animate(_chevron), child: const Icon( - Icons.keyboard_arrow_down, + LucideIcons.chevronDown, size: 16, color: NhamColors.textHelp, ), @@ -311,7 +312,7 @@ class _OptionRowState extends State<_OptionRow> { ), ), if (widget.selected) - const Icon(Icons.check, size: 16, color: NhamColors.accent) + const Icon(LucideIcons.check, size: 16, color: NhamColors.accent) else const SizedBox(width: 16, height: 16), ], diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart index ed9c4fd4..9e90422c 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -6,9 +7,10 @@ import '../../../theme/nham_typography.dart'; /// RN port of `components/onboarding/wizard/language-toggle.tsx`. /// -/// Web shows GB/VN SVG flags (`country-flag-icons`); on mobile we use the -/// regional-indicator flag emoji (🇬🇧 English, 🇻🇳 Tiếng Việt), which iOS/Android -/// render natively. lucide `Check` → [Icons.check]. Picking a language switches +/// Web shows GB/VN SVG flags (`country-flag-icons`). Emoji are banned in the +/// brand, so on mobile each option carries a small Lora language-code monogram +/// in a tinted disc (`EN` / `VI`) — the brand's monogram-in-tinted-disc pattern +/// — instead of a regional-indicator flag emoji. Picking a language switches /// the app locale live (see `ScreenOrigin`). class LanguageToggle extends StatelessWidget { const LanguageToggle({ @@ -20,9 +22,9 @@ class LanguageToggle extends StatelessWidget { final String value; // 'en' | 'vi' final ValueChanged onChange; - static const List<({String code, String label, String flag})> _languages = [ - (code: 'en', label: 'English', flag: '🇬🇧'), - (code: 'vi', label: 'Tiếng Việt', flag: '🇻🇳'), + static const List<({String code, String label, String mono})> _languages = [ + (code: 'en', label: 'English', mono: 'EN'), + (code: 'vi', label: 'Tiếng Việt', mono: 'VI'), ]; @override @@ -34,7 +36,7 @@ class LanguageToggle extends StatelessWidget { Expanded( child: _LangButton( label: _languages[i].label, - flag: _languages[i].flag, + mono: _languages[i].mono, selected: value == _languages[i].code, onTap: () => onChange(_languages[i].code), ), @@ -48,13 +50,13 @@ class LanguageToggle extends StatelessWidget { class _LangButton extends StatefulWidget { const _LangButton({ required this.label, - required this.flag, + required this.mono, required this.selected, required this.onTap, }); final String label; - final String flag; + final String mono; final bool selected; final VoidCallback onTap; @@ -68,46 +70,73 @@ class _LangButtonState extends State<_LangButton> { @override Widget build(BuildContext context) { // Unselected: hover:border-[#C9A87C]/50 (border lightens toward accent). - final borderColor = widget.selected - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); - return GestureDetector( - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), + final borderColor = + widget.selected + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + return Semantics( + button: true, + selected: widget.selected, + excludeSemantics: true, + label: widget.label, onTap: widget.onTap, - child: AnimatedContainer( - // transition-colors ~150ms ease. - duration: const Duration(milliseconds: 150), - curve: const Cubic(0.25, 0.1, 0.25, 1), - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp3, - ), - decoration: BoxDecoration( - color: widget.selected ? NhamColors.accent10 : NhamColors.cream, - borderRadius: BorderRadius.circular(NhamRadii.containerLg), - border: Border.all(color: borderColor), - ), - child: Row( - children: [ - // h-5 w-7 rounded flag on web → regional-indicator emoji here. - Text(widget.flag, style: const TextStyle(fontSize: 20)), - const SizedBox(width: NhamSpacing.sp3), - Expanded( - child: Text( - widget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.text), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedContainer( + // transition-colors ~150ms ease. + duration: const Duration(milliseconds: 150), + curve: const Cubic(0.25, 0.1, 0.25, 1), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: widget.selected ? NhamColors.accent10 : NhamColors.cream, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + // h-5 w-7 rounded flag on web → Lora language-code monogram disc. + Container( + width: 28, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: NhamColors.accent10, + borderRadius: BorderRadius.circular(NhamRadii.sm), + ), + child: Text( + widget.mono, + style: NhamTextStyles.serifRegular( + fontSize: 11, + ).copyWith(color: NhamColors.text, letterSpacing: 0.5), + ), ), - ), - if (widget.selected) ...[ const SizedBox(width: NhamSpacing.sp3), - const Icon(Icons.check, size: 16, color: NhamColors.accent), + Expanded( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansMedium( + fontSize: 14, + ).copyWith(color: NhamColors.text), + ), + ), + if (widget.selected) ...[ + const SizedBox(width: NhamSpacing.sp3), + const Icon( + LucideIcons.check, + size: 16, + color: NhamColors.accent, + ), + ], ], - ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart index 0385de3e..52858575 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../models/onboarding.dart'; import '../../../theme/nham_colors.dart'; @@ -11,6 +12,7 @@ import '../data/profile_row.dart'; import '../logic/step_one_defaults.dart'; import '../providers/onboarding_providers.dart'; import '../screens/screen_body_metrics.dart'; +import '../../../shared/widgets/top_toast.dart'; import '../screens/screen_cooking.dart'; import '../screens/screen_origin.dart'; import 'step_indicator.dart'; @@ -35,8 +37,9 @@ double? _parseAggression(String? raw, double fallback) { /// The modal entrance animation (scale/opacity/y) is intentionally NOT here: it /// belongs to the dialog presentation ([showOnboardingDialog]), not the wizard. /// -/// lucide icons → Material: `X`→[Icons.close], `ArrowLeft`→[Icons.arrow_back], -/// `ArrowRight`→[Icons.arrow_forward], `SkipForward`→[Icons.skip_next]. +/// Icons are Lucide one-for-one: `X`→[LucideIcons.x], +/// `ArrowLeft`→[LucideIcons.arrowLeft], `ArrowRight`→[LucideIcons.arrowRight], +/// `SkipForward`→[LucideIcons.skipForward]. class OnboardingWizard extends ConsumerStatefulWidget { const OnboardingWizard({ super.key, @@ -129,26 +132,11 @@ class _OnboardingWizardState extends ConsumerState { } void _showSaveError() { - final messenger = ScaffoldMessenger.maybeOf(context); - if (messenger == null) return; - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - backgroundColor: NhamColors.cardCream, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - side: const BorderSide(color: NhamColors.border), - ), - content: Text( - tr('onboarding.saveError'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), - ), - ), - ); + showTopToast( + context, + tr('onboarding.saveError'), + variant: TopToastVariant.error, + ); } void _handleNext() { @@ -307,7 +295,7 @@ class _CloseButtonState extends State<_CloseButton> { color: _pressed ? const Color(0x80EAE7E0) : Colors.transparent, ), child: Icon( - Icons.close, + LucideIcons.x, size: 20, color: _pressed ? NhamColors.text : NhamColors.textHelp, ), @@ -558,7 +546,7 @@ class _BackButtonState extends State<_BackButton> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.arrow_back, size: 16, color: color), + Icon(LucideIcons.arrowLeft, size: 16, color: color), const SizedBox(width: NhamSpacing.sp2), Text( tr('common.back'), @@ -623,7 +611,7 @@ class _SkipButtonState extends State<_SkipButton> { ), ) else - Icon(Icons.skip_next, size: 14, color: textColor), + Icon(LucideIcons.skipForward, size: 14, color: textColor), ], ), ), @@ -695,7 +683,7 @@ class _NextButtonState extends State<_NextButton> { ), ) else - const Icon(Icons.arrow_forward, + const Icon(LucideIcons.arrowRight, size: 16, color: NhamColors.cream), ], ), diff --git a/apps/mobile-flutter/lib/features/settings/controls/country_select.dart b/apps/mobile-flutter/lib/features/settings/controls/country_select.dart index 61a4e245..f33569f0 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/country_select.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/country_select.dart @@ -50,16 +50,17 @@ class _CountrySelectState extends State { final box = _triggerKey.currentContext?.findRenderObject() as RenderBox?; final width = box?.size.width ?? 0; _entry = OverlayEntry( - builder: (_) => _CountryDropdown( - link: _link, - width: width, - selectedValue: widget.value, - onPick: (v) { - widget.onChange(v); - _close(); - }, - onDismiss: _close, - ), + builder: + (_) => _CountryDropdown( + link: _link, + width: width, + selectedValue: widget.value, + onPick: (v) { + widget.onChange(v); + _close(); + }, + onDismiss: _close, + ), ); Overlay.of(context).insert(_entry!); setState(() => _open = true); @@ -78,84 +79,105 @@ class _CountrySelectState extends State { @override Widget build(BuildContext context) { final selected = - kCountries.where((c) => c.value == widget.value).map((c) => c.vi).firstOrNull; + kCountries + .where((c) => c.value == widget.value) + .map((c) => c.vi) + .firstOrNull; final hasValue = widget.value != null; + final triggerLabel = + hasValue + ? (selected != null ? '${widget.value} ($selected)' : widget.value!) + : tr('onboarding.origin.selectCountry'); - final borderColor = _open - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + final borderColor = + _open + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); - return CompositedTransformTarget( - link: _link, - child: Stack( - children: [ - GestureDetector( - onTap: _toggle, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - key: _triggerKey, - // py-2.5 pl-4 pr-(10|4) - padding: EdgeInsets.fromLTRB( - NhamSpacing.sp4, - 10, - hasValue ? 40 : NhamSpacing.sp4, - 10, - ), - decoration: BoxDecoration( - color: _open ? NhamColors.elev : NhamColors.cream, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - border: Border.all(color: borderColor), - ), - child: Text( - hasValue - ? (selected != null - ? '${widget.value} ($selected)' - : widget.value!) - : tr('onboarding.origin.selectCountry'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm).copyWith( - color: hasValue ? NhamColors.text : NhamColors.textWarm, + return Semantics( + button: true, + excludeSemantics: true, + label: triggerLabel, + onTap: _toggle, + child: CompositedTransformTarget( + link: _link, + child: Stack( + children: [ + GestureDetector( + onTap: _toggle, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: Container( + key: _triggerKey, + width: double.infinity, + // py-2.5 pl-4 pr-(10|4) + padding: EdgeInsets.fromLTRB( + NhamSpacing.sp4, + 10, + hasValue ? 40 : NhamSpacing.sp4, + 10, + ), + decoration: BoxDecoration( + color: _open ? NhamColors.elev : NhamColors.cream, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + border: Border.all(color: borderColor), + ), + child: Text( + triggerLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith( + color: hasValue ? NhamColors.text : NhamColors.textWarm, + ), ), ), ), - ), - if (hasValue) - // Clear button overlay: right 10px, vertically centered, rounded-md - // p-1, '×' glyph, color textWarm → text on press. - Positioned( - right: 10, - top: 0, - bottom: 0, - child: Center( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => widget.onChange(null), - onTapDown: (_) => setState(() => _clearPressed = true), - onTapUp: (_) => setState(() => _clearPressed = false), - onTapCancel: () => setState(() => _clearPressed = false), - child: Container( - padding: const EdgeInsets.all(NhamSpacing.sp1), // p-1 - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(NhamRadii.md), - ), - child: Text( - '×', // × - style: NhamTextStyles.sansRegular(fontSize: 16).copyWith( - height: 1, - color: _clearPressed - ? NhamColors.text - : NhamColors.textWarm, + if (hasValue) + // Clear button overlay: right 10px, vertically centered, rounded-md + // p-1, '×' glyph, color textWarm → text on press. + Positioned( + right: 10, + top: 0, + bottom: 0, + child: Center( + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('common.remove'), + onTap: () => widget.onChange(null), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onChange(null), + onTapDown: (_) => setState(() => _clearPressed = true), + onTapUp: (_) => setState(() => _clearPressed = false), + onTapCancel: () => setState(() => _clearPressed = false), + child: Container( + padding: const EdgeInsets.all(NhamSpacing.sp1), // p-1 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(NhamRadii.md), + ), + child: Text( + '×', // × + style: NhamTextStyles.sansRegular( + fontSize: 16, + ).copyWith( + height: 1, + color: + _clearPressed + ? NhamColors.text + : NhamColors.textWarm, + ), + ), ), ), ), ), ), - ), - ], + ], + ), ), ); } @@ -192,13 +214,14 @@ class _CountryDropdownState extends State<_CountryDropdown> { @override Widget build(BuildContext context) { - final filtered = _query.isEmpty - ? kCountries - : kCountries.where((c) { - final q = _query.toLowerCase(); - return c.value.toLowerCase().contains(q) || - c.vi.toLowerCase().contains(q); - }).toList(); + final filtered = + _query.isEmpty + ? kCountries + : kCountries.where((c) { + final q = _query.toLowerCase(); + return c.value.toLowerCase().contains(q) || + c.vi.toLowerCase().contains(q); + }).toList(); return Stack( children: [ @@ -246,24 +269,25 @@ class _CountryDropdownState extends State<_CountryDropdown> { // Search header — p-2, border-b Container( padding: const EdgeInsets.all(NhamSpacing.sp2), - decoration: const Border( - bottom: BorderSide(color: NhamColors.inputBorder), - ).toBoxDecoration(), + decoration: + const Border( + bottom: BorderSide(color: NhamColors.inputBorder), + ).toBoxDecoration(), child: TextField( controller: _search, autofocus: true, onChanged: (v) => setState(() => _query = v), style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.text), + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.text), decoration: InputDecoration( isDense: true, filled: true, fillColor: NhamColors.track, hintText: tr('onboarding.origin.searchCountry'), hintStyle: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.textWarm), + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.textWarm), contentPadding: const EdgeInsets.symmetric( horizontal: NhamSpacing.sp3, vertical: NhamSpacing.sp2, @@ -277,36 +301,39 @@ class _CountryDropdownState extends State<_CountryDropdown> { // List — max-h-48 (192px), p-1 ConstrainedBox( constraints: const BoxConstraints(maxHeight: 192), - child: filtered.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, - ), - child: Text( - tr('onboarding.origin.noCountries'), - textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.textWarm), + child: + filtered.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2, + ), + child: Text( + tr('onboarding.origin.noCountries'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.textWarm), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all( + NhamSpacing.sp1, + ), + shrinkWrap: true, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: filtered.length, + itemBuilder: (_, i) { + final c = filtered[i]; + return _CountryRow( + label: c.value, + vi: c.vi, + selected: widget.selectedValue == c.value, + onTap: () => widget.onPick(c.value), + ); + }, ), - ) - : ListView.builder( - padding: const EdgeInsets.all(NhamSpacing.sp1), - shrinkWrap: true, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - itemCount: filtered.length, - itemBuilder: (_, i) { - final c = filtered[i]; - return _CountryRow( - label: c.value, - vi: c.vi, - selected: widget.selectedValue == c.value, - onTap: () => widget.onPick(c.value), - ); - }, - ), ), ], ), @@ -320,9 +347,9 @@ class _CountryDropdownState extends State<_CountryDropdown> { } OutlineInputBorder _searchBorder() => OutlineInputBorder( - borderRadius: BorderRadius.circular(NhamRadii.lg), // rounded-lg = 10 - borderSide: BorderSide.none, - ); + borderRadius: BorderRadius.circular(NhamRadii.lg), // rounded-lg = 10 + borderSide: BorderSide.none, + ); } class _CountryRow extends StatefulWidget { @@ -347,44 +374,57 @@ class _CountryRowState extends State<_CountryRow> { @override Widget build(BuildContext context) { - final Color? bg = widget.selected - ? NhamColors.accent10 - : (_pressed ? NhamColors.track : null); + final Color? bg = + widget.selected + ? NhamColors.accent10 + : (_pressed ? NhamColors.track : null); - return GestureDetector( + return Semantics( + button: true, + selected: widget.selected, + excludeSemantics: true, + label: '${widget.label}, ${widget.vi}', onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(NhamRadii.lg), // rounded-lg = 10 - ), - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.label, - style: (widget.selected - ? NhamTextStyles.sansMedium( - fontSize: NhamFontSize.detail) - : NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail)) - .copyWith(color: NhamColors.text), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular( + NhamRadii.lg, + ), // rounded-lg = 10 + ), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2, + ), + child: Row( + children: [ + Expanded( + child: Text( + widget.label, + style: (widget.selected + ? NhamTextStyles.sansMedium( + fontSize: NhamFontSize.detail, + ) + : NhamTextStyles.sansRegular( + fontSize: NhamFontSize.detail, + )) + .copyWith(color: NhamColors.text), + ), ), - ), - Text( - widget.vi, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xxs) - .copyWith(color: NhamColors.textWarm), - ), - ], + Text( + widget.vi, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xxs, + ).copyWith(color: NhamColors.textWarm), + ), + ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart index ab62f0d3..a8296945 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -26,18 +27,23 @@ class CustomSelect extends StatefulWidget { required this.options, required this.value, required this.onChange, + this.placeholder, }); final List options; final String value; final ValueChanged onChange; + /// Muted trigger text shown while [value] matches no option (e.g. a field + /// that must be genuinely chosen and starts empty). + final String? placeholder; + @override State createState() => _CustomSelectState(); } class _CustomSelectState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final LayerLink _link = LayerLink(); final GlobalKey _triggerKey = GlobalKey(); OverlayEntry? _entry; @@ -73,18 +79,19 @@ class _CustomSelectState extends State final box = _triggerKey.currentContext?.findRenderObject() as RenderBox?; final width = box?.size.width ?? 0; _entry = OverlayEntry( - builder: (_) => _DropdownOverlay( - link: _link, - width: width, - animation: _popover, - options: widget.options, - value: widget.value, - onPick: (v) { - widget.onChange(v); - _close(); - }, - onDismiss: _close, - ), + builder: + (_) => _DropdownOverlay( + link: _link, + width: width, + animation: _popover, + options: widget.options, + value: widget.value, + onPick: (v) { + widget.onChange(v); + _close(); + }, + onDismiss: _close, + ), ); Overlay.of(context).insert(_entry!); setState(() => _open = true); @@ -105,68 +112,81 @@ class _CustomSelectState extends State @override Widget build(BuildContext context) { - final selected = widget.options - .where((o) => o.value == widget.value) - .map((o) => o.label) - .firstOrNull; + final selected = + widget.options + .where((o) => o.value == widget.value) + .map((o) => o.label) + .firstOrNull; - final borderColor = _open - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + final borderColor = + _open + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); - return CompositedTransformTarget( - link: _link, - child: GestureDetector( - onTap: _toggle, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - key: _triggerKey, - // Outer ring: `ring-1 ring-accent/20` when open, sitting 1px outside - // the 1px border at the same `rounded-lg` (8px) radius. - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(NhamRadii.md + 1), - border: Border.all( - color: _open ? NhamColors.accent20 : Colors.transparent, - width: 1, - ), - ), + return Semantics( + button: true, + excludeSemantics: true, + label: selected ?? widget.placeholder ?? '', + onTap: _toggle, + child: CompositedTransformTarget( + link: _link, + child: GestureDetector( + onTap: _toggle, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), child: Container( + key: _triggerKey, + // Outer ring: `ring-1 ring-accent/20` when open, sitting 1px outside + // the 1px border at the same `rounded-lg` (8px) radius. decoration: BoxDecoration( - color: NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - border: Border.all(color: borderColor, width: 1), - boxShadow: _open ? const [NhamShadows.sm] : null, - ), - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, + borderRadius: BorderRadius.circular(NhamRadii.md + 1), + border: Border.all( + color: _open ? NhamColors.accent20 : Colors.transparent, + width: 1, + ), ), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: NhamSpacing.sp2), - child: Text( - selected ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + child: Container( + decoration: BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + border: Border.all(color: borderColor, width: 1), + boxShadow: _open ? const [NhamShadows.sm] : null, + ), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2, + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: NhamSpacing.sp2), + child: Text( + selected ?? widget.placeholder ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith( + color: + selected != null + ? NhamColors.text + : NhamColors.textHelp, + ), + ), ), ), - ), - RotationTransition( - turns: Tween(begin: 0, end: 0.5).animate(_chevron), - child: const Icon( - Icons.keyboard_arrow_down, - size: 16, - color: NhamColors.textWarm, + RotationTransition( + turns: Tween(begin: 0, end: 0.5).animate(_chevron), + child: const Icon( + LucideIcons.chevronDown, + size: 16, + color: NhamColors.textWarm, + ), ), - ), - ], + ], + ), ), ), ), @@ -236,8 +256,7 @@ class _DropdownOverlay extends StatelessWidget { child: Material( color: Colors.transparent, child: Container( - padding: - const EdgeInsets.symmetric(vertical: 6), // py-1.5 + padding: const EdgeInsets.symmetric(vertical: 6), // py-1.5 decoration: BoxDecoration( color: NhamColors.elev, borderRadius: BorderRadius.circular(NhamRadii.buttonXl), @@ -293,34 +312,47 @@ class _DropdownRowState extends State<_DropdownRow> { @override Widget build(BuildContext context) { - return GestureDetector( + return Semantics( + button: true, + selected: widget.selected, + excludeSemantics: true, + label: widget.option.label, onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-colors - color: _pressed ? NhamColors.track : Colors.transparent, - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2 + 2, // py-2.5 = 10 - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.option.label, - style: (widget.selected - ? NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - : NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm)) - .copyWith(color: NhamColors.text), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-colors + color: _pressed ? NhamColors.track : Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2 + 2, // py-2.5 = 10 + ), + child: Row( + children: [ + Expanded( + child: Text( + widget.option.label, + style: (widget.selected + ? NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + : NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + )) + .copyWith(color: NhamColors.text), + ), ), - ), - if (widget.selected) - const Icon(Icons.check, size: 16, color: NhamColors.accent) - else - const SizedBox(width: 16, height: 16), - ], + if (widget.selected) + const Icon( + LucideIcons.check, + size: 16, + color: NhamColors.accent, + ) + else + const SizedBox(width: 16, height: 16), + ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart b/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart index 5bfa7fe4..85395629 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart @@ -77,44 +77,59 @@ class _OptionButtonState extends State<_OptionButton> { @override Widget build(BuildContext context) { // Inactive `hover:text-[#2C2416]` — text darkens to `text` on press. - final color = widget.active || _pressed - ? NhamColors.text - : NhamColors.textWarm; - return GestureDetector( + final color = + widget.active || _pressed ? NhamColors.text : NhamColors.textWarm; + final label = + widget.option.hint == null + ? widget.option.label + : '${widget.option.label}, ${widget.option.hint}'; + return Semantics( + button: true, + selected: widget.active, + excludeSemantics: true, + label: label, onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-all - alignment: Alignment.center, - // py-2 - padding: const EdgeInsets.symmetric(vertical: NhamSpacing.sp2), - decoration: BoxDecoration( - color: widget.active ? NhamColors.elev : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 - boxShadow: widget.active ? const [NhamShadows.sm] : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.option.label, - textAlign: TextAlign.center, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.detail) - .copyWith(color: color), - ), - if (widget.option.hint != null) - Padding( - padding: const EdgeInsets.only(top: 2), // mt-0.5 - child: Text( - widget.option.hint!, - textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.eyebrow) - .copyWith(height: 13 / 10, color: color.withValues(alpha: 0.7)), - ), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-all + alignment: Alignment.center, + // py-2 + padding: const EdgeInsets.symmetric(vertical: NhamSpacing.sp2), + decoration: BoxDecoration( + color: widget.active ? NhamColors.elev : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 + boxShadow: widget.active ? const [NhamShadows.sm] : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.option.label, + textAlign: TextAlign.center, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.detail, + ).copyWith(color: color), ), - ], + if (widget.option.hint != null) + Padding( + padding: const EdgeInsets.only(top: 2), // mt-0.5 + child: Text( + widget.option.hint!, + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.eyebrow, + ).copyWith( + height: 13 / 10, + color: color.withValues(alpha: 0.7), + ), + ), + ), + ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart b/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart deleted file mode 100644 index a55fe155..00000000 --- a/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_theme.dart'; -import '../../../theme/nham_typography.dart'; - -/// A single tab descriptor for [TabStrip]. -class TabStripItem { - final String id; - final String label; - const TabStripItem({required this.id, required this.label}); -} - -/// RN port of the web settings `TabsList` — a pill segmented control. -/// -/// Container: `inputBorder40` bg, radius 12, 4px inner padding, 4px gap. Active -/// pill: white (`elev`) fill + `shadow.xs`, rounded-md (6). Press: 0.7 opacity. -class TabStrip extends StatelessWidget { - const TabStrip({ - super.key, - required this.tabs, - required this.active, - required this.onChange, - }); - - final List tabs; - final String active; - final ValueChanged onChange; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(NhamSpacing.sp1), - decoration: BoxDecoration( - color: NhamColors.inputBorder40, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - ), - child: Row( - children: [ - for (var i = 0; i < tabs.length; i++) ...[ - if (i > 0) const SizedBox(width: NhamSpacing.sp1), - Expanded(child: _TabButton( - tab: tabs[i], - active: tabs[i].id == active, - onTap: () => onChange(tabs[i].id), - )), - ], - ], - ), - ); - } -} - -class _TabButton extends StatelessWidget { - const _TabButton({ - required this.tab, - required this.active, - required this.onTap, - }); - - final TabStripItem tab; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp1 + 2, // py-1.5 = 6 - ), - decoration: BoxDecoration( - color: active ? NhamColors.elev : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 - boxShadow: active ? const [NhamShadows.sm] : null, - ), - child: Text( - tab.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm).copyWith( - color: active ? NhamColors.text : NhamColors.textWarm, - ), - ), - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart b/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart new file mode 100644 index 00000000..89172256 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart @@ -0,0 +1,50 @@ +import '../data/profile_providers.dart'; +import '../widgets/profile_form_values.dart'; +import 'tdee.dart'; + +/// Builds the flat `PUT /api/v1/profile` payload from the current form values, +/// recomputing TDEE + targets exactly as the web `handleSave` does. Shared by +/// every focused settings editor (the numeric goal editor and the +/// instant-commit cooking / region editors) so each persists the SAME contract. +/// +/// Requires complete body metrics — callers gate on a non-null profile and a +/// passing [validateBodyMetrics] before instant-committing. +ProfileSavePayload buildProfilePayload( + ProfileFormValues v, + String preferredLocale, +) { + final bmr = calcBMR( + biologicalSex: v.biologicalSex!, + weightKg: v.weightKg!, + heightCm: v.heightCm!, + age: v.age!, + ); + final tdee = calcTDEE(bmr, v.activityLevel); + final targets = calcDailyTargets(tdee, v.goal, v.aggression, v.carbSplit); + final clampedCalories = targets.calories < 500 ? 500.0 : targets.calories; + final macros = calcMacroGrams(clampedCalories, v.carbSplit); + + return ProfileSavePayload( + weightKg: v.weightKg!, + heightCm: v.heightCm!, + age: v.age!, + biologicalSex: v.biologicalSex!, + activityLevel: v.activityLevel, + tdeeKcal: tdee, + goal: v.goal, + aggression: v.aggression, + carbSplit: v.carbSplit, + calorieTarget: clampedCalories.round(), + proteinTargetG: macros.proteinG.round(), + carbsTargetG: macros.carbsG.round(), + fatTargetG: macros.fatG.round(), + countryOfOrigin: v.countryOfOrigin, + countryOfResidence: v.countryOfResidence, + preferredLocale: preferredLocale, + oilUsage: v.oilUsage, + defaultRicePortion: v.defaultRicePortion, + sugarBraised: v.sugarBraised, + defaultProteinPortion: v.defaultProteinPortion, + brothConsumption: v.brothConsumption, + ); +} diff --git a/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart b/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart index 1b1c0d56..5a9a68b4 100644 --- a/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart +++ b/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart @@ -31,7 +31,8 @@ class BodyMetrics extends StatelessWidget { // Live TDEE / targets — recomputed on every form mutation (the controller // notifies, the screen rebuilds this panel). Matches RN useWatch + useMemo. - final allMetricsFilled = v.weightKg != null && + final allMetricsFilled = v.biologicalSex != null && + v.weightKg != null && !(v.weightKg!.isNaN) && v.heightCm != null && v.age != null; @@ -39,7 +40,7 @@ class BodyMetrics extends StatelessWidget { int? tdee; if (allMetricsFilled) { final bmr = calcBMR( - biologicalSex: v.biologicalSex, + biologicalSex: v.biologicalSex!, weightKg: v.weightKg!, heightCm: v.heightCm!, age: v.age!, @@ -59,13 +60,32 @@ class BodyMetrics extends StatelessWidget { children: [ _Field( label: t('biologicalSex'), - child: CustomSelect( - value: v.biologicalSex.name, - onChange: (s) => form.update( - (f) => f.biologicalSex = BiologicalSex.values.byName(s)), - options: [ - CustomSelectOption(value: 'male', label: t('male')), - CustomSelectOption(value: 'female', label: t('female')), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomSelect( + // Empty until genuinely chosen — no silent male default. + value: v.biologicalSex?.name ?? '', + placeholder: t('sexPlaceholder'), + onChange: (s) { + form.update((f) => + f.biologicalSex = BiologicalSex.values.byName(s)); + form.clearError(ProfileField.biologicalSex); + }, + options: [ + CustomSelectOption(value: 'male', label: t('male')), + CustomSelectOption(value: 'female', label: t('female')), + ], + ), + if (form.errorFor(ProfileField.biologicalSex) != null) ...[ + const SizedBox(height: 6), // gap-1.5 + Text( + form.errorFor(ProfileField.biologicalSex)!, + style: + NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) + .copyWith(color: NhamColors.danger), + ), + ], ], ), ), diff --git a/apps/mobile-flutter/lib/features/settings/panels/regional.dart b/apps/mobile-flutter/lib/features/settings/panels/regional.dart index a296b198..0db583de 100644 --- a/apps/mobile-flutter/lib/features/settings/panels/regional.dart +++ b/apps/mobile-flutter/lib/features/settings/panels/regional.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -28,14 +29,14 @@ class Regional extends StatelessWidget { ), const SizedBox(height: NhamSpacing.sp5), _CountryField( - icon: Icons.public, // lucide Globe + icon: LucideIcons.globe, label: tr('onboarding.origin.countryOfOrigin'), value: v.countryOfOrigin, onChange: (s) => form.update((f) => f.countryOfOrigin = s), ), const SizedBox(height: NhamSpacing.sp4), _CountryField( - icon: Icons.place, // lucide MapPin + icon: LucideIcons.mapPin, label: tr('onboarding.origin.countryOfResidence'), value: v.countryOfResidence, onChange: (s) => form.update((f) => f.countryOfResidence = s), diff --git a/apps/mobile-flutter/lib/features/settings/screens/account_section.dart b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart new file mode 100644 index 00000000..99760099 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart @@ -0,0 +1,457 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../data/api_client.dart'; +import '../../../data/session_provider.dart'; +import '../../../shared/widgets/nham_primitives.dart'; +import '../../../shared/widgets/top_toast.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// Account section of the settings list: export data, sign out (with a +/// confirmation sheet), and permanent account deletion. Account deletion is an +/// App Store requirement whenever the app offers account creation. +class AccountSection extends ConsumerStatefulWidget { + const AccountSection({super.key}); + + @override + ConsumerState createState() => _AccountSectionState(); +} + +class _AccountSectionState extends ConsumerState { + bool _exporting = false; + bool _signingOut = false; + + Future _export() async { + if (_exporting) return; + setState(() => _exporting = true); + try { + final data = await ref.read(apiClientProvider).exportMyData(); + final pretty = const JsonEncoder.withIndent(' ').convert(data); + final dir = await getTemporaryDirectory(); + final stamp = DateTime.now().toIso8601String().split('T').first; + final file = File('${dir.path}/nham-data-$stamp.json'); + await file.writeAsString(pretty); + await Share.shareXFiles([XFile(file.path)]); + } catch (_) { + _toast(tr('settings.account.exportError')); + } finally { + if (mounted) setState(() => _exporting = false); + } + } + + Future _confirmSignOut() async { + HapticFeedback.lightImpact(); // sheet-open cue + final confirmed = await showCupertinoModalPopup( + context: context, + builder: + (sheetContext) => CupertinoActionSheet( + title: Text(tr('settings.account.signOutConfirmTitle')), + actions: [ + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () => Navigator.of(sheetContext).pop(true), + child: Text(tr('settings.account.signOut')), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(sheetContext).pop(false), + child: Text(tr('settings.account.cancel')), + ), + ), + ); + if (confirmed != true || _signingOut) return; + setState(() => _signingOut = true); + try { + await ref.read(authControllerProvider).signOut(); + if (!mounted) return; + context.go('/sign-in'); + } catch (_) { + if (!mounted) return; + setState(() => _signingOut = false); + _toast(tr('app.userMenu.signOutError')); + } + } + + void _openDelete() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _AccountDeleteScreen()), + ); + } + + void _toast(String message) { + showTopToast(context, message, variant: TopToastVariant.error); + } + + @override + Widget build(BuildContext context) { + final busy = _exporting || _signingOut; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(left: NhamSpacing.sp3, bottom: 4), + child: Text( + tr('settings.account.title').toUpperCase(), + style: NhamTextStyles.sansBold( + fontSize: 10, + ).copyWith(letterSpacing: 2.0, color: NhamColors.textMuted), + ), + ), + _AccountRow( + icon: LucideIcons.download, + label: tr('settings.account.exportTitle'), + busy: _exporting, + enabled: !busy, + onTap: _export, + ), + _AccountRow( + icon: LucideIcons.logOut, + label: tr('settings.account.signOut'), + enabled: !busy, + onTap: _confirmSignOut, + ), + _AccountRow( + icon: LucideIcons.trash2, + label: tr('settings.account.delete'), + danger: true, + enabled: !busy, + onTap: _openDelete, + ), + ], + ); + } +} + +class _AccountRow extends StatefulWidget { + const _AccountRow({ + required this.icon, + required this.label, + required this.onTap, + this.danger = false, + this.busy = false, + this.enabled = true, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool danger; + final bool busy; + final bool enabled; + + @override + State<_AccountRow> createState() => _AccountRowState(); +} + +class _AccountRowState extends State<_AccountRow> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final color = widget.danger ? NhamColors.danger : NhamColors.textMuted; + final pressedColor = widget.danger ? NhamColors.danger : NhamColors.text; + final fill = + widget.danger + ? const Color(0x1AD37B69) // danger @ 10% + : NhamColors.hover50; + + return Semantics( + button: true, + enabled: widget.enabled, + excludeSemantics: true, + label: widget.label, + onTap: widget.enabled ? widget.onTap : null, + child: Opacity( + opacity: widget.enabled ? 1.0 : 0.6, + child: GestureDetector( + onTap: widget.enabled ? widget.onTap : null, + onTapDown: + widget.enabled ? (_) => setState(() => _pressed = true) : null, + onTapUp: + widget.enabled ? (_) => setState(() => _pressed = false) : null, + onTapCancel: + widget.enabled ? () => setState(() => _pressed = false) : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + decoration: BoxDecoration( + color: _pressed ? fill : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + children: [ + Icon( + widget.icon, + size: 16, + color: _pressed ? pressedColor : color, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: _pressed ? pressedColor : color), + ), + ), + if (widget.busy) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NhamColors.textMuted, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Pushed delete-account screen: plain-language consequences and a type-to- +/// confirm gate before the irreversible deletion. +class _AccountDeleteScreen extends ConsumerStatefulWidget { + const _AccountDeleteScreen(); + + @override + ConsumerState<_AccountDeleteScreen> createState() => + _AccountDeleteScreenState(); +} + +class _AccountDeleteScreenState extends ConsumerState<_AccountDeleteScreen> { + final _controller = TextEditingController(); + bool _deleting = false; + + @override + void initState() { + super.initState(); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _canDelete => + _controller.text.trim() == tr('settings.account.deleteConfirmWord') && + !_deleting; + + Future _delete() async { + if (!_canDelete) return; + setState(() => _deleting = true); + try { + await ref.read(apiClientProvider).deleteAccount(); + } catch (_) { + if (!mounted) return; + setState(() => _deleting = false); + showTopToast( + context, + tr('settings.account.deleteError'), + variant: TopToastVariant.error, + ); + return; + } + + // The account is gone server-side. A local sign-out failure should not make + // the destructive action look like it failed. + try { + await ref.read(authControllerProvider).signOut(); + } catch (_) { + // Ignore: routing to sign-in clears the user's path out of the deleted + // account state, and Supabase will refresh/reject the stale session. + } + if (!mounted) return; + context.go('/sign-in'); + } + + @override + Widget build(BuildContext context) { + return Screen( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DeleteBackHeader(onBack: () => Navigator.of(context).maybePop()), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp5, + NhamSpacing.sp4, + NhamSpacing.sp5, + NhamSpacing.sp6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('settings.account.deleteScreenTitle'), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: NhamSpacing.sp3), + Text( + tr('settings.account.deleteConsequence'), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text, height: 1.5), + ), + const SizedBox(height: NhamSpacing.sp5), + Text( + tr( + 'settings.account.deleteConfirmLabel', + namedArgs: { + 'word': tr('settings.account.deleteConfirmWord'), + }, + ), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.textMuted), + ), + const SizedBox(height: 6), + TextField( + controller: _controller, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.characters, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + decoration: InputDecoration( + filled: true, + fillColor: NhamColors.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + borderSide: const BorderSide(color: NhamColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + borderSide: const BorderSide(color: NhamColors.danger), + ), + ), + ), + const SizedBox(height: NhamSpacing.sp4), + _DeleteButton( + enabled: _canDelete, + deleting: _deleting, + onTap: _delete, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _DeleteButton extends StatelessWidget { + const _DeleteButton({ + required this.enabled, + required this.deleting, + required this.onTap, + }); + + final bool enabled; + final bool deleting; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: enabled ? 1.0 : 0.4, + child: GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: NhamColors.danger, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Center( + child: + deleting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + tr('settings.account.deleteConfirmAction'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), + ), + ), + ), + ); + } +} + +class _DeleteBackHeader extends StatelessWidget { + const _DeleteBackHeader({required this.onBack}); + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: NhamColors.surface, + border: Border(bottom: BorderSide(color: NhamColors.border)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onBack, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.textMuted, + ), + const SizedBox(width: 6), + Text( + tr('settings.title'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index c8007e9d..553d6a44 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -1,9 +1,13 @@ import 'dart:ui'; +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../data/session_provider.dart'; import '../../../shared/widgets/nham_primitives.dart'; @@ -11,139 +15,350 @@ import '../../../shell/app_header.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; +import '../data/countries.dart'; import '../data/profile_providers.dart'; +import '../panels/cooking.dart'; +import '../widgets/instant_commit_editor.dart'; +import '../../../shared/widgets/top_toast.dart'; import '../widgets/profile_form.dart'; +import '../widgets/region_editor.dart'; +import 'account_section.dart'; + +/// The marketing version string (no `package_info_plus` dependency in pubspec, +/// so this is rendered statically — keep in sync with `pubspec.yaml`). +const String _appVersion = '1.0.1'; +const String _privacyUrl = 'https://nham.app/privacy'; +const String _termsUrl = 'https://nham.app/terms'; -/// Settings tab — two-level nav (list → profile drill-in), mirroring the RN -/// expo-router stack inside the settings tab. A nested [Navigator] owns the -/// drill-in so the single contract route `/settings` keeps one screen widget. +/// Settings tab — a single scrollable root of grouped preference rows, each +/// pushing ONE focused editor (Cupertino swipe-back). The numeric goal editor +/// keeps the felt save bar; toggle/select editors instant-commit. A nested +/// [Navigator] owns the drill-in so the `/settings` route stays one widget. class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) { return Navigator( - onGenerateRoute: (settings) => MaterialPageRoute( - settings: settings, - builder: (_) => const _SettingsList(), - ), + onGenerateRoute: + (settings) => MaterialPageRoute( + settings: settings, + builder: (_) => const _SettingsList(), + ), ); } } -/// Settings list view — mirrors the web sidebar's drill-in nav. Tap "Profile" -/// to push the profile editor onto the (nested) stack. -class _SettingsList extends StatelessWidget { +/// Settings root: grouped preference rows with current-value sublines, the +/// account group, and an about/legal group. +class _SettingsList extends ConsumerWidget { const _SettingsList(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(currentSessionProvider); + final userId = session?.user.id; + final profileAsync = ref.watch(profileProvider(userId != null)); + final profile = profileAsync.valueOrNull; + return Screen( bottom: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), + child: AppHeader(onBack: () => GoRouter.of(context).pop()), ), Expanded( - child: Padding( + child: ListView( padding: const EdgeInsets.fromLTRB( NhamSpacing.sp4, NhamSpacing.sp2, NhamSpacing.sp4, - 0, + NhamSpacing.sp6, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - // Web sidebar h2: text-lg (18) font-medium tracking-tight, - // Lora. - tr('settings.title'), - style: NhamTextStyles.serifMedium(fontSize: NhamFontSize.lg) - .copyWith( - letterSpacing: NhamTracking.tight, - color: NhamColors.text), - ), - const SizedBox(height: NhamSpacing.sp4), - _ProfileRowTile( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const _ProfileScreen(), - ), - ), + children: [ + Text( + tr('settings.title'), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.lg, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, ), - ], - ), + ), + const SizedBox(height: NhamSpacing.sp4), + + // ── Preferences ───────────────────────────────────────────── + _GroupLabel(tr('settings.preferences')), + _PreferenceRow( + icon: LucideIcons.target, + label: tr('settings.rows.goalPace'), + subline: _goalPaceSubline(context, profile), + onTap: () => _push(context, _EditorKind.goal), + ), + _PreferenceRow( + icon: LucideIcons.utensilsCrossed, + label: tr('settings.rows.cooking'), + subline: tr('settings.profilePanel.cookingSubtitle'), + onTap: () => _push(context, _EditorKind.cooking), + ), + _PreferenceRow( + icon: LucideIcons.globe, + label: tr('settings.rows.region'), + subline: _regionSubline(context, profile), + onTap: () => _push(context, _EditorKind.region), + ), + + const SizedBox(height: NhamSpacing.sp5), + const AccountSection(), + + const SizedBox(height: NhamSpacing.sp5), + // ── About ─────────────────────────────────────────────────── + _GroupLabel(tr('settings.about.title')), + _InfoRow( + icon: LucideIcons.info, + label: tr('settings.about.version'), + value: _appVersion, + ), + _PreferenceRow( + icon: LucideIcons.shieldCheck, + label: tr('settings.about.privacy'), + subline: _privacyUrl, + onTap: () => _copyLink(context, _privacyUrl), + ), + _PreferenceRow( + icon: LucideIcons.fileText, + label: tr('settings.about.terms'), + subline: _termsUrl, + onTap: () => _copyLink(context, _termsUrl), + ), + ], ), ), ], ), ); } + + void _push(BuildContext context, _EditorKind kind) { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => _ProfileScreen(kind: kind))); + } + + void _copyLink(BuildContext context, String url) { + Clipboard.setData(ClipboardData(text: url)); + showTopToast(context, tr('common.copied')); + } + + /// "Cutting · 0.50 kg/wk" — the saved goal + pace, or "Not set" when no + /// profile / no goal is configured. + String _goalPaceSubline(BuildContext context, ProfileRow? profile) { + if (profile == null) return tr('settings.rows.notSet'); + final goal = profile.goal; + if (goal == null) return tr('settings.rows.notSet'); + final goalLabel = switch (goal) { + 'cutting' => tr('onboarding.bodyMetrics.cutting'), + 'bulking' => tr('onboarding.bodyMetrics.bulking'), + _ => tr('onboarding.bodyMetrics.maintaining'), + }; + if (goal == 'maintaining') return goalLabel; + final aggression = double.tryParse(profile.aggression ?? ''); + if (aggression == null) return goalLabel; + final unit = tr('onboarding.bodyMetrics.weightUnit'); + // Locale decimal separator (vi "0,50") + localized per-week suffix. + final paceFmt = + NumberFormat.decimalPattern(context.locale.languageCode) + ..minimumFractionDigits = 2 + ..maximumFractionDigits = 2; + final pace = tr( + 'settings.rows.pacePerWeek', + namedArgs: {'pace': paceFmt.format(aggression), 'unit': unit}, + ); + return '$goalLabel · $pace'; + } + + /// "Việt Nam · Tiếng Việt" — residence country + current app language, or + /// just the language when no country is set. + String _regionSubline(BuildContext context, ProfileRow? profile) { + final lang = context.locale.languageCode == 'vi' ? 'Tiếng Việt' : 'English'; + final residence = profile?.countryOfResidence; + if (residence == null || residence.isEmpty) return lang; + final label = _countryLabel(residence, context.locale.languageCode); + return '$label · $lang'; + } + + String _countryLabel(String value, String locale) { + for (final c in kCountries) { + if (c.value == value) return locale == 'vi' ? c.vi : c.value; + } + return value; + } +} + +/// The focused editor a preference row pushes onto the stack. +enum _EditorKind { goal, cooking, region } + +class _GroupLabel extends StatelessWidget { + const _GroupLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: NhamSpacing.sp3, bottom: 4), + child: Text( + text.toUpperCase(), + style: NhamTextStyles.sansBold( + fontSize: 10, + ).copyWith(letterSpacing: 2.0, color: NhamColors.textMuted), + ), + ); + } } -class _ProfileRowTile extends StatefulWidget { - const _ProfileRowTile({required this.onTap}); +/// A grouped preference row: an icon, a label with a current-value subline, and +/// a trailing chevron. Presses fade the hover fill in and darken the text. +class _PreferenceRow extends StatefulWidget { + const _PreferenceRow({ + required this.icon, + required this.label, + required this.subline, + required this.onTap, + }); + + final IconData icon; + final String label; + final String subline; final VoidCallback onTap; @override - State<_ProfileRowTile> createState() => _ProfileRowTileState(); + State<_PreferenceRow> createState() => _PreferenceRowState(); } -class _ProfileRowTileState extends State<_ProfileRowTile> { +class _PreferenceRowState extends State<_PreferenceRow> { bool _pressed = false; @override Widget build(BuildContext context) { - return GestureDetector( + return Semantics( + button: true, + excludeSemantics: true, + label: '${widget.label}, ${widget.subline}', onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - // Web nav item: no border. rounded-xl px-3 py-2.5 gap-2.5, inactive - // text-muted, on press hover/50 fill + text darkens to text. - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: 10, // py-2.5 - ), - decoration: BoxDecoration( - color: _pressed ? NhamColors.hover50 : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // rounded-xl - ), - child: Row( - children: [ - Icon( - Icons.person_outline, - size: 16, - color: _pressed ? NhamColors.text : NhamColors.textMuted, - ), - const SizedBox(width: 10), // gap-2.5 - Expanded( - child: Text( - tr('settings.sidebar.profile'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith( - color: _pressed ? NhamColors.text : NhamColors.textMuted, + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover50 : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + children: [ + Icon( + widget.icon, + size: 16, + color: _pressed ? NhamColors.text : NhamColors.textMuted, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 2), + Text( + widget.subline, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs, + ).copyWith(color: NhamColors.textMuted), + ), + ], ), ), - ), - // ChevronRight inactive = text-muted/50. - const Icon(Icons.chevron_right, size: 16, color: NhamColors.textMuted50), - ], + const Icon( + LucideIcons.chevronRight, + size: 16, + color: NhamColors.textMuted50, + ), + ], + ), ), ), ); } } -/// Profile editor screen — pushed from the settings list. Back header mirrors -/// the web shell's ArrowLeft + "Settings" link. +/// A non-navigating info row (label on the left, a static value on the right) — +/// used for the app version. +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + child: Row( + children: [ + Icon(icon, size: 16, color: NhamColors.textMuted), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ), + Text( + value, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith( + color: NhamColors.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +/// Focused profile editor screen — pushed from a settings row. Renders ONE of +/// the goal / cooking / region editors. Back header mirrors the web shell's +/// ArrowLeft + "Settings" link. class _ProfileScreen extends ConsumerWidget { - const _ProfileScreen(); + const _ProfileScreen({required this.kind}); + + final _EditorKind kind; @override Widget build(BuildContext context, WidgetRef ref) { @@ -156,32 +371,61 @@ class _ProfileScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Back header — bg-[#FDFCF8]/90 backdrop-blur-sm, border-b. const _BackHeader(), Expanded( - child: userId == null - ? _Centered( - child: Text( - tr('common.notSignedIn'), - style: NhamTextStyles.bodySmall().copyWith(color: NhamColors.text), - ), - ) - : profileAsync.when( - loading: () => const _Centered( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(NhamColors.accent), + child: + userId == null + ? _Centered( + child: Text( + tr('common.notSignedIn'), + style: NhamTextStyles.bodySmall().copyWith( + color: NhamColors.text, + ), ), + ) + : profileAsync.when( + loading: + () => const _Centered( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + NhamColors.accent, + ), + ), + ), + // A flaky fetch is NOT an absent profile — only a + // genuinely-null profile (onboarding never ran) gets the + // re-onboarding empty state. An error offers a retry, not + // a misleading "Start setup". + error: + (_, __) => _ProfileLoadError( + onRetry: () { + unawaited( + ref.refresh(profileProvider(true).future), + ); + }, + ), + data: + (profile) => + profile != null + ? _editor(profile) + : const _ProfileEmpty(), ), - error: (_, __) => const _ProfileEmpty(), - data: (profile) => profile != null - ? ProfileForm(profile: profile) - : const _ProfileEmpty(), - ), ), ], ), ); } + + Widget _editor(ProfileRow profile) => switch (kind) { + _EditorKind.goal => ProfileForm(profile: profile), + _EditorKind.cooking => InstantCommitEditor( + profile: profile, + title: tr('settings.rows.cooking'), + subtitle: tr('settings.profilePanel.cookingSubtitle'), + child: const Cooking(), + ), + _EditorKind.region => RegionEditor(profile: profile), + }; } class _Centered extends StatelessWidget { @@ -190,11 +434,11 @@ class _Centered extends StatelessWidget { @override Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(NhamSpacing.sp6), - child: child, - ), - ); + child: Padding( + padding: const EdgeInsets.all(NhamSpacing.sp6), + child: child, + ), + ); } /// Empty state when no profile exists yet (onboarding never ran). @@ -211,14 +455,19 @@ class _ProfileEmpty extends StatelessWidget { children: [ Text( tr('settings.profilePage.emptyTitle'), - style: NhamTextStyles.serifMedium(fontSize: NhamFontSize.h3) - .copyWith(letterSpacing: NhamTracking.tight, color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), ), const SizedBox(height: NhamSpacing.sp4), Text( tr('settings.profilePage.emptyDescription'), - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(height: 22 / 14, color: NhamColors.textWarm), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(height: 22 / 14, color: NhamColors.textWarm), ), const SizedBox(height: NhamSpacing.sp4), // RN routes "Start setup" to /logging (where the onboarding overlay @@ -226,21 +475,88 @@ class _ProfileEmpty extends StatelessWidget { // descendant context, so this crosses tabs correctly. Align( alignment: Alignment.centerLeft, - child: GestureDetector( + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('settings.profilePage.startSetup'), onTap: () => context.go('/logging'), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp5, - vertical: 10, - ), - decoration: BoxDecoration( - color: NhamColors.text, - borderRadius: BorderRadius.circular(NhamRadii.pill), + child: GestureDetector( + onTap: () => context.go('/logging'), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: 10, + ), + decoration: BoxDecoration( + color: NhamColors.text, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + child: Text( + tr('settings.profilePage.startSetup'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), ), - child: Text( - tr('settings.profilePage.startSetup'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} + +/// Profile load failed (a flaky fetch, not an absent profile). Shows a neutral +/// error + a retry — never the re-onboarding "Start setup" CTA, which would +/// strand a configured user in a false "set up your profile" dead-end. +class _ProfileLoadError extends StatelessWidget { + const _ProfileLoadError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp5), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr('common.error'), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), + ), + const SizedBox(height: NhamSpacing.sp4), + Align( + alignment: Alignment.centerLeft, + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('common.retry'), + onTap: onRetry, + child: GestureDetector( + onTap: onRetry, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: 10, + ), + decoration: BoxDecoration( + color: NhamColors.text, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + child: Text( + tr('common.retry'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), ), ), ), @@ -270,34 +586,41 @@ class _BackHeaderState extends State<_BackHeader> { return ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), // backdrop-blur-sm - child: GestureDetector( - behavior: HitTestBehavior.opaque, + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('settings.title'), onTap: () => Navigator.of(context).pop(), - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp3, - ), - decoration: const BoxDecoration( - color: Color(0xE6FDFCF8), // cream @ 90% - border: Border( - bottom: BorderSide(color: NhamColors.inputBorder), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.arrow_back, size: 16, color: color), - const SizedBox(width: 6), // gap-1.5 - Text( - tr('settings.title'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: color), + decoration: const BoxDecoration( + color: Color(0xE6FDFCF8), // cream @ 90% + border: Border( + bottom: BorderSide(color: NhamColors.inputBorder), ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.arrowLeft, size: 16, color: color), + const SizedBox(width: 6), // gap-1.5 + Text( + tr('settings.title'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: color), + ), + ], + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart new file mode 100644 index 00000000..d93b2dcc --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart @@ -0,0 +1,234 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/profile_providers.dart'; +import '../logic/profile_payload.dart'; +import 'profile_form_controller.dart'; +import 'profile_form_values.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// A focused settings editor for non-numeric preferences (cooking habits, +/// region & language). Toggle/select changes instant-commit — the moment a +/// value diverges, the full profile payload is re-saved (targets recomputed +/// from the unchanged body metrics) and a success haptic fires. No save bar: +/// per the audit, the bar is reserved for the numeric goal editor. +/// +/// Seeds its own [ProfileFormController] from [profile] and exposes it to the +/// [child] panel via a [ProfileFormScope], so the existing panels (which read +/// `ProfileFormController.of(context)`) drop in unchanged. +class InstantCommitEditor extends ConsumerStatefulWidget { + const InstantCommitEditor({ + super.key, + required this.profile, + required this.title, + required this.subtitle, + required this.child, + }); + + final ProfileRow profile; + final String title; + final String subtitle; + final Widget child; + + @override + ConsumerState createState() => + _InstantCommitEditorState(); +} + +class _InstantCommitEditorState extends ConsumerState { + late final ProfileFormController _controller = ProfileFormController( + ProfileFormValues.fromRow(widget.profile), + ); + String? _errorText; + + /// The app locale at the last successful save. A locale change is committed + /// even when no form field is dirty (preferredLocale lives outside the form). + late String _savedLocale = + widget.profile.raw['preferredLocale'] as String? ?? + WidgetsBinding.instance.platformDispatcher.locale.languageCode; + + @override + void initState() { + super.initState(); + _controller.addListener(_onChanged); + } + + @override + void dispose() { + _controller.removeListener(_onChanged); + _controller.dispose(); + super.dispose(); + } + + /// Guards against the markSaved()-notifies-listeners feedback loop: while a + /// commit is settling we ignore the controller notifications it triggers. + bool _committing = false; + + void _onChanged() { + setState(() {}); + // A controller notification only fires on a user edit (the panels and the + // language row both notify on change) — never on mount — so each genuine + // edit instant-commits. markSaved() inside _commit() re-notifies; _committing + // suppresses that so we don't loop. + if (_committing) return; + final localeChanged = context.locale.languageCode != _savedLocale; + if (_controller.isDirty || localeChanged) _commit(); + } + + /// The values/locale of a failed commit, held for the retry affordance + /// (the visible controls roll back to the last-saved state on failure). + ProfileFormValues? _failedValues; + String? _failedLocale; + + Future _commit() async { + final locale = context.locale.languageCode; + // Instant-commit requires complete body metrics (an onboarded profile). The + // editors are only reachable for such a profile, but guard anyway so a + // partial profile never PUTs nulls. + if (validateBodyMetrics(_controller.values).isNotEmpty) { + // Nothing was sent — don't let a device-side locale change diverge from + // the server; revert it and say quietly why nothing saved. + if (locale != _savedLocale) { + await context.setLocale(Locale(_savedLocale)); + } + if (mounted) { + setState(() => _errorText = tr('settings.profilePanel.incompleteHint')); + } + return; + } + + _committing = true; + final committed = _controller.values.clone(); + final payload = buildProfilePayload(committed, locale); + final ok = await ref.read(saveProfileProvider.notifier).save(payload); + if (!mounted) { + _committing = false; + return; + } + if (ok) { + // One save-success cue app-wide: mediumImpact (matches logging + the + // goal editor), not the lighter selection tick. + HapticFeedback.mediumImpact(); + // Baseline exactly what was sent — edits made while the save was in + // flight stay dirty and re-commit below, instead of being silently + // baselined away. + _controller.markSavedAs(committed); + _savedLocale = locale; + _failedValues = null; + _failedLocale = null; + if (_errorText != null) setState(() => _errorText = null); + _committing = false; + if (_controller.isDirty || context.locale.languageCode != _savedLocale) { + await _commit(); + } + } else { + // Visual rollback: the controls return to their last-saved values, and a + // device-side locale change is reverted so device and server never + // diverge. The attempted values are held for "Try again". + _failedValues = _controller.values.clone(); + _failedLocale = locale; + _controller.reset(); + if (context.locale.languageCode != _savedLocale) { + await context.setLocale(Locale(_savedLocale)); + } + if (mounted) { + setState(() => _errorText = tr('settings.profilePanel.saveError')); + } + _committing = false; + } + } + + /// Re-applies the failed commit's values (and locale) and commits again. + Future _retry() async { + final values = _failedValues; + if (values == null) return; + final locale = _failedLocale; + _failedValues = null; + _failedLocale = null; + setState(() => _errorText = null); + if (locale != null && locale != context.locale.languageCode) { + await context.setLocale(Locale(locale)); + if (!mounted) return; + } + // setValues notifies; _onChanged sees the dirty/locale state and commits. + _controller.setValues(values); + } + + @override + Widget build(BuildContext context) { + return ProfileFormScope( + controller: _controller, + child: ListView( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp4, + NhamSpacing.sp2, + NhamSpacing.sp4, + NhamSpacing.sp8, + ), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + Text( + widget.title, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), + ), + const SizedBox(height: NhamSpacing.sp1), + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), + child: Text( + widget.subtitle, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.detail, + ).copyWith(height: 20 / 13, color: NhamColors.textWarm), + ), + ), + if (_errorText != null) + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + _errorText!, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.danger), + ), + ), + if (_failedValues != null) ...[ + const SizedBox(width: NhamSpacing.sp3), + Semantics( + button: true, + excludeSemantics: true, + label: tr('settings.profilePanel.retry'), + onTap: _retry, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _retry, + child: Text( + tr('settings.profilePanel.retry'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ), + ), + ], + ], + ), + ), + widget.child, + ], + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart index 13986055..4557cf85 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart @@ -2,31 +2,27 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../features/logging/widgets/count_up.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; -import '../controls/tab_strip.dart'; import '../data/profile_providers.dart'; -import '../logic/tdee.dart'; +import '../logic/profile_payload.dart'; import '../panels/body_metrics.dart'; -import '../panels/cooking.dart'; -import '../panels/regional.dart'; import 'profile_form_controller.dart'; import 'profile_form_values.dart'; -enum _SectionId { bodyMetrics, regional, cooking } - -const _fieldSection = { - ProfileField.weightKg: _SectionId.bodyMetrics, - ProfileField.heightCm: _SectionId.bodyMetrics, - ProfileField.age: _SectionId.bodyMetrics, -}; - -/// RN port of web `components/settings/profile/index.tsx`. The tabbed profile -/// editor: header + subtitle, [TabStrip], the active panel inside a cream card, -/// and a floating Save/Cancel bar that fades in when the form is dirty. +/// The "Goal & pace" focused editor — the one numeric settings surface, so it +/// keeps the felt floating Save/Cancel bar (toggle/select editors instant-commit +/// instead). Renders the body-metrics + goal/pace/split/target panel inside a +/// cream card; the bar fades in when the form is dirty and morphs into a +/// "Changes saved · N kcal/day" confirmation on save. class ProfileForm extends ConsumerStatefulWidget { const ProfileForm({super.key, required this.profile}); @@ -39,17 +35,30 @@ class ProfileForm extends ConsumerStatefulWidget { class _ProfileFormState extends ConsumerState { late final ProfileFormController _controller = ProfileFormController(ProfileFormValues.fromRow(widget.profile)); - _SectionId _activeTab = _SectionId.bodyMetrics; String? _errorText; + // Felt-save state: after a successful save the bar morphs into a + // "Changes saved · N kcal/day" confirmation, the target counting old→new, + // holding ~1.6s before dissolving. Null = no confirmation showing. + int? _savedCalorieTarget; + int _previousCalorieTarget = 0; + Timer? _savedTimer; + @override void initState() { super.initState(); _controller.addListener(_onFormChanged); + // The saved calorie target lives in the raw profile row (no typed getter); + // it seeds the count-up's "from" so the morph animates old→new. + final rawTarget = widget.profile.raw['calorieTarget']; + _previousCalorieTarget = rawTarget is num + ? rawTarget.round() + : int.tryParse(rawTarget?.toString() ?? '') ?? 0; } @override void dispose() { + _savedTimer?.cancel(); _controller.removeListener(_onFormChanged); _controller.dispose(); super.dispose(); @@ -67,58 +76,35 @@ class _ProfileFormState extends ConsumerState { final errors = validateBodyMetrics(v); if (errors.isNotEmpty) { _controller.setErrors(errors); - final first = errors.keys.first; - final section = _fieldSection[first]; setState(() { - if (section != null) _activeTab = section; _errorText = tr('settings.profilePanel.invalidError'); }); return; } _controller.setErrors(const {}); - // Compute targets exactly as RN `handleSave`. - final bmr = calcBMR( - biologicalSex: v.biologicalSex, - weightKg: v.weightKg!, - heightCm: v.heightCm!, - age: v.age!, - ); - final tdee = calcTDEE(bmr, v.activityLevel); - final targets = - calcDailyTargets(tdee, v.goal, v.aggression, v.carbSplit); - final clampedCalories = targets.calories < 500 ? 500.0 : targets.calories; - final macros = calcMacroGrams(clampedCalories, v.carbSplit); - - final payload = ProfileSavePayload( - weightKg: v.weightKg!, - heightCm: v.heightCm!, - age: v.age!, - biologicalSex: v.biologicalSex, - activityLevel: v.activityLevel, - tdeeKcal: tdee, - goal: v.goal, - aggression: v.aggression, - carbSplit: v.carbSplit, - calorieTarget: clampedCalories.round(), - proteinTargetG: macros.proteinG.round(), - carbsTargetG: macros.carbsG.round(), - fatTargetG: macros.fatG.round(), - countryOfOrigin: v.countryOfOrigin, - countryOfResidence: v.countryOfResidence, - preferredLocale: context.locale.languageCode, - oilUsage: v.oilUsage, - defaultRicePortion: v.defaultRicePortion, - sugarBraised: v.sugarBraised, - defaultProteinPortion: v.defaultProteinPortion, - brothConsumption: v.brothConsumption, - ); + // Compute targets exactly as RN `handleSave` (shared with the instant-commit + // editors via buildProfilePayload). + final payload = buildProfilePayload(v, context.locale.languageCode); setState(() => _errorText = null); final ok = await ref.read(saveProfileProvider.notifier).save(payload); if (!mounted) return; if (ok) { + HapticFeedback.mediumImpact(); // success cue on save _controller.markSaved(); + // Felt save: morph the bar into "Changes saved · N kcal/day", count the + // target old→new, hold ~1.6s, then dissolve. + final newTarget = payload.calorieTarget; + _savedTimer?.cancel(); + setState(() => _savedCalorieTarget = newTarget); + _savedTimer = Timer(const Duration(milliseconds: 1600), () { + if (!mounted) return; + setState(() { + _previousCalorieTarget = newTarget; + _savedCalorieTarget = null; + }); + }); } else { setState(() => _errorText = tr('settings.profilePanel.saveError')); } @@ -127,16 +113,6 @@ class _ProfileFormState extends ConsumerState { @override Widget build(BuildContext context) { final saving = ref.watch(saveProfileProvider).isLoading; - final tabs = [ - (id: _SectionId.bodyMetrics, title: tr('settings.bodyMetrics'), - subtitle: tr('settings.profilePanel.bodyMetricsSubtitle')), - (id: _SectionId.regional, title: tr('settings.regional'), - subtitle: tr('settings.profilePanel.regionalSubtitle')), - (id: _SectionId.cooking, title: tr('settings.cooking'), - subtitle: tr('settings.profilePanel.cookingSubtitle')), - ]; - final activeSubtitle = - tabs.firstWhere((t) => t.id == _activeTab).subtitle; return ProfileFormScope( controller: _controller, @@ -152,7 +128,7 @@ class _ProfileFormState extends ConsumerState { keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, children: [ Text( - tr('settings.profilePage.title'), + tr('settings.rows.goalPace'), style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.h3) .copyWith(letterSpacing: NhamTracking.tight, color: NhamColors.text), ), @@ -160,21 +136,11 @@ class _ProfileFormState extends ConsumerState { Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), child: Text( - tr('settings.profilePage.description'), + tr('settings.profilePanel.bodyMetricsSubtitle'), style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) .copyWith(height: 20 / 13, color: NhamColors.textWarm), ), ), - TabStrip( - tabs: [ - for (final t in tabs) - TabStripItem(id: t.id.name, label: t.title), - ], - active: _activeTab.name, - onChange: (id) => setState(() => - _activeTab = _SectionId.values.byName(id)), - ), - const SizedBox(height: NhamSpacing.sp2), Container( padding: const EdgeInsets.all(NhamSpacing.sp3), decoration: BoxDecoration( @@ -182,24 +148,7 @@ class _ProfileFormState extends ConsumerState { borderRadius: BorderRadius.circular(NhamRadii.containerLg), border: Border.all(color: NhamColors.inputBorder), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp5), - child: Text( - activeSubtitle, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.textWarm), - ), - ), - switch (_activeTab) { - _SectionId.bodyMetrics => const BodyMetrics(), - _SectionId.regional => const Regional(), - _SectionId.cooking => const Cooking(), - }, - ], - ), + child: const BodyMetrics(), ), ], ), @@ -208,9 +157,11 @@ class _ProfileFormState extends ConsumerState { right: 0, bottom: 0, child: _SaveBar( - visible: _controller.isDirty, + visible: _controller.isDirty || _savedCalorieTarget != null, errorText: _errorText, saving: saving, + savedTarget: _savedCalorieTarget, + previousTarget: _previousCalorieTarget, onCancel: _handleCancel, onSave: _handleSave, ), @@ -230,6 +181,8 @@ class _SaveBar extends StatefulWidget { required this.visible, required this.errorText, required this.saving, + required this.savedTarget, + required this.previousTarget, required this.onCancel, required this.onSave, }); @@ -237,6 +190,11 @@ class _SaveBar extends StatefulWidget { final bool visible; final String? errorText; final bool saving; + + /// When non-null, the bar shows the post-save confirmation morph instead of + /// the Cancel/Save buttons. + final int? savedTarget; + final int previousTarget; final VoidCallback onCancel; final VoidCallback onSave; @@ -317,6 +275,8 @@ class _SaveBarState extends State<_SaveBar> _BackdropCard( errorText: widget.errorText, saving: widget.saving, + savedTarget: widget.savedTarget, + previousTarget: widget.previousTarget, onCancel: widget.onCancel, onSave: widget.onSave, ), @@ -335,12 +295,16 @@ class _BackdropCard extends StatelessWidget { const _BackdropCard({ required this.errorText, required this.saving, + required this.savedTarget, + required this.previousTarget, required this.onCancel, required this.onSave, }); final String? errorText; final bool saving; + final int? savedTarget; + final int previousTarget; final VoidCallback onCancel; final VoidCallback onSave; @@ -373,36 +337,42 @@ class _BackdropCard extends StatelessWidget { ), ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (errorText != null) - Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), - child: Text( - errorText!, - textAlign: TextAlign.right, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) - .copyWith(color: NhamColors.danger), - ), + child: savedTarget != null + ? _SavedConfirmation( + target: savedTarget!, + previousTarget: previousTarget, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (errorText != null) + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), + child: Text( + errorText!, + textAlign: TextAlign.right, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs) + .copyWith(color: NhamColors.danger), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _GhostButton( + label: tr('common.cancel'), + onTap: saving ? null : onCancel, + ), + const SizedBox(width: NhamSpacing.sp3), + _SaveButton( + saving: saving, + onTap: saving ? null : onSave, + ), + ], + ), + ], ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _GhostButton( - label: tr('common.cancel'), - onTap: saving ? null : onCancel, - ), - const SizedBox(width: NhamSpacing.sp3), - _SaveButton( - saving: saving, - onTap: saving ? null : onSave, - ), - ], - ), - ], - ), ), ), ); @@ -451,6 +421,59 @@ class _GhostButtonState extends State<_GhostButton> { } } +/// The post-save confirmation that the save bar morphs into: a sage check, the +/// "Changes saved" line, then the new calorie target counting up from the +/// previous value (CountUpText) with a "kcal/day" unit. Holds ~1.6s upstream. +class _SavedConfirmation extends StatelessWidget { + const _SavedConfirmation({ + required this.target, + required this.previousTarget, + }); + + final int target; + final int previousTarget; + + @override + Widget build(BuildContext context) { + final locale = context.locale.languageCode; + final fmt = NumberFormat.decimalPattern(locale); + final reduceMotion = + MediaQuery.maybeDisableAnimationsOf(context) ?? false; + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Icon(LucideIcons.check, size: 16, color: NhamColors.success), + const SizedBox(width: 8), + Text( + tr('settings.saved'), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.text), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + '·', + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.border), + ), + ), + CountUpText( + value: target.toDouble(), + from: previousTarget.toDouble(), + enabled: !reduceMotion, + format: (v) => '${fmt.format(v.round())} ' + '${tr('onboarding.bodyMetrics.perDay')}', + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm).copyWith( + color: NhamColors.text, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + class _SaveButton extends StatefulWidget { const _SaveButton({required this.saving, required this.onTap}); final bool saving; diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart index dfc4ad3b..6d666386 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart @@ -65,6 +65,21 @@ class ProfileFormController extends ChangeNotifier { notifyListeners(); } + /// Marks [saved] (the values actually sent) as the clean baseline — used by + /// instant-commit so edits made while a save was in flight stay dirty and + /// re-commit, instead of being silently baselined away. + void markSavedAs(ProfileFormValues saved) { + _initial = saved.clone(); + notifyListeners(); + } + + /// Replaces the live values wholesale (the retry affordance re-applying a + /// failed commit's values) and notifies. + void setValues(ProfileFormValues v) { + _values = v.clone(); + notifyListeners(); + } + static ProfileFormController of(BuildContext context) { final scope = context .dependOnInheritedWidgetOfExactType(); diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart index 57c26caf..c364a5d6 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart @@ -9,7 +9,9 @@ import '../data/profile_providers.dart'; /// nullable because the user can clear an input mid-edit; validation guards the /// save. `weightKg` is nullable (the DecimalInput reports `null` while empty). class ProfileFormValues { - BiologicalSex biologicalSex; + /// Null until the user has actually chosen — never silently defaulted to + /// male, which would bake a wrong BMR into the saved targets. + BiologicalSex? biologicalSex; double? weightKg; int? heightCm; int? age; @@ -88,10 +90,16 @@ class ProfileFormValues { } return ProfileFormValues( - biologicalSex: _sexFrom(p.biologicalSex) ?? BiologicalSex.male, - weightKg: p.weightKg ?? 65, - heightCm: p.heightCm ?? 165, - age: p.age ?? 25, + // Like the numeric metrics below, sex starts EMPTY when the profile has + // none — a silent male default would compute a wrong BMR the user never + // chose. validateBodyMetrics forces a genuine selection instead. + biologicalSex: _sexFrom(p.biologicalSex), + // Numeric body metrics start EMPTY when the profile has none — never + // fabricated 65/165/25, which a user who skipped onboarding could save as + // if they were real. validateBodyMetrics forces a genuine entry instead. + weightKg: p.weightKg, + heightCm: p.heightCm, + age: p.age, activityLevel: _activityFrom(p.activityLevel) ?? ActivityLevel.light, goal: _goalFrom(p.goal) ?? Goal.maintaining, aggression: parseAggression(), @@ -174,7 +182,7 @@ BrothConsumption? _brothFrom(String? s) => switch (s) { }; /// Field identifiers, used to map a validation error to its owning tab. -enum ProfileField { weightKg, heightCm, age } +enum ProfileField { biologicalSex, weightKg, heightCm, age } /// Validates the body-metrics numeric fields exactly as the RN zod schema does /// (`createBodyMetricsSchema`). Returns the per-field localized error messages @@ -182,6 +190,11 @@ enum ProfileField { weightKg, heightCm, age } Map validateBodyMetrics(ProfileFormValues v) { final errors = {}; + if (v.biologicalSex == null) { + errors[ProfileField.biologicalSex] = + tr('validation.bodyMetrics.sexRequired'); + } + final w = v.weightKg; if (w == null || w.isNaN) { errors[ProfileField.weightKg] = tr('validation.bodyMetrics.weightRequired'); diff --git a/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart new file mode 100644 index 00000000..ee635a61 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart @@ -0,0 +1,93 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../onboarding/widgets/language_toggle.dart'; +import '../data/profile_providers.dart'; +import '../panels/regional.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import 'instant_commit_editor.dart'; +import 'profile_form_controller.dart'; + +/// The "Region & language" focused editor. Wraps the [Regional] country panel +/// (origin / residence) plus an app-language selector inside an +/// [InstantCommitEditor], so every change persists immediately. Switching the +/// language live-sets the app locale (the new locale is what +/// `buildProfilePayload` writes as `preferredLocale`). +class RegionEditor extends StatelessWidget { + const RegionEditor({super.key, required this.profile}); + + final ProfileRow profile; + + @override + Widget build(BuildContext context) { + return InstantCommitEditor( + profile: profile, + title: tr('settings.rows.region'), + subtitle: tr('settings.profilePanel.regionalSubtitle'), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _LanguageField(), + SizedBox(height: NhamSpacing.sp6), + Regional(), + ], + ), + ); + } +} + +/// Language row: a labelled [LanguageToggle]. Switching the language sets the +/// app locale live and nudges the form controller so the instant-commit save +/// fires (persisting the new `preferredLocale`). +class _LanguageField extends StatefulWidget { + const _LanguageField(); + + @override + State<_LanguageField> createState() => _LanguageFieldState(); +} + +class _LanguageFieldState extends State<_LanguageField> { + @override + Widget build(BuildContext context) { + final form = ProfileFormController.of(context); + final current = context.locale.languageCode; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon( + LucideIcons.languages, + size: 16, + color: NhamColors.accent, + ), + const SizedBox(width: NhamSpacing.sp2), + Text( + tr('settings.language'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.text), + ), + ], + ), + const SizedBox(height: NhamSpacing.sp2), + LanguageToggle( + value: current, + onChange: (v) { + if (v == current) return; + context.setLocale(Locale(v)).then((_) { + if (!mounted) return; + // Nudge the controller to fire a notification. preferredLocale + // lives outside the form, so InstantCommitEditor detects the + // locale change and commits even though no field value changed. + form.update((f) => f.countryOfResidence = f.countryOfResidence); + }); + }, + ), + ], + ); + } +} diff --git a/apps/mobile-flutter/lib/main.dart b/apps/mobile-flutter/lib/main.dart index 6e3ce5db..60171c54 100644 --- a/apps/mobile-flutter/lib/main.dart +++ b/apps/mobile-flutter/lib/main.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'app.dart'; import 'services/supabase_service.dart'; @@ -19,6 +20,12 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized(); + // Lora (serif) is bundled under assets/google_fonts/ and resolved by + // google_fonts — never fetch over HTTP, so a cold offline start still renders + // in the brand serif instead of the fallback. The UI sans (Be Vietnam Pro) is + // a native pubspec font family, always available offline. + GoogleFonts.config.allowRuntimeFetching = false; + if (_supabaseUrl.isEmpty || _supabaseAnonKey.isEmpty) { throw StateError( 'Missing SUPABASE_URL / SUPABASE_ANON_KEY — pass them via --dart-define ' diff --git a/apps/mobile-flutter/lib/models/nutrition.dart b/apps/mobile-flutter/lib/models/nutrition.dart index 5dd0c2f8..e9c88e56 100644 --- a/apps/mobile-flutter/lib/models/nutrition.dart +++ b/apps/mobile-flutter/lib/models/nutrition.dart @@ -3,10 +3,11 @@ /// Ported from `lib/nutrition/types.ts`. library; -enum NutritionRange { d7, d30, d90 } +enum NutritionRange { d1, d7, d30, d90 } extension NutritionRangeValue on NutritionRange { String get value => switch (this) { + NutritionRange.d1 => '1d', NutritionRange.d7 => '7d', NutritionRange.d30 => '30d', NutritionRange.d90 => '90d', @@ -14,11 +15,12 @@ extension NutritionRangeValue on NutritionRange { } /// Input range that may include 'auto'. -enum NutritionRangeInput { auto, d7, d30, d90 } +enum NutritionRangeInput { auto, d1, d7, d30, d90 } extension NutritionRangeInputValue on NutritionRangeInput { String get value => switch (this) { NutritionRangeInput.auto => 'auto', + NutritionRangeInput.d1 => '1d', NutritionRangeInput.d7 => '7d', NutritionRangeInput.d30 => '30d', NutritionRangeInput.d90 => '90d', @@ -397,7 +399,6 @@ class NutrientCardData { final String? caveatKey; final List? contextMetrics; final SourceBreakdown? sourceBreakdown; - final bool supportsCandidates; const NutrientCardData({ required this.nutrient, @@ -415,7 +416,6 @@ class NutrientCardData { this.caveatKey, this.contextMetrics, this.sourceBreakdown, - required this.supportsCandidates, }); factory NutrientCardData.fromJson(Map json) => @@ -445,7 +445,6 @@ class NutrientCardData { ? SourceBreakdown.fromJson( json['sourceBreakdown'] as Map) : null, - supportsCandidates: json['supportsCandidates'] as bool, ); Map toJson() => { @@ -464,7 +463,6 @@ class NutrientCardData { 'caveatKey': caveatKey, 'contextMetrics': contextMetrics?.map((e) => e.toJson()).toList(), 'sourceBreakdown': sourceBreakdown?.toJson(), - 'supportsCandidates': supportsCandidates, }; NutrientCardData copyWith({ @@ -483,7 +481,6 @@ class NutrientCardData { String? Function()? caveatKey, List? Function()? contextMetrics, SourceBreakdown? Function()? sourceBreakdown, - bool? supportsCandidates, }) => NutrientCardData( nutrient: nutrient ?? this.nutrient, @@ -508,7 +505,6 @@ class NutrientCardData { sourceBreakdown: sourceBreakdown != null ? sourceBreakdown() : this.sourceBreakdown, - supportsCandidates: supportsCandidates ?? this.supportsCandidates, ); } @@ -537,6 +533,100 @@ class EducationCardData { }; } +/// One bucket (a day for 7d, a week for 30d) of a metric's time series. +class DaySeriesBucket { + final String startDate; + final String endDate; + final double? value; + final double? ratioOfTarget; + + const DaySeriesBucket({ + required this.startDate, + required this.endDate, + required this.value, + required this.ratioOfTarget, + }); + + factory DaySeriesBucket.fromJson(Map json) => DaySeriesBucket( + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + value: (json['value'] as num?)?.toDouble(), + ratioOfTarget: (json['ratioOfTarget'] as num?)?.toDouble(), + ); + + Map toJson() => { + 'startDate': startDate, + 'endDate': endDate, + 'value': value, + 'ratioOfTarget': ratioOfTarget, + }; +} + +/// A single metric's per-bucket series (macro or micronutrient). +class NutrientDaySeries { + final String metric; // DaySeriesMetricKey: calories/protein/carbohydrate/fat/ + final String labelKey; + final String unit; + final double? target; + final List buckets; + final double? min; + final double? max; + + const NutrientDaySeries({ + required this.metric, + required this.labelKey, + required this.unit, + required this.target, + required this.buckets, + required this.min, + required this.max, + }); + + factory NutrientDaySeries.fromJson(Map json) => + NutrientDaySeries( + metric: json['metric'] as String, + labelKey: json['labelKey'] as String, + unit: json['unit'] as String, + target: (json['target'] as num?)?.toDouble(), + buckets: (json['buckets'] as List) + .map((e) => DaySeriesBucket.fromJson(e as Map)) + .toList(), + min: (json['min'] as num?)?.toDouble(), + max: (json['max'] as num?)?.toDouble(), + ); + + Map toJson() => { + 'metric': metric, + 'labelKey': labelKey, + 'unit': unit, + 'target': target, + 'buckets': buckets.map((e) => e.toJson()).toList(), + 'min': min, + 'max': max, + }; +} + +/// The overview's per-bucket time axis (macros + default micronutrients). +class NutritionDaySeries { + final String unit; // 'day' | 'week' + final List series; + + const NutritionDaySeries({required this.unit, required this.series}); + + factory NutritionDaySeries.fromJson(Map json) => + NutritionDaySeries( + unit: json['unit'] as String, + series: (json['series'] as List) + .map((e) => NutrientDaySeries.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'unit': unit, + 'series': series.map((e) => e.toJson()).toList(), + }; +} + class NutritionSummary { final List mostConsistent; final List needsAttention; @@ -590,6 +680,7 @@ class NutritionOverview { final List steady; final List moreNutrients; final List educationCards; + final NutritionDaySeries daySeries; const NutritionOverview({ required this.requestedRange, @@ -608,6 +699,7 @@ class NutritionOverview { required this.steady, required this.moreNutrients, required this.educationCards, + required this.daySeries, }); factory NutritionOverview.fromJson(Map json) { @@ -646,6 +738,8 @@ class NutritionOverview { .map( (e) => EducationCardData.fromJson(e as Map)) .toList(), + daySeries: NutritionDaySeries.fromJson( + json['daySeries'] as Map), ); } @@ -669,6 +763,7 @@ class NutritionOverview { 'steady': steady.map((e) => e.toJson()).toList(), 'moreNutrients': moreNutrients.map((e) => e.toJson()).toList(), 'educationCards': educationCards.map((e) => e.toJson()).toList(), + 'daySeries': daySeries.toJson(), }; NutritionOverview copyWith({ @@ -688,6 +783,7 @@ class NutritionOverview { List? steady, List? moreNutrients, List? educationCards, + NutritionDaySeries? daySeries, }) => NutritionOverview( requestedRange: requestedRange ?? this.requestedRange, @@ -706,5 +802,6 @@ class NutritionOverview { steady: steady ?? this.steady, moreNutrients: moreNutrients ?? this.moreNutrients, educationCards: educationCards ?? this.educationCards, + daySeries: daySeries ?? this.daySeries, ); } diff --git a/apps/mobile-flutter/lib/router.dart b/apps/mobile-flutter/lib/router.dart index 9e7f7ca0..e09b441b 100644 --- a/apps/mobile-flutter/lib/router.dart +++ b/apps/mobile-flutter/lib/router.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -19,6 +19,7 @@ import 'services/supabase_service.dart'; import 'shell/placeholder_screen.dart'; import 'shell/tab_scaffold.dart'; import 'theme/nham_colors.dart'; +import 'theme/nham_typography.dart'; final _rootKey = GlobalKey(debugLabel: 'root'); final _shellKey = GlobalKey(debugLabel: 'shell'); @@ -26,11 +27,13 @@ final _shellKey = GlobalKey(debugLabel: 'shell'); /// The app's [GoRouter], wired to Riverpod for the auth redirect. /// /// Routing model (per the port contract): -/// • A [StatefulShellRoute] hosts the primary destinations behind the shell's -/// left nav drawer (`/dashboard`, `/nutrition`, `/logging`, `/groups`, -/// `/admin`) plus `/settings` (drawer footer). Each is its own branch so -/// state/scroll persist across drawer navigations. -/// • `/sign-in`, `/sign-up`, and `/onboarding` are standalone root routes. +/// • A [StatefulShellRoute] hosts the primary destinations behind the bottom +/// tab bar (`/dashboard`, `/nutrition`, `/logging`) plus the off-bar +/// `/groups` and `/admin`. Each is its own branch so state/scroll persist +/// across tab switches. +/// • `/sign-in`, `/sign-up`, `/onboarding`, `/welcome`, and `/settings` are +/// standalone root routes (`/settings` pushes over the shell from the +/// header avatar with Cupertino swipe-back). /// • `/` redirects based on auth + onboarding state. /// /// The redirect mirrors the RN gates (`app/index.tsx` + @@ -144,10 +147,19 @@ final routerProvider = Provider((ref) { parentNavigatorKey: _rootKey, builder: (context, state) => const WelcomeSetupScreen(), ), + // Settings pushes over the shell (Cupertino swipe-back) from the header + // avatar — it's an account surface, not a primary tab destination. + GoRoute( + path: '/settings', + parentNavigatorKey: _rootKey, + pageBuilder: + (context, state) => + const CupertinoPage(child: SettingsScreen()), + ), // The primary destinations — each its own branch so state/scroll persist - // across drawer navigations. Order mirrors the web nav list (dashboard, - // nutrition, logging, groups, admin) plus settings (drawer footer). + // across tab switches. Order: dashboard, nutrition, logging, groups, + // admin (the last two are off-bar — reachable by route, not the tab bar). StatefulShellRoute.indexedStack( parentNavigatorKey: _rootKey, builder: @@ -204,14 +216,6 @@ final routerProvider = Provider((ref) { ), ], ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsScreen(), - ), - ], - ), ], ), ], @@ -256,23 +260,61 @@ class _GoRouterAuthRefresh extends ChangeNotifier { } } -/// Minimal cream splash shown on the index route while the redirect resolves. -/// Mirrors RN's centered [ActivityIndicator] on the surface background. -class _SplashScreen extends StatelessWidget { +/// Cream splash shown on the index route while the redirect resolves. The +/// first frame of brand: the Lora "Nhẩm" wordmark breathing gently on the cream +/// surface, instead of a generic Material spinner. The cream background matches +/// the native LaunchScreen so the native→Flutter handoff is seamless. +class _SplashScreen extends StatefulWidget { const _SplashScreen(); + @override + State<_SplashScreen> createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State<_SplashScreen> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + + @override + void initState() { + super.initState(); + // Gentle breathing pulse, paused under reduced-motion. + if (!WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations) { + _controller.repeat(reverse: true); + } else { + _controller.value = 1; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const ColoredBox( + final wordmark = Text( + 'Nhẩm', + style: NhamTextStyles.serifRegular( + fontSize: 32, + ).copyWith(color: NhamColors.text), + ); + return ColoredBox( color: NhamColors.surface, child: Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(NhamColors.accent), + child: FadeTransition( + opacity: Tween(begin: 0.5, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ), + child: wordmark, ), ), ); diff --git a/apps/mobile-flutter/lib/shared/widgets/nham_text.dart b/apps/mobile-flutter/lib/shared/widgets/nham_text.dart index 9abdc8a6..5b49dd31 100644 --- a/apps/mobile-flutter/lib/shared/widgets/nham_text.dart +++ b/apps/mobile-flutter/lib/shared/widgets/nham_text.dart @@ -70,8 +70,9 @@ class NhamText extends StatelessWidget { static TextStyle styleFor(NhamTextVariant variant) { switch (variant) { case NhamTextVariant.display: - // serifSemiBold, display size, display leading + tracking. - return NhamTextStyles.serifSemiBold( + // Lora regular (never bold — the bible's hardest type rule), display + // size, display leading + tracking. + return NhamTextStyles.serifRegular( fontSize: NhamFontSize.display, height: NhamLeading.display, ).copyWith( @@ -130,7 +131,7 @@ class NhamText extends StatelessWidget { height: NhamLeading.relaxed, ).copyWith(color: NhamColors.text); case NhamTextVariant.numDisplay: - return NhamTextStyles.serifSemiBold(fontSize: NhamFontSize.h2).copyWith( + return NhamTextStyles.serifRegular(fontSize: NhamFontSize.h2).copyWith( letterSpacing: NhamTracking.display, color: NhamColors.text, fontFeatures: const [_tabularNums], diff --git a/apps/mobile-flutter/lib/shared/widgets/target_progress_bar.dart b/apps/mobile-flutter/lib/shared/widgets/target_progress_bar.dart index 35b7b1b1..e7206f34 100644 --- a/apps/mobile-flutter/lib/shared/widgets/target_progress_bar.dart +++ b/apps/mobile-flutter/lib/shared/widgets/target_progress_bar.dart @@ -15,6 +15,7 @@ class TargetProgressBar extends StatefulWidget { this.duration = const Duration(milliseconds: 700), this.delay = Duration.zero, this.semanticLabel, + this.fillColor, }); /// Current percent toward target (0..100+); null when no target is set. @@ -31,6 +32,10 @@ class TargetProgressBar extends StatefulWidget { final String? semanticLabel; + /// Overrides the resting fill color (e.g. emerald on an on-target card). The + /// exceed (danger) and no-value (stone) colors still take precedence. + final Color? fillColor; + @override State createState() => _TargetProgressBarState(); } @@ -95,7 +100,7 @@ class _TargetProgressBarState extends State ? NhamColors.stone50 : widget.showExceed ? NhamColors.danger - : NhamColors.text; + : widget.fillColor ?? NhamColors.text; final bar = LayoutBuilder( builder: (context, constraints) { diff --git a/apps/mobile-flutter/lib/shared/widgets/top_toast.dart b/apps/mobile-flutter/lib/shared/widgets/top_toast.dart new file mode 100644 index 00000000..090714bd --- /dev/null +++ b/apps/mobile-flutter/lib/shared/widgets/top_toast.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../theme/nham_colors.dart'; +import '../../theme/nham_theme.dart'; +import '../../theme/nham_typography.dart'; + +/// A top-toast's tone — sets the leading icon + its color. +enum TopToastVariant { success, error } + +/// Show a brief toast pinned to the TOP of the screen (matches the web's +/// top-anchored toasts) — a cream pill that slides+fades in, holds, then leaves. +/// Used for save confirmations, quiet acknowledgements, and error notices +/// (`variant: TopToastVariant.error`). +/// +/// When [actionLabel] is given the pill becomes interactive (e.g. an "Undo" +/// affordance) and holds for [duration]. The returned future completes when the +/// toast is gone — by tap, timeout, or the action — mirroring the Material +/// `SnackBar.closed` future so callers can finalize work on dismissal. +Future showTopToast( + BuildContext context, + String message, { + TopToastVariant variant = TopToastVariant.success, + String? actionLabel, + VoidCallback? onAction, + Duration duration = const Duration(milliseconds: 2200), +}) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return Future.value(); + final completer = Completer(); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => _TopToast( + message: message, + variant: variant, + actionLabel: actionLabel, + onAction: onAction, + duration: duration, + onDone: () { + if (entry.mounted) entry.remove(); + if (!completer.isCompleted) completer.complete(); + }, + ), + ); + overlay.insert(entry); + return completer.future; +} + +class _TopToast extends StatefulWidget { + const _TopToast({ + required this.message, + required this.variant, + required this.actionLabel, + required this.onAction, + required this.duration, + required this.onDone, + }); + + final String message; + final TopToastVariant variant; + final String? actionLabel; + final VoidCallback? onAction; + final Duration duration; + final VoidCallback onDone; + + @override + State<_TopToast> createState() => _TopToastState(); +} + +class _TopToastState extends State<_TopToast> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + ); + late final Animation _slide = Tween( + begin: const Offset(0, -0.4), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)); + + Timer? _hold; + + @override + void initState() { + super.initState(); + _c.forward(); + _hold = Timer(widget.duration, _dismiss); + } + + Future _dismiss() async { + if (!mounted) return; + _hold?.cancel(); + await _c.reverse(); + widget.onDone(); + } + + void _onAction() { + widget.onAction?.call(); + _dismiss(); + } + + @override + void dispose() { + _hold?.cancel(); + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hasAction = widget.actionLabel != null; + + final pill = Container( + margin: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp2, + ), + padding: EdgeInsets.fromLTRB( + NhamSpacing.sp4, + NhamSpacing.sp3, + hasAction ? NhamSpacing.sp2 : NhamSpacing.sp4, + NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: NhamColors.cardCream, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all(color: NhamColors.border), + boxShadow: const [NhamShadows.md], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.variant == TopToastVariant.error + ? LucideIcons.circleAlert + : LucideIcons.check, + size: 16, + color: widget.variant == TopToastVariant.error + ? NhamColors.danger + : NhamColors.accent, + ), + const SizedBox(width: NhamSpacing.sp2), + Flexible( + child: Text( + widget.message, + textAlign: hasAction ? TextAlign.left : TextAlign.center, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ), + if (hasAction) ...[ + const SizedBox(width: NhamSpacing.sp2), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onAction, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp2, + vertical: NhamSpacing.sp1, + ), + child: Text( + widget.actionLabel!, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.accentDark), + ), + ), + ), + ], + ], + ), + ); + + final content = FadeTransition( + opacity: _c, + child: SlideTransition( + position: _slide, + child: Align(alignment: Alignment.topCenter, child: pill), + ), + ); + + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + // Non-action toasts never eat taps; action toasts must be tappable. + child: hasAction ? content : IgnorePointer(child: content), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/shell/app_header.dart b/apps/mobile-flutter/lib/shell/app_header.dart index 3eb80032..62b92ec6 100644 --- a/apps/mobile-flutter/lib/shell/app_header.dart +++ b/apps/mobile-flutter/lib/shell/app_header.dart @@ -1,162 +1,151 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../features/onboarding/providers/onboarding_providers.dart'; import '../theme/nham_colors.dart'; import '../theme/nham_theme.dart'; import 'tab_scaffold.dart'; -/// In-flow mobile header — ported 1:1 from the RN `AppHeader` -/// (`components/app/app-header.tsx`). +/// In-flow app header. /// -/// A hamburger button on the left that triggers [onMenu], a flexible center -/// [child] slot (the logging screen fills it with the timeline date strip, -/// like the web header slot), and a right spacer mirroring the hamburger so -/// the slot stays centered. +/// Left slot: a hamburger that opens the left nav drawer (via [NavDrawerScope]), +/// or a back chevron when [onBack] is given (pushed screens like Settings). A +/// flexible center [child] slot (the dashboard greeting, the logging date chip), +/// and a right spacer mirroring the left slot so the center stays centered. /// -/// When [expanded] is true the header morphs (chip pill → full-width strip): -/// the full row is handed to [child] (the timeline week strip) and the -/// hamburger + spacer are dropped so the strip isn't squeezed — mirroring the -/// web header's data-strip-mode contract. The container width/shape animates -/// over 280ms with the same `cubic-bezier(0.16, 1, 0.3, 1)` easing; the slot -/// cross-fades in over 150ms. -/// -/// lucide-react-native `Menu` → Material `Icons.menu`. +/// The hamburger carries the onboarding pulse-dot when setup is incomplete +/// (the indicator that used to live on the retired avatar disc / bottom bar). class AppHeader extends StatelessWidget { - const AppHeader({this.child, this.expanded = false, this.onMenu, super.key}); + const AppHeader({this.child, this.onBack, this.trailing, super.key}); /// Center slot content. final Widget? child; - /// Strip mode — hand the full row to [child] and drop the hamburger/spacer. - final bool expanded; - - /// Hamburger tap handler. When null the button falls back to opening the - /// shell's left nav drawer via [NavDrawerScope] — so no feature screen needs - /// to pass a handler (matches the web Sheet trigger). - final VoidCallback? onMenu; + /// When non-null, a back chevron replaces the hamburger (pushed screens). + final VoidCallback? onBack; - // Web animates the container shape over 0.28s with this easing. - static const Duration _morphDuration = Duration(milliseconds: 280); - static const Curve _morphCurve = Cubic(0.16, 1, 0.3, 1); - static const Duration _fadeDuration = Duration(milliseconds: 150); + /// Optional right-slot content (e.g. the nutrition date toggle). When null a + /// 44×44 spacer mirrors the leading slot so [child] stays centered. + final Widget? trailing; - // RN: HIT = 44 (square hit target for the hamburger + the mirrored spacer). + // Square hit target for the side slots. static const double _hit = 44; @override Widget build(BuildContext context) { - final Widget content = - expanded - ? _FadeKey( - // Re-key on the mode so AnimatedSwitcher cross-fades on morph. - key: const ValueKey('expanded'), - duration: _fadeDuration, - child: child ?? const SizedBox.shrink(), - ) - : Row( - key: const ValueKey('collapsed'), - children: [ - _MenuButton(onMenu: onMenu), - Expanded( - child: _FadeKey( - duration: _fadeDuration, - child: Align( - alignment: Alignment.center, - child: child ?? const SizedBox.shrink(), - ), - ), - ), - const SizedBox(width: _hit, height: _hit), - ], - ); + final Widget leading = + onBack != null ? _BackButton(onBack: onBack!) : const AppMenuButton(); return Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp1), - child: AnimatedSize( - duration: _morphDuration, - curve: _morphCurve, - child: AnimatedSwitcher( - duration: _morphDuration, - switchInCurve: _morphCurve, - switchOutCurve: _morphCurve, - child: content, - ), + child: Row( + children: [ + leading, + Expanded( + child: Align( + alignment: Alignment.center, + child: child ?? const SizedBox.shrink(), + ), + ), + // Trailing slot, or a spacer that mirrors the leading slot so the + // center stays centered when there's nothing on the right. + trailing ?? + const SizedBox(width: _hit, height: _hit), + ], ), ); } } -/// Simple fade-in on first mount, mirroring RN's `FadeIn.duration(150)`. -class _FadeKey extends StatefulWidget { - const _FadeKey({required this.child, required this.duration, super.key}); - - final Widget child; - final Duration duration; +/// The hamburger — 44×44 hit area, radius 6, 22px Menu glyph. Opens the shell's +/// left nav drawer via [NavDrawerScope]. Carries the onboarding pulse-dot when +/// setup is incomplete. +class AppMenuButton extends ConsumerStatefulWidget { + const AppMenuButton({super.key}); @override - State<_FadeKey> createState() => _FadeKeyState(); + ConsumerState createState() => _AppMenuButtonState(); } -class _FadeKeyState extends State<_FadeKey> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: widget.duration, - )..forward(); +class _AppMenuButtonState extends ConsumerState { + bool _pressed = false; - @override - void dispose() { - _c.dispose(); - super.dispose(); - } + void _open() => NavDrawerScope.maybeOf(context)?.open(); @override Widget build(BuildContext context) { - return FadeTransition(opacity: _c, child: widget.child); + final onboardingIncomplete = ref.watch(onboardingResumeProvider); + + return Semantics( + button: true, + label: tr('app.shell.openMenu'), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: _open, + child: SizedBox( + width: AppHeader._hit, + height: AppHeader._hit, + child: Center( + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover40 : null, + borderRadius: BorderRadius.circular(NhamRadii.sm), + ), + child: const Icon( + LucideIcons.menu, + size: 22, + color: NhamColors.text, + ), + ), + if (onboardingIncomplete) + const Positioned(top: 2, right: 2, child: _OnboardingDot()), + ], + ), + ), + ), + ), + ); } } -/// The hamburger button. 44×44 hit area, radius 6 (rounded-md), 20px Menu icon -/// in espresso `text`, with a hover@60% press fill animated over ~150ms -/// (mobile-nav.tsx:99 `transition-colors hover:bg-nham-hover/60`). When -/// onboarding is incomplete, a pulsing 8px accent dot with a 2px cream ring -/// sits at top:6 right:6 (mobile-nav.tsx:102-104 + OnboardingDot). -class _MenuButton extends ConsumerStatefulWidget { - const _MenuButton({required this.onMenu}); +/// Left-slot back chevron — 44×44 hit area, radius 6, espresso glyph. +class _BackButton extends StatefulWidget { + const _BackButton({required this.onBack}); - final VoidCallback? onMenu; + final VoidCallback onBack; @override - ConsumerState<_MenuButton> createState() => _MenuButtonState(); + State<_BackButton> createState() => _BackButtonState(); } -class _MenuButtonState extends ConsumerState<_MenuButton> { +class _BackButtonState extends State<_BackButton> { bool _pressed = false; - void _handleTap() { - final onMenu = widget.onMenu; - if (onMenu != null) { - onMenu(); - return; - } - NavDrawerScope.maybeOf(context)?.open(); - } - @override Widget build(BuildContext context) { - final onboardingIncomplete = ref.watch(onboardingResumeProvider); - return Semantics( button: true, - label: tr('app.shell.openMenu'), + label: tr('common.back'), child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), - onTap: _handleTap, + onTap: widget.onBack, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, @@ -166,13 +155,10 @@ class _MenuButtonState extends ConsumerState<_MenuButton> { color: _pressed ? NhamColors.hover40 : null, borderRadius: BorderRadius.circular(NhamRadii.sm), ), - child: Stack( - alignment: Alignment.center, - children: [ - const Icon(Icons.menu, size: 20, color: NhamColors.text), - if (onboardingIncomplete) - const Positioned(top: 6, right: 6, child: _OnboardingDot()), - ], + child: const Icon( + LucideIcons.chevronLeft, + size: 22, + color: NhamColors.text, ), ), ), @@ -194,13 +180,25 @@ class _OnboardingDotState extends State<_OnboardingDot> late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), - )..repeat(reverse: true); + ); late final Animation _opacity = Tween( begin: 1, end: 0.45, ).animate(CurvedAnimation(parent: _c, curve: Curves.easeInOut)); + @override + void initState() { + super.initState(); + if (!WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations) { + _c.repeat(reverse: true); + } + } + @override void dispose() { _c.dispose(); diff --git a/apps/mobile-flutter/lib/shell/placeholder_screen.dart b/apps/mobile-flutter/lib/shell/placeholder_screen.dart index 37e6c25a..cb9d3bc6 100644 --- a/apps/mobile-flutter/lib/shell/placeholder_screen.dart +++ b/apps/mobile-flutter/lib/shell/placeholder_screen.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../shared/widgets/widgets.dart'; import '../theme/nham_theme.dart'; @@ -20,9 +21,19 @@ class PlaceholderScreen extends StatelessWidget { return Screen( child: Column( children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), + // Back affordance: a deep link can land here directly, so the + // header must always offer a way out (pop if possible, else home). + child: AppHeader( + onBack: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/logging'); + } + }, + ), ), Expanded( child: Center( diff --git a/apps/mobile-flutter/lib/shell/sidebar.dart b/apps/mobile-flutter/lib/shell/sidebar.dart index 85133c58..632cb9e0 100644 --- a/apps/mobile-flutter/lib/shell/sidebar.dart +++ b/apps/mobile-flutter/lib/shell/sidebar.dart @@ -2,26 +2,21 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../data/session_provider.dart'; import '../features/onboarding/providers/onboarding_providers.dart'; import '../features/onboarding/widgets/onboarding_dialog.dart'; +import '../shared/widgets/top_toast.dart'; import '../theme/nham_colors.dart'; import '../theme/nham_theme.dart'; import '../theme/nham_typography.dart'; /// One drawer nav destination — mirrors the web `NavItemConfig` /// (`components/app/nav-items.ts`): href, i18n label key, Lucide icon, and the -/// admin-only gate. -/// -/// lucide-react → Material icon mapping (web uses single, non-filled glyphs; -/// active state only changes color, never the glyph): -/// LayoutDashboard → Icons.grid_view -/// Activity → Icons.show_chart (pulse/activity line) -/// UtensilsCrossed → Icons.restaurant -/// Users2 → Icons.group -/// ShieldCheck → Icons.verified_user +/// admin-only gate. Lucide glyphs throughout (active state only changes color, +/// never the glyph), matching the rest of the app's iconography. class _NavItem { const _NavItem({ required this.href, @@ -42,27 +37,27 @@ const List<_NavItem> _navItems = [ _NavItem( href: '/dashboard', labelKey: 'app.mainSidebar.dashboard', - icon: Icons.grid_view, + icon: LucideIcons.layoutDashboard, ), _NavItem( href: '/nutrition', labelKey: 'app.mainSidebar.nutrition', - icon: Icons.show_chart, + icon: LucideIcons.activity, ), _NavItem( href: '/logging', labelKey: 'app.mainSidebar.logging', - icon: Icons.restaurant, + icon: LucideIcons.utensilsCrossed, ), _NavItem( href: '/groups', labelKey: 'app.mainSidebar.groups', - icon: Icons.group, + icon: LucideIcons.users, ), _NavItem( href: '/admin', labelKey: 'app.mainSidebar.admin', - icon: Icons.verified_user, + icon: LucideIcons.shieldCheck, adminOnly: true, ), ]; @@ -116,12 +111,14 @@ class Sidebar extends ConsumerWidget { bottom: false, child: Column( children: [ - // ── Header: name + email ────────────────────────────────────── + // ── Header: avatar + name + email (the primary identity block) ── Container( width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp4, + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp4, + NhamSpacing.sp5, + NhamSpacing.sp4, + NhamSpacing.sp4, ), decoration: const BoxDecoration( border: Border( @@ -131,28 +128,37 @@ class Sidebar extends ConsumerWidget { ), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - label, - style: NhamTextStyles.sansRegular( - fontSize: 15, - ).copyWith(color: NhamColors.text), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (email != null) ...[ - const SizedBox(height: 4), - Text( - email, - style: NhamTextStyles.sansRegular( - fontSize: 11.5, - ).copyWith(color: NhamColors.textMuted), - maxLines: 1, - overflow: TextOverflow.ellipsis, + _Avatar(initial: _deriveInitial(label, email)), + const SizedBox(width: NhamSpacing.sp3), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: NhamTextStyles.sansSemiBold( + fontSize: 16, + ).copyWith(color: NhamColors.text), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (email != null) ...[ + const SizedBox(height: 2), + Text( + email, + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.textMuted), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], ), - ], + ), ], ), ), @@ -208,10 +214,8 @@ class Sidebar extends ConsumerWidget { ), const SizedBox(height: 12), ], - _AccountCard(label: label, email: email), - const SizedBox(height: 8), // mt-2 _FooterRow( - icon: Icons.settings, + icon: LucideIcons.settings, label: tr('app.mainSidebar.settings'), active: _isActiveRoute(location, '/settings'), onTap: () { @@ -311,90 +315,45 @@ class _NavRowState extends State<_NavRow> { } } -/// Footer account card — white fill, radius 12, 36px gradient avatar w/ ring, -/// bold initial, name + email column (both truncated). -class _AccountCard extends StatelessWidget { - const _AccountCard({required this.label, required this.email}); +/// 44px gradient avatar with a soft accent ring + bold initial — anchors the +/// drawer's identity header. +class _Avatar extends StatelessWidget { + const _Avatar({required this.initial}); - final String label; - final String? email; + final String initial; @override Widget build(BuildContext context) { - final initial = _deriveInitial(label, email); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + width: 44, + height: 44, + alignment: Alignment.center, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular( - NhamRadii.buttonXl, - ), // rounded-xl=12 + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0x66C9A87C), // accent @ 40% + Color(0x8CE8D5B5), // border @ 55% + ], + ), + border: Border.all(color: const Color(0x40C9A87C), width: 1), ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0x66C9A87C), // accent @ 40% - Color(0x8CE8D5B5), // border @ 55% - ], - ), - border: Border.all( - color: const Color(0x40C9A87C), // accent @ 25% - width: 1, - ), - ), - child: Text( - initial, - style: NhamTextStyles.sansBold( - fontSize: 13, - ).copyWith(color: NhamColors.btn), - ), - ), - const SizedBox(width: 12), // gap-3 - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: NhamTextStyles.sansMedium( - fontSize: 13, - ).copyWith(color: NhamColors.text), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (email != null) - Text( - email!, - style: NhamTextStyles.sansRegular( - fontSize: 11, - ).copyWith(color: NhamColors.textMuted), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], + child: Text( + initial, + style: NhamTextStyles.sansBold(fontSize: 16).copyWith( + color: NhamColors.btn, + ), ), ); } +} - static String _deriveInitial(String label, String? email) { - final source = label.trim().isNotEmpty ? label.trim() : (email ?? ''); - if (source.isEmpty) return '·'; - return source.characters.first.toUpperCase(); - } +String _deriveInitial(String label, String? email) { + final source = label.trim().isNotEmpty ? label.trim() : (email ?? ''); + if (source.isEmpty) return '·'; + return source.characters.first.toUpperCase(); } /// Footer Settings row — radius 12, 16px icon, 13px medium label; active = @@ -490,8 +449,10 @@ class _SignOutRowState extends State<_SignOutRow> { debugPrint('Sign-out failed: $error'); if (!mounted) return; setState(() => _signingOut = false); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - SnackBar(content: Text(tr('app.userMenu.signOutError'))), + showTopToast( + context, + tr('app.userMenu.signOutError'), + variant: TopToastVariant.error, ); } } @@ -521,7 +482,7 @@ class _SignOutRowState extends State<_SignOutRow> { ), child: Row( children: [ - const Icon(Icons.logout, size: 16, color: NhamColors.danger), + const Icon(LucideIcons.logOut, size: 16, color: NhamColors.danger), const SizedBox(width: 12), Text( tr('app.userMenu.signOut'), @@ -559,15 +520,9 @@ class _OnboardingNudge extends ConsumerWidget { padding: const EdgeInsets.all(NhamSpacing.sp4), // p-4 = 16 decoration: BoxDecoration( borderRadius: BorderRadius.circular(NhamRadii.xxl), // rounded-2xl = 18 - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0x1AC9A87C), // accent @ 10% - NhamColors.surface, - Color(0x8CF0EAE0), // hover @ 55% - ], - ), + // Solid card on the cream drawer — hierarchy from the border, not a + // gradient wash. + color: NhamColors.elev, border: Border.all( color: const Color(0x40C9A87C), // accent @ 25% width: 1, @@ -576,41 +531,23 @@ class _OnboardingNudge extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Step counter row (sparkle chip + counter). - Row( - children: [ - Container( - width: 20, - height: 20, - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0x33C9A87C), // accent @ 20% - borderRadius: BorderRadius.circular(NhamRadii.md), - ), - child: const Icon( - Icons.auto_awesome, - size: 12, - color: NhamColors.accent, - ), - ), - const SizedBox(width: 6), // gap-1.5 - Text( - tr( - 'app.onboardingNudge.stepCounter', - namedArgs: {'current': '$safeStep', 'total': '$_total'}, - ).toUpperCase(), - style: NhamTextStyles.sansMedium(fontSize: 10).copyWith( - color: NhamColors.textMuted, - letterSpacing: 0.6, // 0.06em of 10px - ), - ), - ], + // Step counter eyebrow. + Text( + tr( + 'app.onboardingNudge.stepCounter', + namedArgs: {'current': '$safeStep', 'total': '$_total'}, + ).toUpperCase(), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.eyebrow) + .copyWith( + color: NhamColors.textMuted, + letterSpacing: NhamTracking.wide, // 0.06em + ), ), const SizedBox(height: 8), // mb-2 Text( tr('app.onboardingNudge.title'), - style: NhamTextStyles.sansMedium( - fontSize: 12, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, height: NhamLeading.snug, ).copyWith(color: NhamColors.text), ), @@ -618,7 +555,7 @@ class _OnboardingNudge extends ConsumerWidget { Text( tr('app.onboardingNudge.description'), style: NhamTextStyles.sansRegular( - fontSize: 11, + fontSize: NhamFontSize.detail, height: NhamLeading.relaxed, ).copyWith(color: NhamColors.textMuted), ), @@ -693,8 +630,8 @@ class _NudgeCtaState extends State<_NudgeCta> { ), child: Text( tr('app.onboardingNudge.cta'), - style: NhamTextStyles.sansMedium( - fontSize: 11, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.detail, ).copyWith(color: Colors.white), ), ), diff --git a/apps/mobile-flutter/lib/theme/nham_colors.dart b/apps/mobile-flutter/lib/theme/nham_colors.dart index 93042307..494ed317 100644 --- a/apps/mobile-flutter/lib/theme/nham_colors.dart +++ b/apps/mobile-flutter/lib/theme/nham_colors.dart @@ -67,6 +67,7 @@ abstract final class NhamColors { static const Color textMuted50 = Color(0x808B7355); // 50% static const Color textMuted60 = Color(0x998B7355); // 60% static const Color textMuted70 = Color(0xB38B7355); // 70% — macro-bar labels + static const Color textMuted80 = Color(0xCC8B7355); // 80% — legacy-macro note static const Color placeholderMuted40 = Color(0x668B7355); // 40% // ── Hover alpha variants ───────────────────────────────────────────── @@ -82,7 +83,13 @@ abstract final class NhamColors { static const Color macroFat = Color(0xFFA8A29E); // ── Status ─────────────────────────────────────────────────────────── - static const Color success = Color(0xFF7CA368); // sage + static const Color success = Color(0xFF7CA368); // sage (heatmap/legacy) + // Fresh emerald for "on target" cards — brighter + cleaner than the olive + // sage, reads modern against the warm cream surface. + static const Color successAccent = Color(0xFF1FA971); // emerald — text + bar + static const Color successDark = Color(0xFF14855A); // deeper emerald — figures + static const Color successFaint = Color(0xFFEAF7F0); // mint — met-card fill + static const Color successBorder = Color(0x331FA971); // emerald @ 20% — hairline static const Color danger = Color(0xFFD37B69); // terracotta // ── Adherence heatmap diverging scale ──────────────────────────────── diff --git a/apps/mobile-flutter/lib/theme/nham_theme.dart b/apps/mobile-flutter/lib/theme/nham_theme.dart index d25f86e9..ed0d777b 100644 --- a/apps/mobile-flutter/lib/theme/nham_theme.dart +++ b/apps/mobile-flutter/lib/theme/nham_theme.dart @@ -183,6 +183,21 @@ abstract final class NhamTheme { thickness: 1, space: 0, ), + // Branded toasts: espresso pill, cream text, soft radius — not the stock + // dark-gray Material pill (sign-out errors, the undo toast, etc.). + snackBarTheme: SnackBarThemeData( + backgroundColor: NhamColors.text, // espresso + contentTextStyle: NhamTextStyles.body().copyWith( + fontSize: NhamFontSize.sm, + color: NhamColors.surface, // cream + ), + actionTextColor: NhamColors.accent, + behavior: SnackBarBehavior.floating, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NhamRadii.xl), + ), + ), ); } } diff --git a/apps/mobile-flutter/lib/theme/nham_typography.dart b/apps/mobile-flutter/lib/theme/nham_typography.dart index ffed7ed4..b7a9194f 100644 --- a/apps/mobile-flutter/lib/theme/nham_typography.dart +++ b/apps/mobile-flutter/lib/theme/nham_typography.dart @@ -34,11 +34,17 @@ abstract final class NhamTracking { static const double display = -1.4; } -/// Text styles built from Lora (serif) and DM Sans (sans-serif). +/// Text styles built from Lora (serif) and Be Vietnam Pro (sans-serif). /// /// Lora = display/serif (headings, numbers > 18px, meal quotes -- never bold). -/// DM Sans = UI/body (buttons, labels, tabular numbers). +/// Be Vietnam Pro = UI/body (buttons, labels, tabular numbers). It replaces the +/// previous DM Sans, whose bundled subset lacked Vietnamese diacritics and made +/// `phở` / `bún chả` fall back mid-word; Be Vietnam Pro covers the full +/// Vietnamese set, so all UI text renders in one consistent typeface. abstract final class NhamTextStyles { + /// The bundled sans family (declared in pubspec `fonts:`). + static const String sansFamily = 'BeVietnamPro'; + // ── Serif (Lora) ─────────────────────────────────────────────────── static TextStyle serifRegular({double? fontSize, double? height}) => @@ -55,12 +61,8 @@ abstract final class NhamTextStyles { height: height, ); - static TextStyle serifSemiBold({double? fontSize, double? height}) => - GoogleFonts.lora( - fontWeight: FontWeight.w600, - fontSize: fontSize, - height: height, - ); + // No serifSemiBold: Lora is allowed up to w500 for select display/number + // treatments, but never w600+ bold weight. static TextStyle serifItalic({double? fontSize, double? height}) => GoogleFonts.lora( @@ -70,31 +72,31 @@ abstract final class NhamTextStyles { height: height, ); - // ── Sans (DM Sans) ──────────────────────────────────────────────── + // ── Sans (Be Vietnam Pro) ───────────────────────────────────────── - static TextStyle sansRegular({double? fontSize, double? height}) => - GoogleFonts.dmSans( + static TextStyle sansRegular({double? fontSize, double? height}) => TextStyle( + fontFamily: sansFamily, fontWeight: FontWeight.w400, fontSize: fontSize, height: height, ); - static TextStyle sansMedium({double? fontSize, double? height}) => - GoogleFonts.dmSans( + static TextStyle sansMedium({double? fontSize, double? height}) => TextStyle( + fontFamily: sansFamily, fontWeight: FontWeight.w500, fontSize: fontSize, height: height, ); - static TextStyle sansSemiBold({double? fontSize, double? height}) => - GoogleFonts.dmSans( + static TextStyle sansSemiBold({double? fontSize, double? height}) => TextStyle( + fontFamily: sansFamily, fontWeight: FontWeight.w600, fontSize: fontSize, height: height, ); - static TextStyle sansBold({double? fontSize, double? height}) => - GoogleFonts.dmSans( + static TextStyle sansBold({double? fontSize, double? height}) => TextStyle( + fontFamily: sansFamily, fontWeight: FontWeight.w700, fontSize: fontSize, height: height, @@ -103,57 +105,39 @@ abstract final class NhamTextStyles { // ── Semantic presets ────────────────────────────────────────────── static TextStyle displayLarge() => serifRegular( - fontSize: NhamFontSize.display, - height: NhamLeading.display, - ).copyWith(letterSpacing: NhamTracking.display); + fontSize: NhamFontSize.display, + height: NhamLeading.display, + ).copyWith(letterSpacing: NhamTracking.display); - static TextStyle heading1() => serifRegular( - fontSize: NhamFontSize.h1, - height: NhamLeading.tight, - ); + static TextStyle heading1() => + serifRegular(fontSize: NhamFontSize.h1, height: NhamLeading.tight); - static TextStyle heading2() => serifRegular( - fontSize: NhamFontSize.h2, - height: NhamLeading.tight, - ); + static TextStyle heading2() => + serifRegular(fontSize: NhamFontSize.h2, height: NhamLeading.tight); - static TextStyle heading3() => serifMedium( - fontSize: NhamFontSize.h3, - height: NhamLeading.snug, - ); + static TextStyle heading3() => + serifMedium(fontSize: NhamFontSize.h3, height: NhamLeading.snug); - static TextStyle heading4() => serifMedium( - fontSize: NhamFontSize.h4, - height: NhamLeading.snug, - ); + static TextStyle heading4() => + serifMedium(fontSize: NhamFontSize.h4, height: NhamLeading.snug); - static TextStyle bodyLarge() => sansRegular( - fontSize: NhamFontSize.lg, - height: NhamLeading.normal, - ); + static TextStyle bodyLarge() => + sansRegular(fontSize: NhamFontSize.lg, height: NhamLeading.normal); - static TextStyle body() => sansRegular( - fontSize: NhamFontSize.md, - height: NhamLeading.normal, - ); + static TextStyle body() => + sansRegular(fontSize: NhamFontSize.md, height: NhamLeading.normal); - static TextStyle bodySmall() => sansRegular( - fontSize: NhamFontSize.sm, - height: NhamLeading.normal, - ); + static TextStyle bodySmall() => + sansRegular(fontSize: NhamFontSize.sm, height: NhamLeading.normal); - static TextStyle caption() => sansRegular( - fontSize: NhamFontSize.xs, - height: NhamLeading.normal, - ); + static TextStyle caption() => + sansRegular(fontSize: NhamFontSize.xs, height: NhamLeading.normal); static TextStyle eyebrow() => sansSemiBold( - fontSize: NhamFontSize.eyebrow, - height: NhamLeading.normal, - ).copyWith(letterSpacing: NhamTracking.eyebrow); + fontSize: NhamFontSize.eyebrow, + height: NhamLeading.normal, + ).copyWith(letterSpacing: NhamTracking.eyebrow); - static TextStyle buttonLabel() => sansSemiBold( - fontSize: NhamFontSize.sm, - height: NhamLeading.snug, - ); + static TextStyle buttonLabel() => + sansSemiBold(fontSize: NhamFontSize.sm, height: NhamLeading.snug); } diff --git a/apps/mobile-flutter/pubspec.lock b/apps/mobile-flutter/pubspec.lock index 74acba9c..6d0b7fed 100644 --- a/apps/mobile-flutter/pubspec.lock +++ b/apps/mobile-flutter/pubspec.lock @@ -97,8 +97,16 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - crypto: + cross_file: dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -486,7 +494,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -621,6 +629,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: transitive description: @@ -677,6 +701,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + sign_in_with_apple: + dependency: "direct main" + description: + name: sign_in_with_apple + sha256: e84a62e17b7e463abf0a64ce826c2cd1f0b72dff07b7b275e32d5302d76fb4c5 + url: "https://pub.dev" + source: hosted + version: "6.1.4" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: c2ef2ce6273fce0c61acd7e9ff5be7181e33d7aa2b66508b39418b786cca2119 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: "2f7c38368f49e3f2043bca4b46a4a61aaae568c140a79aa0675dc59ad0ca49bc" + url: "https://pub.dev" + source: hosted + version: "2.1.1" sky_engine: dependency: transitive description: flutter diff --git a/apps/mobile-flutter/pubspec.yaml b/apps/mobile-flutter/pubspec.yaml index 08ab8fdd..c48e491a 100644 --- a/apps/mobile-flutter/pubspec.yaml +++ b/apps/mobile-flutter/pubspec.yaml @@ -26,6 +26,10 @@ dependencies: lucide_icons_flutter: ^3.1.14 intl: ^0.20.2 uuid: ^4.5.1 + sign_in_with_apple: ^6.1.4 + crypto: ^3.0.6 + share_plus: ^10.1.4 + path_provider: ^2.1.5 dev_dependencies: flutter_test: @@ -36,3 +40,22 @@ flutter: uses-material-design: true assets: - assets/l10n/ + # Bundled brand fonts (OFL). google_fonts resolves the Lora files in here by + # their API filenames — do NOT rename — so serif renders on a cold offline + # start instead of falling back while fetching. Runtime fetching is disabled + # in main.dart. + - assets/google_fonts/ + # Be Vietnam Pro is the UI sans (replaces DM Sans, whose bundled subset lacked + # Vietnamese diacritics). Declared as a native family — referenced directly by + # NhamTextStyles.sansFamily — so the full Vietnamese glyph set is guaranteed. + fonts: + - family: BeVietnamPro + fonts: + - asset: assets/google_fonts/BeVietnamPro-Regular.ttf + weight: 400 + - asset: assets/google_fonts/BeVietnamPro-Medium.ttf + weight: 500 + - asset: assets/google_fonts/BeVietnamPro-SemiBold.ttf + weight: 600 + - asset: assets/google_fonts/BeVietnamPro-Bold.ttf + weight: 700 diff --git a/apps/mobile-flutter/test/widget_test.dart b/apps/mobile-flutter/test/widget_test.dart index 2ceaf944..bee56555 100644 --- a/apps/mobile-flutter/test/widget_test.dart +++ b/apps/mobile-flutter/test/widget_test.dart @@ -1,11 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:nham_mobile/app.dart'; +import 'package:nham_mobile/services/supabase_service.dart'; void main() { - testWidgets('Nham placeholder renders', (WidgetTester tester) async { - await tester.pumpWidget(const NhamApp()); + TestWidgetsFlutterBinding.ensureInitialized(); - expect(find.text('Nham'), findsOneWidget); + setUpAll(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/shared_preferences'), + (call) async => call.method == 'getAll' ? {} : null, + ); + await EasyLocalization.ensureInitialized(); + await SupabaseService.initialize( + url: 'https://example.supabase.co', + anonKey: 'test-anon-key', + ); + }); + + testWidgets('branded auth welcome renders', (WidgetTester tester) async { + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('vi')], + path: 'assets/l10n', + fallbackLocale: const Locale('en'), + child: const ProviderScope(child: NhamApp()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Nhẩm'), findsOneWidget); + expect( + find.text('Track Vietnamese meals without the guesswork'), + findsOneWidget, + ); }); } diff --git a/components/nutrition/rows/food-chip-row.tsx b/components/nutrition/rows/food-chip-row.tsx index 03166619..f5202be0 100644 --- a/components/nutrition/rows/food-chip-row.tsx +++ b/components/nutrition/rows/food-chip-row.tsx @@ -1,23 +1,11 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { getFoodSourceCandidates } from '@/lib/nutrition/actions'; -import { - SUPPORTED_CANDIDATE_NUTRIENT_SET, - type SupportedCandidateNutrient, -} from '@/lib/nutrition/catalog/nutrients'; import type { NutritionNutrientKey } from '@/lib/nutrition/types'; import { cn } from '@/lib/utils'; -function asSupported( - nutrient: NutritionNutrientKey -): SupportedCandidateNutrient | null { - return SUPPORTED_CANDIDATE_NUTRIENT_SET.has(nutrient) - ? (nutrient as SupportedCandidateNutrient) - : null; -} - interface FoodChipRowProps { nutrient: NutritionNutrientKey; enabled?: boolean; @@ -34,35 +22,27 @@ export function FoodChipRow({ limit = 5, }: FoodChipRowProps) { const t = useTranslations('nutrition'); - const tRoot = useTranslations(); - const supported = asSupported(nutrient); + const locale = useLocale(); const query = useQuery({ - queryKey: ['nutrition', 'candidates', supported], - queryFn: () => { - if (!supported) { - throw new Error('Unsupported candidate nutrient.'); - } - return getFoodSourceCandidates({ nutrient: supported }); - }, - enabled: enabled && supported !== null, + queryKey: ['nutrition', 'candidates', nutrient], + queryFn: () => getFoodSourceCandidates({ nutrient }), + enabled, retry: false, staleTime: 60_000, }); - if (!supported) return null; - if (query.isLoading) { return ; } // Distinguish a fetch failure (logged for ops + visible "couldn't load" - // message) from a successful response with zero candidates ("no ideas"). + // message) from a successful response with zero foods ("no ideas"). // Both stay visually quiet so they don't overwhelm the editorial layout. if (query.isError) { console.error( '[nutrition] candidates query failed for nutrient', - supported, + nutrient, query.error ); return ( @@ -72,26 +52,26 @@ export function FoodChipRow({ ); } - if (query.isSuccess && !query.data.candidates.length) { + if (query.isSuccess && !query.data.foods.length) { return (

{t('focus.noFoodIdeas')}

); } - if (!query.data?.candidates.length) { + if (!query.data?.foods.length) { return null; } - const chips = query.data.candidates.slice(0, limit); + const chips = query.data.foods.slice(0, limit); return (
{t('focus.tryLabel')} - {chips.map((candidate) => ( + {chips.map((food) => ( - {tRoot(candidate.nameKey)} + {locale === 'vi' ? food.name : food.nameEn} ))}
diff --git a/components/nutrition/rows/nutrient-detail.tsx b/components/nutrition/rows/nutrient-detail.tsx index 782b3da3..519aacae 100644 --- a/components/nutrition/rows/nutrient-detail.tsx +++ b/components/nutrition/rows/nutrient-detail.tsx @@ -40,7 +40,6 @@ export function NutrientDetail({ card, daySeries }: NutrientDetailProps) { }); const showChips = - card.supportsCandidates && card.confidence >= 40 && card.percentOfTarget !== null && card.percentOfTarget < 90; diff --git a/components/nutrition/sections/verdict-hero.tsx b/components/nutrition/sections/verdict-hero.tsx index 0ea30b7b..fc1c0468 100644 --- a/components/nutrition/sections/verdict-hero.tsx +++ b/components/nutrition/sections/verdict-hero.tsx @@ -13,6 +13,7 @@ interface VerdictHeroProps { } const PERIOD_KEYS: Record = { + '1d': 'verdict.period1d', '7d': 'verdict.period7d', '30d': 'verdict.period30d', '90d': 'verdict.period90d', diff --git a/lib/api/contracts/nutrition.ts b/lib/api/contracts/nutrition.ts index 476ebf41..4051fecb 100644 --- a/lib/api/contracts/nutrition.ts +++ b/lib/api/contracts/nutrition.ts @@ -40,16 +40,19 @@ export const candidatesSchema = z.object({ nutrient: foodSourceCandidatesInputSchema.shape.nutrient, }); -export type { FoodSourceCandidate } from '@/lib/nutrition/catalog/food-source-candidates'; +// A food suggestion derived from the food-composition table: the food's name +// (Vietnamese + English) and its per-100g amount of the requested nutrient. +export type FoodSourceCandidate = { + id: string; + name: string; + nameEn: string; + amount: number; + unit: string; +}; -// Candidates response — render-ready, i18n-key-only. Re-exported so the mobile -// `['nutrition','candidates',nutrient]` query is typed without re-deriving the -// action return type. Both source modules below are pure (type-only/zod imports, -// no server action), so this stays mobile-safe (type-only re-export). -export type { SupportedCandidateNutrient } from '@/lib/nutrition/catalog/nutrients'; // Overview response type (exported interface from the action's type module). export type { NutritionOverview } from '@/lib/nutrition/types'; export type CandidatesResponse = { - nutrient: import('@/lib/nutrition/catalog/nutrients').SupportedCandidateNutrient; - candidates: import('@/lib/nutrition/catalog/food-source-candidates').FoodSourceCandidate[]; + nutrient: import('@/lib/nutrition/types').NutritionNutrientKey; + foods: FoodSourceCandidate[]; }; diff --git a/lib/nutrition/__tests__/actions/candidates.test.ts b/lib/nutrition/__tests__/actions/candidates.test.ts index e39d480e..4d912001 100644 --- a/lib/nutrition/__tests__/actions/candidates.test.ts +++ b/lib/nutrition/__tests__/actions/candidates.test.ts @@ -1,13 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockRequireAuthAndProfile } = vi.hoisted(() => ({ +const { mockRequireAuthAndProfile, mockLimit } = vi.hoisted(() => ({ mockRequireAuthAndProfile: vi.fn(), + mockLimit: vi.fn(), })); vi.mock('@/lib/auth', () => ({ requireAuthAndProfile: mockRequireAuthAndProfile, })); +vi.mock('@/lib/db', () => { + const chain = { + select: vi.fn(() => chain), + from: vi.fn(() => chain), + where: vi.fn(() => chain), + orderBy: vi.fn(() => chain), + limit: mockLimit, + }; + return { db: chain }; +}); + import { requireAuthAndProfile } from '@/lib/auth'; import { getFoodSourceCandidates } from '@/lib/nutrition/actions/candidates'; @@ -20,21 +32,32 @@ describe('getFoodSourceCandidates', () => { }); }); - it('rejects unsupported nutrient keys', async () => { + it('rejects non-card nutrient keys (e.g. hidden vitamin H)', async () => { await expect( getFoodSourceCandidates({ nutrient: 'vitaminHMcg' }) ).rejects.toThrow(); }); - it('authenticates before returning curated candidates', async () => { + it('returns deduped DB-derived foods with the nutrient unit', async () => { + mockLimit.mockResolvedValue([ + { id: '1', name: 'Đậu phụ', nameEn: 'Tofu', amount: '350' }, + // Same food (raw/cooked variant) — deduped by English name. + { id: '2', name: 'Đậu phụ luộc', nameEn: 'tofu', amount: '300' }, + { id: '3', name: 'Cải bẹ xanh', nameEn: 'Mustard greens', amount: '200' }, + ]); + const result = await getFoodSourceCandidates({ nutrient: 'calciumMg' }); expect(requireAuthAndProfile).toHaveBeenCalledTimes(1); expect(result.nutrient).toBe('calciumMg'); - expect(result.candidates).toHaveLength(5); - expect(result.candidates[0]).toMatchObject({ - nutrient: 'calciumMg', - id: 'tofu', + expect(result.foods).toHaveLength(2); + expect(result.foods[0]).toMatchObject({ + id: '1', + name: 'Đậu phụ', + nameEn: 'Tofu', + amount: 350, + unit: 'mg', }); + expect(result.foods[1]).toMatchObject({ nameEn: 'Mustard greens' }); }); }); diff --git a/lib/nutrition/__tests__/catalog/food-source-candidates.test.ts b/lib/nutrition/__tests__/catalog/food-source-candidates.test.ts deleted file mode 100644 index 1d43719a..00000000 --- a/lib/nutrition/__tests__/catalog/food-source-candidates.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { FoodSourceCandidate } from '@/lib/nutrition/catalog/food-source-candidates'; -import { getCuratedFoodSourceCandidates } from '@/lib/nutrition/catalog/food-source-candidates'; -import { SUPPORTED_CANDIDATE_NUTRIENTS } from '@/lib/nutrition/catalog/nutrients'; - -const I18N_KEY_PREFIX = 'nutrition.candidates.'; - -describe('curated food source candidates', () => { - it('provides at least five candidates for every supported nutrient', () => { - for (const nutrient of SUPPORTED_CANDIDATE_NUTRIENTS) { - expect(getCuratedFoodSourceCandidates(nutrient)).toHaveLength(5); - } - }); - - it('uses nutrition candidate i18n keys for all user-facing copy', () => { - for (const nutrient of SUPPORTED_CANDIDATE_NUTRIENTS) { - const candidates = getCuratedFoodSourceCandidates(nutrient); - - for (const candidate of candidates) { - expect(candidate.nameKey).toMatch(/^nutrition\.candidates\./); - expect(candidate.servingKey).toMatch(/^nutrition\.candidates\./); - expect(candidate.rationaleKey).toMatch(/^nutrition\.candidates\./); - - if (candidate.cautionKey) { - expect(candidate.cautionKey).toMatch(/^nutrition\.candidates\./); - } - } - } - }); - - it('keeps nutrient ownership and ids stable for each candidate list', () => { - for (const nutrient of SUPPORTED_CANDIDATE_NUTRIENTS) { - const candidates = getCuratedFoodSourceCandidates(nutrient); - const ids = new Set(); - - for (const candidate of candidates) { - expect(candidate.nutrient).toBe(nutrient); - expect(candidate.id).toBeTruthy(); - expect(candidate.id.trim()).toBe(candidate.id); - expect(ids.has(candidate.id)).toBe(false); - ids.add(candidate.id); - - expect(candidate.nameKey).toBe( - `${I18N_KEY_PREFIX}${nutrient}.${candidate.id}.name` - ); - expect(candidate.servingKey).toBe( - `${I18N_KEY_PREFIX}${nutrient}.${candidate.id}.serving` - ); - expect(candidate.rationaleKey).toBe( - `${I18N_KEY_PREFIX}${nutrient}.${candidate.id}.rationale` - ); - - if (candidate.cautionKey) { - expect(candidate.cautionKey).toBe( - `${I18N_KEY_PREFIX}${nutrient}.${candidate.id}.caution` - ); - } - } - } - }); - - it('returns frozen candidate collections and objects', () => { - const candidates = getCuratedFoodSourceCandidates('calciumMg'); - - expect(Object.isFrozen(candidates)).toBe(true); - expect(Object.isFrozen(candidates[0])).toBe(true); - expect(() => - (candidates as FoodSourceCandidate[]).push({ - nutrient: 'calciumMg', - id: 'fake', - nameKey: 'nutrition.candidates.calciumMg.fake.name', - servingKey: 'nutrition.candidates.calciumMg.fake.serving', - rationaleKey: 'nutrition.candidates.calciumMg.fake.rationale', - }) - ).toThrow(TypeError); - expect(() => Object.assign(candidates[0], { id: 'changed' })).toThrow( - TypeError - ); - expect(getCuratedFoodSourceCandidates('calciumMg')).toHaveLength(5); - expect(getCuratedFoodSourceCandidates('calciumMg')[0]?.id).toBe('tofu'); - }); -}); diff --git a/lib/nutrition/__tests__/catalog/nutrients.test.ts b/lib/nutrition/__tests__/catalog/nutrients.test.ts index c1308339..9f56ccdb 100644 --- a/lib/nutrition/__tests__/catalog/nutrients.test.ts +++ b/lib/nutrition/__tests__/catalog/nutrients.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + CANDIDATE_NUTRIENTS, DEFAULT_NUTRIENTS, getNutrientMeta, HIDDEN_NUTRIENTS, - SUPPORTED_CANDIDATE_NUTRIENTS, + MORE_NUTRIENTS, } from '@/lib/nutrition/catalog/nutrients'; describe('nutrition nutrient metadata', () => { @@ -25,10 +26,11 @@ describe('nutrition nutrient metadata', () => { ]); }); - it('marks candidate-supported nutrients as default scored nutrients', () => { - for (const nutrient of SUPPORTED_CANDIDATE_NUTRIENTS) { - expect(DEFAULT_NUTRIENTS).toContain(nutrient); - } + it('offers candidates for every default and extended nutrient', () => { + expect(CANDIDATE_NUTRIENTS).toEqual([ + ...DEFAULT_NUTRIENTS, + ...MORE_NUTRIENTS, + ]); }); it('defines units and message keys for default nutrients', () => { diff --git a/lib/nutrition/__tests__/catalog/reference-targets.test.ts b/lib/nutrition/__tests__/catalog/reference-targets.test.ts index 98b5c587..582e6584 100644 --- a/lib/nutrition/__tests__/catalog/reference-targets.test.ts +++ b/lib/nutrition/__tests__/catalog/reference-targets.test.ts @@ -126,18 +126,18 @@ describe('resolveMicronutrientTargets', () => { }); }); - it('does not score age-dependent Vietnam iron when female age is missing', () => { + it('defaults age-unknown Vietnam female iron to the premenopausal RDA', () => { const targets = resolveMicronutrientTargets({ ...baseProfile, biologicalSex: 'female', age: null, }); + // Age unknown → keep the (higher) premenopausal floor rather than null. expect(targets.ironMg).toMatchObject({ - value: null, - source: 'unsupported', - sourceLabelKey: 'nutrition.targetSources.unsupported', - applicability: 'unsupported', + value: 24, + source: 'vietnam_rda', + applicability: 'scored', }); expect(targets.vitaminCMg).toMatchObject({ value: 70, @@ -165,17 +165,19 @@ describe('resolveMicronutrientTargets', () => { }); }); - it('does not score sex-dependent nutrients when biological sex is missing', () => { + it('uses the sex-neutral average when biological sex is missing', () => { const targets = resolveMicronutrientTargets({ ...baseProfile, biologicalSex: null, }); + // Sex unknown → mean of male/female RDA (iron: (10 + 24) / 2 = 17) so the + // nutrient still scores instead of showing "no target". expect(targets.ironMg).toMatchObject({ - value: null, - source: 'unsupported', - sourceLabelKey: 'nutrition.targetSources.unsupported', - applicability: 'unsupported', + value: 17, + source: 'vietnam_rda', + sourceLabelKey: 'nutrition.targetSources.vietnamRda', + applicability: 'scored', }); expect(targets.vitaminCMg).toMatchObject({ value: 70, diff --git a/lib/nutrition/__tests__/pattern/aggregation.test.ts b/lib/nutrition/__tests__/pattern/aggregation.test.ts index 157405e2..fd1658df 100644 --- a/lib/nutrition/__tests__/pattern/aggregation.test.ts +++ b/lib/nutrition/__tests__/pattern/aggregation.test.ts @@ -88,7 +88,7 @@ describe('nutrition aggregation helpers', () => { ).toBeUndefined(); }); - it('maps unsupported target sources and supportsCandidates defaults', () => { + it('maps unsupported target sources to the unsupported label key', () => { const card = buildNutrientCard({ nutrient: 'sodiumMg', averagePerDay: 400, @@ -100,19 +100,5 @@ describe('nutrition aggregation helpers', () => { expect(card.targetSourceLabelKey).toBe( 'nutrition.targetSources.unsupported' ); - expect(card.supportsCandidates).toBe(false); - }); - - it('preserves supportsCandidates when enabled', () => { - const card = buildNutrientCard({ - nutrient: 'calciumMg', - averagePerDay: 800, - target: 1000, - targetSource: 'who_fao', - confidence: 80, - supportsCandidates: true, - }); - - expect(card.supportsCandidates).toBe(true); }); }); diff --git a/lib/nutrition/__tests__/schemas.test.ts b/lib/nutrition/__tests__/schemas.test.ts index b9ba9118..7a9ba27a 100644 --- a/lib/nutrition/__tests__/schemas.test.ts +++ b/lib/nutrition/__tests__/schemas.test.ts @@ -31,22 +31,23 @@ describe('nutrition overview schema', () => { }); describe('food source candidates schema', () => { - it('accepts supported candidate nutrient keys', () => { + it('accepts any card nutrient (default + extended)', () => { expect( - foodSourceCandidatesInputSchema.parse({ - nutrient: 'ironMg', - }) - ).toEqual({ - nutrient: 'ironMg', - }); + foodSourceCandidatesInputSchema.parse({ nutrient: 'ironMg' }) + ).toEqual({ nutrient: 'ironMg' }); + // Extended nutrients are candidates now (DB-derived foods). + expect( + foodSourceCandidatesInputSchema.safeParse({ nutrient: 'magnesiumMg' }) + .success + ).toBe(true); }); - it('rejects unsupported candidate nutrients', () => { - expect( - foodSourceCandidatesInputSchema.safeParse({ - nutrient: 'magnesiumMg', - }).success - ).toBe(false); + it('rejects non-card nutrients (hidden / education / macros)', () => { + for (const nutrient of ['vitaminHMcg', 'vitaminDMcg', 'fiberG']) { + expect( + foodSourceCandidatesInputSchema.safeParse({ nutrient }).success + ).toBe(false); + } }); }); diff --git a/lib/nutrition/actions/candidates.ts b/lib/nutrition/actions/candidates.ts index 38cbe8cd..fc093705 100644 --- a/lib/nutrition/actions/candidates.ts +++ b/lib/nutrition/actions/candidates.ts @@ -1,15 +1,98 @@ 'use server'; +import { and, desc, notIlike, notInArray, sql } from 'drizzle-orm'; import { requireAuthAndProfile } from '@/lib/auth'; -import { getCuratedFoodSourceCandidates } from '../catalog/food-source-candidates'; +import { db } from '@/lib/db'; +import { vietnameseFoodComposition } from '@/lib/db/schema'; +import { NUTRIENT_META } from '../catalog/nutrients'; import { foodSourceCandidatesInputSchema } from '../schemas'; +// Ranking by raw per-100g density surfaces things that are dense but you'd never +// portion to fill a gap: dried spices (1g of dried thyme tops vitamin K), +// fortified breakfast cereals, oils, sweets, shake/drink mixes, and +// yeast/leavening agents. Exclude those whole categories (USDA SR + VN FCT +// naming variants) so suggestions read like foods you'd actually eat. Whole-food +// groups — meats, vegetables, fruits, legumes, fish, nuts, grains, roots — stay. +const EXCLUDED_TYPES_EN = [ + 'Condiments, traditional sauces', + 'Spices and Herbs', + 'Breakfast Cereals', + 'Fats and Oils', + 'Oil, lard, butter', + 'Sweets', + 'Sugar, confectionery', + 'Beverages', + 'Beverage and liquor', + 'Baked Products', + 'Soups, Sauces, and Gravies', +]; + +// The "Other" catch-all holds legit foods (e.g. goose liver) alongside fortified +// snack/supplement entries from USDA. Drop those by name marker rather than +// excluding the whole group: "Formulated bar, SNICKERS…", "…vitamin and mineral +// fortified", "Yeast extract spread". +const EXCLUDED_NAME_PATTERNS = [ + '%formulated%', + '%fortified%', + '%yeast extract%', +]; + +// A pool larger than what's shown at once, so the client can cycle through +// alternatives ("don't have these?") without another round-trip. +const POOL_SIZE = 18; + +/// Top Vietnamese foods for a nutrient, ranked by per-100g density — derived +/// from the food-composition table so it works for ANY tracked nutrient (no +/// curated list). Returns a pool the client pages through. export async function getFoodSourceCandidates(input: unknown) { - const parsed = foodSourceCandidatesInputSchema.parse(input); + const { nutrient } = foodSourceCandidatesInputSchema.parse(input); await requireAuthAndProfile(); - return { - nutrient: parsed.nutrient, - candidates: getCuratedFoodSourceCandidates(parsed.nutrient), - }; + const column = vietnameseFoodComposition[nutrient]; + const unit = NUTRIENT_META[nutrient].unit; + + const rows = await db + .select({ + id: vietnameseFoodComposition.id, + name: vietnameseFoodComposition.namePrimary, + nameEn: vietnameseFoodComposition.nameEn, + amount: column, + }) + .from(vietnameseFoodComposition) + .where( + and( + sql`${column} > 0`, + notInArray(vietnameseFoodComposition.typeEn, EXCLUDED_TYPES_EN), + ...EXCLUDED_NAME_PATTERNS.map((pattern) => + notIlike(vietnameseFoodComposition.nameEn, pattern) + ) + ) + ) + .orderBy(desc(column)) + // Overfetch, then dedupe raw/cooked variants of the same food below. + .limit(POOL_SIZE * 3); + + const seen = new Set(); + const foods: Array<{ + id: string; + name: string; + nameEn: string; + amount: number; + unit: string; + }> = []; + for (const row of rows) { + const key = row.nameEn.trim().toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + foods.push({ + id: row.id, + name: row.name, + nameEn: row.nameEn, + amount: Number(row.amount ?? 0), + unit, + }); + if (foods.length >= POOL_SIZE) break; + } + + return { nutrient, foods }; } diff --git a/lib/nutrition/actions/overview/mapper.ts b/lib/nutrition/actions/overview/mapper.ts index f3237b70..e42c7da3 100644 --- a/lib/nutrition/actions/overview/mapper.ts +++ b/lib/nutrition/actions/overview/mapper.ts @@ -3,8 +3,6 @@ import { DEFAULT_NUTRIENTS, getNutrientMeta, MORE_NUTRIENTS, - SUPPORTED_CANDIDATE_NUTRIENT_SET, - type SupportedCandidateNutrient, } from '../../catalog/nutrients'; import { type MicronutrientTarget, @@ -40,6 +38,7 @@ import type { NutritionDaySeries, NutritionNutrientKey, NutritionOverview, + NutritionRange, NutritionRangeInput, } from '../../types'; import type { OverviewMealItemRow } from './query'; @@ -53,15 +52,23 @@ const SPOTLIGHT_LIMIT = 2; const SPOTLIGHT_MIN_CONFIDENCE = 40; const SPOTLIGHT_MAX_PERCENT = 90; +// Day-series bucket granularity per resolved range: short ranges bucket by day, +// long ranges by week. Table-driven so a new range needs no conditional edits. +const RANGE_BUCKET_UNIT: Record = { + '1d': 'day', + '7d': 'day', + '30d': 'week', + '90d': 'week', +}; + function isSpotlightCandidate(card: NutrientCardData): boolean { + // Every default micronutrient can surface food candidates (the composition + // table has a column for each), so the gate is purely confidence + gap. + // `partitionSpotlight` is only ever called on the default micronutrients. return ( - card.supportsCandidates && card.confidence >= SPOTLIGHT_MIN_CONFIDENCE && card.percentOfTarget !== null && - card.percentOfTarget < SPOTLIGHT_MAX_PERCENT && - SUPPORTED_CANDIDATE_NUTRIENT_SET.has( - card.nutrient as SupportedCandidateNutrient - ) + card.percentOfTarget < SPOTLIGHT_MAX_PERCENT ); } @@ -320,7 +327,6 @@ function buildNutrientCards({ caveatKey: nutrient === 'sodiumMg' ? getSodiumCaveatKey(sodiumStats) : undefined, sourceBreakdown, - supportsCandidates: DEFAULT_NUTRIENT_SET.has(nutrient), nutrientType: target.nutrientType, }); }); @@ -479,7 +485,7 @@ function buildDaySeries({ profile: NutritionProfile; targets: Record; }): NutritionDaySeries { - const unit: DaySeriesBucketUnit = resolvedRange === '7d' ? 'day' : 'week'; + const unit: DaySeriesBucketUnit = RANGE_BUCKET_UNIT[resolvedRange]; const step = unit === 'day' ? 1 : 7; const bounds = buildBucketBounds(period.startDate, period.endDate, step); @@ -560,7 +566,10 @@ export function mapOverviewRowsToDto({ macroConsistency: { averageConsistencyPct: 0, weakestMacro: null }, }, macros: [], - daySeries: { unit: resolvedRange === '7d' ? 'day' : 'week', series: [] }, + daySeries: { + unit: RANGE_BUCKET_UNIT[resolvedRange], + series: [], + }, micronutrients: [], spotlight: [], steady: [], diff --git a/lib/nutrition/catalog/food-source-candidates.ts b/lib/nutrition/catalog/food-source-candidates.ts deleted file mode 100644 index 6503a927..00000000 --- a/lib/nutrition/catalog/food-source-candidates.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { SupportedCandidateNutrient } from './nutrients'; - -export interface FoodSourceCandidate { - readonly nutrient: SupportedCandidateNutrient; - readonly id: string; - readonly nameKey: string; - readonly servingKey: string; - readonly rationaleKey: string; - readonly cautionKey?: string; -} - -const c = ( - nutrient: SupportedCandidateNutrient, - id: string, - caution = false -): FoodSourceCandidate => - Object.freeze({ - nutrient, - id, - nameKey: `nutrition.candidates.${nutrient}.${id}.name`, - servingKey: `nutrition.candidates.${nutrient}.${id}.serving`, - rationaleKey: `nutrition.candidates.${nutrient}.${id}.rationale`, - cautionKey: caution - ? `nutrition.candidates.${nutrient}.${id}.caution` - : undefined, - }); - -const freezeCandidates = ( - candidates: readonly FoodSourceCandidate[] -): readonly FoodSourceCandidate[] => Object.freeze([...candidates]); - -export const CURATED_FOOD_SOURCE_CANDIDATES: Readonly< - Record -> = Object.freeze({ - calciumMg: freezeCandidates([ - c('calciumMg', 'tofu'), - c('calciumMg', 'smallFishWithBones'), - c('calciumMg', 'yogurt'), - c('calciumMg', 'mustardGreens'), - c('calciumMg', 'soyMilk'), - ]), - ironMg: freezeCandidates([ - c('ironMg', 'clams'), - c('ironMg', 'leanBeef'), - c('ironMg', 'porkLiver', true), - c('ironMg', 'waterSpinach'), - c('ironMg', 'mungBeans'), - ]), - vitaminCMg: freezeCandidates([ - c('vitaminCMg', 'guava'), - c('vitaminCMg', 'pomelo'), - c('vitaminCMg', 'papaya'), - c('vitaminCMg', 'mustardGreens'), - c('vitaminCMg', 'freshHerbs'), - ]), - phosphorusMg: freezeCandidates([ - c('phosphorusMg', 'fish'), - c('phosphorusMg', 'eggs'), - c('phosphorusMg', 'tofu'), - c('phosphorusMg', 'chicken'), - c('phosphorusMg', 'peanuts'), - ]), - vitaminB1Mg: freezeCandidates([ - c('vitaminB1Mg', 'pork'), - c('vitaminB1Mg', 'mungBeans'), - c('vitaminB1Mg', 'brownRice'), - c('vitaminB1Mg', 'peanuts'), - c('vitaminB1Mg', 'soybeans'), - ]), - vitaminB2Mg: freezeCandidates([ - c('vitaminB2Mg', 'eggs'), - c('vitaminB2Mg', 'yogurt'), - c('vitaminB2Mg', 'porkLiver', true), - c('vitaminB2Mg', 'fish'), - c('vitaminB2Mg', 'mushrooms'), - ]), - vitaminPpMg: freezeCandidates([ - c('vitaminPpMg', 'chicken'), - c('vitaminPpMg', 'fish'), - c('vitaminPpMg', 'peanuts'), - c('vitaminPpMg', 'leanPork'), - c('vitaminPpMg', 'mushrooms'), - ]), - vitaminAMcg: freezeCandidates([ - c('vitaminAMcg', 'carrots'), - c('vitaminAMcg', 'pumpkin'), - c('vitaminAMcg', 'sweetPotato'), - c('vitaminAMcg', 'eggYolk'), - c('vitaminAMcg', 'darkLeafyGreens'), - ]), -}); - -const EMPTY_CANDIDATES = Object.freeze([]) as readonly FoodSourceCandidate[]; - -export function getCuratedFoodSourceCandidates( - nutrient: SupportedCandidateNutrient -): readonly FoodSourceCandidate[] { - return CURATED_FOOD_SOURCE_CANDIDATES[nutrient] ?? EMPTY_CANDIDATES; -} diff --git a/lib/nutrition/catalog/nutrients.ts b/lib/nutrition/catalog/nutrients.ts index ce312187..74472555 100644 --- a/lib/nutrition/catalog/nutrients.ts +++ b/lib/nutrition/catalog/nutrients.ts @@ -26,6 +26,13 @@ export const MORE_NUTRIENTS = [ 'vitaminKMcg', ] as const satisfies readonly NutritionNutrientKey[]; +// The nutrients shown as cards (default + extended) — every one can surface +// DB-derived food suggestions, since the composition table has a column for each. +export const CANDIDATE_NUTRIENTS = [ + ...DEFAULT_NUTRIENTS, + ...MORE_NUTRIENTS, +] as const satisfies readonly NutritionNutrientKey[]; + export const HIDDEN_NUTRIENTS = [ 'vitaminHMcg', ] as const satisfies readonly NutritionNutrientKey[]; @@ -34,28 +41,7 @@ export const EDUCATION_NUTRIENTS = [ 'vitaminDMcg', ] as const satisfies readonly NutritionNutrientKey[]; -export const SUPPORTED_CANDIDATE_NUTRIENTS = [ - 'calciumMg', - 'ironMg', - 'vitaminCMg', - 'phosphorusMg', - 'vitaminB1Mg', - 'vitaminB2Mg', - 'vitaminPpMg', - 'vitaminAMcg', -] as const satisfies readonly DefaultNutrientKey[]; - export type DefaultNutrientKey = (typeof DEFAULT_NUTRIENTS)[number]; -export type SupportedCandidateNutrient = - (typeof SUPPORTED_CANDIDATE_NUTRIENTS)[number]; - -/** - * Pre-built lookup set for `SUPPORTED_CANDIDATE_NUTRIENTS`. Use this instead of - * constructing `new Set(SUPPORTED_CANDIDATE_NUTRIENTS)` in callsites. - */ -export const SUPPORTED_CANDIDATE_NUTRIENT_SET: ReadonlySet = new Set( - SUPPORTED_CANDIDATE_NUTRIENTS -); export const NUTRIENT_META: Record = { fiberG: { diff --git a/lib/nutrition/catalog/reference-targets.ts b/lib/nutrition/catalog/reference-targets.ts index d58b203d..0858156b 100644 --- a/lib/nutrition/catalog/reference-targets.ts +++ b/lib/nutrition/catalog/reference-targets.ts @@ -125,8 +125,24 @@ const VIETNAM_RDA: Partial> = { female: { value: 1000, unit: 'mg' }, }, ironMg: { - male: { value: 10, unit: 'mg' }, - female: { value: 24, unit: 'mg' }, + // Premenopausal women need more iron (menstrual losses); the RDA drops to + // the male level at 50. Age-unknown resolves to the <50 band (female 24). + ageBands: [ + { + minAge: 50, + row: { + male: { value: 10, unit: 'mg' }, + female: { value: 10, unit: 'mg' }, + }, + }, + { + minAge: 0, + row: { + male: { value: 10, unit: 'mg' }, + female: { value: 24, unit: 'mg' }, + }, + }, + ], }, magnesiumMg: { // Bảng 18, RDA, 20–29 yr. @@ -448,11 +464,10 @@ function resolveBiologicalSex(value: string | null): BiologicalSex | null { return null; } -function isSexIndependentTarget(targetRow: TargetRow): boolean { - return ( - targetRow.male.value === targetRow.female.value && - targetRow.male.unit === targetRow.female.unit - ); +/// Round a derived (averaged) target to a sensible precision: whole numbers at +/// scale, one decimal for small values like vitamin B6 (~2.1 mg). +function roundTarget(value: number): number { + return value >= 10 ? Math.round(value) : Math.round(value * 10) / 10; } function getDefaultUnit(key: NutritionNutrientKey): 'mg' | 'mcg' { @@ -524,37 +539,25 @@ export function resolveMicronutrientTargets( ? resolveAgeBand(entry, profile.age) : entry; - if (!sex && !isSexIndependentTarget(targetRow)) { - targets[key] = createUnsupportedTarget(key); - continue; - } - - // Iron in VN context for women splits at age 50 (postmenopausal RDA - // drops from 24 → 10 mg). Preserved from the previous behavior; - // scoped here because it isn't a clean band split (only F changes). - if ( - key === 'ironMg' && - source === 'vietnam_rda' && - sex === 'female' && - (profile.age === null || !Number.isFinite(profile.age)) - ) { - targets[key] = createUnsupportedTarget(key); - continue; + // When biological sex is unknown we still give a usable target instead of + // "no target": the sex-neutral mean of the male/female RDA. For + // sex-independent nutrients the mean equals either value, so this is a + // no-op there. + let value: number | null; + let unit: (typeof targetRow.male)['unit']; + if (sex) { + const target = targetRow[sex]; + value = target.value; + unit = target.unit; + } else { + value = roundTarget((targetRow.male.value + targetRow.female.value) / 2); + unit = targetRow.male.unit; } - const target = targetRow[sex ?? 'male']; - const value = - key === 'ironMg' && - source === 'vietnam_rda' && - sex === 'female' && - (profile.age ?? 0) >= 50 - ? 10 - : target.value; - targets[key] = { key, value, - unit: target.unit, + unit, source, sourceLabelKey: REFERENCE_SOURCES[source].labelKey, applicability: 'scored', diff --git a/lib/nutrition/pattern/aggregation.ts b/lib/nutrition/pattern/aggregation.ts index 031cc4ea..8287abdd 100644 --- a/lib/nutrition/pattern/aggregation.ts +++ b/lib/nutrition/pattern/aggregation.ts @@ -31,7 +31,6 @@ interface BuildNutrientCardInput { betaCaroteneAveragePerDay?: number | null; caveatKey?: string; sourceBreakdown?: NutrientCardData['sourceBreakdown']; - supportsCandidates?: boolean; nutrientType?: NutrientType; } @@ -106,7 +105,6 @@ export function buildNutrientCard({ betaCaroteneAveragePerDay, caveatKey, sourceBreakdown, - supportsCandidates = false, nutrientType, }: BuildNutrientCardInput): NutrientCardData { const nutrientMeta = getNutrientMeta(nutrient); @@ -138,6 +136,5 @@ export function buildNutrientCard({ ] : undefined, sourceBreakdown, - supportsCandidates, }; } diff --git a/lib/nutrition/pattern/date-range.ts b/lib/nutrition/pattern/date-range.ts index f00d3a9b..78707f50 100644 --- a/lib/nutrition/pattern/date-range.ts +++ b/lib/nutrition/pattern/date-range.ts @@ -4,6 +4,7 @@ import { sql } from 'drizzle-orm'; import type { BucketTimezone, NutritionRange } from '../types'; const RANGE_DAYS: Record = { + '1d': 1, '7d': 7, '30d': 30, '90d': 90, diff --git a/lib/nutrition/pattern/summary.ts b/lib/nutrition/pattern/summary.ts index da0c7429..ad39941f 100644 --- a/lib/nutrition/pattern/summary.ts +++ b/lib/nutrition/pattern/summary.ts @@ -22,6 +22,7 @@ interface MacroConsistencyInput { } const MINIMUM_LOGGED_DAYS: Record = { + '1d': 1, '7d': 3, '30d': 10, '90d': 30, diff --git a/lib/nutrition/schemas.ts b/lib/nutrition/schemas.ts index 6067665a..a1382f40 100644 --- a/lib/nutrition/schemas.ts +++ b/lib/nutrition/schemas.ts @@ -1,8 +1,14 @@ import { z } from 'zod'; -import { SUPPORTED_CANDIDATE_NUTRIENTS } from './catalog/nutrients'; +import { CANDIDATE_NUTRIENTS } from './catalog/nutrients'; -export const nutritionRangeSchema = z.enum(['7d', '30d', '90d']); -export const nutritionRangeInputSchema = z.enum(['auto', '7d', '30d', '90d']); +export const nutritionRangeSchema = z.enum(['1d', '7d', '30d', '90d']); +export const nutritionRangeInputSchema = z.enum([ + 'auto', + '1d', + '7d', + '30d', + '90d', +]); export const timezoneOffsetSchema = z .number() .int() @@ -16,5 +22,5 @@ export const nutritionOverviewInputSchema = z.object({ }); export const foodSourceCandidatesInputSchema = z.object({ - nutrient: z.enum(SUPPORTED_CANDIDATE_NUTRIENTS), + nutrient: z.enum(CANDIDATE_NUTRIENTS), }); diff --git a/lib/nutrition/types.ts b/lib/nutrition/types.ts index af62ecd8..a2e1986c 100644 --- a/lib/nutrition/types.ts +++ b/lib/nutrition/types.ts @@ -1,4 +1,4 @@ -export type NutritionRange = '7d' | '30d' | '90d'; +export type NutritionRange = '1d' | '7d' | '30d' | '90d'; export type NutritionRangeInput = 'auto' | NutritionRange; export type BucketTimezone = 'local' | 'utc'; export type TargetSource = 'vietnam_rda' | 'who_fao' | 'nasem' | 'unsupported'; @@ -146,7 +146,6 @@ export interface NutrientCardData { faoVietnamConfidence: number | null; missingSodiumCondimentItems?: number; }; - supportsCandidates: boolean; } export interface EducationCardData { diff --git a/messages/en.json b/messages/en.json index 00765fd6..d4d2e3a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -459,6 +459,7 @@ "gatheringSub": "{days} complete days so far — keep going and the picture sharpens.", "trustLineNoConfidence": "{days} complete days", "partialNote": "· {count, plural, one {# partial day set aside} other {# partial days set aside}}", + "period1d": "today", "period7d": "the last 7 days", "period30d": "the last 30 days", "period90d": "the last 90 days" diff --git a/messages/vi.json b/messages/vi.json index 26638a06..04ba9ace 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -459,6 +459,7 @@ "gatheringSub": "Đã có {days} ngày đủ bữa — cứ tiếp tục, bức tranh sẽ rõ dần.", "trustLineNoConfidence": "{days} ngày đủ bữa", "partialNote": "· {count, plural, other {tạm bỏ qua # ngày ghi thiếu}}", + "period1d": "hôm nay", "period7d": "7 ngày qua", "period30d": "30 ngày qua", "period90d": "90 ngày qua"