From ee5a7660cbcd36268444d02fea98cafb4f0990c3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 22 Jun 2026 18:22:42 +0200 Subject: [PATCH] Add Flutter companion app skeleton with local meal logging Milestone 1 of the diet-app-as-wise-balloon plan: a phone-native way to log meals away from the PC, sharing the exact on-disk JSON shape diet_guard already uses (same field names, no translation layer). - lib/models/: 1:1 Dart mirrors of the Python dataclasses (Nutrition, FoodEntry, MealItem, FoodBankRecord, Slot), including the per-100g/ amount-eaten portion scaling that matches _resolve.resolve_nutrition's semantics exactly. - lib/services/log_storage_service.dart: plain-JSON persistence to food_log.json's exact shape (no sqflite -- the canonical format already is this JSON). - lib/services/foodbank_service.dart: ports _foodbank.py's upsert/fuzzy search logic for autocomplete. - lib/screens/: log_meal_screen.dart (single-item logging) and meal_builder_screen.dart (composite multi-item meals, logging full per-component macros via the new components field). Verified end-to-end on a physical device (BL9000): built, installed, logged a real meal through the UI. 77 Flutter tests passing, `flutter analyze` clean against very_good_analysis. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF --- app/.gitignore | 45 ++ app/.metadata | 30 ++ app/README.md | 17 + app/analysis_options.yaml | 32 ++ app/android/.gitignore | 14 + app/android/app/build.gradle.kts | 45 ++ app/android/app/src/debug/AndroidManifest.xml | 7 + app/android/app/src/main/AndroidManifest.xml | 45 ++ .../com/kuhy/diet_guard_app/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + app/android/build.gradle.kts | 24 ++ app/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + app/android/settings.gradle.kts | 26 ++ app/lib/main.dart | 30 ++ app/lib/models/food_bank_record.dart | 73 ++++ app/lib/models/food_entry.dart | 165 ++++++++ app/lib/models/food_suggestion.dart | 16 + app/lib/models/local_time.dart | 30 ++ app/lib/models/meal_component.dart | 60 +++ app/lib/models/meal_item.dart | 61 +++ app/lib/models/nutrition.dart | 98 +++++ app/lib/models/slot.dart | 64 +++ app/lib/screens/log_meal_screen.dart | 178 ++++++++ app/lib/screens/meal_builder_screen.dart | 144 +++++++ app/lib/services/foodbank_service.dart | 251 +++++++++++ app/lib/services/fuzzy.dart | 69 +++ app/lib/services/log_storage_service.dart | 181 ++++++++ .../widgets/autocomplete_suggestion_list.dart | 41 ++ app/lib/widgets/macro_input_row.dart | 124 ++++++ app/lib/widgets/slot_status_bar.dart | 51 +++ app/pubspec.lock | 397 ++++++++++++++++++ app/pubspec.yaml | 23 + app/test/models/food_entry_test.dart | 152 +++++++ app/test/models/local_time_test.dart | 25 ++ app/test/models/meal_item_test.dart | 60 +++ app/test/models/nutrition_test.dart | 139 ++++++ app/test/models/slot_test.dart | 86 ++++ app/test/screens/log_meal_screen_test.dart | 155 +++++++ .../screens/meal_builder_screen_test.dart | 99 +++++ app/test/services/foodbank_service_test.dart | 181 ++++++++ app/test/services/fuzzy_test.dart | 35 ++ .../services/log_storage_service_test.dart | 177 ++++++++ app/test/widget_test.dart | 35 ++ 53 files changed, 3568 insertions(+) create mode 100644 app/.gitignore create mode 100644 app/.metadata create mode 100644 app/README.md create mode 100644 app/analysis_options.yaml create mode 100644 app/android/.gitignore create mode 100644 app/android/app/build.gradle.kts create mode 100644 app/android/app/src/debug/AndroidManifest.xml create mode 100644 app/android/app/src/main/AndroidManifest.xml create mode 100644 app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt create mode 100644 app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/android/app/src/main/res/values-night/styles.xml create mode 100644 app/android/app/src/main/res/values/styles.xml create mode 100644 app/android/app/src/profile/AndroidManifest.xml create mode 100644 app/android/build.gradle.kts create mode 100644 app/android/gradle.properties create mode 100644 app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 app/android/settings.gradle.kts create mode 100644 app/lib/main.dart create mode 100644 app/lib/models/food_bank_record.dart create mode 100644 app/lib/models/food_entry.dart create mode 100644 app/lib/models/food_suggestion.dart create mode 100644 app/lib/models/local_time.dart create mode 100644 app/lib/models/meal_component.dart create mode 100644 app/lib/models/meal_item.dart create mode 100644 app/lib/models/nutrition.dart create mode 100644 app/lib/models/slot.dart create mode 100644 app/lib/screens/log_meal_screen.dart create mode 100644 app/lib/screens/meal_builder_screen.dart create mode 100644 app/lib/services/foodbank_service.dart create mode 100644 app/lib/services/fuzzy.dart create mode 100644 app/lib/services/log_storage_service.dart create mode 100644 app/lib/widgets/autocomplete_suggestion_list.dart create mode 100644 app/lib/widgets/macro_input_row.dart create mode 100644 app/lib/widgets/slot_status_bar.dart create mode 100644 app/pubspec.lock create mode 100644 app/pubspec.yaml create mode 100644 app/test/models/food_entry_test.dart create mode 100644 app/test/models/local_time_test.dart create mode 100644 app/test/models/meal_item_test.dart create mode 100644 app/test/models/nutrition_test.dart create mode 100644 app/test/models/slot_test.dart create mode 100644 app/test/screens/log_meal_screen_test.dart create mode 100644 app/test/screens/meal_builder_screen_test.dart create mode 100644 app/test/services/foodbank_service_test.dart create mode 100644 app/test/services/fuzzy_test.dart create mode 100644 app/test/services/log_storage_service_test.dart create mode 100644 app/test/widget_test.dart diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..fdcdbd8 --- /dev/null +++ b/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + - platform: android + create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..cf54a36 --- /dev/null +++ b/app/README.md @@ -0,0 +1,17 @@ +# diet_guard_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..bac0810 --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,32 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Promote key lints to errors so they fail CI, not just warn. + missing_required_param: error + unnecessary_null_comparison: error + dead_code: error + invalid_annotation_target: error + exclude: + - build/** + - "**/*.g.dart" + - "**/*.freezed.dart" + - "test/**" + +linter: + rules: + # very_good_analysis enables most rules; add extras it doesn't include. + always_use_package_imports: true + avoid_print: true + avoid_relative_lib_imports: true + cancel_subscriptions: true + close_sinks: true + comment_references: false # conflicts with typical Flutter API docs style + directives_ordering: true + lines_longer_than_80_chars: true + public_member_api_docs: true + unawaited_futures: true diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 0000000..1e1a8a0 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.kuhy.diet_guard_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.kuhy.diet_guard_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9ffa9c1 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt new file mode 100644 index 0000000..852aa10 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.kuhy.diet_guard_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..0016e37 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,30 @@ +/// App entry point: initializes local storage services, then shows the +/// primary meal-logging screen. +library; + +import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await LogStorageService.init(); + await FoodBankService.init(); + runApp(const DietGuardApp()); +} + +/// Root widget for the Diet Guard companion app. +class DietGuardApp extends StatelessWidget { + /// Creates the [DietGuardApp] root widget. + const DietGuardApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Diet Guard', + theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true), + home: const LogMealScreen(), + ); + } +} diff --git a/app/lib/models/food_bank_record.dart b/app/lib/models/food_bank_record.dart new file mode 100644 index 0000000..76a7142 --- /dev/null +++ b/app/lib/models/food_bank_record.dart @@ -0,0 +1,73 @@ +/// One entry in the local food bank (autocomplete index), mirroring +/// diet_guard's `_foodbank.BankRecord`. +library; + +/// A previously-logged food's remembered macros and use count. +/// +/// Mirrors `_foodbank.py`'s on-disk shape: `{desc, kcal, protein_g, +/// carbs_g, fat_g, grams, count, components?}`. Unlike [FoodEntry], a +/// composite record's `components` here are bare names (the bank is an +/// autocomplete index, not the source of truth for component macros -- +/// those live on the log entry itself, see `MealComponent`). +class FoodBankRecord { + /// Creates a [FoodBankRecord] from its stored fields. + const FoodBankRecord({ + required this.desc, + required this.kcal, + required this.proteinG, + required this.carbsG, + required this.fatG, + required this.grams, + required this.count, + this.components, + }); + + /// Builds a [FoodBankRecord] from its JSON map representation. + factory FoodBankRecord.fromJson(Map json) => + FoodBankRecord( + desc: json['desc'] as String? ?? '', + kcal: (json['kcal'] as num?)?.toDouble() ?? 0, + proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0, + carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, + fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, + grams: (json['grams'] as num?)?.toDouble() ?? 0, + count: (json['count'] as num?)?.toDouble() ?? 0, + components: (json['components'] as List?)?.cast(), + ); + + /// The food or meal's display name, as the user typed it. + final String desc; + + /// Calories per the stored portion. + final double kcal; + + /// Protein in grams. + final double proteinG; + + /// Carbohydrate in grams. + final double carbsG; + + /// Fat in grams. + final double fatG; + + /// Portion weight in grams. + final double grams; + + /// Number of times this food has been logged (ranks staples first). + final double count; + + /// Component names, for a composite meal record only. + final List? components; + + /// Returns this record as a JSON-ready map with snake_case keys. + Map toJson() => { + 'desc': desc, + 'kcal': kcal, + 'protein_g': proteinG, + 'carbs_g': carbsG, + 'fat_g': fatG, + 'grams': grams, + 'count': count, + if (components != null) 'components': components, + }; +} diff --git a/app/lib/models/food_entry.dart b/app/lib/models/food_entry.dart new file mode 100644 index 0000000..248e462 --- /dev/null +++ b/app/lib/models/food_entry.dart @@ -0,0 +1,165 @@ +/// A single logged meal entry, mirroring one `food_log.json` array element. +library; + +import 'package:diet_guard_app/models/meal_component.dart'; + +/// One logged meal, as stored in `food_log.json` under its date key. +/// +/// Field names and shapes mirror diet_guard's `_state.log_meal` entry +/// exactly, so this app's local storage *is* the wire format -- no +/// translation layer is needed when syncing with the PC app. +class FoodEntry { + /// Creates a [FoodEntry] from its stored fields. + const FoodEntry({ + required this.time, + required this.desc, + required this.grams, + required this.kcal, + required this.proteinG, + required this.carbsG, + required this.fatG, + required this.source, + this.id, + this.slot, + this.hmac, + this.components, + this.deleted = false, + this.imagePath, + }); + + /// Builds a [FoodEntry] from its JSON map representation. + /// + /// Missing/non-numeric macro fields default to 0, mirroring + /// `_state._entry_float`'s tolerance of a hand-edited or partial entry. + factory FoodEntry.fromJson(Map json) => FoodEntry( + id: json['id'] as String?, + time: json['time'] as String? ?? '', + desc: json['desc'] as String? ?? '', + grams: (json['grams'] as num?)?.toDouble() ?? 0, + kcal: (json['kcal'] as num?)?.toDouble() ?? 0, + proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0, + carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, + fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, + source: json['source'] as String? ?? 'manual', + slot: json['slot'] as int?, + hmac: json['hmac'] as String?, + components: (json['components'] as List?) + ?.cast>() + .map(MealComponent.fromJson) + .toList(), + deleted: json['deleted'] as bool? ?? false, + imagePath: json['imagePath'] as String?, + ); + + /// Stable identity for sync merge (UUID v4). Null only for legacy entries + /// written before this field existed. + final String? id; + + /// ISO-8601 local timestamp with second precision, kept as a opaque + /// string (not parsed to [DateTime]) so it round-trips byte-for-byte -- + /// the same field the PC's HMAC is computed over. + final String time; + + /// The user's free-text meal description. + final String desc; + + /// Portion weight in grams (0 if unknown). + final double grams; + + /// Calories for this entry. + final double kcal; + + /// Protein in grams. + final double proteinG; + + /// Carbohydrate in grams. + final double carbsG; + + /// Fat in grams. + final double fatG; + + /// Provenance label (e.g. `"manual"`, `"food bank"`, `"meal"`). + final String source; + + /// The meal-slot hour this entry satisfies (8/12/16/20), or null for a + /// snack that counts toward calories but satisfies no slot. + final int? slot; + + /// HMAC signature, present on entries that have passed through the PC's + /// signing step. Never computed on the phone -- it never holds the key. + final String? hmac; + + /// For a composite ("meal"-sourced) entry, each component's own macros. + final List? components; + + /// Tombstone flag: true once this entry has been undone. Kept (not + /// physically removed) so a sync merge can't resurrect a stale copy. + final bool deleted; + + /// Local file path to an attached photo, if any. Phone-local only -- + /// never read from a pulled remote copy and stripped before push. + final String? imagePath; + + /// Returns the full local-storage representation, including [imagePath]. + Map toLocalJson() => { + ...toSyncJson(), + if (imagePath != null) 'imagePath': imagePath, + }; + + /// Returns what gets pushed to this device's sync snapshot. + /// + /// Excludes [imagePath] (meaningless on another device) and [hmac] (the + /// phone never computes one; the PC re-signs on merge regardless of + /// origin, so an inbound signature would only be stripped there anyway). + Map toSyncJson() => { + if (id != null) 'id': id, + 'time': time, + 'desc': desc, + 'grams': grams, + 'kcal': kcal, + 'protein_g': proteinG, + 'carbs_g': carbsG, + 'fat_g': fatG, + 'source': source, + if (slot != null) 'slot': slot, + if (components != null) + 'components': components!.map((c) => c.toJson()).toList(), + if (deleted) 'deleted': true, + }; + + /// Returns a copy of this entry with [imagePath] replaced. + FoodEntry copyWithImagePath(String? imagePath) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: grams, + kcal: kcal, + proteinG: proteinG, + carbsG: carbsG, + fatG: fatG, + source: source, + slot: slot, + hmac: hmac, + components: components, + deleted: deleted, + imagePath: imagePath, + ); + + /// Returns a copy of this entry tombstoned (`deleted: true`). + FoodEntry copyWithDeleted() => FoodEntry( + id: id, + time: time, + desc: desc, + grams: grams, + kcal: kcal, + proteinG: proteinG, + carbsG: carbsG, + fatG: fatG, + source: source, + slot: slot, + hmac: hmac, + components: components, + deleted: true, + imagePath: imagePath, + ); +} diff --git a/app/lib/models/food_suggestion.dart b/app/lib/models/food_suggestion.dart new file mode 100644 index 0000000..2951329 --- /dev/null +++ b/app/lib/models/food_suggestion.dart @@ -0,0 +1,16 @@ +/// An autocomplete result, mirroring `_foodbank.search_foods`'s return type. +library; + +import 'package:diet_guard_app/models/nutrition.dart'; + +/// One ranked autocomplete suggestion: a display name and its macros. +class FoodSuggestion { + /// Creates a [FoodSuggestion] from its display name and macros. + const FoodSuggestion({required this.name, required this.nutrition}); + + /// The food or meal's display name. + final String name; + + /// The food or meal's stored macros. + final Nutrition nutrition; +} diff --git a/app/lib/models/local_time.dart b/app/lib/models/local_time.dart new file mode 100644 index 0000000..5b68a5a --- /dev/null +++ b/app/lib/models/local_time.dart @@ -0,0 +1,30 @@ +/// Local-time formatting that matches diet_guard's Python ISO format. +library; + +/// Returns [now] as an ISO-8601 string with a fixed local UTC offset and +/// second precision, matching Python's +/// `now_local().isoformat(timespec="seconds")`, e.g. +/// `"2026-06-22T15:08:11+02:00"`. +/// +/// Dart's own [DateTime.toIso8601String] omits the UTC offset for a local +/// (non-UTC) [DateTime], so this fills that gap to keep the `time` field +/// byte-comparable with entries the PC app writes. +String isoLocalSeconds(DateTime now) { + final offset = now.timeZoneOffset; + final sign = offset.isNegative ? '-' : '+'; + final absOffset = offset.abs(); + String two(int value) => value.toString().padLeft(2, '0'); + String four(int value) => value.toString().padLeft(4, '0'); + final offsetHours = two(absOffset.inHours); + final offsetMinutes = two(absOffset.inMinutes.remainder(60)); + return '${four(now.year)}-${two(now.month)}-${two(now.day)}' + 'T${two(now.hour)}:${two(now.minute)}:${two(now.second)}' + '$sign$offsetHours:$offsetMinutes'; +} + +/// Returns [now]'s local calendar date as `YYYY-MM-DD`. +/// +/// Local, not UTC: mirrors `_state._today()` -- "what I ate today" is a +/// local-calendar concept, so a meal eaten late in the evening must not +/// roll into tomorrow's total. +String localDateKey(DateTime now) => isoLocalSeconds(now).substring(0, 10); diff --git a/app/lib/models/meal_component.dart b/app/lib/models/meal_component.dart new file mode 100644 index 0000000..0c77cda --- /dev/null +++ b/app/lib/models/meal_component.dart @@ -0,0 +1,60 @@ +/// A composite meal's per-component record, carried on a log entry. +library; + +/// One component's name and macros, as stored on a composite log entry's +/// `components` list. +/// +/// Mirrors the dict shape `_meal.item_to_component` builds on the Python +/// side: `{name, kcal, protein_g, carbs_g, fat_g, grams}`. Carrying full +/// macros (not just the name) lets a food bank rebuilt purely by replaying +/// the log recover each component's standalone nutrition, not just the +/// composite's summed total. +class MealComponent { + /// Creates a [MealComponent] from its name and macro fields. + const MealComponent({ + required this.name, + required this.kcal, + required this.proteinG, + required this.carbsG, + required this.fatG, + required this.grams, + }); + + /// Builds a [MealComponent] from its JSON map representation. + factory MealComponent.fromJson(Map json) => MealComponent( + name: json['name'] as String? ?? '', + kcal: (json['kcal'] as num?)?.toDouble() ?? 0, + proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0, + carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, + fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, + grams: (json['grams'] as num?)?.toDouble() ?? 0, + ); + + /// The component's food name (e.g. `"chicken"`). + final String name; + + /// Calories for this component's portion. + final double kcal; + + /// Protein in grams. + final double proteinG; + + /// Carbohydrate in grams. + final double carbsG; + + /// Fat in grams. + final double fatG; + + /// Portion weight in grams. + final double grams; + + /// Returns this component as a JSON-ready map with snake_case keys. + Map toJson() => { + 'name': name, + 'kcal': kcal, + 'protein_g': proteinG, + 'carbs_g': carbsG, + 'fat_g': fatG, + 'grams': grams, + }; +} diff --git a/app/lib/models/meal_item.dart b/app/lib/models/meal_item.dart new file mode 100644 index 0000000..c9e4ff8 --- /dev/null +++ b/app/lib/models/meal_item.dart @@ -0,0 +1,61 @@ +/// Composite "meal" support, mirroring diet_guard's `_meal.py`. +library; + +import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; + +/// Provenance stamped on a summed meal, matching `_meal.MEAL_SOURCE`. +const String mealSource = 'meal'; + +/// One named component of a composite meal, with its own nutrition. +/// +/// Mirrors `_meal.MealItem`. +class MealItem { + /// Creates a [MealItem] from a component's name and resolved macros. + const MealItem({required this.name, required this.nutrition}); + + /// The component's food name (e.g. `"chicken"`). + final String name; + + /// The component's resolved macros for the amount eaten. + final Nutrition nutrition; +} + +/// Returns the summed nutrition of a meal's [items]. +/// +/// Every macro and the portion weight are added across the items and +/// rounded to 0.1, and the result is stamped `source: mealSource` so it is +/// distinguishable from a single food. Mirrors `_meal.meal_total`. +Nutrition mealTotal(List items) { + double sumOf(double Function(MealItem) field) { + var total = 0.0; + for (final item in items) { + total += field(item); + } + return double.parse(total.toStringAsFixed(1)); + } + + return Nutrition( + kcal: sumOf((item) => item.nutrition.kcal), + proteinG: sumOf((item) => item.nutrition.proteinG), + carbsG: sumOf((item) => item.nutrition.carbsG), + fatG: sumOf((item) => item.nutrition.fatG), + grams: sumOf((item) => item.nutrition.grams), + source: mealSource, + ); +} + +/// Returns a composite meal's per-component log record for [item]. +/// +/// Carries the component's full macros (not just its name) so a food bank +/// rebuilt purely by replaying the log can recover each component's +/// standalone nutrition, not just the composite's summed total. Mirrors +/// `_meal.item_to_component`. +MealComponent itemToComponent(MealItem item) => MealComponent( + name: item.name, + kcal: item.nutrition.kcal, + proteinG: item.nutrition.proteinG, + carbsG: item.nutrition.carbsG, + fatG: item.nutrition.fatG, + grams: item.nutrition.grams, +); diff --git a/app/lib/models/nutrition.dart b/app/lib/models/nutrition.dart new file mode 100644 index 0000000..82f6326 --- /dev/null +++ b/app/lib/models/nutrition.dart @@ -0,0 +1,98 @@ +/// Per-portion macro estimate, mirroring diet_guard's Python `Nutrition`. +library; + +/// Estimated calories and macros for a logged or in-progress portion. +/// +/// Field names match diet_guard's `_estimator.Nutrition` dataclass exactly +/// (`kcal`, `proteinG`, `carbsG`, `fatG`, `grams`, `source`) so JSON written +/// by this app round-trips through the PC app's schema with no translation. +class Nutrition { + /// Creates a [Nutrition] from its macro fields and provenance label. + const Nutrition({ + required this.kcal, + required this.proteinG, + required this.carbsG, + required this.fatG, + required this.grams, + required this.source, + }); + + /// Calories for the portion. + final double kcal; + + /// Protein in grams. + final double proteinG; + + /// Carbohydrate in grams. + final double carbsG; + + /// Fat in grams. + final double fatG; + + /// Portion weight in grams (0 when unknown). + final double grams; + + /// Where these macros came from (e.g. `"manual"`, `"food bank"`). + final String source; +} + +/// Rescales [nutrition] to a new portion weight in [grams] (pure). +/// +/// Mirrors `_estimator.scale_nutrition`: a stored or typed macro set +/// describes *some* basis portion (`nutrition.grams`), and eating a +/// different amount scales every macro proportionally. When the basis +/// weight or [grams] is unknown (`<= 0`), there is nothing to scale from, +/// so the macros are kept and only the recorded weight is updated. +Nutrition scaleNutrition(Nutrition nutrition, double grams) { + if (nutrition.grams <= 0 || grams <= 0) { + return Nutrition( + kcal: nutrition.kcal, + proteinG: nutrition.proteinG, + carbsG: nutrition.carbsG, + fatG: nutrition.fatG, + grams: grams > 0 ? grams : nutrition.grams, + source: nutrition.source, + ); + } + final factor = grams / nutrition.grams; + double scale(double value) => + double.parse((value * factor).toStringAsFixed(1)); + return Nutrition( + kcal: scale(nutrition.kcal), + proteinG: scale(nutrition.proteinG), + carbsG: scale(nutrition.carbsG), + fatG: scale(nutrition.fatG), + grams: grams, + source: nutrition.source, + ); +} + +/// Builds the eaten-portion [Nutrition] from typed macros that may describe +/// a different reference weight than what was actually eaten -- e.g. "250 +/// kcal per 100 g, I ate 150 g" -- mirroring `_resolve.resolve_nutrition`'s +/// manual-macro branch so both apps compute portions identically. +/// +/// Leaving [perGrams] at 0 means the typed macros already describe the +/// full eaten portion (the original, pre-scaling behaviour); leaving +/// [ateGrams] at 0 assumes the eaten amount equals [perGrams]. +Nutrition nutritionForPortion({ + required double kcal, + required double proteinG, + required double carbsG, + required double fatG, + required double perGrams, + required double ateGrams, + required String source, +}) { + final referenceGrams = perGrams > 0 ? perGrams : ateGrams; + final eatenGrams = ateGrams > 0 ? ateGrams : referenceGrams; + final reference = Nutrition( + kcal: kcal, + proteinG: proteinG, + carbsG: carbsG, + fatG: fatG, + grams: referenceGrams, + source: source, + ); + return scaleNutrition(reference, eatenGrams); +} diff --git a/app/lib/models/slot.dart b/app/lib/models/slot.dart new file mode 100644 index 0000000..ee82bdd --- /dev/null +++ b/app/lib/models/slot.dart @@ -0,0 +1,64 @@ +/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py`. +/// +/// Deliberately I/O-free and clock-free: every function is a total function +/// of its `now` argument and the fixed slot constants below, so the +/// time-of-day edges are exhaustively unit-testable without mocking the +/// wall clock. Shared between the in-app status bar and the background +/// notification check (Milestone 4), exactly like the Python original is +/// shared between the gate dashboard and the lock decision. +library; + +/// First slot hour of the day (08:00), mirrors `GATE_DAY_START_HOUR`. +const int gateDayStartHour = 8; + +/// Hours between slots, mirrors `GATE_SLOT_INTERVAL_HOURS`. +const int gateSlotIntervalHours = 4; + +/// Exclusive end of the enforcement window (22:00), mirrors +/// `GATE_EATING_END_HOUR`. +const int gateEatingEndHour = 22; + +/// Returns the fixed meal-slot hours for a day, e.g. `(8, 12, 16, 20)`. +/// +/// Mirrors `_slots.day_slots`. +List daySlots() { + final slots = []; + for (var hour = gateDayStartHour; hour < gateEatingEndHour; + hour += gateSlotIntervalHours) { + slots.add(hour); + } + return slots; +} + +/// Returns true if [now] is inside the daily slot-enforcement window. +/// +/// Mirrors `_slots.within_enforcement_window`. +bool withinEnforcementWindow(DateTime now) => + now.hour >= gateDayStartHour && now.hour < gateEatingEndHour; + +/// Returns today's slots whose hour has arrived as of [now]. +/// +/// Empty outside the enforcement window. Mirrors `_slots.elapsed_slots`. +List elapsedSlots(DateTime now) { + if (!withinEnforcementWindow(now)) return const []; + return daySlots().where((slot) => slot <= now.hour).toList(); +} + +/// Returns elapsed slots not yet covered by [logged]. +/// +/// Mirrors `_slots.missing_slots`. +List missingSlots(DateTime now, Set logged) => + elapsedSlots(now).where((slot) => !logged.contains(slot)).toList(); + +/// Returns the most recent elapsed slot as of [now], or null. +/// +/// Mirrors `_slots.current_slot`. +int? currentSlot(DateTime now) { + final elapsed = elapsedSlots(now); + return elapsed.isEmpty ? null : elapsed.last; +} + +/// Returns a human `HH:00` label for [slot], e.g. `"08:00"`. +/// +/// Mirrors `_slots.slot_label`. +String slotLabel(int slot) => '${(slot % 24).toString().padLeft(2, '0')}:00'; diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart new file mode 100644 index 0000000..7a6f85e --- /dev/null +++ b/app/lib/screens/log_meal_screen.dart @@ -0,0 +1,178 @@ +/// Single-food meal logging screen -- the app's primary, done-criterion +/// screen: "I can open the diet app on my phone and fill meal I ate." +library; + +import 'dart:async'; + +import 'package:diet_guard_app/models/food_suggestion.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/screens/meal_builder_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:diet_guard_app/widgets/slot_status_bar.dart'; +import 'package:flutter/material.dart'; + +/// Lets the user log one food item, with food-bank autocomplete and +/// today's slot status, or hop into [MealBuilderScreen] for a composite +/// multi-item meal. +class LogMealScreen extends StatefulWidget { + /// Creates a [LogMealScreen]. + const LogMealScreen({super.key}); + + @override + State createState() => _LogMealScreenState(); +} + +class _LogMealScreenState extends State { + final TextEditingController _descController = TextEditingController(); + final MacroControllers _macros = MacroControllers(); + List _suggestions = const []; + Set _loggedSlots = {}; + String _source = 'manual'; + String? _status; + + @override + void initState() { + super.initState(); + _descController.addListener(_onDescChanged); + for (final controller in [ + _macros.kcal, + _macros.protein, + _macros.carbs, + _macros.fat, + _macros.perGrams, + _macros.grams, + ]) { + controller.addListener(_onMacroEdited); + } + unawaited(_refreshSlots()); + unawaited(_onDescChanged()); + } + + @override + void dispose() { + _descController.dispose(); + _macros.dispose(); + super.dispose(); + } + + Future _refreshSlots() async { + final logged = await LogStorageService.instance.loggedSlotsToday(); + if (!mounted) return; + setState(() => _loggedSlots = logged); + } + + void _onMacroEdited() { + if (_source == 'food bank') { + setState(() => _source = 'manual'); + } + } + + Future _onDescChanged() async { + final matches = await FoodBankService.instance.search( + _descController.text, + ); + if (!mounted) return; + setState(() => _suggestions = matches); + } + + void _onSuggestionSelected(FoodSuggestion suggestion) { + _descController.text = suggestion.name; + _macros.kcal.text = suggestion.nutrition.kcal.toStringAsFixed(0); + _macros.protein.text = suggestion.nutrition.proteinG.toStringAsFixed(0); + _macros.carbs.text = suggestion.nutrition.carbsG.toStringAsFixed(0); + _macros.fat.text = suggestion.nutrition.fatG.toStringAsFixed(0); + _macros.perGrams.text = suggestion.nutrition.grams.toStringAsFixed(0); + _macros.grams.text = suggestion.nutrition.grams.toStringAsFixed(0); + setState(() { + _source = 'food bank'; + _suggestions = const []; + }); + } + + double _parse(TextEditingController controller) => + double.tryParse(controller.text.trim()) ?? 0; + + Future _onLogMeal() async { + final desc = _descController.text.trim(); + if (desc.isEmpty) { + setState(() => _status = 'Type what you ate first.'); + return; + } + final nutrition = nutritionForPortion( + kcal: _parse(_macros.kcal), + proteinG: _parse(_macros.protein), + carbsG: _parse(_macros.carbs), + fatG: _parse(_macros.fat), + perGrams: _parse(_macros.perGrams), + ateGrams: _parse(_macros.grams), + source: _source, + ); + final slot = currentSlot(DateTime.now()); + await LogStorageService.instance.logMeal(desc, nutrition, slot: slot); + final log = await LogStorageService.instance.readLog(); + await FoodBankService.instance.rebuildAndPersist(log); + if (!mounted) return; + _descController.clear(); + _macros.clear(); + setState(() => _source = 'manual'); + await _refreshSlots(); + if (!mounted) return; + setState(() => _status = 'Logged "$desc".'); + } + + Future _onBuildMeal() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MealBuilderScreen()), + ); + await _refreshSlots(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Diet Guard')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SlotStatusBar(now: DateTime.now(), loggedSlots: _loggedSlots), + const SizedBox(height: 16), + TextField( + controller: _descController, + decoration: const InputDecoration(labelText: 'What did you eat?'), + ), + AutocompleteSuggestionList( + suggestions: _suggestions, + onSelected: _onSuggestionSelected, + ), + const SizedBox(height: 12), + MacroInputRow(controllers: _macros), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _onLogMeal, + child: const Text('Log meal'), + ), + OutlinedButton( + onPressed: _onBuildMeal, + child: const Text('Build a multi-item meal'), + ), + ], + ), + if (_status != null) ...[ + const SizedBox(height: 12), + Text(_status!), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/meal_builder_screen.dart b/app/lib/screens/meal_builder_screen.dart new file mode 100644 index 0000000..08e4b44 --- /dev/null +++ b/app/lib/screens/meal_builder_screen.dart @@ -0,0 +1,144 @@ +/// Composite multi-item meal flow, mirroring `_gatelock_mealflow.py`'s +/// add-item/log-meal loop. +library; + +import 'package:diet_guard_app/models/meal_item.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:flutter/material.dart'; + +/// A screen for building and logging a multi-item meal as one composite +/// entry, e.g. a dinner of soup + chicken + rice. +class MealBuilderScreen extends StatefulWidget { + /// Creates a [MealBuilderScreen]. + const MealBuilderScreen({super.key}); + + @override + State createState() => _MealBuilderScreenState(); +} + +class _MealBuilderScreenState extends State { + final TextEditingController _itemDescController = TextEditingController(); + final TextEditingController _mealNameController = TextEditingController(); + final MacroControllers _macros = MacroControllers(); + final List _items = []; + String? _status; + + @override + void dispose() { + _itemDescController.dispose(); + _mealNameController.dispose(); + _macros.dispose(); + super.dispose(); + } + + double _parse(TextEditingController controller) => + double.tryParse(controller.text.trim()) ?? 0; + + void _onAddItem() { + final desc = _itemDescController.text.trim(); + if (desc.isEmpty) { + setState(() => _status = 'Type the item first, then add it.'); + return; + } + final nutrition = nutritionForPortion( + kcal: _parse(_macros.kcal), + proteinG: _parse(_macros.protein), + carbsG: _parse(_macros.carbs), + fatG: _parse(_macros.fat), + perGrams: _parse(_macros.perGrams), + ateGrams: _parse(_macros.grams), + source: 'manual', + ); + setState(() { + _items.add(MealItem(name: desc, nutrition: nutrition)); + _itemDescController.clear(); + _macros.clear(); + _status = 'Added $desc. Add another, or log the meal.'; + }); + } + + Future _onLogMeal() async { + if (_items.isEmpty) { + setState(() => _status = 'Add at least one item first.'); + return; + } + final name = _mealNameController.text.trim().isEmpty + ? 'meal' + : _mealNameController.text.trim(); + final total = mealTotal(_items); + final components = _items.map(itemToComponent).toList(); + final slot = currentSlot(DateTime.now()); + await LogStorageService.instance.logMeal( + name, + total, + slot: slot, + components: components, + ); + final log = await LogStorageService.instance.readLog(); + await FoodBankService.instance.rebuildAndPersist(log); + if (!mounted) return; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final total = mealTotal(_items); + return Scaffold( + appBar: AppBar(title: const Text('Build a meal')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _mealNameController, + decoration: const InputDecoration( + labelText: 'Meal name (optional)', + ), + ), + const SizedBox(height: 12), + if (_items.isNotEmpty) ...[ + Text( + 'So far (${_items.length}): ' + '${_items.map((i) => i.name).join(', ')} -> ' + '${total.kcal.toStringAsFixed(0)} kcal ' + 'P${total.proteinG.toStringAsFixed(0)} ' + 'C${total.carbsG.toStringAsFixed(0)} ' + 'F${total.fatG.toStringAsFixed(0)}', + ), + const SizedBox(height: 12), + ], + TextField( + controller: _itemDescController, + decoration: const InputDecoration(labelText: 'Item name'), + ), + const SizedBox(height: 8), + MacroInputRow(controllers: _macros), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _onAddItem, + child: const Text('Add item'), + ), + ElevatedButton( + onPressed: _onLogMeal, + child: const Text('Log meal'), + ), + ], + ), + if (_status != null) ...[ + const SizedBox(height: 12), + Text(_status!), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/services/foodbank_service.dart b/app/lib/services/foodbank_service.dart new file mode 100644 index 0000000..93ac914 --- /dev/null +++ b/app/lib/services/foodbank_service.dart @@ -0,0 +1,251 @@ +/// Local food-bank (autocomplete index), mirroring diet_guard's +/// `_foodbank.py` -- but *derived*, not synced (see Milestone 3's decision +/// to avoid counter-merge logic entirely: every device rebuilds its own +/// bank by replaying its own post-merge log). +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/food_suggestion.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/fuzzy.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Below this similarity ratio a non-substring candidate is dropped. +/// Mirrors `_foodbank._FUZZY_THRESHOLD`. +const double fuzzyThreshold = 0.6; + +/// Default number of autocomplete suggestions to surface. Mirrors +/// `_foodbank.DEFAULT_SUGGESTIONS`. +const int defaultSuggestions = 8; + +String _normalize(String description) => description.trim().toLowerCase(); + +Nutrition _recordToNutrition(FoodBankRecord record) => Nutrition( + kcal: record.kcal, + proteinG: record.proteinG, + carbsG: record.carbsG, + fatG: record.fatG, + grams: record.grams, + source: 'food bank', +); + +String _displayName(FoodBankRecord record, String key) => + record.desc.trim().isEmpty ? key : record.desc; + +/// Singleton service for the locally-rebuilt food bank. +class FoodBankService { + FoodBankService._(this._file); + + static FoodBankService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called. + static FoodBankService get instance => _instance!; + + final File _file; + + /// Initializes the singleton, pointing at the app's documents directory. + static Future init() async { + if (_instance != null) return _instance!; + final dir = await getApplicationDocumentsDirectory(); + final svc = FoodBankService._(File(p.join(dir.path, 'food_bank.json'))); + _instance = svc; + return svc; + } + + /// Resets the singleton so [init] can be called again in tests. + @visibleForTesting + static void resetForTesting({Directory? testDir}) { + _instance = testDir == null + ? null + : FoodBankService._(File(p.join(testDir.path, 'food_bank.json'))); + } + + /// Reads the persisted bank (empty map on a missing/unparsable file). + Future> readBank() async { + if (!_file.existsSync()) return {}; + String raw; + try { + raw = await _file.readAsString(); + } on FileSystemException { + return {}; + } + Object? data; + try { + data = jsonDecode(raw); + } on FormatException { + return {}; + } + if (data is! Map) return {}; + final result = {}; + for (final mapEntry in data.entries) { + final key = mapEntry.key; + final value = mapEntry.value; + if (key is String && value is Map) { + result[key] = FoodBankRecord.fromJson(value.cast()); + } + } + return result; + } + + /// Persists [bank] to disk, creating the parent directory if needed. + Future writeBank(Map bank) async { + await _file.parent.create(recursive: true); + final encoded = { + for (final mapEntry in bank.entries) + mapEntry.key: mapEntry.value.toJson(), + }; + await _file.writeAsString(jsonEncode(encoded)); + } + + /// Rebuilds the bank by replaying [log]'s entries in a fixed, device- + /// independent order (by `time` then `id`), so any two devices that + /// converge on the same merged log also converge on the same bank. + /// + /// Pure -- no I/O -- so it is independently unit-testable, mirroring + /// `_foodbank.remember_food`/`remember_meal`'s upsert semantics: latest + /// macros win per normalized name, `count` increments per occurrence. + static Map rebuild(DayLog log) { + final entries = log.values + .expand((entries) => entries) + .where((entry) => !entry.deleted) + .toList() + ..sort((a, b) { + final byTime = a.time.compareTo(b.time); + return byTime != 0 ? byTime : (a.id ?? '').compareTo(b.id ?? ''); + }); + final bank = {}; + for (final entry in entries) { + final components = entry.components; + if (components != null) { + for (final component in components) { + _upsert( + bank, + component.name, + Nutrition( + kcal: component.kcal, + proteinG: component.proteinG, + carbsG: component.carbsG, + fatG: component.fatG, + grams: component.grams, + source: 'food bank', + ), + null, + ); + } + _upsert( + bank, + entry.desc, + Nutrition( + kcal: entry.kcal, + proteinG: entry.proteinG, + carbsG: entry.carbsG, + fatG: entry.fatG, + grams: entry.grams, + source: entry.source, + ), + components.map((c) => c.name).toList(), + ); + } else { + _upsert( + bank, + entry.desc, + Nutrition( + kcal: entry.kcal, + proteinG: entry.proteinG, + carbsG: entry.carbsG, + fatG: entry.fatG, + grams: entry.grams, + source: entry.source, + ), + null, + ); + } + } + return bank; + } + + static void _upsert( + Map bank, + String description, + Nutrition nutrition, + List? components, + ) { + final key = _normalize(description); + if (key.isEmpty) return; + final previous = bank[key]; + final count = (previous?.count ?? 0) + 1; + bank[key] = FoodBankRecord( + desc: description.trim(), + kcal: nutrition.kcal, + proteinG: nutrition.proteinG, + carbsG: nutrition.carbsG, + fatG: nutrition.fatG, + grams: nutrition.grams, + count: count, + components: components, + ); + } + + /// Rebuilds the bank from [log] and persists it, returning the result. + Future> rebuildAndPersist(DayLog log) async { + final bank = rebuild(log); + await writeBank(bank); + return bank; + } + + /// Returns banked foods matching [query], best match first. + /// + /// An empty query returns the most-logged foods. Mirrors + /// `_foodbank.search_foods`. + Future> search( + String query, { + int limit = defaultSuggestions, + }) async { + final bank = await readBank(); + final normalized = _normalize(query); + if (normalized.isEmpty) return _rankedAll(bank, limit); + + final scored = <(double score, double count, FoodSuggestion suggestion)>[]; + for (final mapEntry in bank.entries) { + final score = matchScore(normalized, mapEntry.key); + if (score < fuzzyThreshold) continue; + final record = mapEntry.value; + scored.add(( + score, + record.count, + FoodSuggestion( + name: _displayName(record, mapEntry.key), + nutrition: _recordToNutrition(record), + ), + )); + } + scored.sort((a, b) { + final byScore = b.$1.compareTo(a.$1); + return byScore != 0 ? byScore : b.$2.compareTo(a.$2); + }); + return scored.take(limit).map((s) => s.$3).toList(); + } + + List _rankedAll( + Map bank, + int limit, + ) { + final ranked = bank.entries.toList() + ..sort((a, b) => b.value.count.compareTo(a.value.count)); + return ranked + .take(limit) + .map( + (mapEntry) => FoodSuggestion( + name: _displayName(mapEntry.value, mapEntry.key), + nutrition: _recordToNutrition(mapEntry.value), + ), + ) + .toList(); + } +} diff --git a/app/lib/services/fuzzy.dart b/app/lib/services/fuzzy.dart new file mode 100644 index 0000000..0a31e10 --- /dev/null +++ b/app/lib/services/fuzzy.dart @@ -0,0 +1,69 @@ +/// Shared typo-tolerant string matching, mirroring diet_guard's `_fuzzy.py`. +/// +/// Ports the *intent* of `_fuzzy.py`'s scoring -- word-by-word matching so a +/// short typo isn't drowned out by a long multi-word name -- rather than a +/// line-for-line port of `difflib.SequenceMatcher`, which has no direct +/// Dart equivalent. A longest-common-subsequence ratio stands in for +/// SequenceMatcher's matching-blocks algorithm; both converge on +/// near-1.0 for an exact match and fall off smoothly for typos, but scores +/// are not guaranteed bit-identical to the Python implementation for the +/// same inputs. +library; + +double _sequenceRatio(String a, String b) { + if (a.isEmpty && b.isEmpty) return 1; + if (a.isEmpty || b.isEmpty) return 0; + final lcs = _longestCommonSubsequenceLength(a, b); + return 2.0 * lcs / (a.length + b.length); +} + +int _longestCommonSubsequenceLength(String a, String b) { + var previous = List.filled(b.length + 1, 0); + for (var i = 1; i <= a.length; i++) { + final current = List.filled(b.length + 1, 0); + for (var j = 1; j <= b.length; j++) { + current[j] = a[i - 1] == b[j - 1] + ? previous[j - 1] + 1 + : (previous[j] > current[j - 1] ? previous[j] : current[j - 1]); + } + previous = current; + } + return previous[b.length]; +} + +/// Returns the non-empty whitespace-separated words in [text]. +List _words(String text) => + text.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + +/// Scores [query] against [name] word-by-word (length-penalty free). +/// +/// Mirrors `_fuzzy.token_score`. +double tokenScore(String query, String name) { + final queryWords = _words(query); + final nameWords = _words(name); + if (queryWords.isEmpty || nameWords.isEmpty) { + return _sequenceRatio(query, name); + } + var total = 0.0; + for (final word in queryWords) { + var best = 0.0; + for (final target in nameWords) { + final score = _sequenceRatio(word, target); + if (score > best) best = score; + } + total += best; + } + return total / queryWords.length; +} + +/// Scores how well [name] matches [query] (higher is better). +/// +/// A substring hit scores at or above 1.0 (boosted by how much of [name] +/// the query covers); otherwise falls back to the token-aware fuzzy score. +/// Mirrors `_fuzzy.match_score`. +double matchScore(String query, String name) { + if (query.isNotEmpty && name.contains(query)) { + return 1.0 + query.length / name.length; + } + return tokenScore(query, name); +} diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart new file mode 100644 index 0000000..16bf8a7 --- /dev/null +++ b/app/lib/services/log_storage_service.dart @@ -0,0 +1,181 @@ +/// Local persistence for the food log, mirroring diet_guard's `_state.py`. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// The on-disk log shape: date key (`YYYY-MM-DD`) to that day's entries. +typedef DayLog = Map>; + +/// Singleton service reading/writing `food_log.json` verbatim. +/// +/// Stores plain JSON matching diet_guard's exact on-disk schema rather than +/// a SQL database: the canonical format already *is* this JSON (it is also +/// the sync payload, see Milestone 3), so a SQL schema would only add a +/// second representation to keep in lockstep for no query benefit -- +/// autocomplete is small-corpus fuzzy string matching, not a relational +/// query. +class LogStorageService { + LogStorageService._(this._file); + + static LogStorageService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called. + static LogStorageService get instance => _instance!; + + final File _file; + + /// Initializes the singleton, pointing at the app's documents directory + /// (phone-sandboxed; no external-storage permission needed). + static Future init() async { + if (_instance != null) return _instance!; + final dir = await getApplicationDocumentsDirectory(); + final svc = LogStorageService._(File(p.join(dir.path, 'food_log.json'))); + _instance = svc; + return svc; + } + + /// Resets the singleton so [init] can be called again in tests. + /// + /// When [testDir] is given, subsequent reads/writes go there instead of + /// the real documents directory, so a test can never touch real data. + @visibleForTesting + static void resetForTesting({Directory? testDir}) { + _instance = testDir == null + ? null + : LogStorageService._(File(p.join(testDir.path, 'food_log.json'))); + } + + /// Reads the full log, including tombstoned entries. + /// + /// Returns an empty log on a missing or unparsable file, mirroring + /// `_state._read_raw_log`'s defensive read. + Future readLog() async { + if (!_file.existsSync()) return {}; + String raw; + try { + raw = await _file.readAsString(); + } on FileSystemException { + return {}; + } + Object? data; + try { + data = jsonDecode(raw); + } on FormatException { + return {}; + } + if (data is! Map) return {}; + final result = >{}; + for (final mapEntry in data.entries) { + final key = mapEntry.key; + final value = mapEntry.value; + if (key is! String || value is! List) continue; + result[key] = value + .whereType>() + .map((m) => FoodEntry.fromJson(m.cast())) + .toList(); + } + return result; + } + + /// Persists the full log to disk, creating the parent directory if + /// needed, mirroring `_state._write_log`. + Future writeLog(DayLog log) async { + await _file.parent.create(recursive: true); + final encoded = { + for (final mapEntry in log.entries) + mapEntry.key: mapEntry.value.map((e) => e.toLocalJson()).toList(), + }; + await _file.writeAsString(jsonEncode(encoded)); + } + + /// Appends a signed-on-PC-eventually entry for [desc] to today's log. + /// + /// Mirrors `_state.log_meal`: always assigns a fresh `id`, never computes + /// an `hmac` (the phone never holds the shared key -- the PC re-signs on + /// merge, see Milestone 3). + Future logMeal( + String desc, + Nutrition nutrition, { + int? slot, + List? components, + String? imagePath, + }) async { + final now = DateTime.now(); + final entry = FoodEntry( + id: const Uuid().v4(), + time: isoLocalSeconds(now), + desc: desc, + grams: nutrition.grams, + kcal: nutrition.kcal, + proteinG: nutrition.proteinG, + carbsG: nutrition.carbsG, + fatG: nutrition.fatG, + source: nutrition.source, + slot: slot, + components: components, + imagePath: imagePath, + ); + final log = await readLog(); + log.putIfAbsent(localDateKey(now), () => []).add(entry); + await writeLog(log); + return entry; + } + + /// Tombstones today's most recently logged, not-yet-undone entry. + /// + /// Mirrors `_state.undo_last_today`: marks the entry `deleted` in place + /// rather than removing it, so a later sync merge can't resurrect a + /// stale copy of the same entry from another device. + Future undoLastToday() async { + final log = await readLog(); + final today = localDateKey(DateTime.now()); + final entries = log[today]; + if (entries == null || entries.isEmpty) return null; + for (var i = entries.length - 1; i >= 0; i--) { + if (entries[i].deleted) continue; + final tombstoned = entries[i].copyWithDeleted(); + entries[i] = tombstoned; + log[today] = entries; + await writeLog(log); + return tombstoned; + } + return null; + } + + /// Returns today's non-tombstoned entries, mirrors `_state.today_entries`. + Future> todayEntries() async { + final log = await readLog(); + final entries = log[localDateKey(DateTime.now())] ?? const []; + return entries.where((e) => !e.deleted).toList(); + } + + /// Returns today's total calories, mirrors `_state.today_total_kcal`. + Future todayTotalKcal() async { + final entries = await todayEntries(); + var total = 0.0; + for (final entry in entries) { + total += entry.kcal; + } + return double.parse(total.toStringAsFixed(1)); + } + + /// Returns the slot hours already satisfied today, mirrors + /// `_state.logged_slots_today`. + Future> loggedSlotsToday() async { + final entries = await todayEntries(); + return entries + .where((e) => e.slot != null) + .map((e) => e.slot!) + .toSet(); + } +} diff --git a/app/lib/widgets/autocomplete_suggestion_list.dart b/app/lib/widgets/autocomplete_suggestion_list.dart new file mode 100644 index 0000000..5d7d9b3 --- /dev/null +++ b/app/lib/widgets/autocomplete_suggestion_list.dart @@ -0,0 +1,41 @@ +/// Renders ranked food-bank autocomplete suggestions. +library; + +import 'package:diet_guard_app/models/food_suggestion.dart'; +import 'package:flutter/material.dart'; + +/// A tappable list of [FoodSuggestion]s, each filling the form on tap. +class AutocompleteSuggestionList extends StatelessWidget { + /// Creates an [AutocompleteSuggestionList] for [suggestions]. + const AutocompleteSuggestionList({ + required this.suggestions, + required this.onSelected, + super.key, + }); + + /// Ranked suggestions to display, best match first. + final List suggestions; + + /// Called with the chosen suggestion when the user taps it. + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + if (suggestions.isEmpty) return const SizedBox.shrink(); + return ListView.builder( + shrinkWrap: true, + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + return ListTile( + dense: true, + title: Text(suggestion.name), + subtitle: Text( + '${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal', + ), + onTap: () => onSelected(suggestion), + ); + }, + ); + } +} diff --git a/app/lib/widgets/macro_input_row.dart b/app/lib/widgets/macro_input_row.dart new file mode 100644 index 0000000..daa74f9 --- /dev/null +++ b/app/lib/widgets/macro_input_row.dart @@ -0,0 +1,124 @@ +/// A row of macro entry fields (kcal/protein/carbs/fat/grams), with an +/// optional reference weight so a label's per-100g macros can be typed +/// directly and scaled to the amount actually eaten. +library; + +import 'package:flutter/material.dart'; + +/// Text controllers for one macro-entry row, owned by the calling screen so +/// it can read/clear/prefill values around the row's lifecycle. +class MacroControllers { + /// Creates a fresh set of empty macro controllers. + MacroControllers() + : kcal = TextEditingController(), + protein = TextEditingController(), + carbs = TextEditingController(), + fat = TextEditingController(), + perGrams = TextEditingController(), + grams = TextEditingController(); + + /// Calories controller. + final TextEditingController kcal; + + /// Protein (g) controller. + final TextEditingController protein; + + /// Carbohydrate (g) controller. + final TextEditingController carbs; + + /// Fat (g) controller. + final TextEditingController fat; + + /// Reference weight (g) the typed macros are stated for, e.g. `100` for + /// a per-100g label. Blank means the macros already describe the full + /// eaten portion. + final TextEditingController perGrams; + + /// Portion weight actually eaten (g). Blank assumes the eaten amount + /// equals [perGrams]. + final TextEditingController grams; + + /// Clears every field's text. + void clear() { + kcal.clear(); + protein.clear(); + carbs.clear(); + fat.clear(); + perGrams.clear(); + grams.clear(); + } + + /// Disposes every controller. + void dispose() { + kcal.dispose(); + protein.dispose(); + carbs.dispose(); + fat.dispose(); + perGrams.dispose(); + grams.dispose(); + } +} + +/// A labeled row of number-entry fields for calories, macros, and the +/// optional reference-weight-vs-eaten-weight split. +class MacroInputRow extends StatelessWidget { + /// Creates a [MacroInputRow] bound to [controllers]. + const MacroInputRow({required this.controllers, super.key}); + + /// The text controllers this row reads from and writes to. + final MacroControllers controllers; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: _macroField('kcal', controllers.kcal)), + const SizedBox(width: 8), + Expanded( + child: _macroField( + 'macros per (g)', + controllers.perGrams, + helperText: 'e.g. 100 for a per-100g label', + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: _macroField('protein g', controllers.protein)), + const SizedBox(width: 8), + Expanded(child: _macroField('carbs g', controllers.carbs)), + const SizedBox(width: 8), + Expanded(child: _macroField('fat g', controllers.fat)), + ], + ), + const SizedBox(height: 8), + _macroField( + 'amount eaten (g)', + controllers.grams, + helperText: "blank = same as 'macros per'", + ), + ], + ); + } + + Widget _macroField( + String label, + TextEditingController controller, { + String? helperText, + }) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: label, + helperText: helperText, + isDense: true, + ), + ); + } +} diff --git a/app/lib/widgets/slot_status_bar.dart b/app/lib/widgets/slot_status_bar.dart new file mode 100644 index 0000000..85d604a --- /dev/null +++ b/app/lib/widgets/slot_status_bar.dart @@ -0,0 +1,51 @@ +/// Shows today's 08:00/12:00/16:00/20:00 slot status. +library; + +import 'package:diet_guard_app/models/slot.dart'; +import 'package:flutter/material.dart'; + +/// Renders each of today's meal slots as logged / due / upcoming. +class SlotStatusBar extends StatelessWidget { + /// Creates a [SlotStatusBar] for [now] given [loggedSlots] satisfied so + /// far today. + const SlotStatusBar({ + required this.now, + required this.loggedSlots, + super.key, + }); + + /// Reference time used to decide which slots have elapsed. + final DateTime now; + + /// Slot hours already satisfied by today's log. + final Set loggedSlots; + + @override + Widget build(BuildContext context) { + final elapsed = elapsedSlots(now).toSet(); + return Wrap( + spacing: 8, + runSpacing: 4, + children: daySlots().map((slot) { + final label = slotLabel(slot); + final String status; + final Color color; + if (loggedSlots.contains(slot)) { + status = 'logged'; + color = Colors.green; + } else if (elapsed.contains(slot)) { + status = 'DUE'; + color = Colors.red; + } else { + status = 'upcoming'; + color = Colors.grey; + } + return Chip( + label: Text('$label $status'), + backgroundColor: color.withValues(alpha: 0.15), + labelStyle: TextStyle(color: color), + ); + }).toList(), + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..eb92e65 --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,397 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" + url: "https://pub.dev" + source: hosted + version: "10.3.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.38.4" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..7584d1f --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,23 @@ +name: diet_guard_app +description: "Companion phone app for diet_guard: log meals on the go." +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +dependencies: + flutter: + sdk: flutter + path: ^1.9.1 + path_provider: ^2.1.5 + uuid: ^4.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^10.2.0 + +flutter: + uses-material-design: true diff --git a/app/test/models/food_entry_test.dart b/app/test/models/food_entry_test.dart new file mode 100644 index 0000000..b43cdc6 --- /dev/null +++ b/app/test/models/food_entry_test.dart @@ -0,0 +1,152 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FoodEntry.fromJson', () { + test('parses a fully-populated entry', () { + final entry = FoodEntry.fromJson({ + 'id': 'abc-123', + 'time': '2026-06-22T17:41:17+02:00', + 'desc': 'label_food', + 'grams': 150.0, + 'kcal': 300.0, + 'protein_g': 15.0, + 'carbs_g': 30.0, + 'fat_g': 7.5, + 'source': 'manual', + 'slot': 16, + 'hmac': 'deadbeef', + 'components': [ + { + 'name': 'rice', + 'kcal': 200.0, + 'protein_g': 4.0, + 'carbs_g': 44.0, + 'fat_g': 1.0, + 'grams': 150.0, + }, + ], + 'deleted': true, + 'imagePath': '/tmp/photo.jpg', + }); + expect(entry.id, 'abc-123'); + expect(entry.desc, 'label_food'); + expect(entry.kcal, 300.0); + expect(entry.slot, 16); + expect(entry.hmac, 'deadbeef'); + expect(entry.components, hasLength(1)); + expect(entry.components!.first.name, 'rice'); + expect(entry.deleted, isTrue); + expect(entry.imagePath, '/tmp/photo.jpg'); + }); + + test('defaults missing macro fields to 0 and source to manual', () { + final entry = FoodEntry.fromJson(const {'desc': 'mystery food'}); + expect(entry.id, isNull); + expect(entry.kcal, 0); + expect(entry.proteinG, 0); + expect(entry.source, 'manual'); + expect(entry.slot, isNull); + expect(entry.deleted, isFalse); + expect(entry.components, isNull); + }); + }); + + group('toLocalJson vs toSyncJson', () { + test('toLocalJson includes imagePath; toSyncJson excludes it and hmac', () { + const entry = FoodEntry( + id: 'id-1', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + grams: 50, + kcal: 120, + proteinG: 3, + carbsG: 20, + fatG: 2, + source: 'manual', + hmac: 'sig', + imagePath: '/local/photo.jpg', + ); + final local = entry.toLocalJson(); + final sync = entry.toSyncJson(); + expect(local['imagePath'], '/local/photo.jpg'); + expect(sync.containsKey('imagePath'), isFalse); + expect(sync.containsKey('hmac'), isFalse); + expect(sync['desc'], 'toast'); + }); + + test('omits optional fields entirely when unset', () { + const entry = FoodEntry( + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + grams: 50, + kcal: 120, + proteinG: 3, + carbsG: 20, + fatG: 2, + source: 'manual', + ); + final sync = entry.toSyncJson(); + expect(sync.containsKey('id'), isFalse); + expect(sync.containsKey('slot'), isFalse); + expect(sync.containsKey('components'), isFalse); + expect(sync.containsKey('deleted'), isFalse); + }); + + test('includes deleted: true only when tombstoned', () { + const entry = FoodEntry( + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + grams: 50, + kcal: 120, + proteinG: 3, + carbsG: 20, + fatG: 2, + source: 'manual', + deleted: true, + ); + expect(entry.toSyncJson()['deleted'], isTrue); + }); + }); + + group('copyWithImagePath / copyWithDeleted', () { + const base = FoodEntry( + id: 'id-1', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + grams: 50, + kcal: 120, + proteinG: 3, + carbsG: 20, + fatG: 2, + source: 'manual', + components: [ + MealComponent( + name: 'bread', + kcal: 120, + proteinG: 3, + carbsG: 20, + fatG: 2, + grams: 50, + ), + ], + ); + + test('copyWithImagePath only changes imagePath', () { + final updated = base.copyWithImagePath('/new/path.jpg'); + expect(updated.imagePath, '/new/path.jpg'); + expect(updated.id, base.id); + expect(updated.deleted, isFalse); + expect(updated.components, base.components); + }); + + test('copyWithDeleted sets deleted true and preserves everything else', () { + final tombstoned = base.copyWithDeleted(); + expect(tombstoned.deleted, isTrue); + expect(tombstoned.id, base.id); + expect(tombstoned.kcal, base.kcal); + expect(tombstoned.components, base.components); + }); + }); +} diff --git a/app/test/models/local_time_test.dart b/app/test/models/local_time_test.dart new file mode 100644 index 0000000..1c6136d --- /dev/null +++ b/app/test/models/local_time_test.dart @@ -0,0 +1,25 @@ +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isoLocalSeconds', () { + test('includes a positive UTC offset with second precision', () { + final result = isoLocalSeconds( + DateTime(2026, 6, 22, 17, 41, 17), + ); + expect(result, startsWith('2026-06-22T17:41:17')); + expect(result, matches(RegExp(r'[+-]\d{2}:\d{2}$'))); + }); + + test('pads single-digit month, day, and time components', () { + final result = isoLocalSeconds(DateTime(2026, 1, 2, 3, 4, 5)); + expect(result, startsWith('2026-01-02T03:04:05')); + }); + }); + + group('localDateKey', () { + test('returns just the date portion', () { + expect(localDateKey(DateTime(2026, 6, 22, 23, 59)), '2026-06-22'); + }); + }); +} diff --git a/app/test/models/meal_item_test.dart b/app/test/models/meal_item_test.dart new file mode 100644 index 0000000..bfaaaac --- /dev/null +++ b/app/test/models/meal_item_test.dart @@ -0,0 +1,60 @@ +import 'package:diet_guard_app/models/meal_item.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Nutrition _n(double kcal, double protein, double carbs, double fat, double g) => + Nutrition( + kcal: kcal, + proteinG: protein, + carbsG: carbs, + fatG: fat, + grams: g, + source: 'manual', + ); + +void main() { + group('mealTotal', () { + test('sums every macro and the portion weight across items', () { + final items = [ + MealItem(name: 'soup', nutrition: _n(100, 5, 10, 2, 200)), + MealItem(name: 'chicken', nutrition: _n(250, 30, 0, 10, 150)), + ]; + final total = mealTotal(items); + expect(total.kcal, 350); + expect(total.proteinG, 35); + expect(total.carbsG, 10); + expect(total.fatG, 12); + expect(total.grams, 350); + expect(total.source, mealSource); + }); + + test('returns all zeros for an empty meal', () { + final total = mealTotal(const []); + expect(total.kcal, 0); + expect(total.grams, 0); + expect(total.source, mealSource); + }); + + test('rounds the summed values to 0.1', () { + final items = [ + MealItem(name: 'a', nutrition: _n(1.05, 1.05, 1.05, 1.05, 1.05)), + MealItem(name: 'b', nutrition: _n(1.05, 1.05, 1.05, 1.05, 1.05)), + ]; + final total = mealTotal(items); + expect(total.kcal, 2.1); + }); + }); + + group('itemToComponent', () { + test('carries the item\'s name and full macros', () { + final item = MealItem(name: 'rice', nutrition: _n(200, 4, 44, 1, 150)); + final component = itemToComponent(item); + expect(component.name, 'rice'); + expect(component.kcal, 200); + expect(component.proteinG, 4); + expect(component.carbsG, 44); + expect(component.fatG, 1); + expect(component.grams, 150); + }); + }); +} diff --git a/app/test/models/nutrition_test.dart b/app/test/models/nutrition_test.dart new file mode 100644 index 0000000..5e73e33 --- /dev/null +++ b/app/test/models/nutrition_test.dart @@ -0,0 +1,139 @@ +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Nutrition _ref({ + double kcal = 200, + double proteinG = 10, + double carbsG = 20, + double fatG = 5, + double grams = 100, + String source = 'manual', +}) => Nutrition( + kcal: kcal, + proteinG: proteinG, + carbsG: carbsG, + fatG: fatG, + grams: grams, + source: source, +); + +void main() { + group('scaleNutrition', () { + test('scales every macro proportionally to the new weight', () { + final result = scaleNutrition(_ref(), 150); + expect(result.kcal, 300); + expect(result.proteinG, 15); + expect(result.carbsG, 30); + expect(result.fatG, 7.5); + expect(result.grams, 150); + expect(result.source, 'manual'); + }); + + test('is a no-op when the new weight equals the basis weight', () { + final result = scaleNutrition(_ref(), 100); + expect(result.kcal, 200); + expect(result.proteinG, 10); + expect(result.grams, 100); + }); + + test('keeps macros unchanged when the basis weight is unknown', () { + final result = scaleNutrition(_ref(grams: 0), 150); + expect(result.kcal, 200); + expect(result.grams, 150); + }); + + test('keeps macros and basis weight when the new weight is unknown', () { + final result = scaleNutrition(_ref(), 0); + expect(result.kcal, 200); + expect(result.grams, 100); + }); + + test('keeps basis weight when both weights are unknown', () { + final result = scaleNutrition(_ref(grams: 0), 0); + expect(result.grams, 0); + }); + }); + + group('nutritionForPortion', () { + test('scales label macros to the amount actually eaten', () { + final result = nutritionForPortion( + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + perGrams: 100, + ateGrams: 150, + source: 'manual', + ); + expect(result.kcal, 300); + expect(result.proteinG, 15); + expect(result.carbsG, 30); + expect(result.fatG, 7.5); + expect(result.grams, 150); + }); + + test( + 'treats typed macros as totals when per-grams is left blank ' + '(back-compatible with the original single-grams-field behaviour)', + () { + final result = nutritionForPortion( + kcal: 250, + proteinG: 12, + carbsG: 30, + fatG: 8, + perGrams: 0, + ateGrams: 150, + source: 'manual', + ); + expect(result.kcal, 250); + expect(result.proteinG, 12); + expect(result.grams, 150); + }, + ); + + test( + 'assumes the eaten amount equals per-grams when amount eaten is ' + 'left blank', + () { + final result = nutritionForPortion( + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + perGrams: 100, + ateGrams: 0, + source: 'manual', + ); + expect(result.kcal, 200); + expect(result.grams, 100); + }, + ); + + test('keeps macros as typed when both grams fields are blank', () { + final result = nutritionForPortion( + kcal: 90, + proteinG: 3, + carbsG: 4, + fatG: 1, + perGrams: 0, + ateGrams: 0, + source: 'manual', + ); + expect(result.kcal, 90); + expect(result.grams, 0); + }); + + test('stamps the requested source', () { + final result = nutritionForPortion( + kcal: 100, + proteinG: 1, + carbsG: 1, + fatG: 1, + perGrams: 100, + ateGrams: 100, + source: 'food bank', + ); + expect(result.source, 'food bank'); + }); + }); +} diff --git a/app/test/models/slot_test.dart b/app/test/models/slot_test.dart new file mode 100644 index 0000000..43c5520 --- /dev/null +++ b/app/test/models/slot_test.dart @@ -0,0 +1,86 @@ +import 'package:diet_guard_app/models/slot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('daySlots', () { + test('returns the four fixed hourly slots', () { + expect(daySlots(), [8, 12, 16, 20]); + }); + }); + + group('withinEnforcementWindow', () { + test('false before the day start hour', () { + expect(withinEnforcementWindow(DateTime(2026, 6, 22, 7, 59)), isFalse); + }); + + test('true at the day start hour', () { + expect(withinEnforcementWindow(DateTime(2026, 6, 22, 8, 0)), isTrue); + }); + + test('true just before the eating end hour', () { + expect(withinEnforcementWindow(DateTime(2026, 6, 22, 21, 59)), isTrue); + }); + + test('false at the eating end hour (exclusive)', () { + expect(withinEnforcementWindow(DateTime(2026, 6, 22, 22, 0)), isFalse); + }); + }); + + group('elapsedSlots', () { + test('empty outside the enforcement window', () { + expect(elapsedSlots(DateTime(2026, 6, 22, 23, 0)), isEmpty); + }); + + test('only the 8 slot right at day start', () { + expect(elapsedSlots(DateTime(2026, 6, 22, 8, 0)), [8]); + }); + + test('8 and 12 mid-afternoon before 16', () { + expect(elapsedSlots(DateTime(2026, 6, 22, 15, 59)), [8, 12]); + }); + + test('all four slots once 20:00 has passed', () { + expect(elapsedSlots(DateTime(2026, 6, 22, 21, 0)), [8, 12, 16, 20]); + }); + }); + + group('missingSlots', () { + test('excludes already-logged elapsed slots', () { + expect( + missingSlots(DateTime(2026, 6, 22, 17, 0), {8}), + [12, 16], + ); + }); + + test('empty once every elapsed slot is logged', () { + expect( + missingSlots(DateTime(2026, 6, 22, 17, 0), {8, 12, 16}), + isEmpty, + ); + }); + }); + + group('currentSlot', () { + test('null outside the enforcement window', () { + expect(currentSlot(DateTime(2026, 6, 22, 6, 0)), isNull); + }); + + test('returns the most recently elapsed slot', () { + expect(currentSlot(DateTime(2026, 6, 22, 17, 41)), 16); + }); + + test('returns 8 right at day start', () { + expect(currentSlot(DateTime(2026, 6, 22, 8, 0)), 8); + }); + }); + + group('slotLabel', () { + test('pads single-digit hours', () { + expect(slotLabel(8), '08:00'); + }); + + test('formats double-digit hours', () { + expect(slotLabel(20), '20:00'); + }); + }); +} diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart new file mode 100644 index 0000000..bb433ef --- /dev/null +++ b/app/test/screens/log_meal_screen_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + final logMealButton = find.widgetWithText(ElevatedButton, 'Log meal'); + + // The screen's button handlers and description-field listener trigger + // real `dart:io` file I/O as fire-and-forget Futures that Flutter's frame + // scheduler does not track -- pumpAndSettle() can return *before* that + // I/O (and its eventual setState) actually finishes. Every interaction + // that can reach a service call therefore runs inside a single + // tester.runAsync() per test, with a short real delay before each + // pumpAndSettle() to let the in-flight I/O actually complete first. + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets('logging a manually-typed meal persists it as source manual', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.enterText(find.byType(TextField).at(0), 'toast'); + await settle(tester); + await tester.enterText(find.byType(TextField).at(1), '150'); + await tester.enterText(find.byType(TextField).at(3), '5'); + await tester.enterText(find.byType(TextField).at(4), '20'); + await tester.enterText(find.byType(TextField).at(5), '3'); + await settle(tester); + + await tester.tap(logMealButton); + await settle(tester); + + expect(find.text('Logged "toast".'), findsOneWidget); + final entries = await LogStorageService.instance.todayEntries(); + expect(entries.single.source, 'manual'); + expect(entries.single.kcal, 150); + }); + }); + + testWidgets('refuses to log with an empty description', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(logMealButton); + await settle(tester); + + expect(find.text('Type what you ate first.'), findsOneWidget); + expect(await LogStorageService.instance.todayEntries(), isEmpty); + }); + }); + + testWidgets( + 'per-grams and amount-eaten fields scale macros to the eaten portion', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.enterText(find.byType(TextField).at(0), 'label food'); + await settle(tester); + await tester.enterText(find.byType(TextField).at(1), '200'); + await tester.enterText(find.byType(TextField).at(2), '100'); + await tester.enterText(find.byType(TextField).at(3), '10'); + await tester.enterText(find.byType(TextField).at(4), '20'); + await tester.enterText(find.byType(TextField).at(5), '5'); + await tester.enterText(find.byType(TextField).at(6), '150'); + await settle(tester); + + await tester.tap(logMealButton); + await settle(tester); + + final entry = + (await LogStorageService.instance.todayEntries()).single; + expect(entry.kcal, 300); + expect(entry.proteinG, 15); + expect(entry.carbsG, 30); + expect(entry.fatG, 7.5); + expect(entry.grams, 150); + }); + }, + ); + + testWidgets( + 'selecting a food-bank suggestion stamps source food bank, but ' + 'editing a macro afterward reverts it to manual', + (tester) async { + await tester.runAsync(() async { + const seed = FoodEntry( + id: 'seed-1', + time: '2026-06-01T08:00:00+02:00', + desc: 'seeded food', + grams: 100, + kcal: 250, + proteinG: 10, + carbsG: 30, + fatG: 8, + source: 'manual', + ); + await FoodBankService.instance.rebuildAndPersist({ + '2026-06-01': [seed], + }); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + // The empty-query suggestion list shows the only banked food. + await tester.tap(find.text('seeded food')); + await settle(tester); + await tester.tap(logMealButton); + await settle(tester); + + final firstEntry = + (await LogStorageService.instance.todayEntries()).single; + expect(firstEntry.source, 'food bank'); + expect(firstEntry.kcal, 250); + + await tester.tap(find.text('seeded food')); + await settle(tester); + await tester.enterText(find.byType(TextField).at(1), '999'); + await settle(tester); + await tester.tap(logMealButton); + await settle(tester); + + final secondEntry = + (await LogStorageService.instance.todayEntries()).last; + expect(secondEntry.source, 'manual'); + expect(secondEntry.kcal, 999); + }); + }, + ); +} diff --git a/app/test/screens/meal_builder_screen_test.dart b/app/test/screens/meal_builder_screen_test.dart new file mode 100644 index 0000000..91caa78 --- /dev/null +++ b/app/test/screens/meal_builder_screen_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:diet_guard_app/screens/meal_builder_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_builder_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + final addItemButton = find.widgetWithText(ElevatedButton, 'Add item'); + final logMealButton = find.widgetWithText(ElevatedButton, 'Log meal'); + + // See log_meal_screen_test.dart: "Log meal" triggers real dart:io File + // writes as an unawaited Future Flutter's scheduler doesn't track, so a + // short real delay before settling is needed in addition to runAsync(). + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets('refuses to log a meal with no items added', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen())); + await settle(tester); + + await tester.tap(logMealButton); + await settle(tester); + + expect(find.text('Add at least one item first.'), findsOneWidget); + }); + }); + + testWidgets( + 'adds an item with per/ate scaling applied, then logs the composite ' + 'meal with full per-component macros', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget( + const MaterialApp(home: MealBuilderScreen()), + ); + await settle(tester); + + // Field order: [0] meal name, [1] item name, [2] kcal, + // [3] per (g), [4] protein, [5] carbs, [6] fat, [7] ate (g). + await tester.enterText(find.byType(TextField).at(1), 'rice'); + await tester.enterText(find.byType(TextField).at(2), '200'); + await tester.enterText(find.byType(TextField).at(3), '100'); + await tester.enterText(find.byType(TextField).at(4), '4'); + await tester.enterText(find.byType(TextField).at(5), '44'); + await tester.enterText(find.byType(TextField).at(6), '1'); + await tester.enterText(find.byType(TextField).at(7), '150'); + await settle(tester); + await tester.tap(addItemButton); + await settle(tester); + + expect(find.textContaining('So far (1)'), findsOneWidget); + expect(find.textContaining('300 kcal'), findsOneWidget); + + await tester.enterText(find.byType(TextField).at(1), 'chicken'); + await tester.enterText(find.byType(TextField).at(2), '165'); + await tester.enterText(find.byType(TextField).at(4), '31'); + await tester.enterText(find.byType(TextField).at(5), '0'); + await tester.enterText(find.byType(TextField).at(6), '4'); + await settle(tester); + await tester.tap(addItemButton); + await settle(tester); + + await tester.tap(logMealButton); + await settle(tester); + + final entry = + (await LogStorageService.instance.todayEntries()).single; + expect(entry.source, 'meal'); + expect(entry.kcal, 465); // 300 (scaled rice) + 165 (chicken) + expect(entry.components, hasLength(2)); + final rice = entry.components!.firstWhere((c) => c.name == 'rice'); + expect(rice.kcal, 300); + expect(rice.proteinG, 6); + expect(rice.carbsG, 66); + expect(rice.fatG, 1.5); + expect(rice.grams, 150); + }); + }, + ); +} diff --git a/app/test/services/foodbank_service_test.dart b/app/test/services/foodbank_service_test.dart new file mode 100644 index 0000000..bb04f0c --- /dev/null +++ b/app/test/services/foodbank_service_test.dart @@ -0,0 +1,181 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + required String id, + required String time, + required String desc, + double kcal = 100, + List? components, + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 100, + kcal: kcal, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: components != null ? 'meal' : 'manual', + components: components, + deleted: deleted, +); + +void main() { + group('FoodBankService.rebuild (pure)', () { + test('upserts a single-food entry by normalized name', () { + final log = { + '2026-06-22': [ + _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'Toast'), + ], + }; + final bank = FoodBankService.rebuild(log); + expect(bank['toast']!.desc, 'Toast'); + expect(bank['toast']!.count, 1); + }); + + test('increments count and keeps the latest macros on repeat', () { + final log = { + '2026-06-22': [ + _entry( + id: '1', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + kcal: 100, + ), + _entry( + id: '2', + time: '2026-06-22T12:00:00+02:00', + desc: 'toast', + kcal: 120, + ), + ], + }; + final bank = FoodBankService.rebuild(log); + expect(bank['toast']!.count, 2); + expect(bank['toast']!.kcal, 120); + }); + + test('skips a tombstoned entry entirely', () { + final log = { + '2026-06-22': [ + _entry( + id: '1', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + deleted: true, + ), + ], + }; + expect(FoodBankService.rebuild(log), isEmpty); + }); + + test('records a composite entry\'s components and total separately', () { + const components = [ + MealComponent( + name: 'rice', + kcal: 200, + proteinG: 4, + carbsG: 44, + fatG: 1, + grams: 150, + ), + ]; + final log = { + '2026-06-22': [ + _entry( + id: '1', + time: '2026-06-22T20:00:00+02:00', + desc: 'dinner', + kcal: 200, + components: components, + ), + ], + }; + final bank = FoodBankService.rebuild(log); + expect(bank['rice'], isNotNull); + expect(bank['dinner']!.components, ['rice']); + }); + + test('orders replay by time then id so two devices converge', () { + final logA = { + '2026-06-22': [ + _entry(id: 'b', time: '2026-06-22T08:00:00+02:00', desc: 'x'), + _entry(id: 'a', time: '2026-06-22T08:00:00+02:00', desc: 'x'), + ], + }; + final logB = { + '2026-06-22': [ + _entry(id: 'a', time: '2026-06-22T08:00:00+02:00', desc: 'x'), + _entry(id: 'b', time: '2026-06-22T08:00:00+02:00', desc: 'x'), + ], + }; + expect( + FoodBankService.rebuild(logA)['x']!.count, + FoodBankService.rebuild(logB)['x']!.count, + ); + }); + }); + + group('FoodBankService search/persistence', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_bank_'); + FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('readBank returns empty map when no file exists', () async { + expect(await FoodBankService.instance.readBank(), isEmpty); + }); + + test('rebuildAndPersist writes the bank to disk', () async { + final log = { + '2026-06-22': [ + _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'egg'), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + final reread = await FoodBankService.instance.readBank(); + expect(reread['egg'], isNotNull); + }); + + test('search ranks an exact substring match above a fuzzy one', () async { + final log = { + '2026-06-22': [ + _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'banana'), + _entry(id: '2', time: '2026-06-22T08:01:00+02:00', desc: 'banama'), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + final results = await FoodBankService.instance.search('banana'); + expect(results.first.name, 'banana'); + }); + + test('empty query returns the most-logged foods first', () async { + final log = { + '2026-06-22': [ + _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'rare'), + _entry(id: '2', time: '2026-06-22T08:01:00+02:00', desc: 'common'), + _entry(id: '3', time: '2026-06-22T08:02:00+02:00', desc: 'common'), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + final results = await FoodBankService.instance.search(''); + expect(results.first.name, 'common'); + }); + }); +} diff --git a/app/test/services/fuzzy_test.dart b/app/test/services/fuzzy_test.dart new file mode 100644 index 0000000..1771907 --- /dev/null +++ b/app/test/services/fuzzy_test.dart @@ -0,0 +1,35 @@ +import 'package:diet_guard_app/services/fuzzy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('matchScore', () { + test('scores an exact match at least 1.0', () { + expect(matchScore('banana', 'banana'), greaterThanOrEqualTo(1.0)); + }); + + test('boosts a substring match above a typo of similar length', () { + final substring = matchScore('ban', 'banana'); + final typo = matchScore('bnaana', 'banana'); + expect(substring, greaterThan(typo)); + }); + + test('scores an empty query against a name as the fallback token score', + () { + expect(matchScore('', 'banana'), greaterThanOrEqualTo(0)); + }); + + test('scores a clear mismatch low', () { + expect(matchScore('xyz', 'banana'), lessThan(0.6)); + }); + }); + + group('tokenScore', () { + test('matches one query word against the best name word', () { + expect(tokenScore('chicken', 'grilled chicken breast'), 1.0); + }); + + test('falls back to sequence ratio when either side has no words', () { + expect(tokenScore('', ''), 1.0); + }); + }); +} diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart new file mode 100644 index 0000000..8fe63ef --- /dev/null +++ b/app/test/services/log_storage_service_test.dart @@ -0,0 +1,177 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _manual = Nutrition( + kcal: 150, + proteinG: 5, + carbsG: 20, + fatG: 3, + grams: 50, + source: 'manual', +); + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_test_'); + LogStorageService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + group('readLog', () { + test('returns an empty log when no file exists yet', () async { + expect(await LogStorageService.instance.readLog(), isEmpty); + }); + + test('returns an empty log for unparsable JSON', () async { + final file = File('${tempDir.path}/food_log.json'); + await file.writeAsString('not json'); + expect(await LogStorageService.instance.readLog(), isEmpty); + }); + + test('returns an empty log when the JSON root is not a map', () async { + final file = File('${tempDir.path}/food_log.json'); + await file.writeAsString('[]'); + expect(await LogStorageService.instance.readLog(), isEmpty); + }); + + test('skips a date key whose value is not a list', () async { + final file = File('${tempDir.path}/food_log.json'); + await file.writeAsString('{"2026-06-22": "not a list"}'); + expect(await LogStorageService.instance.readLog(), isEmpty); + }); + }); + + group('logMeal', () { + test('assigns a fresh id and never an hmac', () async { + final entry = await LogStorageService.instance.logMeal( + 'toast', + _manual, + slot: 8, + ); + expect(entry.id, isNotEmpty); + expect(entry.hmac, isNull); + expect(entry.slot, 8); + expect(entry.desc, 'toast'); + }); + + test('persists components when given', () async { + const components = [ + MealComponent( + name: 'bread', + kcal: 100, + proteinG: 3, + carbsG: 18, + fatG: 1, + grams: 40, + ), + ]; + final entry = await LogStorageService.instance.logMeal( + 'toast meal', + _manual, + components: components, + ); + expect(entry.components, hasLength(1)); + final reloaded = await LogStorageService.instance.todayEntries(); + expect(reloaded.single.components!.single.name, 'bread'); + }); + + test('two logged meals both persist under today\'s date key', () async { + await LogStorageService.instance.logMeal('a', _manual); + await LogStorageService.instance.logMeal('b', _manual); + final entries = await LogStorageService.instance.todayEntries(); + expect(entries, hasLength(2)); + }); + }); + + group('undoLastToday', () { + test('returns null when today has no entries', () async { + expect(await LogStorageService.instance.undoLastToday(), isNull); + }); + + test('tombstones the most recent entry in place', () async { + await LogStorageService.instance.logMeal('first', _manual); + final second = await LogStorageService.instance.logMeal( + 'second', + _manual, + ); + final undone = await LogStorageService.instance.undoLastToday(); + expect(undone!.id, second.id); + expect(undone.deleted, isTrue); + }); + + test('tombstoned entries are excluded from todayEntries', () async { + await LogStorageService.instance.logMeal('only', _manual); + await LogStorageService.instance.undoLastToday(); + expect(await LogStorageService.instance.todayEntries(), isEmpty); + }); + + test('never touches a previous day\'s entries', () async { + final yesterdayKey = localDateKey( + DateTime.now().subtract(const Duration(days: 1)), + ); + final yesterday = FoodEntry( + id: 'yesterday-1', + time: '${yesterdayKey}T08:00:00+02:00', + desc: 'yesterday meal', + grams: 50, + kcal: 150, + proteinG: 5, + carbsG: 20, + fatG: 3, + source: 'manual', + ); + await LogStorageService.instance.writeLog({yesterdayKey: [yesterday]}); + await LogStorageService.instance.logMeal('today', _manual); + + expect(await LogStorageService.instance.undoLastToday(), isNotNull); + + final log = await LogStorageService.instance.readLog(); + expect(log[yesterdayKey]!.single.deleted, isFalse); + }); + + test('skips an already-tombstoned entry and undoes the one before it', + () async { + final first = await LogStorageService.instance.logMeal('first', _manual); + await LogStorageService.instance.logMeal('second', _manual); + await LogStorageService.instance.undoLastToday(); + final undoneAgain = await LogStorageService.instance.undoLastToday(); + expect(undoneAgain!.id, first.id); + expect(await LogStorageService.instance.undoLastToday(), isNull); + }); + }); + + group('todayTotalKcal', () { + test('sums kcal across today\'s non-tombstoned entries', () async { + await LogStorageService.instance.logMeal('a', _manual); + await LogStorageService.instance.logMeal('b', _manual); + expect(await LogStorageService.instance.todayTotalKcal(), 300.0); + }); + + test('excludes a tombstoned entry from the total', () async { + await LogStorageService.instance.logMeal('a', _manual); + await LogStorageService.instance.logMeal('b', _manual); + await LogStorageService.instance.undoLastToday(); + expect(await LogStorageService.instance.todayTotalKcal(), 150.0); + }); + }); + + group('loggedSlotsToday', () { + test('returns only the slots with a logged entry', () async { + await LogStorageService.instance.logMeal('a', _manual, slot: 8); + await LogStorageService.instance.logMeal('b', _manual); + expect(await LogStorageService.instance.loggedSlotsToday(), {8}); + }); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart new file mode 100644 index 0000000..9e2f2a8 --- /dev/null +++ b/app/test/widget_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:diet_guard_app/main.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_app_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + testWidgets('app launches straight into the meal-logging screen', + (tester) async { + // LogMealScreen's initState does real dart:io file I/O; pumpAndSettle() + // alone never lets that resolve (see log_meal_screen_test.dart). + await tester.runAsync(() async { + await tester.pumpWidget(const DietGuardApp()); + await tester.pumpAndSettle(); + }); + + expect(find.text('Diet Guard'), findsOneWidget); + expect(find.text('What did you eat?'), findsOneWidget); + }); +}