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>
419 lines
11 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|