mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 15:43:25 +02:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
This commit is contained in:
parent
888c877048
commit
ee5a7660cb
45
app/.gitignore
vendored
Normal file
45
app/.gitignore
vendored
Normal file
@ -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
|
||||||
30
app/.metadata
Normal file
30
app/.metadata
Normal file
@ -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'
|
||||||
17
app/README.md
Normal file
17
app/README.md
Normal file
@ -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.
|
||||||
32
app/analysis_options.yaml
Normal file
32
app/analysis_options.yaml
Normal file
@ -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
|
||||||
14
app/android/.gitignore
vendored
Normal file
14
app/android/.gitignore
vendored
Normal file
@ -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
|
||||||
45
app/android/app/build.gradle.kts
Normal file
45
app/android/app/build.gradle.kts
Normal file
@ -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 = "../.."
|
||||||
|
}
|
||||||
7
app/android/app/src/debug/AndroidManifest.xml
Normal file
7
app/android/app/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
45
app/android/app/src/main/AndroidManifest.xml
Normal file
45
app/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="diet_guard_app"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.kuhy.diet_guard_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
12
app/android/app/src/main/res/drawable/launch_background.xml
Normal file
12
app/android/app/src/main/res/drawable/launch_background.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
BIN
app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
app/android/app/src/main/res/values-night/styles.xml
Normal file
18
app/android/app/src/main/res/values-night/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
18
app/android/app/src/main/res/values/styles.xml
Normal file
18
app/android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
app/android/app/src/profile/AndroidManifest.xml
Normal file
7
app/android/app/src/profile/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
24
app/android/build.gradle.kts
Normal file
24
app/android/build.gradle.kts
Normal file
@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
6
app/android/gradle.properties
Normal file
6
app/android/gradle.properties
Normal file
@ -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
|
||||||
5
app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -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
|
||||||
26
app/android/settings.gradle.kts
Normal file
26
app/android/settings.gradle.kts
Normal file
@ -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")
|
||||||
30
app/lib/main.dart
Normal file
30
app/lib/main.dart
Normal file
@ -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<void> 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/lib/models/food_bank_record.dart
Normal file
73
app/lib/models/food_bank_record.dart
Normal file
@ -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<String, dynamic> 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<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 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<String>? components;
|
||||||
|
|
||||||
|
/// Returns this record as a JSON-ready map with snake_case keys.
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'desc': desc,
|
||||||
|
'kcal': kcal,
|
||||||
|
'protein_g': proteinG,
|
||||||
|
'carbs_g': carbsG,
|
||||||
|
'fat_g': fatG,
|
||||||
|
'grams': grams,
|
||||||
|
'count': count,
|
||||||
|
if (components != null) 'components': components,
|
||||||
|
};
|
||||||
|
}
|
||||||
165
app/lib/models/food_entry.dart
Normal file
165
app/lib/models/food_entry.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic>>()
|
||||||
|
.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<MealComponent>? 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<String, Object?> 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<String, Object?> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/lib/models/food_suggestion.dart
Normal file
16
app/lib/models/food_suggestion.dart
Normal file
@ -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;
|
||||||
|
}
|
||||||
30
app/lib/models/local_time.dart
Normal file
30
app/lib/models/local_time.dart
Normal file
@ -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);
|
||||||
60
app/lib/models/meal_component.dart
Normal file
60
app/lib/models/meal_component.dart
Normal file
@ -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<String, dynamic> 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<String, Object?> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'kcal': kcal,
|
||||||
|
'protein_g': proteinG,
|
||||||
|
'carbs_g': carbsG,
|
||||||
|
'fat_g': fatG,
|
||||||
|
'grams': grams,
|
||||||
|
};
|
||||||
|
}
|
||||||
61
app/lib/models/meal_item.dart
Normal file
61
app/lib/models/meal_item.dart
Normal file
@ -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<MealItem> 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,
|
||||||
|
);
|
||||||
98
app/lib/models/nutrition.dart
Normal file
98
app/lib/models/nutrition.dart
Normal file
@ -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);
|
||||||
|
}
|
||||||
64
app/lib/models/slot.dart
Normal file
64
app/lib/models/slot.dart
Normal file
@ -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<int> daySlots() {
|
||||||
|
final slots = <int>[];
|
||||||
|
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<int> 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<int> missingSlots(DateTime now, Set<int> 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';
|
||||||
178
app/lib/screens/log_meal_screen.dart
Normal file
178
app/lib/screens/log_meal_screen.dart
Normal file
@ -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<LogMealScreen> createState() => _LogMealScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogMealScreenState extends State<LogMealScreen> {
|
||||||
|
final TextEditingController _descController = TextEditingController();
|
||||||
|
final MacroControllers _macros = MacroControllers();
|
||||||
|
List<FoodSuggestion> _suggestions = const [];
|
||||||
|
Set<int> _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<void> _refreshSlots() async {
|
||||||
|
final logged = await LogStorageService.instance.loggedSlotsToday();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loggedSlots = logged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMacroEdited() {
|
||||||
|
if (_source == 'food bank') {
|
||||||
|
setState(() => _source = 'manual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _onBuildMeal() async {
|
||||||
|
await Navigator.of(context).push<void>(
|
||||||
|
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!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/lib/screens/meal_builder_screen.dart
Normal file
144
app/lib/screens/meal_builder_screen.dart
Normal file
@ -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<MealBuilderScreen> createState() => _MealBuilderScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MealBuilderScreenState extends State<MealBuilderScreen> {
|
||||||
|
final TextEditingController _itemDescController = TextEditingController();
|
||||||
|
final TextEditingController _mealNameController = TextEditingController();
|
||||||
|
final MacroControllers _macros = MacroControllers();
|
||||||
|
final List<MealItem> _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<void> _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!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
app/lib/services/foodbank_service.dart
Normal file
251
app/lib/services/foodbank_service.dart
Normal file
@ -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<FoodBankService> 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<Map<String, FoodBankRecord>> 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 = <String, FoodBankRecord>{};
|
||||||
|
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<String, dynamic>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists [bank] to disk, creating the parent directory if needed.
|
||||||
|
Future<void> writeBank(Map<String, FoodBankRecord> bank) async {
|
||||||
|
await _file.parent.create(recursive: true);
|
||||||
|
final encoded = <String, Object?>{
|
||||||
|
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<String, FoodBankRecord> 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 = <String, FoodBankRecord>{};
|
||||||
|
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<String, FoodBankRecord> bank,
|
||||||
|
String description,
|
||||||
|
Nutrition nutrition,
|
||||||
|
List<String>? 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<Map<String, FoodBankRecord>> 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<List<FoodSuggestion>> 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<FoodSuggestion> _rankedAll(
|
||||||
|
Map<String, FoodBankRecord> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/lib/services/fuzzy.dart
Normal file
69
app/lib/services/fuzzy.dart
Normal file
@ -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<String> _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);
|
||||||
|
}
|
||||||
181
app/lib/services/log_storage_service.dart
Normal file
181
app/lib/services/log_storage_service.dart
Normal file
@ -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<String, List<FoodEntry>>;
|
||||||
|
|
||||||
|
/// 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<LogStorageService> 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<DayLog> 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 = <String, List<FoodEntry>>{};
|
||||||
|
for (final mapEntry in data.entries) {
|
||||||
|
final key = mapEntry.key;
|
||||||
|
final value = mapEntry.value;
|
||||||
|
if (key is! String || value is! List<dynamic>) continue;
|
||||||
|
result[key] = value
|
||||||
|
.whereType<Map<dynamic, dynamic>>()
|
||||||
|
.map((m) => FoodEntry.fromJson(m.cast<String, dynamic>()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the full log to disk, creating the parent directory if
|
||||||
|
/// needed, mirroring `_state._write_log`.
|
||||||
|
Future<void> writeLog(DayLog log) async {
|
||||||
|
await _file.parent.create(recursive: true);
|
||||||
|
final encoded = <String, Object?>{
|
||||||
|
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<FoodEntry> logMeal(
|
||||||
|
String desc,
|
||||||
|
Nutrition nutrition, {
|
||||||
|
int? slot,
|
||||||
|
List<MealComponent>? 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<FoodEntry?> 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<List<FoodEntry>> todayEntries() async {
|
||||||
|
final log = await readLog();
|
||||||
|
final entries = log[localDateKey(DateTime.now())] ?? const <FoodEntry>[];
|
||||||
|
return entries.where((e) => !e.deleted).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns today's total calories, mirrors `_state.today_total_kcal`.
|
||||||
|
Future<double> 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<Set<int>> loggedSlotsToday() async {
|
||||||
|
final entries = await todayEntries();
|
||||||
|
return entries
|
||||||
|
.where((e) => e.slot != null)
|
||||||
|
.map((e) => e.slot!)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/lib/widgets/autocomplete_suggestion_list.dart
Normal file
41
app/lib/widgets/autocomplete_suggestion_list.dart
Normal file
@ -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<FoodSuggestion> suggestions;
|
||||||
|
|
||||||
|
/// Called with the chosen suggestion when the user taps it.
|
||||||
|
final ValueChanged<FoodSuggestion> 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/lib/widgets/macro_input_row.dart
Normal file
124
app/lib/widgets/macro_input_row.dart
Normal file
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/lib/widgets/slot_status_bar.dart
Normal file
51
app/lib/widgets/slot_status_bar.dart
Normal file
@ -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<int> 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
397
app/pubspec.lock
Normal file
397
app/pubspec.lock
Normal file
@ -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"
|
||||||
23
app/pubspec.yaml
Normal file
23
app/pubspec.yaml
Normal file
@ -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
|
||||||
152
app/test/models/food_entry_test.dart
Normal file
152
app/test/models/food_entry_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
25
app/test/models/local_time_test.dart
Normal file
25
app/test/models/local_time_test.dart
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
60
app/test/models/meal_item_test.dart
Normal file
60
app/test/models/meal_item_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
139
app/test/models/nutrition_test.dart
Normal file
139
app/test/models/nutrition_test.dart
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
app/test/models/slot_test.dart
Normal file
86
app/test/models/slot_test.dart
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
155
app/test/screens/log_meal_screen_test.dart
Normal file
155
app/test/screens/log_meal_screen_test.dart
Normal file
@ -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<void> settle(WidgetTester tester) async {
|
||||||
|
await Future<void>.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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
99
app/test/screens/meal_builder_screen_test.dart
Normal file
99
app/test/screens/meal_builder_screen_test.dart
Normal file
@ -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<void> settle(WidgetTester tester) async {
|
||||||
|
await Future<void>.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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
181
app/test/services/foodbank_service_test.dart
Normal file
181
app/test/services/foodbank_service_test.dart
Normal file
@ -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<MealComponent>? 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
35
app/test/services/fuzzy_test.dart
Normal file
35
app/test/services/fuzzy_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
177
app/test/services/log_storage_service_test.dart
Normal file
177
app/test/services/log_storage_service_test.dart
Normal file
@ -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});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
35
app/test/widget_test.dart
Normal file
35
app/test/widget_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user