app/lib/screens/baseline_screen.dart
John Mizerek 151106aa8e Initial commit: Add Months MVP
Local-first Flutter app that identifies the single behavioral change
most likely to extend lifespan using hazard-based modeling.

Features:
- Risk engine with hazard ratios from meta-analyses
- 50 countries mapped to 4 mortality groups
- 6 modifiable factors: smoking, alcohol, sleep, activity, driving, work hours
- SQLite local storage (no cloud, no accounts)
- Muted clinical UI theme
- 23 unit tests for risk engine

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 21:25:00 -08:00

419 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../models/models.dart';
import '../risk_engine/mortality_tables.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'behavioral_screen.dart';
class BaselineScreen extends StatefulWidget {
const BaselineScreen({super.key});
@override
State<BaselineScreen> createState() => _BaselineScreenState();
}
class _BaselineScreenState extends State<BaselineScreen> {
int _age = 35;
Sex _sex = Sex.male;
String _country = 'United States';
double _heightCm = 170;
double _weightKg = 70;
final Set<Diagnosis> _diagnoses = {};
late List<String> _countries;
@override
void initState() {
super.initState();
_countries = getSupportedCountries();
_loadExistingProfile();
}
Future<void> _loadExistingProfile() async {
final profile = await LocalStorage.getProfile();
if (profile != null) {
setState(() {
_age = profile.age;
_sex = profile.sex;
_country = profile.country;
_heightCm = profile.heightCm;
_weightKg = profile.weightKg;
_diagnoses.clear();
_diagnoses.addAll(profile.diagnoses);
});
}
}
double get _bmi => _weightKg / ((_heightCm / 100) * (_heightCm / 100));
String get _bmiCategory {
if (_bmi < 18.5) return 'Underweight';
if (_bmi < 25) return 'Normal';
if (_bmi < 30) return 'Overweight';
if (_bmi < 35) return 'Obese I';
if (_bmi < 40) return 'Obese II';
return 'Obese III';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Baseline'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Demographics',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'This information establishes your baseline life expectancy.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Age
_buildSectionLabel('Age'),
const SizedBox(height: 8),
_buildAgeSelector(),
const SizedBox(height: 24),
// Sex
_buildSectionLabel('Biological Sex'),
const SizedBox(height: 8),
_buildSexSelector(),
const SizedBox(height: 24),
// Country
_buildSectionLabel('Country'),
const SizedBox(height: 8),
_buildCountryDropdown(),
const SizedBox(height: 24),
// Height
_buildSectionLabel('Height'),
const SizedBox(height: 8),
_buildHeightSlider(),
const SizedBox(height: 24),
// Weight
_buildSectionLabel('Weight'),
const SizedBox(height: 8),
_buildWeightSlider(),
const SizedBox(height: 16),
// BMI display
_buildBmiDisplay(),
const SizedBox(height: 32),
// Existing conditions
Text(
'Existing Conditions',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Select any diagnosed conditions. These affect baseline calculations but are not modifiable factors.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
_buildDiagnosisCheckboxes(),
const SizedBox(height: 32),
// Continue button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _continue,
child: const Text('Continue'),
),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildSectionLabel(String label) {
return Text(
label,
style: Theme.of(context).textTheme.labelLarge,
);
}
Widget _buildAgeSelector() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: _age > 18 ? () => setState(() => _age--) : null,
),
Expanded(
child: Text(
'$_age years',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _age < 100 ? () => setState(() => _age++) : null,
),
],
),
);
}
Widget _buildSexSelector() {
return Row(
children: [
Expanded(
child: _buildToggleButton(
'Male',
_sex == Sex.male,
() => setState(() => _sex = Sex.male),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildToggleButton(
'Female',
_sex == Sex.female,
() => setState(() => _sex = Sex.female),
),
),
],
);
}
Widget _buildToggleButton(String label, bool selected, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: selected ? AppColors.primary : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColors.textSecondary,
),
),
),
);
}
Widget _buildCountryDropdown() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _country,
isExpanded: true,
icon: const Icon(Icons.keyboard_arrow_down),
items: _countries.map((country) {
return DropdownMenuItem(
value: country,
child: Text(country),
);
}).toList(),
onChanged: (value) {
if (value != null) setState(() => _country = value);
},
),
),
);
}
Widget _buildHeightSlider() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_heightCm.round()} cm',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
_cmToFeetInches(_heightCm),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Slider(
value: _heightCm,
min: 120,
max: 220,
divisions: 100,
onChanged: (value) => setState(() => _heightCm = value),
),
],
);
}
Widget _buildWeightSlider() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_weightKg.round()} kg',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'${(_weightKg * 2.205).round()} lbs',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Slider(
value: _weightKg,
min: 30,
max: 200,
divisions: 170,
onChanged: (value) => setState(() => _weightKg = value),
),
],
);
}
Widget _buildBmiDisplay() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'BMI',
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
_bmi.toStringAsFixed(1),
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getBmiColor(),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_bmiCategory,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Color _getBmiColor() {
if (_bmi < 18.5 || _bmi >= 30) return AppColors.warning;
if (_bmi >= 25) return AppColors.primary;
return AppColors.success;
}
Widget _buildDiagnosisCheckboxes() {
return Column(
children: Diagnosis.values.map((diagnosis) {
return CheckboxListTile(
value: _diagnoses.contains(diagnosis),
onChanged: (checked) {
setState(() {
if (checked == true) {
_diagnoses.add(diagnosis);
} else {
_diagnoses.remove(diagnosis);
}
});
},
title: Text(_getDiagnosisLabel(diagnosis)),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
);
}).toList(),
);
}
String _getDiagnosisLabel(Diagnosis diagnosis) {
switch (diagnosis) {
case Diagnosis.cardiovascular:
return 'Cardiovascular disease';
case Diagnosis.diabetes:
return 'Diabetes';
case Diagnosis.cancer:
return 'Cancer (active)';
case Diagnosis.copd:
return 'COPD';
case Diagnosis.hypertension:
return 'Hypertension';
}
}
String _cmToFeetInches(double cm) {
final totalInches = cm / 2.54;
final feet = (totalInches / 12).floor();
final inches = (totalInches % 12).round();
return "$feet'$inches\"";
}
void _continue() async {
final profile = UserProfile(
age: _age,
sex: _sex,
country: _country,
heightCm: _heightCm,
weightKg: _weightKg,
diagnoses: _diagnoses,
);
await LocalStorage.saveProfile(profile);
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BehavioralScreen(profile: profile),
),
);
}
}
}