Compare commits

..

10 commits

Author SHA1 Message Date
John Mizerek
247388c61a Add Skip Forever, app icon, About screen links, release signing
- Add Skip/Skip Forever links to onboarding screens only
- Remove Skip links from questionnaire screens (baseline, behavioral, lifestyle)
- Add app icon (1024x1024) and generate all platform sizes
- Update About screen with correct URLs (privacy, terms, disclaimer, how it works)
- Add "No ads. Ever." section to About screen
- Configure Android release signing with upload keystore
- Add URL intent query for Android 11+ link launching
- Add skipForever preference to LocalStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 13:21:17 -08:00
John Mizerek
90ffe964b4 Add favicon and apple-touch-icon generated from logo
- favicon.png (32x32) for browser tabs
- apple-touch-icon.png (180x180) for iOS home screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 11:02:03 -08:00
John Mizerek
5f46a07194 Add Android beta button to hero section
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 10:56:26 -08:00
John Mizerek
1b2c8392e6 Update privacy bullet: No ads, ever
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 10:36:36 -08:00
John Mizerek
bbaa77558d Add WordPress theme in website/ folder
Custom dark minimal theme for addmonths.com landing page:
- Hero section with phone mockup placeholder
- How It Works, What It Is/Isn't, Privacy, Clarity sections
- iOS/Android beta signup with email capture modal
- Privacy, Terms, Disclaimer page templates
- Beta signups stored to wp_beta_signups table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 10:35:58 -08:00
John Mizerek
be131ea890 Merge branch 'master' of git.payfrit.com:payfrit/addmonths 2026-02-21 10:35:45 -08:00
John Mizerek
6b03e5b7eb Initial Add Months WordPress theme
- Dark minimal design (charcoal/slate/muted green)
- Hero section with phone mockup placeholder
- How It Works (3 steps)
- What It Is / Isn't sections
- Privacy section with pill badges
- Built for Clarity section
- Download section with iOS/Android beta badges
- Email capture modal for beta signups
- Privacy, Terms, Disclaimer page template
- Beta signups stored to wp_beta_signups table
- Admin panel for viewing/exporting signups

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 10:32:28 -08:00
John Mizerek
85e8e1a290 Add saved runs feature with history, comparison, and about screen (v1.2)
Features:
- Save calculation runs with custom labels for later retrieval
- View saved runs list with dominant factor summary
- View detailed results of any saved run
- View original inputs in read-only mode
- Use any saved run as starting point for new calculation
- Compare two saved runs side-by-side
- About screen with app info, help links, privacy policy
- Metric/Imperial unit toggle persisted across sessions
- Info icon in app bar on all screens

Technical:
- New SavedRun model with full serialization
- SQLite saved_runs table with CRUD operations
- readOnly mode for all input screens
- Unit preference stored in LocalStorage
- Database schema upgraded to v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 09:59:58 -08:00
John Mizerek
40275bcd0c Add lifestyle factors screen with diet, social, stress tracking
- Split behavioral inputs into two screens (Habits + Lifestyle)
- Added 5 new modifiable factors: diet quality, processed food,
  drug use, social connection, and stress level
- Updated hazard ratios for all new factors based on meta-analyses
- Model version bumped to 1.1
- Simplified welcome screen with clearer value proposition
- Updated tests for expanded behavioral model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 09:22:48 -08:00
John Mizerek
615e18d03a Improve onboarding with title and progress bar
- Added "Add Months" header with subtitle
- Numbered step indicators (1, 2, 3) with progress lines
- Checkmarks for completed steps
- "Step X of 3" badge on each slide

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 22:50:04 -08:00
65 changed files with 3907 additions and 302 deletions

2
.gitignore vendored
View file

@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
upload-keystore.jks
android/key.properties

View file

@ -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")
}
}
}

View file

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 MiB

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 B

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -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,
);

View file

@ -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 }

View file

@ -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
View 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,
);
}

View file

@ -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,

View file

@ -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;
}

View 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);
}
}
}

View file

@ -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> {
);
}
}
}

View file

@ -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,
),
);
}
),
);
}
}

View 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,
),
),
],
),
);
}
}

View 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,
),
),
);
}
}
}

View file

@ -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 {

View file

@ -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()),
);
}
}

View 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),
),
);
}
}

View 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')),
);
}
}
}

View file

@ -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';

View file

@ -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'),
),
],
),
);
}
}

View file

@ -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;
}
}

View file

@ -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"))
}

View file

@ -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:

View file

@ -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

View file

@ -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,
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
website/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

268
website/front-page.php Normal file
View 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">
&copy; <?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()">&times;</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
View 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
View file

@ -0,0 +1,6 @@
<?php
/**
* Fallback template - redirects to front page
*/
wp_redirect(home_url());
exit;

79
website/page.php Normal file
View 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">
&copy; <?php echo date('Y'); ?> Add Months
</div>
</div>
</div>
</footer>
<?php wp_footer(); ?>
</body>
</html>

600
website/style.css Normal file
View 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;
}
}