diff --git a/fastlane/metadata/android/en-US/images/logo.png b/fastlane/metadata/android/en-US/images/icon.png similarity index 100% rename from fastlane/metadata/android/en-US/images/logo.png rename to fastlane/metadata/android/en-US/images/icon.png diff --git a/fastlane/metadata/android/zh-TW/full_description.txt b/fastlane/metadata/android/zh-TW/full_description.txt new file mode 100644 index 00000000..66013f08 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/full_description.txt @@ -0,0 +1,39 @@ +每一個健身愛好者 - 與您的健身教練 WGER 一起,讓你更健康! + +你是否已經找到了排名第一的健身應用程序,並且喜歡創建自己的運動習慣? 無論你是哪種運動野獸——我們都有一個共同點:我們喜歡追蹤我們的健康數據 + +因此,我們不會因為你仍然使用小筆記本來管理你的健身旅程而批評你,但歡迎來到 2024 年! + +我們為你開發了 100% 免費的數位健康和健身追蹤器應用程序,壓縮到最相關的功能,讓你的生活更輕鬆。開始吧,繼續訓練並慶祝你的進步! + +wger 是一個開源項目,關於: +* 你的身體 +* 你的鍛煉 +* 你的進展 +* 你的數據 + +你的身體: +無需在谷歌上搜尋你最喜歡的食物的成分——從 78000 多種產品中選擇你的日常膳食並查看營養價值。將餐點加入營養計劃中,並在日曆中概述你的飲食。 + +你的鍛鍊: +你知道什麼對你的身體最好。從 200 種不同的練習中選擇越來越多的練習來創建你自己的鍛鍊。然後,使用健身房模式引導你完成訓練,同時一鍵記錄你的體重。 + +你的進展: +永遠不要忘記你的目標。追蹤你的體重並保留你的統計數據。 + +你的數據: +wger 是你的個人化健身日記——但你擁有自己的數據。使用 REST API 來存取它並用它做驚人的事情。 + +請注意:這個免費的應用程式不是基於額外的資金,我們不要求你捐款。不僅如此,它還是一個不斷發展的社區計畫。因此,請隨時為新功能做好準備! + +#OpenSource – 這是什麼意思? + +開源意味著這個應用程式的整個原始碼和它與之通訊的伺服器都是免費的,任何人都可以使用: +* 你想在自己的伺服器上為你還是為你當地的健身房運行 wger?都可以! +* 你是否錯過了某個功能並想要實現它?現在開始! +* 你想檢查任何地方都沒有發送任何東西嗎?你可以! + +加入我們的社區,成為來自世界各地的體育愛好者和 IT 極客的一份子。我們一直致力於調整和優化根據我們的需求量身定制的應用程式。我們喜歡你的意見,因此請隨時加入並貢獻你的願望和想法! + +-> 在 https://github.com/wger-project 上找到原始碼 +-> 在我們的 Discord 伺服器上提問或打個招呼 https://discord.gg/rPWFv6W diff --git a/fastlane/metadata/android/zh-TW/short_description.txt b/fastlane/metadata/android/zh-TW/short_description.txt new file mode 100644 index 00000000..b66d2c04 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/short_description.txt @@ -0,0 +1 @@ +健身、營養和體重紀錄器 diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 04a96f04..18f1432b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -143,11 +143,11 @@ "@reps": { "description": "Shorthand for repetitions, used when space constraints are tighter" }, - "rir": "反復準備", + "rir": "予備レップ", "@rir": { "description": "Shorthand for Repetitions In Reserve" }, - "rirNotUsed": "反復準備が使用されていません", + "rirNotUsed": "予備レップは保存されていません", "@rirNotUsed": { "description": "Label used in RiR slider when the RiR value is not used/saved for the current setting or log" }, @@ -558,5 +558,61 @@ "description": "Translation for close" }, "userProfile": "あなたのプロフィール", - "@userProfile": {} + "@userProfile": {}, + "barbell": "バーベル", + "@barbell": { + "description": "Generated entry for translation for server strings" + }, + "arms": "腕", + "@arms": { + "description": "Generated entry for translation for server strings" + }, + "chest": "胸", + "@chest": { + "description": "Generated entry for translation for server strings" + }, + "dumbbell": "ダンベル", + "@dumbbell": { + "description": "Generated entry for translation for server strings" + }, + "success": "成功", + "@success": { + "description": "Message when an action completed successfully, usually used as a heading" + }, + "cardio": "有酸素運動", + "@cardio": { + "description": "Generated entry for translation for server strings" + }, + "bench": "ベンチ", + "@bench": { + "description": "Generated entry for translation for server strings" + }, + "settingsCacheDescription": "エクササイズキャッシュ", + "@settingsCacheDescription": {}, + "settingsTitle": "設定", + "@settingsTitle": {}, + "settingsCacheTitle": "キャッシュ", + "@settingsCacheTitle": {}, + "back": "戻る", + "@back": { + "description": "Generated entry for translation for server strings" + }, + "abs": "腹筋", + "@abs": { + "description": "Generated entry for translation for server strings" + }, + "biceps": "力こぶ", + "@biceps": { + "description": "Generated entry for translation for server strings" + }, + "body_weight": "体重", + "@body_weight": { + "description": "Generated entry for translation for server strings" + }, + "verify": "確認する", + "@verify": {}, + "calves": "ふくらはぎ", + "@calves": { + "description": "Generated entry for translation for server strings" + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7a1f1ba4..60a9f87b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -61,7 +61,7 @@ "@workoutSession": { "description": "A (logged) workout session" }, - "sameRepetitions": "填写次数和量级,则自动计算出相乘的结果作为数量", + "sameRepetitions": "如果您对所有组别进行相同的重复次数和重量,则只需填写一行即可。例如,对于 4 组,只需输入 10 次重复,这会自动变成「4 x 10」。", "@sameRepetitions": {}, "exercise": "锻炼项目", "@exercise": { @@ -87,7 +87,7 @@ "@impression": { "description": "General impression (e.g. for a workout session) such as good, bad, etc." }, - "notes": "备注", + "notes": "笔记", "@notes": { "description": "Personal notes, e.g. for a workout session" }, @@ -113,7 +113,7 @@ "@newWorkout": { "description": "Header when adding a new workout" }, - "category": "目录", + "category": "类别", "@category": { "description": "Category for an exercise, ingredient, etc." }, @@ -157,7 +157,7 @@ "@labelWorkoutPlans": { "description": "Title for screen workout plans" }, - "loginInstead": "登录", + "loginInstead": "已经有帐号了吗?立即登入", "@loginInstead": {}, "registerInstead": "没有帐户?点击注册", "@registerInstead": {}, @@ -228,7 +228,7 @@ "@aboutSourceTitle": { "description": "Title for source code section in the about dialog" }, - "aboutDescription": "谢谢使用!", + "aboutDescription": "感谢您使用wger! wger 是一个协作开源项目,由来自世界各地的健身爱好者创建。", "@aboutDescription": { "description": "Text in the about dialog" }, @@ -348,13 +348,13 @@ "@addMeal": {}, "save": "保存", "@save": {}, - "name": "给你的计划起个名字", + "name": "名", "@name": { "description": "Name for a workout or nutritional plan" }, - "description": "详细信息", + "description": "描述", "@description": {}, - "logHelpEntriesUnits": "有计量单位和数量的会以图形化的方式展现,类似时间、未完成的项目将不会出现在这里", + "logHelpEntriesUnits": "请注意,系统只会绘制带重量单位(公斤或磅)和重复次数的纪录,其他组合(例如时间或直到力竭)将被忽略。", "@logHelpEntriesUnits": {}, "selectIngredient": "请选择一个营养成分", "@selectIngredient": { @@ -385,17 +385,17 @@ "@addSet": { "description": "Label for the button that adds a set (to a workout day)" }, - "logHelpEntries": "同一项目在当天有多次记录的情况下, 图中只显示训练量较大的项目", + "logHelpEntries": "如果一天内有多个相同重复次数但重量不同的条目,图表只会显示重量较高的条目。", "@logHelpEntries": {}, "plateCalculator": "圈", "@plateCalculator": { "description": "Label used for the plate calculator in the gym mode" }, - "rirNotUsed": "未设置组内重复次数", + "rirNotUsed": "保留次数未储存", "@rirNotUsed": { "description": "Label used in RiR slider when the RiR value is not used/saved for the current setting or log" }, - "rir": "组内重复次数", + "rir": "保留次数", "@rir": { "description": "Shorthand for Repetitions In Reserve" }, @@ -443,7 +443,7 @@ "@aboutContactUsText": { "description": "Text for contact us section in the about dialog" }, - "aboutBugsText": "反馈一下你的使用体验吧", + "aboutBugsText": "如果某些情况未如预期运作或您认为缺少某个功能,请与我们联络。", "@aboutBugsText": { "description": "Text for bugs section in the about dialog" }, @@ -467,7 +467,7 @@ "@weekAverage": { "description": "Header for the column of '7 day average' nutritional values, i.e. what was logged last week" }, - "productFoundDescription": "对应此产品的条形码为{productName}。是否继续?{productName}", + "productFoundDescription": "此条码对应这产品:{productName}。您想继续吗?", "@productFoundDescription": { "description": "Dialog info when product is found with barcode", "type": "text", @@ -487,7 +487,7 @@ "@gPerBodyKg": { "description": "Label used for total sums of e.g. calories or similar in grams per Kg of body weight" }, - "total": "总量", + "total": "总共", "@total": { "description": "Label used for total sums of e.g. calories or similar" }, @@ -537,9 +537,9 @@ "@measurements": { "description": "Categories for the measurements such as biceps size, body fat, etc." }, - "measurementCategoriesHelpText": "测量类别,如“上臂围”或“体脂”", + "measurementCategoriesHelpText": "测量类别,例如“二头肌”或“体脂”", "@measurementCategoriesHelpText": {}, - "measurementEntriesHelpText": "用于度量的单位,如\"cm\"或\"%\"。", + "measurementEntriesHelpText": "用于测量类别的单位,例如「cm」或「%」", "@measurementEntriesHelpText": {}, "value": "数值", "@value": { @@ -562,5 +562,25 @@ "exerciseName": "锻炼名", "@exerciseName": { "description": "Label for the name of a workout exercise" - } + }, + "previous": "前一个", + "@previous": {}, + "kg": "公斤", + "@kg": { + "description": "Generated entry for translation for server strings" + }, + "verify": "确认", + "@verify": {}, + "next": "下一个", + "@next": {}, + "success": "成功", + "@success": { + "description": "Message when an action completed successfully, usually used as a heading" + }, + "lb": "磅", + "@lb": { + "description": "Generated entry for translation for server strings" + }, + "alternativeNames": "別称", + "@alternativeNames": {} } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index e197cc7a..a571c2c8 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -83,7 +83,7 @@ "@exercise": { "description": "An exercise for a workout" }, - "exercises": "動作", + "exercises": "訓練", "@exercises": { "description": "Multiple exercises for a workout" }, @@ -136,5 +136,764 @@ "description": "Equipment needed to perform an exercise" }, "searchNamesInEnglish": "也用英文字詞搜尋", - "@searchNamesInEnglish": {} + "@searchNamesInEnglish": {}, + "logged": "紀錄", + "@logged": { + "description": "Header for the column of 'logged' nutritional values, i.e. what was eaten" + }, + "scanBarcode": "掃描條形碼", + "@scanBarcode": { + "description": "Label for scan barcode button" + }, + "seconds": "秒", + "@seconds": { + "description": "Generated entry for translation for server strings" + }, + "useMetric": "用公制單位表示體重", + "@useMetric": {}, + "set": "組", + "@set": { + "description": "A set in a workout plan" + }, + "repetitionUnit": "重複次數單位", + "@repetitionUnit": {}, + "dayDescriptionHelp": "描述這一天做了什麼(例如:拉力日)或訓練了哪些身體部位(例如:胸部和肩膀)", + "@dayDescriptionHelp": {}, + "comment": "註釋", + "@comment": { + "description": "Comment, additional information" + }, + "impression": "感想", + "@impression": { + "description": "General impression (e.g. for a workout session) such as good, bad, etc." + }, + "sameRepetitions": "如果您對所有組別進行相同的重複次數和重量,則只需填寫一行即可。例如,對於 4 組,只需輸入 10 次重複,這會自動變成「4 x 10」。", + "@sameRepetitions": {}, + "plateCalculator": "槓片", + "@plateCalculator": { + "description": "Label used for the plate calculator in the gym mode" + }, + "gymMode": "健身模式", + "@gymMode": { + "description": "Label when starting the gym mode" + }, + "selectExercises": "如果你想做一個超級組,你可以搜尋幾個訓練,它們會組在一起。", + "@selectExercises": {}, + "workoutSession": "訓練(節)", + "@workoutSession": { + "description": "A (logged) workout session" + }, + "pause": "暫停", + "@pause": { + "description": "Noun, not an imperative! Label used for the pause when using the gym mode" + }, + "jumpTo": "跳到", + "@jumpTo": { + "description": "Imperative. Label used in popup allowing the user to jump to a specific exercise while in the gym mode" + }, + "todaysWorkout": "您今天的訓練", + "@todaysWorkout": {}, + "logHelpEntries": "如果一天內有多個相同重複次數但重量不同的條目,圖表只會顯示重量較高的條目。", + "@logHelpEntries": {}, + "description": "描述", + "@description": {}, + "addIngredient": "增加材料", + "@addIngredient": {}, + "verify": "確認", + "@verify": {}, + "logMeal": "紀錄這餐", + "@logMeal": {}, + "logHelpEntriesUnits": "請注意,系統只會繪製帶重量單位(公斤或磅)和重複次數的紀錄,其他組合(例如時間或直到力竭)將被忽略。", + "@logHelpEntriesUnits": {}, + "name": "名", + "@name": { + "description": "Name for a workout or nutritional plan" + }, + "nutritionalDiary": "營養日記", + "@nutritionalDiary": {}, + "nutritionalPlans": "營養計劃", + "@nutritionalPlans": {}, + "weight": "重量", + "@weight": { + "description": "The weight of a workout log or body weight entry" + }, + "time": "時間", + "@time": { + "description": "The time of a meal or workout" + }, + "searchIngredient": "搜尋材料", + "@searchIngredient": { + "description": "Label on ingredient search form" + }, + "logIngredient": "儲存到營養日記", + "@logIngredient": {}, + "anErrorOccurred": "發生錯誤!", + "@anErrorOccurred": {}, + "noNutritionalPlans": "你沒有營養計劃", + "@noNutritionalPlans": { + "description": "Message shown when the user has no nutritional plans" + }, + "timeStart": "開始時間", + "@timeStart": { + "description": "The starting time of a workout" + }, + "timeEnd": "完結時間", + "@timeEnd": { + "description": "The end time of a workout" + }, + "timeStartAhead": "開始時間不能早於結束時間", + "@timeStartAhead": {}, + "saturatedFat": "飽和脂肪", + "@saturatedFat": {}, + "fatShort": "脂肪", + "@fatShort": { + "description": "The first letter or short name of the word 'Fat', used in overviews" + }, + "fibres": "纖維", + "@fibres": {}, + "sodium": "鈉", + "@sodium": {}, + "edit": "編輯", + "@edit": {}, + "moreMeasurementEntries": "增加新的測量", + "@moreMeasurementEntries": { + "description": "Message shown when the user wants to add new measurement" + }, + "confirmDelete": "您確定要刪除'{toDelete}'嗎?", + "@confirmDelete": { + "description": "Confirmation text before the user deletes an object", + "type": "text", + "placeholders": { + "toDelete": {} + } + }, + "aboutBugsTitle": "有問題或想法嗎?", + "@aboutBugsTitle": { + "description": "Title for bugs section in the about dialog" + }, + "newNutritionalPlan": "新的營養計劃", + "@newNutritionalPlan": {}, + "aboutSourceText": "在 Github 上取得這應用程式的原始程式碼及伺服器", + "@aboutSourceText": { + "description": "Text for source code section in the about dialog" + }, + "aboutDescription": "感謝您使用wger! wger 是一個協作開源項目,由來自世界各地的健身愛好者創建。", + "@aboutDescription": { + "description": "Text in the about dialog" + }, + "enterValue": "請輸入數值", + "@enterValue": { + "description": "Error message when the user hasn't entered a value on a required field" + }, + "enterRepetitionsOrWeight": "請為至少一組輸入重複次數或重量", + "@enterRepetitionsOrWeight": { + "description": "Error message when the user hasn't filled in the forms for exercise sets" + }, + "aboutBugsText": "如果某些情況未如預期運作或您認為缺少某個功能,請與我們聯絡。", + "@aboutBugsText": { + "description": "Text for bugs section in the about dialog" + }, + "previous": "前一個", + "@previous": {}, + "add_exercise_image_license": "影像必須與 CC BY SA 授權相容。如有疑問,請僅上傳你自己拍攝的照片。", + "@add_exercise_image_license": {}, + "verifiedEmail": "已認證電郵", + "@verifiedEmail": {}, + "selectIngredient": "請選擇材料", + "@selectIngredient": { + "description": "Error message when the user hasn't selected an ingredient from the autocompleter" + }, + "barbell": "槓鈴", + "@barbell": { + "description": "Generated entry for translation for server strings" + }, + "arms": "手臂", + "@arms": { + "description": "Generated entry for translation for server strings" + }, + "contributeExerciseWarning": "只有當您的帳戶已超過 {days} 天並且已驗證您的電子郵件時,您才可以貢獻訓練", + "@contributeExerciseWarning": { + "description": "Number of days before which a person can add exercise", + "placeholders": { + "days": { + "type": "String", + "example": "14" + } + } + }, + "dumbbell": "啞鈴", + "@dumbbell": { + "description": "Generated entry for translation for server strings" + }, + "chest": "胸", + "@chest": { + "description": "Generated entry for translation for server strings" + }, + "calves": "小腿肌群", + "@calves": { + "description": "Generated entry for translation for server strings" + }, + "bench": "平板凳", + "@bench": { + "description": "Generated entry for translation for server strings" + }, + "lb": "磅", + "@lb": { + "description": "Generated entry for translation for server strings" + }, + "until_failure": "直到力竭", + "@until_failure": { + "description": "Generated entry for translation for server strings" + }, + "none__bodyweight_exercise_": "無(自重訓練)", + "@none__bodyweight_exercise_": { + "description": "Generated entry for translation for server strings" + }, + "done": "完成", + "@done": {}, + "log": "紀錄", + "@log": { + "description": "Log a specific meal (imperative form)" + }, + "addMeal": "増加餐單", + "@addMeal": {}, + "sugars": "糖", + "@sugars": {}, + "fat": "脂肪", + "@fat": {}, + "calendar": "日曆", + "@calendar": {}, + "optionsLabel": "選項", + "@optionsLabel": { + "description": "Label for the popup with general app options" + }, + "weightUnit": "重量單位", + "@weightUnit": {}, + "newDay": "新一天", + "@newDay": {}, + "newSet": "新組", + "@newSet": { + "description": "Header when adding a new set to a workout day" + }, + "start": "開始", + "@start": { + "description": "Label on button to start the gym mode (i.e., an imperative)" + }, + "selectExercise": "請選擇一個訓練", + "@selectExercise": { + "description": "Error message when the user hasn't selected an exercise in the form" + }, + "whatVariationsExist": "此練習有哪些變體(如果有)?", + "@whatVariationsExist": {}, + "back": "背部", + "@back": { + "description": "Generated entry for translation for server strings" + }, + "category": "類別", + "@category": { + "description": "Category for an exercise, ingredient, etc." + }, + "newWorkout": "新健身計畫", + "@newWorkout": { + "description": "Header when adding a new workout" + }, + "musclesSecondary": "輔助肌", + "@musclesSecondary": { + "description": "secondary muscles trained by an exercise" + }, + "rirNotUsed": "保留次數未儲存", + "@rirNotUsed": { + "description": "Label used in RiR slider when the RiR value is not used/saved for the current setting or log" + }, + "setNr": "設 {nr}", + "@setNr": { + "description": "Header in form indicating the number of the current set. Can also be translated as something like 'Set Nr. xy'.", + "type": "text", + "placeholders": { + "nr": {} + } + }, + "notes": "筆記", + "@notes": { + "description": "Personal notes, e.g. for a workout session" + }, + "plateCalculatorNotDivisible": "沒有足夠的槓片達到目標重量", + "@plateCalculatorNotDivisible": { + "description": "Error message when the current weight is not reachable with plates (e.g. 33.1 kg)" + }, + "save": "儲存", + "@save": {}, + "addSet": "加組", + "@addSet": { + "description": "Label for the button that adds a set (to a workout day)" + }, + "mealLogged": "膳食已記錄到日記", + "@mealLogged": {}, + "measurement": "測量", + "@measurement": {}, + "nutritionalPlan": "營養計劃", + "@nutritionalPlan": {}, + "date": "日期", + "@date": { + "description": "The date of a workout log or body weight entry" + }, + "value": "數值", + "@value": { + "description": "The value of a measurement entry" + }, + "measurementEntriesHelpText": "用於測量類別的單位,例如「cm」或「%」", + "@measurementEntriesHelpText": {}, + "measurements": "測量", + "@measurements": { + "description": "Categories for the measurements such as biceps size, body fat, etc." + }, + "planned": "計劃", + "@planned": { + "description": "Header for the column of 'planned' nutritional values, i.e. what should be eaten" + }, + "gPerBodyKg": "克/體重公斤", + "@gPerBodyKg": { + "description": "Label used for total sums of e.g. calories or similar in grams per Kg of body weight" + }, + "kJ": "千焦", + "@kJ": { + "description": "Energy in a meal in kilo joules, kJ" + }, + "carbohydratesShort": "碳水化合物", + "@carbohydratesShort": { + "description": "The first letter or short name of the word 'Carbohydrates', used in overviews" + }, + "unit": "單位", + "@unit": { + "description": "The unit used for a repetition (kg, time, etc.)" + }, + "newEntry": "新條目", + "@newEntry": { + "description": "Title when adding a new entry such as a weight or log entry" + }, + "amount": "分量", + "@amount": { + "description": "The amount (e.g. in grams) of an ingredient in a meal" + }, + "loadingText": "載入中...", + "@loadingText": { + "description": "Text to show when entries are being loaded in the background: Loading..." + }, + "delete": "刪除", + "@delete": {}, + "aboutSourceTitle": "原始碼", + "@aboutSourceTitle": { + "description": "Title for source code section in the about dialog" + }, + "goToDetailPage": "進入詳情頁面", + "@goToDetailPage": {}, + "aboutContactUsText": "如果您想與我們聊天,請到 Discord 伺服器並取得聯繫", + "@aboutContactUsText": { + "description": "Text for contact us section in the about dialog" + }, + "aboutMastodonText": "在 Mastodon 上關注我們,以了解有關這項目的更新和新聞", + "@aboutMastodonText": { + "description": "Text for the mastodon section in the about dialog" + }, + "appUpdateTitle": "需要更新", + "@appUpdateTitle": {}, + "variations": "變體", + "@variations": { + "description": "Variations of one exercise (e.g. benchpress and benchpress narrow)" + }, + "legs": "腿", + "@legs": { + "description": "Generated entry for translation for server strings" + }, + "lower_back": "下背", + "@lower_back": { + "description": "Generated entry for translation for server strings" + }, + "max_reps": "最大次數", + "@max_reps": { + "description": "Generated entry for translation for server strings" + }, + "quads": "股四頭肌", + "@quads": { + "description": "Generated entry for translation for server strings" + }, + "textPromptTitle": "準備開始?", + "@textPromptTitle": {}, + "body_weight": "體重", + "@body_weight": { + "description": "Generated entry for translation for server strings" + }, + "textPromptSubheading": "按操作按鈕開始", + "@textPromptSubheading": {}, + "ingredient": "材料", + "@ingredient": {}, + "energy": "能量", + "@energy": { + "description": "Energy in a meal, ingredient etc. e.g. in kJ" + }, + "goalEnergy": "能量目標", + "@goalEnergy": {}, + "goalProtein": "蛋白質目標", + "@goalProtein": {}, + "goalFat": "脂肪目標", + "@goalFat": {}, + "onlyLogging": "只追蹤卡路里", + "@onlyLogging": {}, + "addGoalsToPlan": "新增目標至此計劃", + "@addGoalsToPlan": {}, + "measurementCategoriesHelpText": "測量類別,例如“二頭肌”或“體脂”", + "@measurementCategoriesHelpText": {}, + "today": "今天", + "@today": {}, + "percentEnergy": "能量百分比", + "@percentEnergy": {}, + "onlyLoggingHelpText": "如果你只想記錄卡路里但不想設計有特定膳食的詳細營養計劃,請選中此框。", + "@onlyLoggingHelpText": {}, + "addGoalsToPlanHelpText": "這使你可以為計劃設定能量、蛋白質、碳水化合物或脂肪的總體目標。請注意,如果你設定了詳細的膳食計劃,這些數值將取得優先權。", + "@addGoalsToPlanHelpText": {}, + "goalCarbohydrates": "碳水化合物目標", + "@goalCarbohydrates": {}, + "loggedToday": "在今天紀錄", + "@loggedToday": {}, + "verifiedEmailReason": "您需要驗證您的電子郵件才能貢獻訓練項目", + "@verifiedEmailReason": {}, + "alternativeNames": "別稱", + "@alternativeNames": {}, + "productNotFound": "找不到產品", + "@productNotFound": { + "description": "Header label for dialog when product is not found with barcode" + }, + "productNotFoundDescription": "在 wger 資料庫中找不到帶有掃描條碼 {barcode} 的產品", + "@productNotFoundDescription": { + "description": "Dialog info when product is not found with barcode", + "type": "text", + "placeholders": { + "barcode": {} + } + }, + "images": "圖片", + "@images": {}, + "language": "語言", + "@language": {}, + "translateExercise": "立即翻譯此訓練", + "@translateExercise": {}, + "translation": "翻譯", + "@translation": {}, + "contributeExercise": "貢獻一個訓練", + "@contributeExercise": {}, + "baseData": "基礎數據(英文)", + "@baseData": { + "description": "The base data for an exercise such as category, trained muscles, etc." + }, + "cacheWarning": "由於緩存中,改變可能需要一些時間才能在應用程式顯示。", + "@cacheWarning": {}, + "miles": "英里", + "@miles": { + "description": "Generated entry for translation for server strings" + }, + "kilometers_per_hour": "公里/小時", + "@kilometers_per_hour": { + "description": "Generated entry for translation for server strings" + }, + "kilometers": "公里", + "@kilometers": { + "description": "Generated entry for translation for server strings" + }, + "kettlebell": "壺鈴", + "@kettlebell": { + "description": "Generated entry for translation for server strings" + }, + "lats": "背闊肌", + "@lats": { + "description": "Generated entry for translation for server strings" + }, + "kg": "公斤", + "@kg": { + "description": "Generated entry for translation for server strings" + }, + "triceps": "三頭肌", + "@triceps": { + "description": "Generated entry for translation for server strings" + }, + "shoulders": "肩膀", + "@shoulders": { + "description": "Generated entry for translation for server strings" + }, + "unVerifiedEmail": "未認證電郵", + "@unVerifiedEmail": {}, + "glutes": "臀大肌", + "@glutes": { + "description": "Generated entry for translation for server strings" + }, + "cardio": "帶氧運動", + "@cardio": { + "description": "Generated entry for translation for server strings" + }, + "miles_per_hour": "英里/小時", + "@miles_per_hour": { + "description": "Generated entry for translation for server strings" + }, + "supersetWith": "超級組", + "@supersetWith": { + "description": "Text used between exercise cards when adding a new set. Translate as something like 'in a superset with'" + }, + "weekAverage": "七天平均值", + "@weekAverage": { + "description": "Header for the column of '7 day average' nutritional values, i.e. what was logged last week" + }, + "difference": "差異", + "@difference": {}, + "productFound": "找到產品", + "@productFound": { + "description": "Header label for dialog when product is found with barcode" + }, + "close": "關閉", + "@close": { + "description": "Translation for close" + }, + "biceps": "二頭肌", + "@biceps": { + "description": "Generated entry for translation for server strings" + }, + "oneNamePerLine": "每行一個名稱", + "@oneNamePerLine": {}, + "aboutPageTitle": "關於Wger", + "@aboutPageTitle": {}, + "verifiedEmailInfo": "驗證電郵已發送至 {email}", + "@verifiedEmailInfo": { + "placeholders": { + "email": {} + } + }, + "gym_mat": "健身墊", + "@gym_mat": { + "description": "Generated entry for translation for server strings" + }, + "settingsCacheDeletedSnackbar": "成功清除快取", + "@settingsCacheDeletedSnackbar": {}, + "settingsTitle": "設定", + "@settingsTitle": {}, + "settingsCacheTitle": "緩存", + "@settingsCacheTitle": {}, + "settingsCacheDescription": "訓練緩存", + "@settingsCacheDescription": {}, + "gValue": "{value} 克", + "@gValue": { + "description": "A value in grams, e.g. 5 g", + "type": "text", + "placeholders": { + "value": {} + } + }, + "percentValue": "{value} %", + "@percentValue": { + "description": "A value in percent, e.g. 10 %", + "type": "text", + "placeholders": { + "value": {} + } + }, + "noWorkoutPlans": "你沒有健身計畫", + "@noWorkoutPlans": { + "description": "Message shown when the user has no workout plans" + }, + "reps": "次", + "@reps": { + "description": "Shorthand for repetitions, used when space constraints are tighter" + }, + "rir": "保留次數", + "@rir": { + "description": "Shorthand for Repetitions In Reserve" + }, + "selectEntry": "請選擇一個條目", + "@selectEntry": {}, + "nrOfSets": "組 / 訓練: {nrOfSets}", + "@nrOfSets": { + "description": "Label shown on the slider where the user selects the nr of sets", + "type": "text", + "placeholders": { + "nrOfSets": {} + } + }, + "alsoKnownAs": "又稱: {aliases}", + "@alsoKnownAs": { + "placeholders": { + "aliases": {} + }, + "description": "List of alternative names for an exercise" + }, + "abs": "腹肌", + "@abs": { + "description": "Generated entry for translation for server strings" + }, + "energyShort": "能量", + "@energyShort": { + "description": "The first letter or short name of the word 'Energy', used in overviews" + }, + "kcal": "千卡", + "@kcal": { + "description": "Energy in a meal in kilocalories, kcal" + }, + "macronutrients": "宏量營養素", + "@macronutrients": {}, + "proteinShort": "蛋白質", + "@proteinShort": { + "description": "The first letter or short name of the word 'Protein', used in overviews" + }, + "toggleDetails": "切換詳情", + "@toggleDetails": { + "description": "Switch to toggle detail / overview" + }, + "aboutContactUsTitle": "您好!", + "@aboutContactUsTitle": { + "description": "Title for contact us section in the about dialog" + }, + "addImage": "增加圖片", + "@addImage": {}, + "addExercise": "增加鍛鍊項目", + "@addExercise": {}, + "productFoundDescription": "此條碼對應這產品:{productName}。您想繼續嗎?", + "@productFoundDescription": { + "description": "Dialog info when product is found with barcode", + "type": "text", + "placeholders": { + "productName": {} + } + }, + "appUpdateContent": "此版本的應用程式與伺服器不相容,請更新您的應用程式。", + "@appUpdateContent": {}, + "aboutTranslationText": "這應用程式是在 weblate 上翻譯的。如果您也想提供協助,請點擊連結並開始翻譯", + "@aboutTranslationText": { + "description": "Text for translation section in the about dialog" + }, + "enterCharacters": "請輸入 {min} 到 {max} 個字符", + "@enterCharacters": { + "description": "Error message when the user hasn't entered the correct number of characters in a form", + "type": "text", + "placeholders": { + "min": {}, + "max": {} + } + }, + "g": "克", + "@g": { + "description": "Abbreviation for gram" + }, + "protein": "蛋白質", + "@protein": {}, + "carbohydrates": "碳水化合物", + "@carbohydrates": {}, + "surplus": "過剩", + "@surplus": { + "description": "Caloric surplus (either planned or unplanned)" + }, + "deficit": "赤字", + "@deficit": { + "description": "Caloric deficit (either planned or unplanned)" + }, + "kcalValue": "{value} 千卡", + "@kcalValue": { + "description": "A value in kcal, e.g. 500 kcal", + "type": "text", + "placeholders": { + "value": {} + } + }, + "noWeightEntries": "您沒有體重條目", + "@noWeightEntries": { + "description": "Message shown when the user has no logged weight entries" + }, + "noMeasurementEntries": "您沒有測量條目", + "@noMeasurementEntries": {}, + "enterMinCharacters": "請輸入至少 {min} 個字符", + "@enterMinCharacters": { + "description": "Error message when the user hasn't entered the minimum amount characters in a form", + "type": "text", + "placeholders": { + "min": {} + } + }, + "baseNameEnglish": "所有訓練都需要一個英文基本名稱", + "@baseNameEnglish": {}, + "aboutDonateText": "請我們喝杯咖啡來幫助項目,支付服務器費用,並讓我們保持經費", + "@aboutDonateText": {}, + "aboutMastodonTitle": "Mastodon", + "@aboutMastodonTitle": { + "description": "Title for mastodon section in the about dialog" + }, + "aboutTranslationTitle": "翻譯", + "@aboutTranslationTitle": { + "description": "Title for translation section in the about dialog" + }, + "aboutDonateTitle": "捐贈", + "@aboutDonateTitle": {}, + "goToToday": "轉到今天", + "@goToToday": { + "description": "Label on button to jump back to 'today' in the calendar widget" + }, + "total": "總共", + "@total": { + "description": "Label used for total sums of e.g. calories or similar" + }, + "swiss_ball": "抗力球", + "@swiss_ball": { + "description": "Generated entry for translation for server strings" + }, + "sz_bar": "彎曲槓", + "@sz_bar": { + "description": "Generated entry for translation for server strings" + }, + "pull_up_bar": "引體上升桿", + "@pull_up_bar": { + "description": "Generated entry for translation for server strings" + }, + "plates": "槓片", + "@plates": { + "description": "Generated entry for translation for server strings" + }, + "minutes": "分鐘", + "@minutes": { + "description": "Generated entry for translation for server strings" + }, + "incline_bench": "上斜凳", + "@incline_bench": { + "description": "Generated entry for translation for server strings" + }, + "hamstrings": "大腿後肌", + "@hamstrings": { + "description": "Generated entry for translation for server strings" + }, + "selectImage": "請選擇圖片", + "@selectImage": { + "description": "Label and error message when the user hasn't selected an image to save" + }, + "chooseFromLibrary": "從照片庫中選擇", + "@chooseFromLibrary": {}, + "enterValidNumber": "請輸入有效數目", + "@enterValidNumber": { + "description": "Error message when the user has submitted an invalid number (e.g. '3,.,.,.')" + }, + "dataCopied": "資料已複製到新條目", + "@dataCopied": { + "description": "Snackbar message to show on copying data to a new log entry" + }, + "setUnitsAndRir": "設定單位和次數", + "@setUnitsAndRir": { + "description": "Label shown on the slider where the user can toggle showing units and RiR", + "type": "text" + }, + "recentlyUsedIngredients": "最近加入的材料", + "@recentlyUsedIngredients": { + "description": "A message when a user adds a new ingredient to a meal." + }, + "takePicture": "拍張照片", + "@takePicture": {}, + "gallery": "照片庫", + "@gallery": {}, + "next": "下一個", + "@next": {}, + "repetitions": "次數", + "@repetitions": { + "description": "Generated entry for translation for server strings" + } } diff --git a/lib/models/nutrition/ingredient.dart b/lib/models/nutrition/ingredient.dart index 121a43a8..3acf8624 100644 --- a/lib/models/nutrition/ingredient.dart +++ b/lib/models/nutrition/ingredient.dart @@ -18,6 +18,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/models/nutrition/image.dart'; +import 'package:wger/models/nutrition/nutritional_values.dart'; part 'ingredient.g.dart'; @@ -91,4 +92,17 @@ class Ingredient { factory Ingredient.fromJson(Map json) => _$IngredientFromJson(json); Map toJson() => _$IngredientToJson(this); + + NutritionalValues get nutritionalValues { + return NutritionalValues.values( + energy * 1, + protein * 1, + carbohydrates * 1, + carbohydratesSugar * 1, + fat * 1, + fatSaturated * 1, + fiber * 1, + sodium * 1, + ); + } } diff --git a/lib/models/nutrition/log.dart b/lib/models/nutrition/log.dart index 8508e106..ac4aa845 100644 --- a/lib/models/nutrition/log.dart +++ b/lib/models/nutrition/log.dart @@ -83,21 +83,10 @@ class Log { /// Calculations NutritionalValues get nutritionalValues { // This is already done on the server. It might be better to read it from there. - final out = NutritionalValues(); - //final weight = amount; final weight = weightUnitObj == null ? amount : amount * weightUnitObj!.amount * weightUnitObj!.grams; - out.energy = ingredient.energy * weight / 100; - out.protein = ingredient.protein * weight / 100; - out.carbohydrates = ingredient.carbohydrates * weight / 100; - out.carbohydratesSugar = ingredient.carbohydratesSugar * weight / 100; - out.fat = ingredient.fat * weight / 100; - out.fatSaturated = ingredient.fatSaturated * weight / 100; - out.fiber = ingredient.fiber * weight / 100; - out.sodium = ingredient.sodium * weight / 100; - - return out; + return ingredient.nutritionalValues / (100 / weight); } } diff --git a/lib/models/nutrition/meal_item.dart b/lib/models/nutrition/meal_item.dart index 605be5ac..3e1937a2 100644 --- a/lib/models/nutrition/meal_item.dart +++ b/lib/models/nutrition/meal_item.dart @@ -72,6 +72,7 @@ class MealItem { Map toJson() => _$MealItemToJson(this); /// Calculations + /// TODO why does this not consider weightUnitObj ? should we do the same as Log.nutritionalValues here? NutritionalValues get nutritionalValues { // This is already done on the server. It might be better to read it from there. final out = NutritionalValues(); diff --git a/lib/widgets/dashboard/widgets.dart b/lib/widgets/dashboard/widgets.dart index bd428e9f..1f9d4bd8 100644 --- a/lib/widgets/dashboard/widgets.dart +++ b/lib/widgets/dashboard/widgets.dart @@ -42,6 +42,7 @@ import 'package:wger/widgets/measurements/charts.dart'; import 'package:wger/widgets/measurements/forms.dart'; import 'package:wger/widgets/nutrition/charts.dart'; import 'package:wger/widgets/nutrition/forms.dart'; +import 'package:wger/widgets/nutrition/helpers.dart'; import 'package:wger/widgets/weight/forms.dart'; import 'package:wger/widgets/workouts/forms.dart'; @@ -83,23 +84,7 @@ class _DashboardNutritionWidgetState extends State { //textAlign: TextAlign.left, ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.min, - children: [ - MutedText( - '${AppLocalizations.of(context).energyShort} ${meal.plannedNutritionalValues.energy.toStringAsFixed(0)}${AppLocalizations.of(context).kcal}'), - const MutedText(' / '), - MutedText( - '${AppLocalizations.of(context).proteinShort} ${meal.plannedNutritionalValues.protein.toStringAsFixed(0)}${AppLocalizations.of(context).g}'), - const MutedText(' / '), - MutedText( - '${AppLocalizations.of(context).carbohydratesShort} ${meal.plannedNutritionalValues.carbohydrates.toStringAsFixed(0)}${AppLocalizations.of(context).g}'), - const MutedText(' / '), - MutedText( - '${AppLocalizations.of(context).fatShort} ${meal.plannedNutritionalValues.fat.toStringAsFixed(0)}${AppLocalizations.of(context).g} '), - ], - ), + MutedText(getShortNutritionValues(meal.plannedNutritionalValues, context)), IconButton( icon: const Icon(Icons.history_edu), color: wgerPrimaryButtonColor, diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index e1906d82..034d1a7d 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -23,11 +23,14 @@ import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/helpers/ui.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/models/nutrition/log.dart'; import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/screens/nutritional_plan_screen.dart'; +import 'package:wger/widgets/nutrition/helpers.dart'; import 'package:wger/widgets/nutrition/widgets.dart'; class MealForm extends StatelessWidget { @@ -113,23 +116,66 @@ class MealForm extends StatelessWidget { } } -class MealItemForm extends StatelessWidget { - final Meal _meal; - late final MealItem _mealItem; - final List _listMealItems; - late String _barcode; - late bool _test; +Widget MealItemForm(Meal meal, List recent, [String? barcode, bool? test]) { + return IngredientForm( + // TODO we use planId 0 here cause we don't have one and we don't need it I think? + recent: recent.map((e) => Log.fromMealItem(e, 0, e.mealId)).toList(), + onSave: (BuildContext context, MealItem mealItem, DateTime? dt) { + mealItem.mealId = meal.id!; + Provider.of(context, listen: false).addMealItem(mealItem, meal); + }, + barcode: barcode ?? '', + test: test ?? false, + withDate: false); +} +Widget IngredientLogForm(NutritionalPlan plan) { + return IngredientForm( + recent: plan.diaryEntries, + onSave: (BuildContext context, MealItem mealItem, DateTime? dt) { + Provider.of(context, listen: false) + .logIngredientToDiary(mealItem, plan.id!, dt); + }, + withDate: true); +} + +/// IngredientForm is a form that lets the user pick an ingredient (and amount) to +/// log to the diary or to add to a meal. +class IngredientForm extends StatefulWidget { + final Function(BuildContext context, MealItem mealItem, DateTime? dt) onSave; + final List recent; + final bool withDate; + final String barcode; + final bool test; + + const IngredientForm({ + required this.recent, + required this.onSave, + required this.withDate, + this.barcode = '', + this.test = false, + }); + + @override + State createState() => IngredientFormState(); +} + +class IngredientFormState extends State { final _form = GlobalKey(); - final _ingredientIdController = TextEditingController(); final _ingredientController = TextEditingController(); + final _ingredientIdController = TextEditingController(); final _amountController = TextEditingController(); + final _dateController = TextEditingController(); // optional + final _timeController = TextEditingController(); // optional + final _mealItem = MealItem.empty(); - MealItemForm(this._meal, this._listMealItems, [mealItem, code, test]) { - _mealItem = mealItem ?? MealItem.empty(); - _test = test ?? false; - _barcode = code ?? ''; - _mealItem.mealId = _meal.id!; + bool validIngredientId = false; + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _dateController.text = toDate(now)!; + _timeController.text = timeToString(TimeOfDay.fromDateTime(now))!; } TextEditingController get ingredientIdController => _ingredientIdController; @@ -139,114 +185,6 @@ class MealItemForm extends StatelessWidget { @override Widget build(BuildContext context) { final String unit = AppLocalizations.of(context).g; - return Container( - margin: const EdgeInsets.all(20), - child: Form( - key: _form, - child: Column( - children: [ - IngredientTypeahead( - _ingredientIdController, - _ingredientController, - barcode: _barcode, - test: _test, - ), - TextFormField( - key: const Key('field-weight'), - decoration: InputDecoration(labelText: AppLocalizations.of(context).weight), - controller: _amountController, - keyboardType: TextInputType.number, - onFieldSubmitted: (_) {}, - onSaved: (newValue) { - _mealItem.amount = double.parse(newValue!); - }, - validator: (value) { - try { - double.parse(value!); - } catch (error) { - return AppLocalizations.of(context).enterValidNumber; - } - return null; - }, - ), - ElevatedButton( - key: const Key(SUBMIT_BUTTON_KEY_NAME), - child: Text(AppLocalizations.of(context).save), - onPressed: () async { - if (!_form.currentState!.validate()) { - return; - } - _form.currentState!.save(); - _mealItem.ingredientId = int.parse(_ingredientIdController.text); - - try { - Provider.of(context, listen: false) - .addMealItem(_mealItem, _meal); - } on WgerHttpException catch (error) { - showHttpExceptionErrorDialog(error, context); - } catch (error) { - showErrorDialog(error, context); - } - Navigator.of(context).pop(); - }, - ), - if (_listMealItems.isNotEmpty) const SizedBox(height: 10.0), - Container( - padding: const EdgeInsets.all(10.0), - child: Text(AppLocalizations.of(context).recentlyUsedIngredients), - ), - Expanded( - child: ListView.builder( - itemCount: _listMealItems.length, - shrinkWrap: true, - itemBuilder: (context, index) { - return Card( - child: ListTile( - onTap: () { - _ingredientController.text = _listMealItems[index].ingredient.name; - _ingredientIdController.text = - _listMealItems[index].ingredient.id.toString(); - _amountController.text = _listMealItems[index].amount.toStringAsFixed(0); - _mealItem.ingredientId = _listMealItems[index].ingredientId; - _mealItem.amount = _listMealItems[index].amount; - }, - title: Text(_listMealItems[index].ingredient.name), - subtitle: Text('${_listMealItems[index].amount.toStringAsFixed(0)}$unit'), - trailing: const Icon(Icons.copy), - ), - ); - }, - ), - ) - ], - ), - ), - ); - } -} - -class IngredientLogForm extends StatelessWidget { - late MealItem _mealItem; - final NutritionalPlan _plan; - - final _form = GlobalKey(); - final _ingredientController = TextEditingController(); - final _ingredientIdController = TextEditingController(); - final _amountController = TextEditingController(); - final _dateController = TextEditingController(); - final _timeController = TextEditingController(); - - IngredientLogForm(this._plan) { - _mealItem = MealItem.empty(); - final now = DateTime.now(); - _dateController.text = toDate(now)!; - _timeController.text = timeToString(TimeOfDay.fromDateTime(now))!; - } - - @override - Widget build(BuildContext context) { - final diaryEntries = _plan.diaryEntries; - final String unit = AppLocalizations.of(context).g; return Container( margin: const EdgeInsets.all(20), @@ -257,84 +195,142 @@ class IngredientLogForm extends StatelessWidget { IngredientTypeahead( _ingredientIdController, _ingredientController, - ), - TextFormField( - decoration: InputDecoration(labelText: AppLocalizations.of(context).weight), - controller: _amountController, - keyboardType: TextInputType.number, - onFieldSubmitted: (_) {}, - onSaved: (newValue) { - _mealItem.amount = double.parse(newValue!); - }, - validator: (value) { - try { - double.parse(value!); - } catch (error) { - return AppLocalizations.of(context).enterValidNumber; - } - return null; - }, + barcode: widget.barcode, + test: widget.test, ), Row( children: [ Expanded( child: TextFormField( - readOnly: true, - // Stop keyboard from appearing - decoration: InputDecoration( - labelText: AppLocalizations.of(context).date, - // suffixIcon: const Icon(Icons.calendar_today), - ), - enableInteractiveSelection: false, - controller: _dateController, - onTap: () async { - // Show Date Picker Here - final pickedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(DateTime.now().year - 10), - lastDate: DateTime.now(), - ); - - if (pickedDate != null) { - _dateController.text = toDate(pickedDate)!; - } - }, - onSaved: (newValue) { - _dateController.text = newValue!; - }, - ), - ), - Expanded( - child: TextFormField( - key: const Key('field-time'), - decoration: InputDecoration( - labelText: AppLocalizations.of(context).time, - //suffixIcon: const Icon(Icons.punch_clock) - ), - controller: _timeController, - onTap: () async { - // Stop keyboard from appearing - FocusScope.of(context).requestFocus(FocusNode()); - - // Open time picker - final pickedTime = await showTimePicker( - context: context, - initialTime: stringToTime(_timeController.text), - ); - if (pickedTime != null) { - _timeController.text = timeToString(pickedTime)!; - } - }, - onSaved: (newValue) { - _timeController.text = newValue!; - }, + key: const Key('field-weight'), // needed ? + decoration: InputDecoration(labelText: AppLocalizations.of(context).weight), + controller: _amountController, + keyboardType: TextInputType.number, onFieldSubmitted: (_) {}, + onChanged: (value) { + setState(() { + final v = double.tryParse(value); + if (v != null) { + _mealItem.amount = v; + } + }); + }, + onSaved: (value) { + _mealItem.amount = double.parse(value!); + }, + validator: (value) { + try { + double.parse(value!); + } catch (error) { + return AppLocalizations.of(context).enterValidNumber; + } + return null; + }, ), ), + if (widget.withDate) + Expanded( + child: TextFormField( + readOnly: true, + // Stop keyboard from appearing + decoration: InputDecoration( + labelText: AppLocalizations.of(context).date, + // suffixIcon: const Icon(Icons.calendar_today), + ), + enableInteractiveSelection: false, + controller: _dateController, + onTap: () async { + // Show Date Picker Here + final pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(DateTime.now().year - 10), + lastDate: DateTime.now(), + ); + + if (pickedDate != null) { + _dateController.text = toDate(pickedDate)!; + } + }, + onSaved: (newValue) { + _dateController.text = newValue!; + }, + ), + ), + if (widget.withDate) + Expanded( + child: TextFormField( + key: const Key('field-time'), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).time, + //suffixIcon: const Icon(Icons.punch_clock) + ), + controller: _timeController, + onTap: () async { + // Stop keyboard from appearing + FocusScope.of(context).requestFocus(FocusNode()); + + // Open time picker + final pickedTime = await showTimePicker( + context: context, + initialTime: stringToTime(_timeController.text), + ); + if (pickedTime != null) { + _timeController.text = timeToString(pickedTime)!; + } + }, + onSaved: (newValue) { + _timeController.text = newValue!; + }, + onFieldSubmitted: (_) {}, + ), + ), ], ), + if (validIngredientId) + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text( + 'Macros preview', + style: Theme.of(context).textTheme.titleMedium, + ), + FutureBuilder( + future: Provider.of(context, listen: false) + .fetchIngredient(_mealItem.ingredientId), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + _mealItem.ingredient = snapshot.data!; + return ListTile( + leading: IngredientAvatar(ingredient: _mealItem.ingredient), + title: + Text(getShortNutritionValues(_mealItem.nutritionalValues, context)), + ); + } else if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + 'Ingredient lookup error: ${snapshot.error}', + style: const TextStyle(color: Colors.red), + ), + ); + } else { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(), + ); + } + }, + ), + ], + ), + ), ElevatedButton( + key: const Key( + SUBMIT_BUTTON_KEY_NAME), // needed? mealItemForm had it, but not ingredientlogform + child: Text(AppLocalizations.of(context).save), onPressed: () async { if (!_form.currentState!.validate()) { @@ -347,8 +343,7 @@ class IngredientLogForm extends StatelessWidget { var date = DateTime.parse(_dateController.text); final tod = stringToTime(_timeController.text); date = DateTime(date.year, date.month, date.day, tod.hour, tod.minute); - Provider.of(context, listen: false) - .logIngredientToDiary(_mealItem, _plan.id!, date); + widget.onSave(context, _mealItem, date); } on WgerHttpException catch (error) { showHttpExceptionErrorDialog(error, context); } catch (error) { @@ -357,27 +352,33 @@ class IngredientLogForm extends StatelessWidget { Navigator.of(context).pop(); }, ), - if (diaryEntries.isNotEmpty) const SizedBox(height: 10.0), + if (widget.recent.isNotEmpty) const SizedBox(height: 10.0), Container( padding: const EdgeInsets.all(10.0), child: Text(AppLocalizations.of(context).recentlyUsedIngredients), ), Expanded( child: ListView.builder( - itemCount: diaryEntries.length, + itemCount: widget.recent.length, shrinkWrap: true, itemBuilder: (context, index) { return Card( child: ListTile( onTap: () { - _ingredientController.text = diaryEntries[index].ingredient.name; - _ingredientIdController.text = diaryEntries[index].ingredient.id.toString(); - _amountController.text = diaryEntries[index].amount.toStringAsFixed(0); - _mealItem.ingredientId = diaryEntries[index].ingredientId; - _mealItem.amount = diaryEntries[index].amount; + _ingredientController.text = widget.recent[index].ingredient.name; + _ingredientIdController.text = + widget.recent[index].ingredient.id.toString(); + _amountController.text = widget.recent[index].amount.toStringAsFixed(0); + setState(() { + _mealItem.ingredientId = widget.recent[index].ingredientId; + _mealItem.amount = widget.recent[index].amount; + validIngredientId = true; + }); }, - title: Text(_plan.diaryEntries[index].ingredient.name), - subtitle: Text('${diaryEntries[index].amount.toStringAsFixed(0)}$unit'), + title: Text( + '${widget.recent[index].ingredient.name} (${widget.recent[index].amount.toStringAsFixed(0)}$unit)'), + subtitle: Text(getShortNutritionValues( + widget.recent[index].ingredient.nutritionalValues, context)), trailing: const Icon(Icons.copy), ), ); diff --git a/lib/widgets/nutrition/helpers.dart b/lib/widgets/nutrition/helpers.dart index 222e8765..766386b1 100644 --- a/lib/widgets/nutrition/helpers.dart +++ b/lib/widgets/nutrition/helpers.dart @@ -40,3 +40,12 @@ List getMutedNutritionalValues(NutritionalValues values, BuildContext co textAlign: TextAlign.right, ), ]; + +String getShortNutritionValues(NutritionalValues values, BuildContext context) { + final loc = AppLocalizations.of(context); + final e = '${loc.energyShort} ${loc.kcalValue(values.energy.toStringAsFixed(0))}'; + final p = '${loc.proteinShort} ${loc.gValue(values.protein.toStringAsFixed(0))}'; + final c = '${loc.carbohydratesShort} ${loc.gValue(values.carbohydrates.toStringAsFixed(0))}'; + final f = '${loc.fatShort} ${loc.gValue(values.fat.toStringAsFixed(0))}'; + return '$e / $p / $c / $f'; +} diff --git a/lib/widgets/nutrition/meal.dart b/lib/widgets/nutrition/meal.dart index 3048bed0..253fde07 100644 --- a/lib/widgets/nutrition/meal.dart +++ b/lib/widgets/nutrition/meal.dart @@ -20,13 +20,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:wger/helpers/consts.dart'; -import 'package:wger/helpers/misc.dart'; import 'package:wger/models/nutrition/log.dart'; import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/screens/form_screen.dart'; -import 'package:wger/widgets/core/core.dart'; import 'package:wger/widgets/nutrition/charts.dart'; import 'package:wger/widgets/nutrition/forms.dart'; import 'package:wger/widgets/nutrition/helpers.dart'; @@ -208,18 +206,7 @@ class MealItemWidget extends StatelessWidget { final values = _item.nutritionalValues; return ListTile( - leading: _item.ingredient.image != null - ? GestureDetector( - child: CircleAvatar(backgroundImage: NetworkImage(_item.ingredient.image!.image)), - onTap: () async { - if (_item.ingredient.image!.objectUrl != '') { - return launchURL(_item.ingredient.image!.objectUrl, context); - } else { - return; - } - }, - ) - : const CircleIconAvatar(Icon(Icons.image, color: Colors.grey)), + leading: IngredientAvatar(ingredient: _item.ingredient), title: Text( '${_item.amount.toStringAsFixed(0)}$unit ${_item.ingredient.name}', overflow: TextOverflow.ellipsis, @@ -273,18 +260,7 @@ class LogDiaryItemWidget extends StatelessWidget { final values = _item.nutritionalValues; return ListTile( - leading: _item.ingredient.image != null - ? GestureDetector( - child: CircleAvatar(backgroundImage: NetworkImage(_item.ingredient.image!.image)), - onTap: () async { - if (_item.ingredient.image!.objectUrl != '') { - return launchURL(_item.ingredient.image!.objectUrl, context); - } else { - return; - } - }, - ) - : const CircleIconAvatar(Icon(Icons.image, color: Colors.grey)), + leading: IngredientAvatar(ingredient: _item.ingredient), title: Text( '${_item.amount.toStringAsFixed(0)}$unit ${_item.ingredient.name}', overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 2da1593d..0cc2d24c 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -25,9 +25,11 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/misc.dart'; import 'package:wger/helpers/platform.dart'; import 'package:wger/helpers/ui.dart'; import 'package:wger/models/exercises/ingredient_api.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/models/nutrition/log.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/nutrition.dart'; @@ -35,14 +37,18 @@ import 'package:wger/widgets/core/core.dart'; import 'package:wger/widgets/nutrition/helpers.dart'; class ScanReader extends StatelessWidget { - String? scannedr; - @override Widget build(BuildContext context) => Scaffold( body: ReaderWidget( onScan: (result) { - scannedr = result.text; - Navigator.pop(context, scannedr); + // notes: + // 1. even if result.isValid, result.error is always non-null (and set to "") + // 2. i've never encountered scan errors to see when they occur, and + // i wouldn't know what to do about them anyway, so we simply return + // result.text in such case (which presumably will be null, or "") + // 3. when user cancels (swipe left / back button) this code is no longer + // run and the caller receives null + Navigator.pop(context, result.text); }, ), ); @@ -52,14 +58,11 @@ class IngredientTypeahead extends StatefulWidget { final TextEditingController _ingredientController; final TextEditingController _ingredientIdController; - String? barcode = ''; - - //Code? result; - - late final bool? test; + final String barcode; + final bool? test; final bool showScanner; - IngredientTypeahead( + const IngredientTypeahead( this._ingredientIdController, this._ingredientController, { this.showScanner = true, @@ -73,21 +76,29 @@ class IngredientTypeahead extends StatefulWidget { class _IngredientTypeaheadState extends State { var _searchEnglish = true; + late String barcode; + + @override + void initState() { + super.initState(); + barcode = widget.barcode; // for unit tests + } Future readerscan(BuildContext context) async { - String scannedcode; try { - scannedcode = - await Navigator.of(context).push(MaterialPageRoute(builder: (context) => ScanReader())); - - if (scannedcode.compareTo('-1') == 0) { + final code = await Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => ScanReader())); + if (code == null) { return ''; } + + if (code.compareTo('-1') == 0) { + return ''; + } + return code; } on PlatformException { return ''; } - - return scannedcode; } @override @@ -164,15 +175,22 @@ class _IngredientTypeaheadState extends State { Widget scanButton() { return IconButton( key: const Key('scan-button'), + icon: const FaIcon(FontAwesomeIcons.barcode), onPressed: () async { try { if (!widget.test!) { - widget.barcode = await readerscan(context); + barcode = await readerscan(context); } - if (widget.barcode!.isNotEmpty) { + if (barcode.isNotEmpty) { + if (!mounted) { + return; + } final result = await Provider.of(context, listen: false) - .searchIngredientWithCode(widget.barcode!); + .searchIngredientWithCode(barcode); + if (!mounted) { + return; + } if (result != null) { showDialog( @@ -209,7 +227,7 @@ class _IngredientTypeaheadState extends State { key: const Key('notFound-dialog'), title: Text(AppLocalizations.of(context).productNotFound), content: Text( - AppLocalizations.of(context).productNotFoundDescription(widget.barcode!), + AppLocalizations.of(context).productNotFoundDescription(barcode), ), actions: [ TextButton( @@ -225,15 +243,11 @@ class _IngredientTypeaheadState extends State { } } } catch (e) { - if (context.mounted) { + if (mounted) { showErrorDialog(e, context); - // Need to pop back since reader scan is a widget - // otherwise returns null when back button is pressed - return Navigator.pop(context); } } }, - icon: const FaIcon(FontAwesomeIcons.barcode), ); } } @@ -323,3 +337,23 @@ class NutritionDiaryEntry extends StatelessWidget { ); } } + +class IngredientAvatar extends StatelessWidget { + final Ingredient ingredient; + + const IngredientAvatar({super.key, required this.ingredient}); + + @override + Widget build(BuildContext context) { + return ingredient.image != null + ? GestureDetector( + child: CircleAvatar(backgroundImage: NetworkImage(ingredient.image!.image)), + onTap: () async { + if (ingredient.image!.objectUrl != '') { + return launchURL(ingredient.image!.objectUrl, context); + } + }, + ) + : const CircleIconAvatar(Icon(Icons.image, color: Colors.grey)); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8f449290..ea4d0b5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -912,10 +912,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "2c582551839386fa7ddbc7770658be7c0f87f388a4bff72066478f597c34d17f" + sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "8.0.0" package_info_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51b05c40..a245edb6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,7 +45,7 @@ dependencies: intl: ^0.18.1 json_annotation: ^4.8.1 version: ^3.0.2 - package_info_plus: ^7.0.0 + package_info_plus: ^8.0.0 provider: ^6.1.2 # Note, do not update rive! https://github.com/wger-project/flutter/issues/577 rive: ^0.12.4 diff --git a/test/nutrition/nutritional_meal_item_form_test.dart b/test/nutrition/nutritional_meal_item_form_test.dart index 2ba3dc7d..03770c7c 100644 --- a/test/nutrition/nutritional_meal_item_form_test.dart +++ b/test/nutrition/nutritional_meal_item_form_test.dart @@ -101,7 +101,7 @@ void main() { home: Scaffold( body: Scrollable( viewportBuilder: (BuildContext context, ViewportOffset position) => - MealItemForm(meal, const [], null, code, test), + MealItemForm(meal, const [], code, test), ), ), routes: { @@ -213,7 +213,7 @@ void main() { testWidgets('confirm found ingredient dialog', (WidgetTester tester) async { await tester.pumpWidget(createMealItemFormScreen(meal1, '123', true)); - final MealItemForm formScreen = tester.widget(find.byType(MealItemForm)); + final IngredientFormState formState = tester.state(find.byType(IngredientForm)); await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); @@ -223,7 +223,7 @@ void main() { await tester.tap(find.byKey(const Key('found-dialog-confirm-button'))); await tester.pumpAndSettle(); - expect(formScreen.ingredientIdController.text, '1'); + expect(formState.ingredientIdController.text, '1'); }); testWidgets('close found ingredient dialog', (WidgetTester tester) async { @@ -293,7 +293,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget(createMealItemFormScreen(meal1, '123', true)); - final MealItemForm formScreen = tester.widget(find.byType(MealItemForm)); + final IngredientFormState formState = tester.state(find.byType(IngredientForm)); await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); @@ -303,7 +303,7 @@ void main() { await tester.tap(find.byKey(const Key('found-dialog-confirm-button'))); await tester.pumpAndSettle(); - expect(formScreen.ingredientIdController.text, '1'); + expect(formState.ingredientIdController.text, '1'); await tester.enterText(find.byKey(const Key('field-weight')), '2'); await tester.pumpAndSettle(); @@ -313,7 +313,7 @@ void main() { await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME))); await tester.pumpAndSettle(); - expect(formScreen.mealItem.amount, 2); + expect(formState.mealItem.amount, 2); verify(mockNutrition.addMealItem(any, meal1)); });