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:
Krzysztof kuhy Rudnicki 2026-06-22 18:22:42 +02:00
parent 888c877048
commit ee5a7660cb
53 changed files with 3568 additions and 0 deletions

45
app/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

View 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 = "../.."
}

View 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>

View 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>

View File

@ -0,0 +1,5 @@
package com.kuhy.diet_guard_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View 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:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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>

View 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>

View 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>

View 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)
}

View 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

View 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

View 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
View 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(),
);
}
}

View 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,
};
}

View 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,
);
}

View 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;
}

View 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);

View 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,
};
}

View 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,
);

View 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
View 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';

View 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!),
],
],
),
),
);
}
}

View 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!),
],
],
),
),
);
}
}

View 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();
}
}

View 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);
}

View 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();
}
}

View 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),
);
},
);
}
}

View 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,
),
);
}
}

View 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
View 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
View 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

View 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);
});
});
}

View 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');
});
});
}

View 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);
});
});
}

View 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');
});
});
}

View 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');
});
});
}

View 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);
});
},
);
}

View 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);
});
},
);
}

View 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');
});
});
}

View 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);
});
});
}

View 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
View 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);
});
}