Add lifestyle factors screen with diet, social, stress tracking
- Split behavioral inputs into two screens (Habits + Lifestyle) - Added 5 new modifiable factors: diet quality, processed food, drug use, social connection, and stress level - Updated hazard ratios for all new factors based on meta-analyses - Model version bumped to 1.1 - Simplified welcome screen with clearer value proposition - Updated tests for expanded behavioral model Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
615e18d03a
commit
40275bcd0c
9 changed files with 600 additions and 176 deletions
|
|
@ -1,12 +1,20 @@
|
|||
import 'enums.dart';
|
||||
|
||||
class BehavioralInputs {
|
||||
// Screen 1: Basic behaviors
|
||||
final SmokingStatus smoking;
|
||||
final int cigarettesPerDay; // only relevant if smoking == current
|
||||
final int cigarettesPerDay;
|
||||
final AlcoholLevel alcohol;
|
||||
final double sleepHours;
|
||||
final bool sleepConsistent;
|
||||
final ActivityLevel activity;
|
||||
|
||||
// Screen 2: Lifestyle factors
|
||||
final DietQuality diet;
|
||||
final ProcessedFoodLevel processedFood;
|
||||
final DrugUse drugUse;
|
||||
final SocialConnection social;
|
||||
final StressLevel stress;
|
||||
final DrivingExposure driving;
|
||||
final WorkHoursLevel workHours;
|
||||
|
||||
|
|
@ -17,6 +25,11 @@ class BehavioralInputs {
|
|||
required this.sleepHours,
|
||||
required this.sleepConsistent,
|
||||
required this.activity,
|
||||
required this.diet,
|
||||
required this.processedFood,
|
||||
required this.drugUse,
|
||||
required this.social,
|
||||
required this.stress,
|
||||
required this.driving,
|
||||
required this.workHours,
|
||||
});
|
||||
|
|
@ -28,10 +41,41 @@ class BehavioralInputs {
|
|||
sleepHours: 7.5,
|
||||
sleepConsistent: true,
|
||||
activity: ActivityLevel.high,
|
||||
diet: DietQuality.excellent,
|
||||
processedFood: ProcessedFoodLevel.rarely,
|
||||
drugUse: DrugUse.none,
|
||||
social: SocialConnection.strong,
|
||||
stress: StressLevel.low,
|
||||
driving: DrivingExposure.low,
|
||||
workHours: WorkHoursLevel.normal,
|
||||
);
|
||||
|
||||
/// Create partial inputs from screen 1 (with defaults for screen 2)
|
||||
factory BehavioralInputs.fromScreen1({
|
||||
required SmokingStatus smoking,
|
||||
int cigarettesPerDay = 0,
|
||||
required AlcoholLevel alcohol,
|
||||
required double sleepHours,
|
||||
required bool sleepConsistent,
|
||||
required ActivityLevel activity,
|
||||
}) {
|
||||
return BehavioralInputs(
|
||||
smoking: smoking,
|
||||
cigarettesPerDay: cigarettesPerDay,
|
||||
alcohol: alcohol,
|
||||
sleepHours: sleepHours,
|
||||
sleepConsistent: sleepConsistent,
|
||||
activity: activity,
|
||||
diet: DietQuality.fair,
|
||||
processedFood: ProcessedFoodLevel.frequent,
|
||||
drugUse: DrugUse.none,
|
||||
social: SocialConnection.moderate,
|
||||
stress: StressLevel.moderate,
|
||||
driving: DrivingExposure.low,
|
||||
workHours: WorkHoursLevel.normal,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'smoking': smoking.name,
|
||||
'cigarettesPerDay': cigarettesPerDay,
|
||||
|
|
@ -39,6 +83,11 @@ class BehavioralInputs {
|
|||
'sleepHours': sleepHours,
|
||||
'sleepConsistent': sleepConsistent,
|
||||
'activity': activity.name,
|
||||
'diet': diet.name,
|
||||
'processedFood': processedFood.name,
|
||||
'drugUse': drugUse.name,
|
||||
'social': social.name,
|
||||
'stress': stress.name,
|
||||
'driving': driving.name,
|
||||
'workHours': workHours.name,
|
||||
};
|
||||
|
|
@ -51,6 +100,14 @@ class BehavioralInputs {
|
|||
sleepHours: (json['sleepHours'] as num).toDouble(),
|
||||
sleepConsistent: json['sleepConsistent'] as bool,
|
||||
activity: ActivityLevel.values.byName(json['activity'] as String),
|
||||
diet: DietQuality.values.byName(json['diet'] as String? ?? 'fair'),
|
||||
processedFood: ProcessedFoodLevel.values
|
||||
.byName(json['processedFood'] as String? ?? 'frequent'),
|
||||
drugUse: DrugUse.values.byName(json['drugUse'] as String? ?? 'none'),
|
||||
social: SocialConnection.values
|
||||
.byName(json['social'] as String? ?? 'moderate'),
|
||||
stress:
|
||||
StressLevel.values.byName(json['stress'] as String? ?? 'moderate'),
|
||||
driving: DrivingExposure.values.byName(json['driving'] as String),
|
||||
workHours: WorkHoursLevel.values.byName(json['workHours'] as String),
|
||||
);
|
||||
|
|
@ -62,6 +119,11 @@ class BehavioralInputs {
|
|||
double? sleepHours,
|
||||
bool? sleepConsistent,
|
||||
ActivityLevel? activity,
|
||||
DietQuality? diet,
|
||||
ProcessedFoodLevel? processedFood,
|
||||
DrugUse? drugUse,
|
||||
SocialConnection? social,
|
||||
StressLevel? stress,
|
||||
DrivingExposure? driving,
|
||||
WorkHoursLevel? workHours,
|
||||
}) =>
|
||||
|
|
@ -72,6 +134,11 @@ class BehavioralInputs {
|
|||
sleepHours: sleepHours ?? this.sleepHours,
|
||||
sleepConsistent: sleepConsistent ?? this.sleepConsistent,
|
||||
activity: activity ?? this.activity,
|
||||
diet: diet ?? this.diet,
|
||||
processedFood: processedFood ?? this.processedFood,
|
||||
drugUse: drugUse ?? this.drugUse,
|
||||
social: social ?? this.social,
|
||||
stress: stress ?? this.stress,
|
||||
driving: driving ?? this.driving,
|
||||
workHours: workHours ?? this.workHours,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,3 +13,14 @@ enum WorkHoursLevel { normal, elevated, high, extreme }
|
|||
enum Diagnosis { cardiovascular, diabetes, cancer, copd, hypertension }
|
||||
|
||||
enum Confidence { high, moderate, emerging }
|
||||
|
||||
// Lifestyle factors
|
||||
enum DietQuality { poor, fair, good, excellent }
|
||||
|
||||
enum ProcessedFoodLevel { daily, frequent, occasional, rarely }
|
||||
|
||||
enum DrugUse { none, occasional, regular, daily }
|
||||
|
||||
enum SocialConnection { isolated, limited, moderate, strong }
|
||||
|
||||
enum StressLevel { low, moderate, high, chronic }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import '../models/models.dart';
|
|||
import 'hazard_ratios.dart';
|
||||
import 'mortality_tables.dart';
|
||||
|
||||
const String modelVersion = '1.0';
|
||||
const String modelVersion = '1.1';
|
||||
|
||||
/// Maximum combined hazard ratio (prevents unrealistic compounding).
|
||||
const double _maxCombinedHR = 4.0;
|
||||
|
|
@ -19,11 +19,19 @@ const double _highMultiplier = 1.2;
|
|||
double computeCombinedHazard(BehavioralInputs inputs, double bmi) {
|
||||
double hr = 1.0;
|
||||
|
||||
// Screen 1 factors
|
||||
hr *= getSmokingHR(inputs.smoking, inputs.cigarettesPerDay);
|
||||
hr *= getAlcoholHR(inputs.alcohol);
|
||||
hr *= getSleepHR(inputs.sleepHours, inputs.sleepConsistent);
|
||||
hr *= getActivityHR(inputs.activity);
|
||||
hr *= getBmiHR(bmi);
|
||||
|
||||
// Screen 2 factors
|
||||
hr *= getDietHR(inputs.diet);
|
||||
hr *= getProcessedFoodHR(inputs.processedFood);
|
||||
hr *= getDrugUseHR(inputs.drugUse);
|
||||
hr *= getSocialHR(inputs.social);
|
||||
hr *= getStressHR(inputs.stress);
|
||||
hr *= getDrivingHR(inputs.driving);
|
||||
hr *= getWorkHoursHR(inputs.workHours);
|
||||
|
||||
|
|
@ -38,7 +46,6 @@ LifespanDelta _computeDelta(
|
|||
String behaviorKey,
|
||||
) {
|
||||
if (currentHR <= modifiedHR) {
|
||||
// No improvement possible or already optimal
|
||||
return LifespanDelta(
|
||||
lowMonths: 0,
|
||||
highMonths: 0,
|
||||
|
|
@ -46,11 +53,9 @@ LifespanDelta _computeDelta(
|
|||
);
|
||||
}
|
||||
|
||||
// Delta years ≈ baselineYears × (1 - modifiedHR/currentHR) × dampingFactor
|
||||
final rawDeltaYears =
|
||||
baselineYears * (1 - modifiedHR / currentHR) * _dampingFactor;
|
||||
|
||||
// Convert to months with uncertainty range
|
||||
final midpointMonths = rawDeltaYears * 12;
|
||||
final lowMonths = (midpointMonths * _lowMultiplier).round();
|
||||
final highMonths = (midpointMonths * _highMultiplier).round();
|
||||
|
|
@ -76,6 +81,16 @@ BehavioralInputs _setToOptimal(BehavioralInputs inputs, String behaviorKey) {
|
|||
return inputs.copyWith(sleepHours: 7.5, sleepConsistent: true);
|
||||
case 'activity':
|
||||
return inputs.copyWith(activity: ActivityLevel.high);
|
||||
case 'diet':
|
||||
return inputs.copyWith(diet: DietQuality.excellent);
|
||||
case 'processedFood':
|
||||
return inputs.copyWith(processedFood: ProcessedFoodLevel.rarely);
|
||||
case 'drugUse':
|
||||
return inputs.copyWith(drugUse: DrugUse.none);
|
||||
case 'social':
|
||||
return inputs.copyWith(social: SocialConnection.strong);
|
||||
case 'stress':
|
||||
return inputs.copyWith(stress: StressLevel.low);
|
||||
case 'driving':
|
||||
return inputs.copyWith(driving: DrivingExposure.low);
|
||||
case 'workHours':
|
||||
|
|
@ -99,6 +114,16 @@ bool _isOptimal(BehavioralInputs inputs, String behaviorKey) {
|
|||
inputs.sleepConsistent;
|
||||
case 'activity':
|
||||
return inputs.activity == ActivityLevel.high;
|
||||
case 'diet':
|
||||
return inputs.diet == DietQuality.excellent;
|
||||
case 'processedFood':
|
||||
return inputs.processedFood == ProcessedFoodLevel.rarely;
|
||||
case 'drugUse':
|
||||
return inputs.drugUse == DrugUse.none;
|
||||
case 'social':
|
||||
return inputs.social == SocialConnection.strong;
|
||||
case 'stress':
|
||||
return inputs.stress == StressLevel.low;
|
||||
case 'driving':
|
||||
return inputs.driving == DrivingExposure.low;
|
||||
case 'workHours':
|
||||
|
|
@ -114,6 +139,11 @@ const _modifiableBehaviors = [
|
|||
'alcohol',
|
||||
'sleep',
|
||||
'activity',
|
||||
'diet',
|
||||
'processedFood',
|
||||
'drugUse',
|
||||
'social',
|
||||
'stress',
|
||||
'driving',
|
||||
'workHours',
|
||||
];
|
||||
|
|
@ -123,28 +153,22 @@ CalculationResult calculateRankedFactors(
|
|||
UserProfile profile,
|
||||
BehavioralInputs inputs,
|
||||
) {
|
||||
// Get baseline remaining life expectancy
|
||||
final baselineYears = getRemainingLifeExpectancy(
|
||||
profile.age,
|
||||
profile.sex,
|
||||
profile.country,
|
||||
);
|
||||
|
||||
// Apply existing condition modifiers (reduces baseline)
|
||||
final conditionHR = getDiagnosisHR(profile.diagnoses);
|
||||
final adjustedBaselineYears = baselineYears / conditionHR;
|
||||
|
||||
// Calculate current combined HR from behaviors
|
||||
final currentHR = computeCombinedHazard(inputs, profile.bmi);
|
||||
|
||||
// Calculate delta for each modifiable behavior
|
||||
final factors = <RankedFactor>[];
|
||||
|
||||
for (final behaviorKey in _modifiableBehaviors) {
|
||||
// Skip if already optimal
|
||||
if (_isOptimal(inputs, behaviorKey)) continue;
|
||||
|
||||
// Compute HR with this behavior set to optimal
|
||||
final modifiedInputs = _setToOptimal(inputs, behaviorKey);
|
||||
final modifiedHR = computeCombinedHazard(modifiedInputs, profile.bmi);
|
||||
|
||||
|
|
@ -155,7 +179,6 @@ CalculationResult calculateRankedFactors(
|
|||
behaviorKey,
|
||||
);
|
||||
|
||||
// Only include if there's meaningful gain (> 1 month)
|
||||
if (delta.highMonths >= 1) {
|
||||
factors.add(RankedFactor(
|
||||
behaviorKey: behaviorKey,
|
||||
|
|
@ -165,8 +188,8 @@ CalculationResult calculateRankedFactors(
|
|||
}
|
||||
}
|
||||
|
||||
// Sort by midpoint delta descending
|
||||
factors.sort((a, b) => b.delta.midpointMonths.compareTo(a.delta.midpointMonths));
|
||||
factors.sort(
|
||||
(a, b) => b.delta.midpointMonths.compareTo(a.delta.midpointMonths));
|
||||
|
||||
return CalculationResult(
|
||||
rankedFactors: factors,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ double getSleepHR(double hours, bool consistent) {
|
|||
hr = 1.10;
|
||||
}
|
||||
|
||||
// Inconsistent sleep schedule adds additional risk
|
||||
if (!consistent) {
|
||||
hr *= 1.05;
|
||||
}
|
||||
|
|
@ -70,8 +69,7 @@ double getBmiHR(double bmi) {
|
|||
if (bmi >= 30 && bmi < 35) return 1.2;
|
||||
if (bmi >= 35 && bmi < 40) return 1.4;
|
||||
if (bmi >= 40) return 1.8;
|
||||
// Underweight
|
||||
return 1.15;
|
||||
return 1.15; // Underweight
|
||||
}
|
||||
|
||||
double getDrivingHR(DrivingExposure level) {
|
||||
|
|
@ -100,6 +98,78 @@ double getWorkHoursHR(WorkHoursLevel level) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- New lifestyle factors ---
|
||||
|
||||
/// Diet quality - based on Mediterranean diet studies
|
||||
double getDietHR(DietQuality level) {
|
||||
switch (level) {
|
||||
case DietQuality.excellent:
|
||||
return 1.0;
|
||||
case DietQuality.good:
|
||||
return 1.05;
|
||||
case DietQuality.fair:
|
||||
return 1.15;
|
||||
case DietQuality.poor:
|
||||
return 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
/// Processed food consumption - ultra-processed food studies
|
||||
double getProcessedFoodHR(ProcessedFoodLevel level) {
|
||||
switch (level) {
|
||||
case ProcessedFoodLevel.rarely:
|
||||
return 1.0;
|
||||
case ProcessedFoodLevel.occasional:
|
||||
return 1.05;
|
||||
case ProcessedFoodLevel.frequent:
|
||||
return 1.12;
|
||||
case ProcessedFoodLevel.daily:
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drug use - excluding alcohol/tobacco (cannabis, recreational drugs)
|
||||
double getDrugUseHR(DrugUse level) {
|
||||
switch (level) {
|
||||
case DrugUse.none:
|
||||
return 1.0;
|
||||
case DrugUse.occasional:
|
||||
return 1.05;
|
||||
case DrugUse.regular:
|
||||
return 1.15;
|
||||
case DrugUse.daily:
|
||||
return 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
/// Social connection - loneliness/isolation meta-analyses
|
||||
double getSocialHR(SocialConnection level) {
|
||||
switch (level) {
|
||||
case SocialConnection.strong:
|
||||
return 1.0;
|
||||
case SocialConnection.moderate:
|
||||
return 1.05;
|
||||
case SocialConnection.limited:
|
||||
return 1.2;
|
||||
case SocialConnection.isolated:
|
||||
return 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
/// Chronic stress - based on allostatic load research
|
||||
double getStressHR(StressLevel level) {
|
||||
switch (level) {
|
||||
case StressLevel.low:
|
||||
return 1.0;
|
||||
case StressLevel.moderate:
|
||||
return 1.05;
|
||||
case StressLevel.high:
|
||||
return 1.15;
|
||||
case StressLevel.chronic:
|
||||
return 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
/// Existing conditions modify baseline mortality but are NOT modifiable.
|
||||
double getDiagnosisHR(Set<Diagnosis> diagnoses) {
|
||||
double hr = 1.0;
|
||||
|
|
@ -131,11 +201,16 @@ Confidence getConfidenceForBehavior(String behaviorKey) {
|
|||
case 'smoking':
|
||||
case 'alcohol':
|
||||
case 'activity':
|
||||
case 'social':
|
||||
return Confidence.high;
|
||||
case 'sleep':
|
||||
case 'workHours':
|
||||
case 'diet':
|
||||
case 'stress':
|
||||
return Confidence.moderate;
|
||||
case 'driving':
|
||||
case 'processedFood':
|
||||
case 'drugUse':
|
||||
return Confidence.emerging;
|
||||
default:
|
||||
return Confidence.moderate;
|
||||
|
|
@ -157,6 +232,16 @@ String getDisplayName(String behaviorKey) {
|
|||
return 'Driving Exposure';
|
||||
case 'workHours':
|
||||
return 'Work Hours';
|
||||
case 'diet':
|
||||
return 'Diet Quality';
|
||||
case 'processedFood':
|
||||
return 'Processed Food';
|
||||
case 'drugUse':
|
||||
return 'Drug Use';
|
||||
case 'social':
|
||||
return 'Social Connection';
|
||||
case 'stress':
|
||||
return 'Chronic Stress';
|
||||
default:
|
||||
return behaviorKey;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
|
|||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'results_screen.dart';
|
||||
import 'lifestyle_screen.dart';
|
||||
|
||||
class BehavioralScreen extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
|
|
@ -21,8 +21,6 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
double _sleepHours = 7.5;
|
||||
bool _sleepConsistent = true;
|
||||
ActivityLevel _activity = ActivityLevel.moderate;
|
||||
DrivingExposure _driving = DrivingExposure.low;
|
||||
WorkHoursLevel _workHours = WorkHoursLevel.normal;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -40,8 +38,6 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
_sleepHours = behaviors.sleepHours;
|
||||
_sleepConsistent = behaviors.sleepConsistent;
|
||||
_activity = behaviors.activity;
|
||||
_driving = behaviors.driving;
|
||||
_workHours = behaviors.workHours;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +46,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Behaviors'),
|
||||
title: const Text('Habits'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -62,12 +58,12 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Modifiable Factors',
|
||||
'Daily Habits',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'These behaviors can be changed. We\'ll identify which has the largest impact.',
|
||||
'Substances, sleep, and activity levels.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
|
@ -100,26 +96,14 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
_buildSectionLabel('Physical Activity'),
|
||||
const SizedBox(height: 12),
|
||||
_buildActivitySelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Driving Exposure
|
||||
_buildSectionLabel('Driving Exposure'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDrivingSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Work Hours
|
||||
_buildSectionLabel('Work Hours'),
|
||||
const SizedBox(height: 12),
|
||||
_buildWorkHoursSelector(),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Calculate button
|
||||
// Continue button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _calculate,
|
||||
child: const Text('Calculate'),
|
||||
onPressed: _continue,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
|
@ -166,7 +150,6 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
max: 40,
|
||||
divisions: 39,
|
||||
onChanged: (value) {
|
||||
// Haptic feedback at 20 (one pack)
|
||||
if (value.round() == 20 && _cigarettesPerDay != 20) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
|
|
@ -264,32 +247,6 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildDrivingSelector() {
|
||||
return _buildSegmentedControl<DrivingExposure>(
|
||||
value: _driving,
|
||||
options: [
|
||||
(DrivingExposure.low, '<50 mi/wk'),
|
||||
(DrivingExposure.moderate, '50-150'),
|
||||
(DrivingExposure.high, '150-300'),
|
||||
(DrivingExposure.veryHigh, '300+'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _driving = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkHoursSelector() {
|
||||
return _buildSegmentedControl<WorkHoursLevel>(
|
||||
value: _workHours,
|
||||
options: [
|
||||
(WorkHoursLevel.normal, '<40'),
|
||||
(WorkHoursLevel.elevated, '40-55'),
|
||||
(WorkHoursLevel.high, '55-70'),
|
||||
(WorkHoursLevel.extreme, '70+'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _workHours = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedControl<T>({
|
||||
required T value,
|
||||
required List<(T, String)> options,
|
||||
|
|
@ -329,29 +286,19 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
void _calculate() async {
|
||||
final behaviors = BehavioralInputs(
|
||||
smoking: _smoking,
|
||||
cigarettesPerDay: _cigarettesPerDay,
|
||||
alcohol: _alcohol,
|
||||
sleepHours: _sleepHours,
|
||||
sleepConsistent: _sleepConsistent,
|
||||
activity: _activity,
|
||||
driving: _driving,
|
||||
workHours: _workHours,
|
||||
);
|
||||
|
||||
await LocalStorage.saveBehaviors(behaviors);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ResultsScreen(
|
||||
profile: widget.profile,
|
||||
behaviors: behaviors,
|
||||
),
|
||||
void _continue() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LifestyleScreen(
|
||||
profile: widget.profile,
|
||||
smoking: _smoking,
|
||||
cigarettesPerDay: _cigarettesPerDay,
|
||||
alcohol: _alcohol,
|
||||
sleepHours: _sleepHours,
|
||||
sleepConsistent: _sleepConsistent,
|
||||
activity: _activity,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
323
lib/screens/lifestyle_screen.dart
Normal file
323
lib/screens/lifestyle_screen.dart
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'results_screen.dart';
|
||||
|
||||
class LifestyleScreen extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
final SmokingStatus smoking;
|
||||
final int cigarettesPerDay;
|
||||
final AlcoholLevel alcohol;
|
||||
final double sleepHours;
|
||||
final bool sleepConsistent;
|
||||
final ActivityLevel activity;
|
||||
|
||||
const LifestyleScreen({
|
||||
super.key,
|
||||
required this.profile,
|
||||
required this.smoking,
|
||||
required this.cigarettesPerDay,
|
||||
required this.alcohol,
|
||||
required this.sleepHours,
|
||||
required this.sleepConsistent,
|
||||
required this.activity,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LifestyleScreen> createState() => _LifestyleScreenState();
|
||||
}
|
||||
|
||||
class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||
DietQuality _diet = DietQuality.fair;
|
||||
ProcessedFoodLevel _processedFood = ProcessedFoodLevel.frequent;
|
||||
DrugUse _drugUse = DrugUse.none;
|
||||
SocialConnection _social = SocialConnection.moderate;
|
||||
StressLevel _stress = StressLevel.moderate;
|
||||
DrivingExposure _driving = DrivingExposure.low;
|
||||
WorkHoursLevel _workHours = WorkHoursLevel.normal;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExistingBehaviors();
|
||||
}
|
||||
|
||||
Future<void> _loadExistingBehaviors() async {
|
||||
final behaviors = await LocalStorage.getBehaviors();
|
||||
if (behaviors != null) {
|
||||
setState(() {
|
||||
_diet = behaviors.diet;
|
||||
_processedFood = behaviors.processedFood;
|
||||
_drugUse = behaviors.drugUse;
|
||||
_social = behaviors.social;
|
||||
_stress = behaviors.stress;
|
||||
_driving = behaviors.driving;
|
||||
_workHours = behaviors.workHours;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Lifestyle'),
|
||||
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(
|
||||
'Lifestyle Factors',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Diet, social life, stress, and daily exposures.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Diet Quality
|
||||
_buildSectionLabel('Diet Quality'),
|
||||
_buildSectionHint('Vegetables, whole foods, variety'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDietSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Processed Food
|
||||
_buildSectionLabel('Processed Food'),
|
||||
_buildSectionHint('Fast food, packaged snacks, sugary drinks'),
|
||||
const SizedBox(height: 12),
|
||||
_buildProcessedFoodSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Drug Use
|
||||
_buildSectionLabel('Recreational Drugs'),
|
||||
_buildSectionHint('Cannabis, stimulants, other substances'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDrugUseSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Social Connection
|
||||
_buildSectionLabel('Social Connection'),
|
||||
_buildSectionHint('Time with friends, family, community'),
|
||||
const SizedBox(height: 12),
|
||||
_buildSocialSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Stress Level
|
||||
_buildSectionLabel('Stress Level'),
|
||||
_buildSectionHint('Work pressure, anxiety, life demands'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStressSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Driving Exposure
|
||||
_buildSectionLabel('Driving Exposure'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDrivingSelector(),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Work Hours
|
||||
_buildSectionLabel('Work Hours'),
|
||||
const SizedBox(height: 12),
|
||||
_buildWorkHoursSelector(),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Calculate button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _calculate,
|
||||
child: const Text('Calculate'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionLabel(String label) {
|
||||
return Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHint(String hint) {
|
||||
return Text(
|
||||
hint,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDietSelector() {
|
||||
return _buildSegmentedControl<DietQuality>(
|
||||
value: _diet,
|
||||
options: [
|
||||
(DietQuality.poor, 'Poor'),
|
||||
(DietQuality.fair, 'Fair'),
|
||||
(DietQuality.good, 'Good'),
|
||||
(DietQuality.excellent, 'Excellent'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _diet = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessedFoodSelector() {
|
||||
return _buildSegmentedControl<ProcessedFoodLevel>(
|
||||
value: _processedFood,
|
||||
options: [
|
||||
(ProcessedFoodLevel.daily, 'Daily'),
|
||||
(ProcessedFoodLevel.frequent, 'Often'),
|
||||
(ProcessedFoodLevel.occasional, 'Sometimes'),
|
||||
(ProcessedFoodLevel.rarely, 'Rarely'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _processedFood = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrugUseSelector() {
|
||||
return _buildSegmentedControl<DrugUse>(
|
||||
value: _drugUse,
|
||||
options: [
|
||||
(DrugUse.none, 'None'),
|
||||
(DrugUse.occasional, 'Occasional'),
|
||||
(DrugUse.regular, 'Regular'),
|
||||
(DrugUse.daily, 'Daily'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _drugUse = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSocialSelector() {
|
||||
return _buildSegmentedControl<SocialConnection>(
|
||||
value: _social,
|
||||
options: [
|
||||
(SocialConnection.isolated, 'Isolated'),
|
||||
(SocialConnection.limited, 'Limited'),
|
||||
(SocialConnection.moderate, 'Moderate'),
|
||||
(SocialConnection.strong, 'Strong'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _social = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStressSelector() {
|
||||
return _buildSegmentedControl<StressLevel>(
|
||||
value: _stress,
|
||||
options: [
|
||||
(StressLevel.low, 'Low'),
|
||||
(StressLevel.moderate, 'Moderate'),
|
||||
(StressLevel.high, 'High'),
|
||||
(StressLevel.chronic, 'Chronic'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _stress = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrivingSelector() {
|
||||
return _buildSegmentedControl<DrivingExposure>(
|
||||
value: _driving,
|
||||
options: [
|
||||
(DrivingExposure.low, '<50 mi/wk'),
|
||||
(DrivingExposure.moderate, '50-150'),
|
||||
(DrivingExposure.high, '150-300'),
|
||||
(DrivingExposure.veryHigh, '300+'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _driving = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkHoursSelector() {
|
||||
return _buildSegmentedControl<WorkHoursLevel>(
|
||||
value: _workHours,
|
||||
options: [
|
||||
(WorkHoursLevel.normal, '<40'),
|
||||
(WorkHoursLevel.elevated, '40-55'),
|
||||
(WorkHoursLevel.high, '55-70'),
|
||||
(WorkHoursLevel.extreme, '70+'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _workHours = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedControl<T>({
|
||||
required T value,
|
||||
required List<(T, String)> options,
|
||||
required ValueChanged<T> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: options.map((option) {
|
||||
final isSelected = value == option.$1;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(option.$1),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
option.$2,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _calculate() async {
|
||||
final behaviors = BehavioralInputs(
|
||||
smoking: widget.smoking,
|
||||
cigarettesPerDay: widget.cigarettesPerDay,
|
||||
alcohol: widget.alcohol,
|
||||
sleepHours: widget.sleepHours,
|
||||
sleepConsistent: widget.sleepConsistent,
|
||||
activity: widget.activity,
|
||||
diet: _diet,
|
||||
processedFood: _processedFood,
|
||||
drugUse: _drugUse,
|
||||
social: _social,
|
||||
stress: _stress,
|
||||
driving: _driving,
|
||||
workHours: _workHours,
|
||||
);
|
||||
|
||||
await LocalStorage.saveBehaviors(behaviors);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ResultsScreen(
|
||||
profile: widget.profile,
|
||||
behaviors: behaviors,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@ export 'onboarding_screen.dart';
|
|||
export 'welcome_screen.dart';
|
||||
export 'baseline_screen.dart';
|
||||
export 'behavioral_screen.dart';
|
||||
export 'lifestyle_screen.dart';
|
||||
export 'results_screen.dart';
|
||||
|
|
|
|||
|
|
@ -13,60 +13,44 @@ class WelcomeScreen extends StatelessWidget {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
// Icon or logo area
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.timeline,
|
||||
size: 40,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Title
|
||||
const Spacer(flex: 1),
|
||||
// Main question - large and centered
|
||||
Text(
|
||||
'Add Months',
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Text(
|
||||
'Identify the single change most likely to extend your lifespan.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
'Simple questions.\nHonest answers.',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontSize: 32,
|
||||
height: 1.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// Features list
|
||||
_buildFeatureItem(
|
||||
context,
|
||||
Icons.shield_outlined,
|
||||
'Private',
|
||||
'All data stays on your device',
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
"What's the single biggest change I can make to live a longer, healthier life?",
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureItem(
|
||||
context,
|
||||
Icons.science_outlined,
|
||||
'Evidence-based',
|
||||
'Hazard ratios from meta-analyses',
|
||||
const Spacer(flex: 2),
|
||||
// Privacy note
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'All data stays on your device',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureItem(
|
||||
context,
|
||||
Icons.trending_up_outlined,
|
||||
'Actionable',
|
||||
'Focus on what matters most',
|
||||
),
|
||||
const Spacer(flex: 3),
|
||||
const SizedBox(height: 24),
|
||||
// Start button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
|
@ -83,43 +67,6 @@ class WelcomeScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureItem(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String title,
|
||||
String description,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: AppColors.primary, size: 22),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToBaseline(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const BaselineScreen()),
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ void main() {
|
|||
sleepHours: 5.0,
|
||||
sleepConsistent: false,
|
||||
activity: ActivityLevel.sedentary,
|
||||
diet: DietQuality.poor,
|
||||
processedFood: ProcessedFoodLevel.daily,
|
||||
drugUse: DrugUse.regular,
|
||||
social: SocialConnection.isolated,
|
||||
stress: StressLevel.chronic,
|
||||
driving: DrivingExposure.veryHigh,
|
||||
workHours: WorkHoursLevel.extreme,
|
||||
);
|
||||
|
|
@ -121,6 +126,11 @@ void main() {
|
|||
sleepHours: 7.5,
|
||||
sleepConsistent: true,
|
||||
activity: ActivityLevel.high,
|
||||
diet: DietQuality.excellent,
|
||||
processedFood: ProcessedFoodLevel.rarely,
|
||||
drugUse: DrugUse.none,
|
||||
social: SocialConnection.strong,
|
||||
stress: StressLevel.low,
|
||||
driving: DrivingExposure.veryHigh,
|
||||
workHours: WorkHoursLevel.normal,
|
||||
);
|
||||
|
|
@ -138,7 +148,7 @@ void main() {
|
|||
|
||||
test('result includes model version', () {
|
||||
final result = calculateRankedFactors(healthyProfile, unhealthyInputs);
|
||||
expect(result.modelVersion, equals('1.0'));
|
||||
expect(result.modelVersion, equals('1.1'));
|
||||
});
|
||||
|
||||
test('sedentary person sees activity as factor', () {
|
||||
|
|
@ -149,6 +159,11 @@ void main() {
|
|||
sleepHours: 7.5,
|
||||
sleepConsistent: true,
|
||||
activity: ActivityLevel.sedentary,
|
||||
diet: DietQuality.excellent,
|
||||
processedFood: ProcessedFoodLevel.rarely,
|
||||
drugUse: DrugUse.none,
|
||||
social: SocialConnection.strong,
|
||||
stress: StressLevel.low,
|
||||
driving: DrivingExposure.low,
|
||||
workHours: WorkHoursLevel.normal,
|
||||
);
|
||||
|
|
@ -205,6 +220,11 @@ void main() {
|
|||
sleepHours: 7.5,
|
||||
sleepConsistent: true,
|
||||
activity: ActivityLevel.sedentary,
|
||||
diet: DietQuality.excellent,
|
||||
processedFood: ProcessedFoodLevel.rarely,
|
||||
drugUse: DrugUse.none,
|
||||
social: SocialConnection.strong,
|
||||
stress: StressLevel.low,
|
||||
driving: DrivingExposure.low,
|
||||
workHours: WorkHoursLevel.normal,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue