# Design Document: Visceral Fat Tracking

## Overview

This feature adds visceral fat tracking to the Albi health tracker app by introducing waist circumference measurement entry, a user profile for static data (height, gender), risk assessment logic, and a dual-axis chart screen. The design follows existing patterns in the codebase: models extend `HealthData`, persistence goes through `DatabaseService` singleton, state management uses `flutter_bloc`, and charts use `fl_chart` via dedicated screen widgets.

The feature touches four layers:
1. **Data layer** — new `WaistEntry` model, `UserProfile` model, database migration v1→v2
2. **Logic layer** — `RiskAssessor` utility, waist unit conversion, `ChartDataService` extension
3. **State layer** — new BLoC events/states for waist entries and user profile
4. **UI layer** — `TrackVisceralFatScreen`, `WaistChartScreen`, navigation integration, shared tips widget

## Architecture

```mermaid
graph TD
    subgraph UI Layer
        A[TrackVisceralFatScreen]
        B[WaistChartScreen]
        C[HomeScreen - Quick Action]
        D[ChartsScreen - Navigation Card]
        E[VisceralFatTipsWidget]
    end

    subgraph State Layer
        F[HealthDataBloc]
    end

    subgraph Logic Layer
        G[RiskAssessor]
        H[WaistUnitConverter]
        I[ChartDataService]
    end

    subgraph Data Layer
        J[DatabaseService]
        K[WaistEntry Model]
        L[UserProfile Model]
    end

    A --> F
    A --> G
    A --> E
    B --> I
    B --> G
    B --> E
    C --> A
    D --> B
    F --> J
    I --> J
    G --> H
    J --> K
    J --> L
```

### Design Decisions

1. **Single-row `user_profile` table** — Height and gender are static profile data, not time-series. Using an upsert pattern (INSERT OR REPLACE) keeps the API simple and avoids multi-row complexity.

2. **Canonical storage in cm** — Same pattern as weight (canonical kg). Storing one canonical unit simplifies all comparisons, risk calculations, and chart aggregation. The `original_unit` column preserves the user's input context for display.

3. **Database migration v2 is additive-only** — Only CREATE TABLE statements, no ALTER or DROP on existing tables. This guarantees zero risk to existing data and straightforward rollback.

4. **Dual-axis chart** — Follows the BloodPressureChartScreen precedent (systolic/diastolic). Here waist circumference occupies the left axis and waist-to-height ratio the right axis, conditional on height being set.

5. **RiskAssessor as a pure utility class** — No state, no dependencies on Flutter. Takes measurements and profile data as parameters, returns classification results. This makes it trivially testable.

6. **Shared tips widget** — A single `VisceralFatTipsWidget` is reused on the entry screen (inline) and the chart screen (popup dialog), ensuring content consistency.

## Components and Interfaces

### 1. WaistEntry Model

```dart
/// Extends HealthData base class for waist circumference tracking
class WaistEntry extends HealthData {
  /// Waist circumference in centimetres (canonical storage unit)
  final double waistCm;
  
  /// The original unit the user entered ('cm', 'mm', 'inches')
  final String originalUnit;

  const WaistEntry({
    super.id,
    required super.timestamp,
    super.notes,
    required this.waistCm,
    required this.originalUnit,
  });

  /// Factory constructor from user input with unit conversion
  factory WaistEntry.fromUserInput({
    int? id,
    required double waistValue,
    required String unit,
    required DateTime timestamp,
    String? notes,
  });

  /// Factory constructor from database JSON
  factory WaistEntry.fromJson(Map<String, dynamic> json);

  @override
  Map<String, dynamic> toJson();

  @override
  WaistEntry copyWith({...});

  /// Get waist measurement in original unit for display
  double get waistInOriginalUnit;

  /// Validate the waist entry (30–300 cm range)
  bool get isValid;
}
```

### 2. UserProfile Model

```dart
/// Static user profile data (not time-series, not extending HealthData)
class UserProfile {
  final double? heightValue;
  final String? heightUnit; // 'cm' or 'inches'
  final String? gender; // 'male' or 'female'

  const UserProfile({
    this.heightValue,
    this.heightUnit,
    this.gender,
  });

  /// Height converted to cm (canonical)
  double? get heightCm;

  /// Whether height is available for ratio calculation
  bool get hasHeight;

  /// Whether gender is available for threshold assessment
  bool get hasGender;

  factory UserProfile.fromJson(Map<String, dynamic> json);
  Map<String, dynamic> toJson();
}
```

### 3. WaistUnitConverter (static utility)

```dart
class WaistUnitConverter {
  static const double mmToCm = 0.1;
  static const double inchesToCm = 2.54;
  static const double cmToMm = 10.0;
  static const double cmToInches = 1.0 / 2.54;

  /// Convert from user unit to canonical cm
  static double toCm(double value, String unit);

  /// Convert from canonical cm to display unit
  static double fromCm(double cm, String unit);

  /// Validate that the unit is supported ('cm', 'mm', 'inches')
  static bool isValidUnit(String unit);

  /// Validate that the canonical cm value is in valid range [30, 300]
  static bool isValidWaistCm(double cm);

  /// Format waist value for display (1 decimal place)
  static String formatWaist(double value, String unit);
}
```

### 4. RiskAssessor (pure utility)

```dart
enum WaistRiskLevel { normal, increasedRisk, cannotAssess }
enum RatioRiskLevel { healthy, increasedRisk, cannotAssess }

class WaistRiskResult {
  final WaistRiskLevel level;
  final String? reason; // null when assessed, explanatory text when cannotAssess
}

class RatioRiskResult {
  final RatioRiskLevel level;
  final double? ratio; // null when cannotAssess
  final String? reason;
}

class RiskAssessor {
  /// Assess waist circumference risk based on gender thresholds
  /// Male: >102 cm = increased risk
  /// Female: >88 cm = increased risk
  static WaistRiskResult assessWaistRisk({
    required double waistCm,
    required String? gender,
  });

  /// Calculate waist-to-height ratio and assess risk
  /// Ratio >= 0.5 = increased risk
  static RatioRiskResult assessWaistToHeightRatio({
    required double waistCm,
    required double? heightCm,
  });
}
```

### 5. DatabaseService Extensions

New methods added to the existing `DatabaseService` singleton:

```dart
// User Profile operations
Future<UserProfile?> getUserProfile();
Future<void> saveUserProfile(UserProfile profile);

// Waist Entry CRUD
Future<int> insertWaistEntry(WaistEntry entry);
Future<List<WaistEntry>> getWaistEntries({DateTime? startDate, DateTime? endDate, int? limit});
Future<WaistEntry?> getWaistEntry(int id);
Future<int> updateWaistEntry(WaistEntry entry);
Future<int> deleteWaistEntry(int id);
Future<WaistEntry?> getLatestWaistEntry();
Future<List<WaistEntry>> findDuplicateWaistEntries(WaistEntry entry);
```

### 6. ChartDataService Extension

New method for waist chart data:

```dart
Future<ChartData> getWaistChartData({
  required ChartTimePeriod timePeriod,
  required String displayUnit,
  bool includeTrend = true,
});
```

### 7. BLoC Events (new)

```dart
class AddWaistEntryEvent extends HealthDataEvent {
  final WaistEntry entry;
}

class LoadWaistEntriesEvent extends HealthDataEvent {
  final DateTime? startDate;
  final DateTime? endDate;
  final int? limit;
}

class SaveUserProfileEvent extends HealthDataEvent {
  final UserProfile profile;
}

class LoadUserProfileEvent extends HealthDataEvent {}
```

### 8. BLoC State Extension

The existing `HealthDataLoaded` state gains:
```dart
final WaistEntry? latestWaist;
final UserProfile? userProfile;
```

### 9. VisceralFatTipsWidget

A stateless widget displaying 6 tips + 1 disclaimer in fixed order. Used:
- Inline on `TrackVisceralFatScreen`
- Inside a dialog launched from `WaistChartScreen`

### 10. TrackVisceralFatScreen

Entry screen following existing patterns (`AddWeightScreen`, `AddBloodPressureScreen`):
- Numeric input for waist with unit selector (cm/mm/inches)
- Date/time picker (defaults to now)
- Optional notes field (max 500 chars)
- Conditional prompts for height/gender if not set
- Instructional note about measurement location
- Inline tips section
- Save button disabled until valid

### 11. WaistChartScreen

Chart screen following `BloodPressureChartScreen` pattern:
- `ChartDateSelector` for period selection
- Dual-axis line chart (waist left axis, ratio right axis when height available)
- Statistics card (average, count, change, trend, min, max, range)
- Tips button opening popup dialog
- Loading/error/empty states

## Data Models

### Database Schema (v2 Migration)

```sql
-- New table: user_profile (single-row, upsert pattern)
CREATE TABLE user_profile (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  height_value REAL,
  height_unit TEXT,
  gender TEXT
);

-- New table: waist_entries (time-series, like weight_entries)
CREATE TABLE waist_entries (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  timestamp TEXT NOT NULL,
  notes TEXT,
  waist_cm REAL NOT NULL,
  original_unit TEXT NOT NULL DEFAULT 'cm'
);

-- Index for waist entries
CREATE INDEX idx_waist_timestamp ON waist_entries(timestamp);
```

### Migration Strategy

```dart
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
  if (oldVersion < 2) {
    await db.transaction((txn) async {
      await txn.execute('''
        CREATE TABLE user_profile (
          id INTEGER PRIMARY KEY CHECK (id = 1),
          height_value REAL,
          height_unit TEXT,
          gender TEXT
        )
      ''');
      await txn.execute('''
        CREATE TABLE waist_entries (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          timestamp TEXT NOT NULL,
          notes TEXT,
          waist_cm REAL NOT NULL,
          original_unit TEXT NOT NULL DEFAULT 'cm'
        )
      ''');
      await txn.execute(
        'CREATE INDEX idx_waist_timestamp ON waist_entries(timestamp)'
      );
    });
  }
}
```

The `AppConstants.databaseVersion` changes from `1` to `2`.

On fresh install (v2 `onCreate`), all five tables are created together.

### Entity Relationship

```mermaid
erDiagram
    USER_PROFILE {
        int id PK "always 1"
        real height_value "nullable, cm or inches"
        text height_unit "nullable, 'cm' or 'inches'"
        text gender "nullable, 'male' or 'female'"
    }

    WAIST_ENTRIES {
        int id PK "autoincrement"
        text timestamp "ISO 8601, NOT NULL"
        text notes "nullable, max 500 chars"
        real waist_cm "NOT NULL, canonical cm"
        text original_unit "NOT NULL, 'cm'|'mm'|'inches'"
    }

    WEIGHT_ENTRIES {
        int id PK
        text timestamp
        text notes
        real weight_kg
        text original_unit
    }

    BLOOD_PRESSURE_ENTRIES {
        int id PK
        text timestamp
        text notes
        int systolic
        int diastolic
    }

    MOOD_ENTRIES {
        int id PK
        text timestamp
        text notes
        text mood
    }
```



## Correctness Properties

*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*

### Property 1: User Profile Save/Load Round-Trip

*For any* valid `UserProfile` (height value in range 50–275 cm equivalent, height unit in {cm, inches}, gender in {male, female, null}), saving the profile via `DatabaseService.saveUserProfile` and immediately retrieving it via `DatabaseService.getUserProfile` SHALL produce a profile with identical height value, height unit, and gender fields.

**Validates: Requirements 1.1, 1.4, 1.5**

### Property 2: Waist Entry Storage Round-Trip

*For any* valid `WaistEntry` (waist_cm in 30–300, original_unit in {cm, mm, inches}, timestamp not in future, notes ≤ 500 chars or null), inserting via `DatabaseService.insertWaistEntry` and retrieving by the returned ID SHALL produce a `WaistEntry` with identical waist_cm, original_unit, timestamp, and notes fields.

**Validates: Requirements 2.1, 2.3, 2.4, 2.5**

### Property 3: Waist Unit Conversion Round-Trip

*For any* valid waist measurement value (such that its cm equivalent is in [30, 300]) and any supported unit (cm, mm, inches), converting to canonical centimetres via `WaistUnitConverter.toCm` and then back to the original unit via `WaistUnitConverter.fromCm` SHALL produce a value within 0.01 of the original input.

**Validates: Requirements 3.1, 3.2, 3.3**

### Property 4: Invalid Unit Rejection

*For any* string that is not one of "cm", "mm", or "inches", calling `WaistUnitConverter.toCm` or `WaistEntry.fromUserInput` with that unit SHALL throw an error or return a validation failure, never producing a stored entry.

**Validates: Requirements 3.4**

### Property 5: Waist Risk Classification Correctness

*For any* waist measurement in cm (30–300) and any gender in {male, female}, the `RiskAssessor.assessWaistRisk` SHALL classify as "increased risk" if and only if the waist exceeds the gender-specific threshold (102 cm for male, 88 cm for female), and as "normal risk" otherwise.

**Validates: Requirements 4.1, 4.2, 4.3, 4.4**

### Property 6: Waist-to-Height Ratio Mathematical Correctness

*For any* waist value in [30, 300] cm and any height value in [50, 275] cm, the ratio computed by `RiskAssessor.assessWaistToHeightRatio` SHALL equal `waistCm / heightCm` rounded to 2 decimal places.

**Validates: Requirements 5.1, 5.6**

### Property 7: Ratio Risk Classification Correctness

*For any* waist value in [30, 300] cm and height value in [50, 275] cm, `RiskAssessor.assessWaistToHeightRatio` SHALL classify the result as "healthy" when the ratio is below 0.5, and as "increased risk" when the ratio is 0.5 or above.

**Validates: Requirements 5.2, 5.3**

### Property 8: Waist Range Validation Correctness

*For any* numeric waist input value and any supported unit (cm, mm, inches), the validation logic SHALL accept the entry if and only if the cm-equivalent value is within [30.0, 300.0], and reject it otherwise with an appropriate error message.

**Validates: Requirements 2.6, 11.1, 11.2**

### Property 9: Height Range Validation Correctness

*For any* numeric height input value and unit (cm or inches), the validation logic SHALL accept the value if and only if the cm-equivalent is within [50.0, 275.0], and reject it otherwise with an appropriate error message.

**Validates: Requirements 11.3, 11.4**

### Property 10: Future Timestamp Rejection

*For any* DateTime that is after `DateTime.now()`, the entry validation SHALL reject the timestamp and prevent saving.

**Validates: Requirements 11.5**

### Property 11: Non-Numeric Input Rejection

*For any* string that cannot be parsed as a valid numeric value (including empty string, whitespace-only, alphabetic, or special characters), the waist circumference input validation SHALL reject the input and display a validation error.

**Validates: Requirements 11.6**

### Property 12: Chart Statistics Mathematical Correctness

*For any* non-empty list of waist entries, the statistics computed by `ChartDataService` SHALL satisfy: average equals the arithmetic mean of all waist values, minimum equals the smallest value, maximum equals the largest value, range equals maximum minus minimum, count equals the list length, and change equals last value minus first value.

**Validates: Requirements 7.4**

### Property 13: Migration Data Preservation

*For any* set of existing weight, blood pressure, and mood entries in a version 1 database, running the v1→v2 migration SHALL preserve all entries such that row counts and all column values in weight_entries, blood_pressure_entries, and mood_entries are identical before and after migration.

**Validates: Requirements 9.1, 9.2**

## Error Handling

### Database Errors

| Error Scenario | Handling Strategy |
|---|---|
| Migration failure | Transaction rollback, database remains at v1, exception propagated to caller. User sees generic error with retry option. |
| Insert/update failure | Exception caught in BLoC, `HealthDataError.database` state emitted, UI shows error message with retry. Form data preserved. |
| Query failure (chart load) | Error state with retry button on chart screen. Previous data cleared. |
| Profile save failure | Error snackbar shown, prompt remains visible for user to retry. |

### Validation Errors

| Error Scenario | Handling Strategy |
|---|---|
| Waist out of range (30–300 cm) | Inline field error message showing acceptable range in current unit. Save button remains disabled. |
| Height out of range (50–275 cm) | Inline field error message. Save disabled for height field. |
| Invalid unit string | `ArgumentError` thrown from converter. BLoC catches and emits validation error state. Should not occur in normal UI flow (units are selected from dropdown). |
| Future timestamp | Inline error on date field. Save disabled. |
| Empty/non-numeric waist input | Inline field error. Save disabled. |
| Notes exceeding 500 chars | TextField maxLength enforces limit. No explicit error needed (input stops at 500). |

### Risk Assessment Edge Cases

| Scenario | Handling |
|---|---|
| Gender not set | `WaistRiskResult` with `cannotAssess` level and reason string. UI shows prompt to set gender. |
| Height not set | `RatioRiskResult` with `cannotAssess` level. Chart hides ratio axis. Entry screen shows prompt. |
| Both height and gender missing | Both assessments return `cannotAssess`. UI shows both prompts (dismissible). |

### State Recovery

- All form screens preserve entered data on error (validation or BLoC error state).
- Chart screens show error state with explicit retry button.
- BLoC automatically refreshes dashboard data after successful operations.

## Testing Strategy

### Unit Tests (Example-Based)

- **UserProfile model**: Serialization, null field handling, height conversion
- **WaistEntry model**: `fromJson`/`toJson`, `fromUserInput` factory, `isValid` property
- **RiskAssessor**: Boundary values (exactly 102, exactly 88, exactly 0.5 ratio), null gender/height
- **VisceralFatTipsWidget**: All 6 tips + disclaimer rendered in order
- **UI screens**: Widget tests for conditional prompts, button states, navigation
- **Database migration**: Fresh install creates all tables, migration preserves data

### Property-Based Tests (Universal Properties)

Property-based testing library: **`dart_quickcheck`** (or `glados` — a Dart PBT library supporting custom generators and shrinking).

Each property test runs a minimum of **100 iterations** with random inputs.

| Property | Test Tag |
|---|---|
| Property 1: Profile round-trip | `Feature: visceral-fat-tracking, Property 1: User Profile Save/Load Round-Trip` |
| Property 2: WaistEntry storage round-trip | `Feature: visceral-fat-tracking, Property 2: Waist Entry Storage Round-Trip` |
| Property 3: Unit conversion round-trip | `Feature: visceral-fat-tracking, Property 3: Waist Unit Conversion Round-Trip` |
| Property 4: Invalid unit rejection | `Feature: visceral-fat-tracking, Property 4: Invalid Unit Rejection` |
| Property 5: Waist risk classification | `Feature: visceral-fat-tracking, Property 5: Waist Risk Classification Correctness` |
| Property 6: Ratio mathematical correctness | `Feature: visceral-fat-tracking, Property 6: Waist-to-Height Ratio Mathematical Correctness` |
| Property 7: Ratio risk classification | `Feature: visceral-fat-tracking, Property 7: Ratio Risk Classification Correctness` |
| Property 8: Waist range validation | `Feature: visceral-fat-tracking, Property 8: Waist Range Validation Correctness` |
| Property 9: Height range validation | `Feature: visceral-fat-tracking, Property 9: Height Range Validation Correctness` |
| Property 10: Future timestamp rejection | `Feature: visceral-fat-tracking, Property 10: Future Timestamp Rejection` |
| Property 11: Non-numeric rejection | `Feature: visceral-fat-tracking, Property 11: Non-Numeric Input Rejection` |
| Property 12: Statistics correctness | `Feature: visceral-fat-tracking, Property 12: Chart Statistics Mathematical Correctness` |
| Property 13: Migration data preservation | `Feature: visceral-fat-tracking, Property 13: Migration Data Preservation` |

### Custom Generators

```dart
// Generator for valid waist values in cm
Generator<double> validWaistCm = doubles(min: 30.0, max: 300.0);

// Generator for valid height in cm
Generator<double> validHeightCm = doubles(min: 50.0, max: 275.0);

// Generator for waist units
Generator<String> waistUnit = oneOf(['cm', 'mm', 'inches']);

// Generator for height units
Generator<String> heightUnit = oneOf(['cm', 'inches']);

// Generator for gender
Generator<String?> gender = oneOf(['male', 'female', null]);

// Generator for valid UserProfile
Generator<UserProfile> validProfile = combine(validHeightCm, heightUnit, gender);

// Generator for valid WaistEntry
Generator<WaistEntry> validWaistEntry = combine(validWaistCm, waistUnit, pastTimestamp, optionalNotes);

// Generator for invalid units
Generator<String> invalidUnit = strings().where((s) => !['cm', 'mm', 'inches'].contains(s));

// Generator for out-of-range waist values (below minimum)
Generator<double> tooSmallWaist = doubles(min: 0.1, max: 29.99);

// Generator for out-of-range waist values (above maximum)  
Generator<double> tooLargeWaist = doubles(min: 300.01, max: 1000.0);
```

### Integration Tests

- End-to-end flow: enter waist measurement → save → verify in chart
- Database migration: v1 database with data → upgrade to v2 → verify all data intact
- BLoC integration: dispatch events → verify state transitions

### Test Coverage Goals

- Pure utility functions (RiskAssessor, WaistUnitConverter): 100% branch coverage via PBT
- Models (WaistEntry, UserProfile): 100% via PBT round-trip + unit tests for edge cases
- DatabaseService new methods: Integration tests with in-memory SQLite
- UI screens: Widget tests covering all conditional branches
- Chart data: PBT for statistics, example tests for empty/error states
