Compare commits
10 commits
c92ecf5774
...
247388c61a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247388c61a | ||
|
|
90ffe964b4 | ||
|
|
5f46a07194 | ||
|
|
1b2c8392e6 | ||
|
|
bbaa77558d | ||
|
|
be131ea890 | ||
|
|
6b03e5b7eb | ||
|
|
85e8e1a290 | ||
|
|
40275bcd0c | ||
|
|
615e18d03a |
2
.gitignore
vendored
|
|
@ -43,3 +43,5 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
upload-keystore.jks
|
||||
android/key.properties
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
|
|
@ -5,6 +8,12 @@ plugins {
|
|||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.payfrit.add_months"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
|
@ -30,11 +39,18 @@ android {
|
|||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="ic_launcher_background">#2D2D2D</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 4 MiB After Width: | Height: | Size: 681 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 547 KiB |
|
Before Width: | Height: | Size: 456 B After Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 977 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export 'enums.dart';
|
|||
export 'user_profile.dart';
|
||||
export 'behavioral_inputs.dart';
|
||||
export 'result.dart';
|
||||
export 'saved_run.dart';
|
||||
|
|
|
|||
80
lib/models/saved_run.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'behavioral_inputs.dart';
|
||||
import 'result.dart';
|
||||
import 'user_profile.dart';
|
||||
|
||||
class SavedRun {
|
||||
final String id;
|
||||
final String label;
|
||||
final CalculationResult result;
|
||||
final UserProfile profile;
|
||||
final BehavioralInputs behaviors;
|
||||
final DateTime createdAt;
|
||||
|
||||
const SavedRun({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.result,
|
||||
required this.profile,
|
||||
required this.behaviors,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
String get displayDate {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(createdAt);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
return 'Today';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays} days ago';
|
||||
} else {
|
||||
return '${createdAt.month}/${createdAt.day}/${createdAt.year}';
|
||||
}
|
||||
}
|
||||
|
||||
String get dominantFactorSummary {
|
||||
final factor = result.dominantFactor;
|
||||
if (factor == null) return 'Optimal';
|
||||
return '${factor.displayName}: ${factor.delta.rangeDisplay} mo';
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'label': label,
|
||||
'result': result.toJson(),
|
||||
'profile': profile.toJson(),
|
||||
'behaviors': behaviors.toJson(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory SavedRun.fromJson(Map<String, dynamic> json) => SavedRun(
|
||||
id: json['id'] as String,
|
||||
label: json['label'] as String,
|
||||
result:
|
||||
CalculationResult.fromJson(json['result'] as Map<String, dynamic>),
|
||||
profile:
|
||||
UserProfile.fromJson(json['profile'] as Map<String, dynamic>),
|
||||
behaviors: BehavioralInputs.fromJson(
|
||||
json['behaviors'] as Map<String, dynamic>),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
SavedRun copyWith({
|
||||
String? id,
|
||||
String? label,
|
||||
CalculationResult? result,
|
||||
UserProfile? profile,
|
||||
BehavioralInputs? behaviors,
|
||||
DateTime? createdAt,
|
||||
}) =>
|
||||
SavedRun(
|
||||
id: id ?? this.id,
|
||||
label: label ?? this.label,
|
||||
result: result ?? this.result,
|
||||
profile: profile ?? this.profile,
|
||||
behaviors: behaviors ?? this.behaviors,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
230
lib/screens/about_screen.dart
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
static const String _howItWorksUrl = 'https://addmonths.com/#how-it-works';
|
||||
static const String _privacyUrl = 'https://addmonths.com/privacy/';
|
||||
static const String _termsUrl = 'https://addmonths.com/terms/';
|
||||
static const String _disclaimerUrl = 'https://addmonths.com/disclaimer/';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('About'),
|
||||
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: [
|
||||
// App name and version
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
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: 16),
|
||||
Text(
|
||||
'Add Months',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Version 1.2',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Description
|
||||
Text(
|
||||
'What is Add Months?',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add Months uses evidence-based hazard ratios from peer-reviewed '
|
||||
'meta-analyses to identify which single lifestyle change could '
|
||||
'have the biggest impact on your lifespan.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Answer simple questions about your demographics and habits, '
|
||||
'and the app calculates which modifiable factor offers the '
|
||||
'greatest potential benefit.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Privacy note
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Your data stays on your device',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'No accounts, no cloud sync, no analytics. '
|
||||
'Everything is stored locally.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// No ads promise
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.block,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'No ads. Ever.',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'No trackers, no monetization schemes. '
|
||||
'Just a simple tool that respects your time.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Links
|
||||
_buildLinkButton(
|
||||
context,
|
||||
icon: Icons.help_outline,
|
||||
label: 'How It Works',
|
||||
onTap: () => _launchUrl(_howItWorksUrl),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildLinkButton(
|
||||
context,
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
label: 'Privacy Policy',
|
||||
onTap: () => _launchUrl(_privacyUrl),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildLinkButton(
|
||||
context,
|
||||
icon: Icons.description_outlined,
|
||||
label: 'Terms of Service',
|
||||
onTap: () => _launchUrl(_termsUrl),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildLinkButton(
|
||||
context,
|
||||
icon: Icons.medical_information_outlined,
|
||||
label: 'Medical Disclaimer',
|
||||
onTap: () => _launchUrl(_disclaimerUrl),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkButton(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.divider),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(
|
||||
Icons.open_in_new,
|
||||
size: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,18 @@ import '../models/models.dart';
|
|||
import '../risk_engine/mortality_tables.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'behavioral_screen.dart';
|
||||
|
||||
class BaselineScreen extends StatefulWidget {
|
||||
const BaselineScreen({super.key});
|
||||
final bool readOnly;
|
||||
final UserProfile? initialProfile;
|
||||
|
||||
const BaselineScreen({
|
||||
super.key,
|
||||
this.readOnly = false,
|
||||
this.initialProfile,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BaselineScreen> createState() => _BaselineScreenState();
|
||||
|
|
@ -19,6 +27,7 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
double _heightCm = 170;
|
||||
double _weightKg = 70;
|
||||
final Set<Diagnosis> _diagnoses = {};
|
||||
bool _useMetric = false;
|
||||
|
||||
late List<String> _countries;
|
||||
|
||||
|
|
@ -26,24 +35,39 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_countries = getSupportedCountries();
|
||||
_loadExistingProfile();
|
||||
_loadInitialData();
|
||||
}
|
||||
|
||||
Future<void> _loadExistingProfile() async {
|
||||
Future<void> _loadInitialData() async {
|
||||
// Load unit preference
|
||||
final useMetric = await LocalStorage.getUseMetricUnits();
|
||||
setState(() => _useMetric = useMetric);
|
||||
|
||||
// If initial profile provided, use that
|
||||
if (widget.initialProfile != null) {
|
||||
_applyProfile(widget.initialProfile!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise load from storage
|
||||
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);
|
||||
});
|
||||
_applyProfile(profile);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyProfile(UserProfile profile) {
|
||||
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 {
|
||||
|
|
@ -59,11 +83,25 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Baseline'),
|
||||
title: Text(widget.readOnly ? 'Baseline (View Only)' : 'Baseline'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_useMetric ? Icons.straighten : Icons.square_foot),
|
||||
tooltip: _useMetric ? 'Using Metric' : 'Using Imperial',
|
||||
onPressed: widget.readOnly ? null : _toggleUnits,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
|
|
@ -129,14 +167,15 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
_buildDiagnosisCheckboxes(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Continue button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _continue,
|
||||
child: const Text('Continue'),
|
||||
// Continue button (hidden in readOnly mode)
|
||||
if (!widget.readOnly)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _continue,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
|
@ -162,7 +201,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: _age > 18 ? () => setState(() => _age--) : null,
|
||||
onPressed: widget.readOnly || _age <= 18
|
||||
? null
|
||||
: () => setState(() => _age--),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -173,7 +214,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _age < 100 ? () => setState(() => _age++) : null,
|
||||
onPressed: widget.readOnly || _age >= 100
|
||||
? null
|
||||
: () => setState(() => _age++),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -187,7 +230,7 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
child: _buildToggleButton(
|
||||
'Male',
|
||||
_sex == Sex.male,
|
||||
() => setState(() => _sex = Sex.male),
|
||||
widget.readOnly ? null : () => setState(() => _sex = Sex.male),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -195,14 +238,14 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
child: _buildToggleButton(
|
||||
'Female',
|
||||
_sex == Sex.female,
|
||||
() => setState(() => _sex = Sex.female),
|
||||
widget.readOnly ? null : () => setState(() => _sex = Sex.female),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToggleButton(String label, bool selected, VoidCallback onTap) {
|
||||
Widget _buildToggleButton(String label, bool selected, VoidCallback? onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
|
|
@ -242,26 +285,35 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
child: Text(country),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _country = value);
|
||||
},
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
if (value != null) setState(() => _country = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeightSlider() {
|
||||
final primaryText = _useMetric
|
||||
? '${_heightCm.round()} cm'
|
||||
: _cmToFeetInches(_heightCm);
|
||||
final secondaryText = _useMetric
|
||||
? _cmToFeetInches(_heightCm)
|
||||
: '${_heightCm.round()} cm';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${_heightCm.round()} cm',
|
||||
primaryText,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
_cmToFeetInches(_heightCm),
|
||||
secondaryText,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
|
|
@ -271,24 +323,30 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
min: 120,
|
||||
max: 220,
|
||||
divisions: 100,
|
||||
onChanged: (value) => setState(() => _heightCm = value),
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) => setState(() => _heightCm = value),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeightSlider() {
|
||||
final lbs = (_weightKg * 2.205).round();
|
||||
final primaryText = _useMetric ? '${_weightKg.round()} kg' : '$lbs lbs';
|
||||
final secondaryText = _useMetric ? '$lbs lbs' : '${_weightKg.round()} kg';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${_weightKg.round()} kg',
|
||||
primaryText,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
'${(_weightKg * 2.205).round()} lbs',
|
||||
secondaryText,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
|
|
@ -298,7 +356,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
min: 30,
|
||||
max: 200,
|
||||
divisions: 170,
|
||||
onChanged: (value) => setState(() => _weightKg = value),
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) => setState(() => _weightKg = value),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -357,15 +417,17 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
children: Diagnosis.values.map((diagnosis) {
|
||||
return CheckboxListTile(
|
||||
value: _diagnoses.contains(diagnosis),
|
||||
onChanged: (checked) {
|
||||
setState(() {
|
||||
if (checked == true) {
|
||||
_diagnoses.add(diagnosis);
|
||||
} else {
|
||||
_diagnoses.remove(diagnosis);
|
||||
}
|
||||
});
|
||||
},
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (checked) {
|
||||
setState(() {
|
||||
if (checked == true) {
|
||||
_diagnoses.add(diagnosis);
|
||||
} else {
|
||||
_diagnoses.remove(diagnosis);
|
||||
}
|
||||
});
|
||||
},
|
||||
title: Text(_getDiagnosisLabel(diagnosis)),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
@ -396,6 +458,12 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
return "$feet'$inches\"";
|
||||
}
|
||||
|
||||
Future<void> _toggleUnits() async {
|
||||
final newValue = !_useMetric;
|
||||
await LocalStorage.setUseMetricUnits(newValue);
|
||||
setState(() => _useMetric = newValue);
|
||||
}
|
||||
|
||||
void _continue() async {
|
||||
final profile = UserProfile(
|
||||
age: _age,
|
||||
|
|
@ -416,4 +484,5 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,20 @@ import 'package:flutter/services.dart';
|
|||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'results_screen.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'lifestyle_screen.dart';
|
||||
|
||||
class BehavioralScreen extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
final bool readOnly;
|
||||
final BehavioralInputs? initialBehaviors;
|
||||
|
||||
const BehavioralScreen({super.key, required this.profile});
|
||||
const BehavioralScreen({
|
||||
super.key,
|
||||
required this.profile,
|
||||
this.readOnly = false,
|
||||
this.initialBehaviors,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BehavioralScreen> createState() => _BehavioralScreenState();
|
||||
|
|
@ -21,40 +29,56 @@ 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() {
|
||||
super.initState();
|
||||
_loadExistingBehaviors();
|
||||
_loadInitialData();
|
||||
}
|
||||
|
||||
Future<void> _loadExistingBehaviors() async {
|
||||
Future<void> _loadInitialData() async {
|
||||
// If initial behaviors provided, use those
|
||||
if (widget.initialBehaviors != null) {
|
||||
_applyBehaviors(widget.initialBehaviors!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise load from storage
|
||||
final behaviors = await LocalStorage.getBehaviors();
|
||||
if (behaviors != null) {
|
||||
setState(() {
|
||||
_smoking = behaviors.smoking;
|
||||
_cigarettesPerDay = behaviors.cigarettesPerDay;
|
||||
_alcohol = behaviors.alcohol;
|
||||
_sleepHours = behaviors.sleepHours;
|
||||
_sleepConsistent = behaviors.sleepConsistent;
|
||||
_activity = behaviors.activity;
|
||||
_driving = behaviors.driving;
|
||||
_workHours = behaviors.workHours;
|
||||
});
|
||||
_applyBehaviors(behaviors);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBehaviors(BehavioralInputs behaviors) {
|
||||
setState(() {
|
||||
_smoking = behaviors.smoking;
|
||||
_cigarettesPerDay = behaviors.cigarettesPerDay;
|
||||
_alcohol = behaviors.alcohol;
|
||||
_sleepHours = behaviors.sleepHours;
|
||||
_sleepConsistent = behaviors.sleepConsistent;
|
||||
_activity = behaviors.activity;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Behaviors'),
|
||||
title: Text(widget.readOnly ? 'Habits (View Only)' : 'Habits'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
|
|
@ -62,12 +86,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,28 +124,17 @@ 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
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _calculate,
|
||||
child: const Text('Calculate'),
|
||||
// Continue button (hidden in readOnly mode)
|
||||
if (!widget.readOnly)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _continue,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
|
@ -144,7 +157,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
(SmokingStatus.former, 'Former'),
|
||||
(SmokingStatus.current, 'Current'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _smoking = value),
|
||||
onChanged: widget.readOnly ? null : (value) => setState(() => _smoking = value),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -165,13 +178,14 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
min: 1,
|
||||
max: 40,
|
||||
divisions: 39,
|
||||
onChanged: (value) {
|
||||
// Haptic feedback at 20 (one pack)
|
||||
if (value.round() == 20 && _cigarettesPerDay != 20) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
setState(() => _cigarettesPerDay = value.round());
|
||||
},
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
if (value.round() == 20 && _cigarettesPerDay != 20) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
setState(() => _cigarettesPerDay = value.round());
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
|
|
@ -204,7 +218,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
(AlcoholLevel.heavy, '15-21'),
|
||||
(AlcoholLevel.veryHeavy, '21+'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _alcohol = value),
|
||||
onChanged: widget.readOnly ? null : (value) => setState(() => _alcohol = value),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +243,9 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
min: 4,
|
||||
max: 12,
|
||||
divisions: 16,
|
||||
onChanged: (value) => setState(() => _sleepHours = value),
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) => setState(() => _sleepHours = value),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -245,7 +261,9 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
),
|
||||
Switch(
|
||||
value: _sleepConsistent,
|
||||
onChanged: (value) => setState(() => _sleepConsistent = value),
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) => setState(() => _sleepConsistent = value),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -260,40 +278,14 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
(ActivityLevel.moderate, 'Moderate'),
|
||||
(ActivityLevel.high, 'High'),
|
||||
],
|
||||
onChanged: (value) => setState(() => _activity = 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),
|
||||
onChanged: widget.readOnly ? null : (value) => setState(() => _activity = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedControl<T>({
|
||||
required T value,
|
||||
required List<(T, String)> options,
|
||||
required ValueChanged<T> onChanged,
|
||||
required ValueChanged<T>? onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -305,7 +297,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
|||
final isSelected = value == option.$1;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(option.$1),
|
||||
onTap: onChanged == null ? null : () => onChanged(option.$1),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -329,29 +321,20 @@ 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
350
lib/screens/compare_runs_screen.dart
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'about_screen.dart';
|
||||
|
||||
class CompareRunsScreen extends StatefulWidget {
|
||||
final SavedRun initialRun;
|
||||
|
||||
const CompareRunsScreen({super.key, required this.initialRun});
|
||||
|
||||
@override
|
||||
State<CompareRunsScreen> createState() => _CompareRunsScreenState();
|
||||
}
|
||||
|
||||
class _CompareRunsScreenState extends State<CompareRunsScreen> {
|
||||
List<SavedRun> _allRuns = [];
|
||||
SavedRun? _selectedRun;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRuns();
|
||||
}
|
||||
|
||||
Future<void> _loadRuns() async {
|
||||
final runs = await LocalStorage.getSavedRuns();
|
||||
// Exclude the initial run from selection options
|
||||
final otherRuns = runs.where((r) => r.id != widget.initialRun.id).toList();
|
||||
setState(() {
|
||||
_allRuns = otherRuns;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Compare Runs'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildContent(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_selectedRun == null) {
|
||||
return _buildRunSelector();
|
||||
}
|
||||
return _buildComparison();
|
||||
}
|
||||
|
||||
Widget _buildRunSelector() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Select a run to compare with',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Comparing: ${widget.initialRun.label}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _allRuns.length,
|
||||
itemBuilder: (context, index) {
|
||||
final run = _allRuns[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
title: Text(run.label),
|
||||
subtitle: Text(run.displayDate),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => setState(() => _selectedRun = run),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildComparison() {
|
||||
final runA = widget.initialRun;
|
||||
final runB = _selectedRun!;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with run labels
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildRunHeader(runA, 'Run A'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildRunHeader(runB, 'Run B'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Dominant factors comparison
|
||||
Text(
|
||||
'Dominant Challenge',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDominantComparison(runA, runB),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Profile changes
|
||||
Text(
|
||||
'Profile Changes',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildProfileComparison(runA.profile, runB.profile),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Change comparison button
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() => _selectedRun = null),
|
||||
child: const Text('Compare with different run'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunHeader(SavedRun run, String tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tag,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
run.label,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
run.displayDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDominantComparison(SavedRun runA, SavedRun runB) {
|
||||
final dominantA = runA.result.dominantFactor;
|
||||
final dominantB = runB.result.dominantFactor;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildCompactFactorCard(dominantA),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildCompactFactorCard(dominantB),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactFactorCard(RankedFactor? factor) {
|
||||
if (factor == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.success),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Optimal',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
factor.displayName,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${factor.delta.rangeDisplay} mo',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileComparison(UserProfile profileA, UserProfile profileB) {
|
||||
final changes = <Widget>[];
|
||||
|
||||
if (profileA.age != profileB.age) {
|
||||
changes.add(_buildChangeRow(
|
||||
'Age',
|
||||
'${profileA.age}',
|
||||
'${profileB.age}',
|
||||
));
|
||||
}
|
||||
|
||||
if (profileA.weightKg != profileB.weightKg) {
|
||||
changes.add(_buildChangeRow(
|
||||
'Weight',
|
||||
'${profileA.weightKg.round()} kg',
|
||||
'${profileB.weightKg.round()} kg',
|
||||
));
|
||||
}
|
||||
|
||||
if (profileA.country != profileB.country) {
|
||||
changes.add(_buildChangeRow(
|
||||
'Country',
|
||||
profileA.country,
|
||||
profileB.country,
|
||||
));
|
||||
}
|
||||
|
||||
if (changes.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No profile changes between runs',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(children: changes);
|
||||
}
|
||||
|
||||
Widget _buildChangeRow(String label, String valueA, String valueB) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
valueA,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_forward, size: 16, color: AppColors.textSecondary),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
valueB,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
365
lib/screens/lifestyle_screen.dart
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'about_screen.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;
|
||||
final bool readOnly;
|
||||
final BehavioralInputs? initialBehaviors;
|
||||
|
||||
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,
|
||||
this.readOnly = false,
|
||||
this.initialBehaviors,
|
||||
});
|
||||
|
||||
@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;
|
||||
bool _useMetric = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialData();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
// Load unit preference
|
||||
final useMetric = await LocalStorage.getUseMetricUnits();
|
||||
setState(() => _useMetric = useMetric);
|
||||
|
||||
// If initial behaviors provided, use those
|
||||
if (widget.initialBehaviors != null) {
|
||||
_applyBehaviors(widget.initialBehaviors!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise load from storage
|
||||
final behaviors = await LocalStorage.getBehaviors();
|
||||
if (behaviors != null) {
|
||||
_applyBehaviors(behaviors);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBehaviors(BehavioralInputs behaviors) {
|
||||
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: Text(widget.readOnly ? 'Lifestyle (View Only)' : 'Lifestyle'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(_useMetric ? 'Driving (km/week)' : 'Driving (mi/week)'),
|
||||
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 (hidden in readOnly mode)
|
||||
if (!widget.readOnly)
|
||||
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: widget.readOnly ? null : (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: widget.readOnly ? null : (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: widget.readOnly ? null : (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: widget.readOnly ? null : (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: widget.readOnly ? null : (value) => setState(() => _stress = value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrivingSelector() {
|
||||
// Show metric (km) or imperial (mi) based on preference
|
||||
final options = _useMetric
|
||||
? [
|
||||
(DrivingExposure.low, '<80 km'),
|
||||
(DrivingExposure.moderate, '80-240'),
|
||||
(DrivingExposure.high, '240-480'),
|
||||
(DrivingExposure.veryHigh, '480+'),
|
||||
]
|
||||
: [
|
||||
(DrivingExposure.low, '<50 mi'),
|
||||
(DrivingExposure.moderate, '50-150'),
|
||||
(DrivingExposure.high, '150-300'),
|
||||
(DrivingExposure.veryHigh, '300+'),
|
||||
];
|
||||
|
||||
return _buildSegmentedControl<DrivingExposure>(
|
||||
value: _driving,
|
||||
options: options,
|
||||
onChanged: widget.readOnly ? null : (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: widget.readOnly ? null : (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 == null ? null : () => 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'baseline_screen.dart';
|
||||
import 'welcome_screen.dart';
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
|
@ -43,28 +45,46 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 24, 32, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Add Months',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Evidence-based lifespan optimization',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Progress bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: _buildProgressBar(),
|
||||
),
|
||||
// Slides
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _slides.length,
|
||||
onPageChanged: (index) => setState(() => _currentPage = index),
|
||||
itemBuilder: (context, index) => _buildSlide(_slides[index]),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildSlide(index, _slides[index]),
|
||||
),
|
||||
),
|
||||
// Bottom section
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
padding: const EdgeInsets.fromLTRB(32, 0, 32, 32),
|
||||
child: Column(
|
||||
children: [
|
||||
// Page indicators
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
_slides.length,
|
||||
(index) => _buildDot(index),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
|
|
@ -75,17 +95,26 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Skip button (not on last page)
|
||||
if (_currentPage < _slides.length - 1)
|
||||
TextButton(
|
||||
onPressed: _skip,
|
||||
child: Text(
|
||||
'Skip',
|
||||
style: TextStyle(color: AppColors.textTertiary),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _skip,
|
||||
child: Text(
|
||||
'Skip',
|
||||
style: TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 40),
|
||||
const SizedBox(width: 24),
|
||||
TextButton(
|
||||
onPressed: _showSkipForeverConfirmation,
|
||||
child: Text(
|
||||
'Skip Forever',
|
||||
style: TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -95,32 +124,112 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSlide(_SlideData slide) {
|
||||
Widget _buildProgressBar() {
|
||||
return Row(
|
||||
children: List.generate(_slides.length, (index) {
|
||||
final isCompleted = index < _currentPage;
|
||||
final isCurrent = index == _currentPage;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: index < _slides.length - 1 ? 8 : 0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Step number row
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted || isCurrent
|
||||
? AppColors.primary
|
||||
: AppColors.divider,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: isCompleted
|
||||
? const Icon(Icons.check,
|
||||
size: 14, color: Colors.white)
|
||||
: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isCurrent
|
||||
? Colors.white
|
||||
: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? AppColors.primary
|
||||
: AppColors.divider,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSlide(int index, _SlideData slide) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Step indicator
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Step ${index + 1} of ${_slides.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Icon
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withAlpha(26),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
slide.icon,
|
||||
size: 56,
|
||||
size: 48,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
const SizedBox(height: 32),
|
||||
// Title
|
||||
Text(
|
||||
slide.title,
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
// Description
|
||||
Text(
|
||||
slide.description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
|
|
@ -133,20 +242,6 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildDot(int index) {
|
||||
final isActive = index == _currentPage;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: isActive ? 24 : 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColors.primary : AppColors.divider,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onButtonPressed() {
|
||||
if (_currentPage < _slides.length - 1) {
|
||||
_controller.nextPage(
|
||||
|
|
@ -167,6 +262,38 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||
MaterialPageRoute(builder: (_) => const BaselineScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSkipForeverConfirmation() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Skip Forever?'),
|
||||
content: const Text(
|
||||
'You won\'t be asked to complete the questionnaire again. '
|
||||
'You can still access the app from the About screen.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await LocalStorage.setSkipForever(true);
|
||||
Navigator.pop(dialogContext);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const WelcomeScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Skip Forever'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideData {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import '../models/models.dart';
|
|||
import '../risk_engine/calculator.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'onboarding_screen.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'baseline_screen.dart';
|
||||
import 'onboarding_screen.dart';
|
||||
import 'saved_runs_screen.dart';
|
||||
|
||||
class ResultsScreen extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
|
|
@ -43,6 +45,15 @@ class _ResultsScreenState extends State<ResultsScreen> {
|
|||
appBar: AppBar(
|
||||
title: const Text('Results'),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
|
|
@ -107,11 +118,26 @@ class _ResultsScreenState extends State<ResultsScreen> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saveRun,
|
||||
child: const Text('Save Run'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _recalculate,
|
||||
child: const Text('Recalculate'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: _viewSavedRuns,
|
||||
child: const Text('View Saved Runs'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
|
|
@ -391,4 +417,55 @@ class _ResultsScreenState extends State<ResultsScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveRun() {
|
||||
final controller = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Save Run'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter a label for this run',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final label = controller.text.trim();
|
||||
if (label.isNotEmpty) {
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await LocalStorage.saveSavedRun(
|
||||
label: label,
|
||||
result: _result,
|
||||
profile: widget.profile,
|
||||
behaviors: widget.behaviors,
|
||||
);
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Run saved')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _viewSavedRuns() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SavedRunsScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
495
lib/screens/saved_run_detail_screen.dart
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'baseline_screen.dart';
|
||||
import 'compare_runs_screen.dart';
|
||||
|
||||
class SavedRunDetailScreen extends StatefulWidget {
|
||||
final SavedRun savedRun;
|
||||
|
||||
const SavedRunDetailScreen({super.key, required this.savedRun});
|
||||
|
||||
@override
|
||||
State<SavedRunDetailScreen> createState() => _SavedRunDetailScreenState();
|
||||
}
|
||||
|
||||
class _SavedRunDetailScreenState extends State<SavedRunDetailScreen> {
|
||||
late SavedRun _savedRun;
|
||||
int _savedRunsCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_savedRun = widget.savedRun;
|
||||
_loadSavedRunsCount();
|
||||
}
|
||||
|
||||
Future<void> _loadSavedRunsCount() async {
|
||||
final count = await LocalStorage.getSavedRunsCount();
|
||||
setState(() => _savedRunsCount = count);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final result = _savedRun.result;
|
||||
final dominant = result.dominantFactor;
|
||||
final secondary = result.secondaryFactor;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Saved Run'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label (editable)
|
||||
_buildLabelRow(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_savedRun.displayDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (dominant != null) ...[
|
||||
// Dominant challenge card
|
||||
_buildDominantCard(dominant),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Secondary factor
|
||||
if (secondary != null) ...[
|
||||
Text(
|
||||
'Secondary Factor',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildSecondaryCard(secondary),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// All factors
|
||||
if (result.rankedFactors.length > 2) ...[
|
||||
Text(
|
||||
'All Factors',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...result.rankedFactors.skip(2).map((factor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildFactorRow(factor),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
] else ...[
|
||||
_buildOptimalCard(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Profile summary
|
||||
_buildProfileSummary(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Model version
|
||||
Center(
|
||||
child: Text(
|
||||
'Model v${result.modelVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Action buttons
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _viewInputs,
|
||||
child: const Text('View Inputs'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _useAsStartingPoint,
|
||||
child: const Text('Use as Starting Point'),
|
||||
),
|
||||
),
|
||||
if (_savedRunsCount >= 2) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _compare,
|
||||
child: const Text('Compare'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLabelRow() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_savedRun.label,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
onPressed: _editLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDominantCard(RankedFactor factor) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primary, AppColors.primaryDark],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'DOMINANT CHALLENGE',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white70,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
factor.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'ESTIMATED GAIN',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white60,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${factor.delta.rangeDisplay} months',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withAlpha(51),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_getConfidenceLabel(factor.delta.confidence),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSecondaryCard(RankedFactor factor) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.divider),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
factor.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${factor.delta.rangeDisplay} months',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildConfidenceBadge(factor.delta.confidence),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFactorRow(RankedFactor factor) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
factor.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'${factor.delta.rangeDisplay} mo',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptimalCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.success.withAlpha(77)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 48,
|
||||
color: AppColors.success,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No significant factors identified',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: AppColors.success,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Behaviors were near optimal at this time.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileSummary() {
|
||||
final profile = _savedRun.profile;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person_outline, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${profile.age} years old, ${profile.sex.name}, ${profile.country}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfidenceBadge(Confidence confidence) {
|
||||
Color color;
|
||||
switch (confidence) {
|
||||
case Confidence.high:
|
||||
color = AppColors.success;
|
||||
break;
|
||||
case Confidence.moderate:
|
||||
color = AppColors.warning;
|
||||
break;
|
||||
case Confidence.emerging:
|
||||
color = AppColors.textTertiary;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_getConfidenceLabel(confidence),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getConfidenceLabel(Confidence confidence) {
|
||||
switch (confidence) {
|
||||
case Confidence.high:
|
||||
return 'High';
|
||||
case Confidence.moderate:
|
||||
return 'Moderate';
|
||||
case Confidence.emerging:
|
||||
return 'Emerging';
|
||||
}
|
||||
}
|
||||
|
||||
void _editLabel() {
|
||||
final controller = TextEditingController(text: _savedRun.label);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Edit Label'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter a label',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final newLabel = controller.text.trim();
|
||||
final navigator = Navigator.of(context);
|
||||
if (newLabel.isNotEmpty) {
|
||||
await LocalStorage.updateSavedRunLabel(_savedRun.id, newLabel);
|
||||
setState(() {
|
||||
_savedRun = _savedRun.copyWith(label: newLabel);
|
||||
});
|
||||
}
|
||||
navigator.pop();
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _viewInputs() {
|
||||
// Navigate to read-only baseline screen with saved data
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BaselineScreen(
|
||||
readOnly: true,
|
||||
initialProfile: _savedRun.profile,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _useAsStartingPoint() {
|
||||
// Navigate to editable baseline screen with saved data pre-filled
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BaselineScreen(
|
||||
initialProfile: _savedRun.profile,
|
||||
),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
void _compare() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CompareRunsScreen(initialRun: _savedRun),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
196
lib/screens/saved_runs_screen.dart
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/models.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'saved_run_detail_screen.dart';
|
||||
|
||||
class SavedRunsScreen extends StatefulWidget {
|
||||
const SavedRunsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SavedRunsScreen> createState() => _SavedRunsScreenState();
|
||||
}
|
||||
|
||||
class _SavedRunsScreenState extends State<SavedRunsScreen> {
|
||||
List<SavedRun> _savedRuns = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSavedRuns();
|
||||
}
|
||||
|
||||
Future<void> _loadSavedRuns() async {
|
||||
final runs = await LocalStorage.getSavedRuns();
|
||||
setState(() {
|
||||
_savedRuns = runs;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Saved Runs'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _savedRuns.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildRunsList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_outline,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary.withAlpha(128),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No saved runs yet',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Save a calculation from the results screen to compare runs over time.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunsList() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _savedRuns.length,
|
||||
itemBuilder: (context, index) {
|
||||
final run = _savedRuns[index];
|
||||
return _buildRunCard(run);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunCard(SavedRun run) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _openRunDetail(run),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
run.label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
run.displayDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
run.dominantFactorSummary,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
color: AppColors.error,
|
||||
onPressed: () => _confirmDelete(run),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openRunDetail(SavedRun run) async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SavedRunDetailScreen(savedRun: run),
|
||||
),
|
||||
);
|
||||
// Refresh list in case label was edited
|
||||
_loadSavedRuns();
|
||||
}
|
||||
|
||||
void _confirmDelete(SavedRun run) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Run?'),
|
||||
content: Text('Delete "${run.label}"? This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_deleteRun(run);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteRun(SavedRun run) async {
|
||||
await LocalStorage.deleteSavedRun(run.id);
|
||||
_loadSavedRuns();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Run deleted')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
export 'onboarding_screen.dart';
|
||||
export 'welcome_screen.dart';
|
||||
export 'about_screen.dart';
|
||||
export 'baseline_screen.dart';
|
||||
export 'behavioral_screen.dart';
|
||||
export 'compare_runs_screen.dart';
|
||||
export 'lifestyle_screen.dart';
|
||||
export 'onboarding_screen.dart';
|
||||
export 'results_screen.dart';
|
||||
export 'saved_run_detail_screen.dart';
|
||||
export 'saved_runs_screen.dart';
|
||||
export 'welcome_screen.dart';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import '../theme.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'baseline_screen.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
|
|
@ -13,60 +15,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,
|
||||
|
|
@ -75,7 +61,26 @@ class WelcomeScreen extends StatelessWidget {
|
|||
child: const Text('Start'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 16),
|
||||
// Skip Forever and About links
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showSkipForeverConfirmation(context),
|
||||
child: const Text('Skip Forever'),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
),
|
||||
child: const Text('About'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -83,46 +88,43 @@ 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()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSkipForeverConfirmation(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Skip Forever?'),
|
||||
content: const Text(
|
||||
'You won\'t be asked to complete the questionnaire again. '
|
||||
'You can still access the app from the About screen.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await LocalStorage.setSkipForever(true);
|
||||
Navigator.pop(dialogContext);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Questionnaire skipped'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Skip Forever'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import 'dart:convert';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/models.dart';
|
||||
|
||||
class LocalStorage {
|
||||
static const _dbName = 'add_months.db';
|
||||
static const _tableName = 'user_data';
|
||||
static const _version = 1;
|
||||
static const _savedRunsTable = 'saved_runs';
|
||||
static const _version = 2;
|
||||
|
||||
static Database? _database;
|
||||
static const _uuid = Uuid();
|
||||
|
||||
static Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
|
|
@ -31,6 +34,30 @@ class LocalStorage {
|
|||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE $_savedRunsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
profile TEXT NOT NULL,
|
||||
behaviors TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute('''
|
||||
CREATE TABLE $_savedRunsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
profile TEXT NOT NULL,
|
||||
behaviors TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -101,10 +128,11 @@ class LocalStorage {
|
|||
return profile != null && behaviors != null;
|
||||
}
|
||||
|
||||
// Delete all data
|
||||
// Delete all data (including saved runs)
|
||||
static Future<void> deleteAllData() async {
|
||||
final db = await database;
|
||||
await db.delete(_tableName);
|
||||
await db.delete(_savedRunsTable);
|
||||
}
|
||||
|
||||
// Get last updated timestamp
|
||||
|
|
@ -122,4 +150,134 @@ class LocalStorage {
|
|||
results.first['updated_at'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Saved Runs operations
|
||||
// ============================================
|
||||
|
||||
static Future<String> saveSavedRun({
|
||||
required String label,
|
||||
required CalculationResult result,
|
||||
required UserProfile profile,
|
||||
required BehavioralInputs behaviors,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final id = _uuid.v4();
|
||||
final now = DateTime.now();
|
||||
|
||||
await db.insert(_savedRunsTable, {
|
||||
'id': id,
|
||||
'label': label,
|
||||
'result': jsonEncode(result.toJson()),
|
||||
'profile': jsonEncode(profile.toJson()),
|
||||
'behaviors': jsonEncode(behaviors.toJson()),
|
||||
'created_at': now.millisecondsSinceEpoch,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
static Future<List<SavedRun>> getSavedRuns() async {
|
||||
final db = await database;
|
||||
final results = await db.query(
|
||||
_savedRunsTable,
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
|
||||
return results.map((row) => SavedRun(
|
||||
id: row['id'] as String,
|
||||
label: row['label'] as String,
|
||||
result: CalculationResult.fromJson(
|
||||
jsonDecode(row['result'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
profile: UserProfile.fromJson(
|
||||
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
behaviors: BehavioralInputs.fromJson(
|
||||
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
|
||||
)).toList();
|
||||
}
|
||||
|
||||
static Future<SavedRun?> getSavedRun(String id) async {
|
||||
final db = await database;
|
||||
final results = await db.query(
|
||||
_savedRunsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
final row = results.first;
|
||||
return SavedRun(
|
||||
id: row['id'] as String,
|
||||
label: row['label'] as String,
|
||||
result: CalculationResult.fromJson(
|
||||
jsonDecode(row['result'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
profile: UserProfile.fromJson(
|
||||
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
behaviors: BehavioralInputs.fromJson(
|
||||
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
|
||||
),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> updateSavedRunLabel(String id, String newLabel) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_savedRunsTable,
|
||||
{'label': newLabel},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> deleteSavedRun(String id) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_savedRunsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> deleteAllSavedRuns() async {
|
||||
final db = await database;
|
||||
await db.delete(_savedRunsTable);
|
||||
}
|
||||
|
||||
static Future<int> getSavedRunsCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM $_savedRunsTable');
|
||||
return result.first['count'] as int;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Unit preference operations
|
||||
// ============================================
|
||||
|
||||
static Future<void> setUseMetricUnits(bool useMetric) async {
|
||||
await _put('useMetricUnits', {'value': useMetric});
|
||||
}
|
||||
|
||||
static Future<bool> getUseMetricUnits() async {
|
||||
final json = await _get('useMetricUnits');
|
||||
if (json == null) return false; // Default to imperial (US)
|
||||
return json['value'] as bool;
|
||||
}
|
||||
|
||||
static Future<void> setSkipForever(bool skip) async {
|
||||
await _put('skipForever', {'value': skip});
|
||||
}
|
||||
|
||||
static Future<bool> getSkipForever() async {
|
||||
final json = await _get('skipForever');
|
||||
if (json == null) return false;
|
||||
return json['value'] as bool;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import Foundation
|
|||
|
||||
import flutter_secure_storage_macos
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
88
pubspec.lock
|
|
@ -121,6 +121,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -525,6 +533,78 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -541,6 +621,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ dependencies:
|
|||
sqflite: ^2.3.0
|
||||
path: ^1.8.3
|
||||
flutter_secure_storage: ^9.0.0
|
||||
uuid: ^4.3.3
|
||||
url_launcher: ^6.2.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -46,8 +48,8 @@ flutter_launcher_icons:
|
|||
ios: true
|
||||
remove_alpha_ios: true
|
||||
image_path: "assets/icon/app_icon.png"
|
||||
adaptive_icon_background: "#FFFFFF"
|
||||
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
|
||||
adaptive_icon_background: "#2D2D2D"
|
||||
adaptive_icon_foreground: "assets/icon/app_icon.png"
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
BIN
website/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
website/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
268
website/front-page.php
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
<head>
|
||||
<meta charset="<?php bloginfo('charset'); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Add Months identifies the single change most likely to extend your lifespan. Private, local-first, no tracking.">
|
||||
<link rel="icon" type="image/png" href="<?php echo get_template_directory_uri(); ?>/assets/images/favicon.png">
|
||||
<link rel="apple-touch-icon" href="<?php echo get_template_directory_uri(); ?>/assets/images/apple-touch-icon.png">
|
||||
<?php wp_head(); ?>
|
||||
</head>
|
||||
<body <?php body_class(); ?>>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h1>Identify the single change most likely to extend your life.</h1>
|
||||
<p class="subheadline">Add Months analyzes your lifestyle and shows your Dominant Challenge — the factor with the greatest potential impact on your lifespan.</p>
|
||||
<div class="hero-ctas">
|
||||
<button class="btn btn-primary" onclick="openModal('ios')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||
</svg>
|
||||
Join iOS Beta
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openModal('android')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.44-.59-3.01-.9-4.47-.9s-3.03.31-4.47.9L5.65 5.67c-.19-.29-.51-.38-.79-.22-.3.16-.42.54-.26.85L6.4 9.48C3.3 11.25 1.28 14.44 1 18h22c-.28-3.56-2.3-6.75-5.4-8.52zM7 15.25c-.69 0-1.25-.56-1.25-1.25S6.31 12.75 7 12.75 8.25 13.31 8.25 14 7.69 15.25 7 15.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z"/>
|
||||
</svg>
|
||||
Join Android Beta
|
||||
</button>
|
||||
<a href="#how-it-works" class="btn btn-secondary">How It Works</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-mockup">
|
||||
<div class="phone-mockup" onclick="openModal('ios')">
|
||||
<div class="mockup-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
<div>App Screenshot</div>
|
||||
<div style="font-size: 0.75rem; margin-top: 4px;">Click to join beta</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section id="how-it-works" class="how-it-works">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2>How It Works</h2>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<h3>Enter your baseline.</h3>
|
||||
<p>Age, health status, and core lifestyle factors.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<h3>Analyze your exposures.</h3>
|
||||
<p>The app models conservative lifespan impacts using established population data.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<h3>See your Dominant Challenge.</h3>
|
||||
<p>The single change with the largest potential gain — right now.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What It Is -->
|
||||
<section class="what-it-is">
|
||||
<div class="container">
|
||||
<div class="is-column">
|
||||
<h2>Add Months is:</h2>
|
||||
<ul class="is-list">
|
||||
<li>A clarity tool.</li>
|
||||
<li>Private and local.</li>
|
||||
<li>Based on population-level risk modeling.</li>
|
||||
<li>Designed for re-evaluation over time.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="isnt-column">
|
||||
<h2>Add Months is not:</h2>
|
||||
<ul class="isnt-list">
|
||||
<li>Medical advice.</li>
|
||||
<li>A habit tracker.</li>
|
||||
<li>A social network.</li>
|
||||
<li>A coaching app.</li>
|
||||
<li>A data collection platform.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy -->
|
||||
<section class="privacy-section">
|
||||
<div class="container">
|
||||
<div class="privacy-content">
|
||||
<h2>Your data never leaves your device.</h2>
|
||||
<div class="privacy-bullets">
|
||||
<span class="privacy-bullet">No accounts required</span>
|
||||
<span class="privacy-bullet">No analytics</span>
|
||||
<span class="privacy-bullet">No ads, ever</span>
|
||||
<span class="privacy-bullet">Encrypted local storage</span>
|
||||
<span class="privacy-bullet">Hard delete = permanent</span>
|
||||
</div>
|
||||
<p class="privacy-statement">We cannot see your inputs. We do not collect or sell behavioral data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clarity -->
|
||||
<section class="clarity">
|
||||
<div class="container">
|
||||
<div class="clarity-content">
|
||||
<h2>Built for Clarity</h2>
|
||||
<p>Most health apps try to optimize everything. Add Months identifies the one lever that matters most — so you can focus your effort where it counts.</p>
|
||||
<p class="clarity-subtle">Re-run whenever your life changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Download -->
|
||||
<section class="download-section">
|
||||
<div class="container">
|
||||
<div class="download-content">
|
||||
<h2>Get Early Access</h2>
|
||||
<p>Join the beta to be among the first to try Add Months.</p>
|
||||
<div class="app-badges">
|
||||
<div class="app-badge" onclick="openModal('ios')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||
</svg>
|
||||
<div class="app-badge-text">
|
||||
<div class="app-badge-small">Join the</div>
|
||||
<div class="app-badge-big">iOS Beta</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-badge" onclick="openModal('android')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.44-.59-3.01-.9-4.47-.9s-3.03.31-4.47.9L5.65 5.67c-.19-.29-.51-.38-.79-.22-.3.16-.42.54-.26.85L6.4 9.48C3.3 11.25 1.28 14.44 1 18h22c-.28-3.56-2.3-6.75-5.4-8.52zM7 15.25c-.69 0-1.25-.56-1.25-1.25S6.31 12.75 7 12.75 8.25 13.31 8.25 14 7.69 15.25 7 15.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z"/>
|
||||
</svg>
|
||||
<div class="app-badge-text">
|
||||
<div class="app-badge-small">Join the</div>
|
||||
<div class="app-badge-big">Android Beta</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-links">
|
||||
<a href="<?php echo home_url('/privacy/'); ?>">Privacy</a>
|
||||
<a href="<?php echo home_url('/terms/'); ?>">Terms</a>
|
||||
<a href="<?php echo home_url('/disclaimer/'); ?>">Disclaimer</a>
|
||||
<a href="https://help.addmonths.com">Help</a>
|
||||
</div>
|
||||
<div class="footer-copyright">
|
||||
© <?php echo date('Y'); ?> Add Months
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Beta Signup Modal -->
|
||||
<div class="modal-overlay" id="signup-modal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
|
||||
<div class="signup-form" id="signup-form">
|
||||
<h2>Join the Beta</h2>
|
||||
<p>Enter your email to get early access when we launch.</p>
|
||||
|
||||
<form id="beta-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" required placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="platform">Platform</label>
|
||||
<select id="platform" name="platform" required>
|
||||
<option value="ios">iOS (TestFlight)</option>
|
||||
<option value="android">Android</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary form-submit">Join Beta</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-success" id="form-success">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<h3>You're on the list!</h3>
|
||||
<p>We'll email you when the beta is ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openModal(platform) {
|
||||
document.getElementById('platform').value = platform;
|
||||
document.getElementById('signup-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('signup-modal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('signup-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('beta-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var email = document.getElementById('email').value;
|
||||
var platform = document.getElementById('platform').value;
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('action', 'beta_signup');
|
||||
formData.append('email', email);
|
||||
formData.append('platform', platform);
|
||||
formData.append('nonce', '<?php echo wp_create_nonce("beta_signup_nonce"); ?>');
|
||||
|
||||
fetch('<?php echo admin_url("admin-ajax.php"); ?>', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('signup-form').classList.add('hidden');
|
||||
document.getElementById('form-success').classList.add('active');
|
||||
})
|
||||
.catch(function(error) {
|
||||
alert('Something went wrong. Please try again.');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php wp_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
194
website/functions.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
/**
|
||||
* Add Months Theme Functions
|
||||
*/
|
||||
|
||||
// Theme setup
|
||||
function addmonths_setup() {
|
||||
add_theme_support('title-tag');
|
||||
add_theme_support('html5', array('search-form', 'comment-form', 'comment-list', 'gallery', 'caption'));
|
||||
}
|
||||
add_action('after_setup_theme', 'addmonths_setup');
|
||||
|
||||
// Enqueue styles
|
||||
function addmonths_scripts() {
|
||||
wp_enqueue_style('addmonths-style', get_stylesheet_uri(), array(), '1.0.0');
|
||||
}
|
||||
add_action('wp_enqueue_scripts', 'addmonths_scripts');
|
||||
|
||||
// Remove WordPress extras for cleaner output
|
||||
remove_action('wp_head', 'wp_generator');
|
||||
remove_action('wp_head', 'wlwmanifest_link');
|
||||
remove_action('wp_head', 'rsd_link');
|
||||
remove_action('wp_head', 'wp_shortlink_wp_head');
|
||||
remove_action('wp_head', 'rest_output_link_wp_head');
|
||||
remove_action('wp_head', 'wp_oembed_add_discovery_links');
|
||||
remove_action('wp_head', 'print_emoji_detection_script', 7);
|
||||
remove_action('wp_print_styles', 'print_emoji_styles');
|
||||
|
||||
// Disable XML-RPC
|
||||
add_filter('xmlrpc_enabled', '__return_false');
|
||||
|
||||
// Create beta signups table on theme activation
|
||||
function addmonths_create_signups_table() {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'beta_signups';
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
email varchar(255) NOT NULL,
|
||||
platform varchar(20) NOT NULL,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY email (email)
|
||||
) $charset_collate;";
|
||||
|
||||
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||
dbDelta($sql);
|
||||
}
|
||||
add_action('after_switch_theme', 'addmonths_create_signups_table');
|
||||
|
||||
// Handle beta signup AJAX
|
||||
function addmonths_handle_signup() {
|
||||
// Verify nonce
|
||||
if (!wp_verify_nonce($_POST['nonce'], 'beta_signup_nonce')) {
|
||||
wp_send_json_error('Invalid request');
|
||||
return;
|
||||
}
|
||||
|
||||
$email = sanitize_email($_POST['email']);
|
||||
$platform = sanitize_text_field($_POST['platform']);
|
||||
|
||||
if (!is_email($email)) {
|
||||
wp_send_json_error('Invalid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($platform, array('ios', 'android'))) {
|
||||
wp_send_json_error('Invalid platform');
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'beta_signups';
|
||||
|
||||
// Check if email already exists
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM $table_name WHERE email = %s",
|
||||
$email
|
||||
));
|
||||
|
||||
if ($exists) {
|
||||
wp_send_json_success('Already signed up');
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert new signup
|
||||
$result = $wpdb->insert(
|
||||
$table_name,
|
||||
array(
|
||||
'email' => $email,
|
||||
'platform' => $platform
|
||||
),
|
||||
array('%s', '%s')
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
wp_send_json_success('Signed up successfully');
|
||||
} else {
|
||||
wp_send_json_error('Failed to sign up');
|
||||
}
|
||||
}
|
||||
add_action('wp_ajax_beta_signup', 'addmonths_handle_signup');
|
||||
add_action('wp_ajax_nopriv_beta_signup', 'addmonths_handle_signup');
|
||||
|
||||
// Admin page to view signups
|
||||
function addmonths_admin_menu() {
|
||||
add_menu_page(
|
||||
'Beta Signups',
|
||||
'Beta Signups',
|
||||
'manage_options',
|
||||
'beta-signups',
|
||||
'addmonths_signups_page',
|
||||
'dashicons-email',
|
||||
30
|
||||
);
|
||||
}
|
||||
add_action('admin_menu', 'addmonths_admin_menu');
|
||||
|
||||
function addmonths_signups_page() {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'beta_signups';
|
||||
|
||||
// Handle CSV export
|
||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||
$signups = $wpdb->get_results("SELECT * FROM $table_name ORDER BY created_at DESC");
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="beta-signups-' . date('Y-m-d') . '.csv"');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
fputcsv($output, array('Email', 'Platform', 'Signed Up'));
|
||||
|
||||
foreach ($signups as $signup) {
|
||||
fputcsv($output, array($signup->email, $signup->platform, $signup->created_at));
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
$signups = $wpdb->get_results("SELECT * FROM $table_name ORDER BY created_at DESC");
|
||||
$total = count($signups);
|
||||
$ios_count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE platform = 'ios'");
|
||||
$android_count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE platform = 'android'");
|
||||
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1>Beta Signups</h1>
|
||||
|
||||
<div style="margin: 20px 0; display: flex; gap: 20px;">
|
||||
<div style="background: #fff; padding: 20px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="margin: 0 0 5px;">Total</h3>
|
||||
<p style="font-size: 32px; margin: 0; font-weight: bold;"><?php echo $total; ?></p>
|
||||
</div>
|
||||
<div style="background: #fff; padding: 20px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="margin: 0 0 5px;">iOS</h3>
|
||||
<p style="font-size: 32px; margin: 0; font-weight: bold;"><?php echo $ios_count; ?></p>
|
||||
</div>
|
||||
<div style="background: #fff; padding: 20px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="margin: 0 0 5px;">Android</h3>
|
||||
<p style="font-size: 32px; margin: 0; font-weight: bold;"><?php echo $android_count; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p><a href="<?php echo admin_url('admin.php?page=beta-signups&export=csv'); ?>" class="button">Export CSV</a></p>
|
||||
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Platform</th>
|
||||
<th>Signed Up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($signups): ?>
|
||||
<?php foreach ($signups as $signup): ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($signup->email); ?></td>
|
||||
<td><?php echo esc_html(ucfirst($signup->platform)); ?></td>
|
||||
<td><?php echo esc_html($signup->created_at); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="3">No signups yet.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
6
website/index.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
/**
|
||||
* Fallback template - redirects to front page
|
||||
*/
|
||||
wp_redirect(home_url());
|
||||
exit;
|
||||
79
website/page.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
<head>
|
||||
<meta charset="<?php bloginfo('charset'); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="<?php echo get_template_directory_uri(); ?>/assets/images/favicon.png">
|
||||
<?php wp_head(); ?>
|
||||
<style>
|
||||
.page-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 120px 24px 80px;
|
||||
}
|
||||
.page-content h1 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.page-content h2 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.page-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-content ul {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.page-content li {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body <?php body_class(); ?>>
|
||||
<div class="page-content">
|
||||
<a href="<?php echo home_url(); ?>" class="back-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
|
||||
</svg>
|
||||
Back to Add Months
|
||||
</a>
|
||||
|
||||
<?php while (have_posts()) : the_post(); ?>
|
||||
<h1><?php the_title(); ?></h1>
|
||||
<?php the_content(); ?>
|
||||
<?php endwhile; ?>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-links">
|
||||
<a href="<?php echo home_url('/privacy/'); ?>">Privacy</a>
|
||||
<a href="<?php echo home_url('/terms/'); ?>">Terms</a>
|
||||
<a href="<?php echo home_url('/disclaimer/'); ?>">Disclaimer</a>
|
||||
<a href="https://help.addmonths.com">Help</a>
|
||||
</div>
|
||||
<div class="footer-copyright">
|
||||
© <?php echo date('Y'); ?> Add Months
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<?php wp_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
600
website/style.css
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
/*
|
||||
Theme Name: Add Months
|
||||
Theme URI: https://addmonths.com
|
||||
Author: Payfrit
|
||||
Author URI: https://payfrit.com
|
||||
Description: Minimal, dark theme for Add Months app landing page
|
||||
Version: 1.0.0
|
||||
License: GNU General Public License v2 or later
|
||||
License URI: http://www.gnu.org/licenses/gpl-2.0.html
|
||||
Text Domain: addmonths
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #666666;
|
||||
--accent: #4a7c59;
|
||||
--accent-hover: #5a9469;
|
||||
--border: #2a2a2a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 3vw, 2.25rem);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 120px 0 80px;
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-text h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-text .subheadline {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2.5rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #e0e0e0;
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-mockup {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.phone-mockup {
|
||||
width: 280px;
|
||||
height: 580px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 40px;
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.phone-mockup:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.phone-mockup::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 80px;
|
||||
height: 24px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mockup-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mockup-placeholder svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* How It Works */
|
||||
.how-it-works {
|
||||
padding: 120px 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.step {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.step h3 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step p {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* What It Is */
|
||||
.what-it-is {
|
||||
padding: 120px 0;
|
||||
}
|
||||
|
||||
.what-it-is .container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 80px;
|
||||
}
|
||||
|
||||
.is-list, .isnt-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.is-list li, .isnt-list li {
|
||||
padding: 12px 0;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.is-list li::before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.isnt-list li::before {
|
||||
content: "✕";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Privacy */
|
||||
.privacy-section {
|
||||
padding: 120px 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.privacy-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.privacy-bullets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.privacy-bullet {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px 24px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.privacy-statement {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Clarity */
|
||||
.clarity {
|
||||
padding: 120px 0;
|
||||
}
|
||||
|
||||
.clarity-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clarity-content p {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.clarity-subtle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Download */
|
||||
.download-section {
|
||||
padding: 120px 0;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
||||
}
|
||||
|
||||
.download-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-badges {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-top: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-badge {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-badge:hover {
|
||||
border-color: var(--text-secondary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-badge svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.app-badge-text {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-badge-small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.app-badge-big {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
padding: 40px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-success.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-success svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-success h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signup-form.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 968px) {
|
||||
.hero-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-text .subheadline {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-mockup {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.what-it-is .container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
padding: 80px 0 60px;
|
||||
}
|
||||
|
||||
.how-it-works,
|
||||
.what-it-is,
|
||||
.privacy-section,
|
||||
.clarity,
|
||||
.download-section {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.phone-mockup {
|
||||
width: 240px;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||