Merge branch 'master' of git.payfrit.com:payfrit/addmonths

This commit is contained in:
John Mizerek 2026-02-21 10:35:45 -08:00
commit be131ea890
152 changed files with 9566 additions and 0 deletions

45
.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

42
.metadata Normal file
View file

@ -0,0 +1,42 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: android
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: ios
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: linux
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: macos
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: web
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

277
CLAUDE.md Normal file
View file

@ -0,0 +1,277 @@
# Add Months
Local-first Flutter app that identifies the single behavioral change most likely to extend lifespan using hazard-based modeling.
## Quick Start
```bash
flutter pub get
flutter test test/risk_engine/ # 23 unit tests
flutter run # Debug mode
flutter run --release -d <device> # Release build
```
## Architecture
```
lib/
├── main.dart # App entry, routing logic
├── theme.dart # Muted clinical color palette
├── models/
│ ├── enums.dart # Sex, SmokingStatus, AlcoholLevel, etc.
│ ├── user_profile.dart # Age, sex, country, height, weight, diagnoses
│ ├── behavioral_inputs.dart # Modifiable behaviors
│ └── result.dart # LifespanDelta, RankedFactor, CalculationResult
├── risk_engine/
│ ├── hazard_ratios.dart # HR constants from meta-analyses
│ ├── mortality_tables.dart # 50 countries → 4 mortality groups
│ └── calculator.dart # Core ranking algorithm
├── screens/
│ ├── welcome_screen.dart # Onboarding
│ ├── baseline_screen.dart # Demographics, BMI, conditions
│ ├── behavioral_screen.dart # Modifiable factors input
│ └── results_screen.dart # Dominant challenge display
└── storage/
└── local_storage.dart # SQLite persistence
```
## Core Principles
1. **Local-first**: All data on device, no cloud, no accounts, no analytics
2. **Evidence-based**: Hazard ratios from peer-reviewed meta-analyses
3. **Privacy**: Delete All Data = full wipe including encryption keys
4. **Neutral tone**: "Exposure", "Factor", "Estimated gain" — no moral language
## Risk Engine
### Hazard Ratio Model
Combined HR = Smoking × Alcohol × Sleep × Activity × BMI × Driving × WorkHours
Capped at 4.0 to prevent unrealistic compounding.
### Key Hazard Ratios
| Factor | Level | HR |
|--------|-------|-----|
| Smoking | Never | 1.0 |
| | Former | 1.3 |
| | Current (<10/day) | 1.8 |
| | Current (10-20/day) | 2.2 |
| | Current (>20/day) | 2.8 |
| Alcohol | None/Light | 1.0 |
| | Moderate (8-14/wk) | 1.1 |
| | Heavy (15-21/wk) | 1.3 |
| | Very Heavy (21+/wk) | 1.6 |
| Sleep | 7-8 hrs | 1.0 |
| | 6-7 hrs | 1.05 |
| | <6 hrs | 1.15 |
| | >8 hrs | 1.10 |
| | + Inconsistent | ×1.05 |
| Activity | High | 1.0 |
| | Moderate | 1.05 |
| | Light | 1.15 |
| | Sedentary | 1.4 |
| BMI | 18.5-25 | 1.0 |
| | 25-30 | 1.1 |
| | 30-35 | 1.2 |
| | 35-40 | 1.4 |
| | 40+ | 1.8 |
| Driving | <50 mi/wk | 1.0 |
| | 50-150 | 1.02 |
| | 150-300 | 1.04 |
| | 300+ | 1.08 |
| Work Hours | <40 | 1.0 |
| | 40-55 | 1.05 |
| | 55-70 | 1.15 |
| | 70+ | 1.3 |
### Existing Conditions (Non-modifiable)
| Condition | HR Multiplier |
|-----------|---------------|
| Cardiovascular | 1.5 |
| Diabetes | 1.4 |
| Cancer (active) | 2.0 |
| COPD | 1.6 |
| Hypertension | 1.2 |
### Delta Calculation
```dart
// Simplified Gompertz-style approximation
rawDeltaYears = baselineYears × (1 - modifiedHR/currentHR) × 0.3
// Convert to months with uncertainty range
lowMonths = rawDeltaYears × 12 × 0.6
highMonths = rawDeltaYears × 12 × 1.4
```
### Ranking Algorithm
1. For each modifiable behavior:
- Compute HR with behavior set to optimal
- Calculate delta months gained
2. Sort by midpoint delta descending
3. Filter out behaviors already at optimal
4. Return ranked list with confidence levels
### Confidence Levels
| Factor | Confidence | Rationale |
|--------|------------|-----------|
| Smoking | High | Extremely well-documented |
| Alcohol (heavy) | High | Strong epidemiological data |
| Physical Activity | High | Large meta-analyses |
| BMI (extreme) | High | Well-established |
| Sleep | Moderate | Growing evidence, some confounding |
| Work Hours | Moderate | Decent studies, cultural variation |
| Driving | Emerging | Harder to isolate, regional variation |
## Mortality Tables
### Country Groups
| Group | LE at Birth (M) | Countries |
|-------|-----------------|-----------|
| A | 81 | Japan, Switzerland, Singapore, Spain, Italy, Australia, Iceland, Israel, Sweden, France, South Korea, Norway |
| B | 77 | USA, UK, Germany, Canada, Netherlands, Belgium, Austria, Finland, Ireland, New Zealand, Denmark, Portugal, Czech Republic, Poland, Chile, Costa Rica, Cuba, UAE, Qatar, Taiwan |
| C | 72 | China, Brazil, Mexico, Russia, Turkey, Argentina, Colombia, Thailand, Vietnam, Malaysia, Iran, Saudi Arabia, Egypt, Ukraine, Romania, Hungary, Peru, Philippines |
| D | 65 | India, Indonesia, South Africa, Pakistan, Bangladesh, Nigeria, Kenya, Ghana, Ethiopia, Myanmar, Nepal, Cambodia |
Female LE = Male LE + 4.5 years
### Remaining Life Expectancy
```dart
// Survivors have higher LE than birth cohort suggests
survivorBonus = currentAge × 0.15 // capped at 5
remainingLE = (leAtBirth - currentAge) + survivorBonus
```
## Storage
### SQLite Schema
```sql
CREATE TABLE user_data (
key TEXT PRIMARY KEY,
value TEXT NOT NULL, -- JSON
updated_at INTEGER NOT NULL
)
```
### Stored Keys
- `profile`: UserProfile JSON
- `behaviors`: BehavioralInputs JSON
- `lastResult`: CalculationResult JSON
### Delete All Data
```dart
await db.delete('user_data'); // Wipes all rows
```
## UI Theme
### Colors (Muted Clinical)
```dart
primary: #4A90A4 // Muted teal
primaryDark: #2D6073
primaryLight: #7BB8CC
surface: #F8FAFB
textPrimary: #1A2B33
textSecondary: #5A6B73
success: #4A9A7C
warning: #B8934A
error: #A45A5A
```
### Typography
- Headlines: SF Pro Display style, tight letter-spacing
- Body: 16px, 1.5 line height
- Labels: 600 weight
## Testing
```bash
# Run all risk engine tests
flutter test test/risk_engine/
# 23 tests covering:
# - Hazard ratios for each behavior
# - Mortality table lookups
# - Combined HR calculation
# - Ranking algorithm
# - Confidence assignments
# - Existing conditions impact
```
Widget tests require SQLite mocking — integration test on device.
## App Icon
Generated programmatically: muted teal tree on white background.
```bash
dart run tool/generate_icon.dart
dart run flutter_launcher_icons
```
## Model Versioning
```dart
const modelVersion = '1.0';
```
Stored with each calculation result. Future updates can show:
"Results updated under model v1.1"
## Screen Flow
```
Welcome → Baseline → Behavioral → Results
↑ ↓
└────── Recalculate ───┘
```
Results screen shows:
- Dominant Challenge (largest gain)
- Estimated Gain range (e.g., "36-60 months")
- Confidence level (High/Moderate/Emerging)
- Secondary factor
- All other factors (if any)
- Delete All Data button
## Key Design Decisions
1. **BMI is baseline only** — affects calculation but not shown as a "challenge"
2. **Cigarettes/day** — slider with haptic at 20 (one pack), max 40
3. **Country** — full dropdown (50 countries), mapped internally to groups
4. **No gamification** — no streaks, badges, or progress tracking
5. **No notifications** — user controls when to recalculate
## Dependencies
```yaml
dependencies:
sqflite: ^2.3.0 # Local database
path: ^1.8.3 # Path utilities
flutter_secure_storage: # Encryption key storage (future)
dev_dependencies:
flutter_launcher_icons: ^0.14.1
```
## Future Enhancements (Out of MVP Scope)
- Partner mode (compare two profiles)
- Export PDF summary
- Drug use factor
- Diet quality factor
- Stress/mental health factor
- Location-based mortality refinement
- Longitudinal tracking

16
README.md Normal file
View file

@ -0,0 +1,16 @@
# add_months
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
analysis_options.yaml Normal file
View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View file

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.payfrit.add_months"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.payfrit.add_months"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
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")
}
}
}
flutter {
source = "../.."
}

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View file

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="add_months"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,5 @@
package com.payfrit.add_months
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View file

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View file

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View file

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 MiB

34
ios/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View file

@ -0,0 +1 @@
#include "Generated.xcconfig"

View file

@ -0,0 +1 @@
#include "Generated.xcconfig"

View file

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.addMonths;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.addMonths.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.addMonths.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.addMonths.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.addMonths;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.addMonths;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View file

@ -0,0 +1 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View file

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

49
ios/Runner/Info.plist Normal file
View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Add Months</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>add_months</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View file

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

107
lib/main.dart Normal file
View file

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'screens/screens.dart';
import 'storage/local_storage.dart';
import 'theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize database
await LocalStorage.database;
// Set preferred orientations
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const AddMonthsApp());
}
class AddMonthsApp extends StatelessWidget {
const AddMonthsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Add Months',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
home: const AppRouter(),
);
}
}
class AppRouter extends StatefulWidget {
const AppRouter({super.key});
@override
State<AppRouter> createState() => _AppRouterState();
}
class _AppRouterState extends State<AppRouter> {
bool _loading = true;
bool _hasData = false;
@override
void initState() {
super.initState();
_checkExistingData();
}
Future<void> _checkExistingData() async {
final hasData = await LocalStorage.hasCompletedSetup();
setState(() {
_hasData = hasData;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
// If user has existing data, go straight to results
if (_hasData) {
return FutureBuilder(
future: _loadExistingData(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasData) {
final data = snapshot.data!;
return ResultsScreen(
profile: data.$1,
behaviors: data.$2,
);
}
return const OnboardingScreen();
},
);
}
return const OnboardingScreen();
}
Future<(dynamic, dynamic)?> _loadExistingData() async {
final profile = await LocalStorage.getProfile();
final behaviors = await LocalStorage.getBehaviors();
if (profile != null && behaviors != null) {
return (profile, behaviors);
}
return null;
}
}

View file

@ -0,0 +1,145 @@
import 'enums.dart';
class BehavioralInputs {
// Screen 1: Basic behaviors
final SmokingStatus smoking;
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;
const BehavioralInputs({
required this.smoking,
this.cigarettesPerDay = 0,
required this.alcohol,
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,
});
static const optimal = BehavioralInputs(
smoking: SmokingStatus.never,
cigarettesPerDay: 0,
alcohol: AlcoholLevel.none,
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,
'alcohol': alcohol.name,
'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,
};
factory BehavioralInputs.fromJson(Map<String, dynamic> json) =>
BehavioralInputs(
smoking: SmokingStatus.values.byName(json['smoking'] as String),
cigarettesPerDay: json['cigarettesPerDay'] as int? ?? 0,
alcohol: AlcoholLevel.values.byName(json['alcohol'] as String),
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),
);
BehavioralInputs copyWith({
SmokingStatus? smoking,
int? cigarettesPerDay,
AlcoholLevel? alcohol,
double? sleepHours,
bool? sleepConsistent,
ActivityLevel? activity,
DietQuality? diet,
ProcessedFoodLevel? processedFood,
DrugUse? drugUse,
SocialConnection? social,
StressLevel? stress,
DrivingExposure? driving,
WorkHoursLevel? workHours,
}) =>
BehavioralInputs(
smoking: smoking ?? this.smoking,
cigarettesPerDay: cigarettesPerDay ?? this.cigarettesPerDay,
alcohol: alcohol ?? this.alcohol,
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,
);
}

26
lib/models/enums.dart Normal file
View file

@ -0,0 +1,26 @@
enum Sex { male, female }
enum SmokingStatus { never, former, current }
enum AlcoholLevel { none, light, moderate, heavy, veryHeavy }
enum ActivityLevel { sedentary, light, moderate, high }
enum DrivingExposure { low, moderate, high, veryHigh }
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 }

5
lib/models/models.dart Normal file
View file

@ -0,0 +1,5 @@
export 'enums.dart';
export 'user_profile.dart';
export 'behavioral_inputs.dart';
export 'result.dart';
export 'saved_run.dart';

90
lib/models/result.dart Normal file
View file

@ -0,0 +1,90 @@
import 'enums.dart';
class LifespanDelta {
final int lowMonths;
final int highMonths;
final Confidence confidence;
const LifespanDelta({
required this.lowMonths,
required this.highMonths,
required this.confidence,
});
int get midpointMonths => ((lowMonths + highMonths) / 2).round();
String get rangeDisplay {
if (lowMonths <= 0 && highMonths <= 0) return '0';
if (lowMonths == highMonths) return '$lowMonths';
return '$lowMonths$highMonths';
}
Map<String, dynamic> toJson() => {
'lowMonths': lowMonths,
'highMonths': highMonths,
'confidence': confidence.name,
};
factory LifespanDelta.fromJson(Map<String, dynamic> json) => LifespanDelta(
lowMonths: json['lowMonths'] as int,
highMonths: json['highMonths'] as int,
confidence: Confidence.values.byName(json['confidence'] as String),
);
}
class RankedFactor {
final String behaviorKey;
final String displayName;
final LifespanDelta delta;
const RankedFactor({
required this.behaviorKey,
required this.displayName,
required this.delta,
});
Map<String, dynamic> toJson() => {
'behaviorKey': behaviorKey,
'displayName': displayName,
'delta': delta.toJson(),
};
factory RankedFactor.fromJson(Map<String, dynamic> json) => RankedFactor(
behaviorKey: json['behaviorKey'] as String,
displayName: json['displayName'] as String,
delta: LifespanDelta.fromJson(json['delta'] as Map<String, dynamic>),
);
}
class CalculationResult {
final List<RankedFactor> rankedFactors;
final String modelVersion;
final DateTime calculatedAt;
const CalculationResult({
required this.rankedFactors,
required this.modelVersion,
required this.calculatedAt,
});
RankedFactor? get dominantFactor =>
rankedFactors.isNotEmpty ? rankedFactors.first : null;
RankedFactor? get secondaryFactor =>
rankedFactors.length > 1 ? rankedFactors[1] : null;
Map<String, dynamic> toJson() => {
'rankedFactors': rankedFactors.map((f) => f.toJson()).toList(),
'modelVersion': modelVersion,
'calculatedAt': calculatedAt.toIso8601String(),
};
factory CalculationResult.fromJson(Map<String, dynamic> json) =>
CalculationResult(
rankedFactors: (json['rankedFactors'] as List<dynamic>)
.map((f) => RankedFactor.fromJson(f as Map<String, dynamic>))
.toList(),
modelVersion: json['modelVersion'] as String,
calculatedAt: DateTime.parse(json['calculatedAt'] as String),
);
}

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

@ -0,0 +1,58 @@
import 'enums.dart';
class UserProfile {
final int age;
final Sex sex;
final String country;
final double heightCm;
final double weightKg;
final Set<Diagnosis> diagnoses;
const UserProfile({
required this.age,
required this.sex,
required this.country,
required this.heightCm,
required this.weightKg,
this.diagnoses = const {},
});
double get bmi => weightKg / ((heightCm / 100) * (heightCm / 100));
Map<String, dynamic> toJson() => {
'age': age,
'sex': sex.name,
'country': country,
'heightCm': heightCm,
'weightKg': weightKg,
'diagnoses': diagnoses.map((d) => d.name).toList(),
};
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
age: json['age'] as int,
sex: Sex.values.byName(json['sex'] as String),
country: json['country'] as String,
heightCm: (json['heightCm'] as num).toDouble(),
weightKg: (json['weightKg'] as num).toDouble(),
diagnoses: (json['diagnoses'] as List<dynamic>)
.map((d) => Diagnosis.values.byName(d as String))
.toSet(),
);
UserProfile copyWith({
int? age,
Sex? sex,
String? country,
double? heightCm,
double? weightKg,
Set<Diagnosis>? diagnoses,
}) =>
UserProfile(
age: age ?? this.age,
sex: sex ?? this.sex,
country: country ?? this.country,
heightCm: heightCm ?? this.heightCm,
weightKg: weightKg ?? this.weightKg,
diagnoses: diagnoses ?? this.diagnoses,
);
}

View file

@ -0,0 +1,199 @@
import 'dart:math';
import '../models/models.dart';
import 'hazard_ratios.dart';
import 'mortality_tables.dart';
const String modelVersion = '1.1';
/// Maximum combined hazard ratio (prevents unrealistic compounding).
const double _maxCombinedHR = 4.0;
/// Damping factor for delta calculation (conservative estimate).
const double _dampingFactor = 0.3;
/// Uncertainty range multipliers (±20% around midpoint).
const double _lowMultiplier = 0.8;
const double _highMultiplier = 1.2;
/// Calculate combined hazard ratio from behavioral inputs.
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);
return min(hr, _maxCombinedHR);
}
/// Calculate lifespan delta when modifying a behavior to optimal.
LifespanDelta _computeDelta(
double baselineYears,
double currentHR,
double modifiedHR,
String behaviorKey,
) {
if (currentHR <= modifiedHR) {
return LifespanDelta(
lowMonths: 0,
highMonths: 0,
confidence: getConfidenceForBehavior(behaviorKey),
);
}
final rawDeltaYears =
baselineYears * (1 - modifiedHR / currentHR) * _dampingFactor;
final midpointMonths = rawDeltaYears * 12;
final lowMonths = (midpointMonths * _lowMultiplier).round();
final highMonths = (midpointMonths * _highMultiplier).round();
return LifespanDelta(
lowMonths: max(0, lowMonths),
highMonths: max(0, highMonths),
confidence: getConfidenceForBehavior(behaviorKey),
);
}
/// Get modified inputs with a specific behavior set to optimal.
BehavioralInputs _setToOptimal(BehavioralInputs inputs, String behaviorKey) {
switch (behaviorKey) {
case 'smoking':
return inputs.copyWith(
smoking: SmokingStatus.never,
cigarettesPerDay: 0,
);
case 'alcohol':
return inputs.copyWith(alcohol: AlcoholLevel.none);
case 'sleep':
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':
return inputs.copyWith(workHours: WorkHoursLevel.normal);
default:
return inputs;
}
}
/// Check if a behavior is already at optimal level.
bool _isOptimal(BehavioralInputs inputs, String behaviorKey) {
switch (behaviorKey) {
case 'smoking':
return inputs.smoking == SmokingStatus.never;
case 'alcohol':
return inputs.alcohol == AlcoholLevel.none ||
inputs.alcohol == AlcoholLevel.light;
case 'sleep':
return inputs.sleepHours >= 7 &&
inputs.sleepHours <= 8 &&
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':
return inputs.workHours == WorkHoursLevel.normal;
default:
return true;
}
}
/// List of modifiable behavior keys.
const _modifiableBehaviors = [
'smoking',
'alcohol',
'sleep',
'activity',
'diet',
'processedFood',
'drugUse',
'social',
'stress',
'driving',
'workHours',
];
/// Calculate ranked factors for a user profile and behavioral inputs.
CalculationResult calculateRankedFactors(
UserProfile profile,
BehavioralInputs inputs,
) {
final baselineYears = getRemainingLifeExpectancy(
profile.age,
profile.sex,
profile.country,
);
final conditionHR = getDiagnosisHR(profile.diagnoses);
final adjustedBaselineYears = baselineYears / conditionHR;
final currentHR = computeCombinedHazard(inputs, profile.bmi);
final factors = <RankedFactor>[];
for (final behaviorKey in _modifiableBehaviors) {
if (_isOptimal(inputs, behaviorKey)) continue;
final modifiedInputs = _setToOptimal(inputs, behaviorKey);
final modifiedHR = computeCombinedHazard(modifiedInputs, profile.bmi);
final delta = _computeDelta(
adjustedBaselineYears,
currentHR,
modifiedHR,
behaviorKey,
);
if (delta.highMonths >= 1) {
factors.add(RankedFactor(
behaviorKey: behaviorKey,
displayName: getDisplayName(behaviorKey),
delta: delta,
));
}
}
factors.sort(
(a, b) => b.delta.midpointMonths.compareTo(a.delta.midpointMonths));
return CalculationResult(
rankedFactors: factors,
modelVersion: modelVersion,
calculatedAt: DateTime.now(),
);
}

View file

@ -0,0 +1,248 @@
import '../models/models.dart';
/// Hazard ratios based on conservative estimates from meta-analyses.
/// All HRs are relative to optimal baseline (HR = 1.0).
double getSmokingHR(SmokingStatus status, int cigarettesPerDay) {
switch (status) {
case SmokingStatus.never:
return 1.0;
case SmokingStatus.former:
return 1.3;
case SmokingStatus.current:
if (cigarettesPerDay < 10) return 1.8;
if (cigarettesPerDay <= 20) return 2.2;
return 2.8;
}
}
double getAlcoholHR(AlcoholLevel level) {
switch (level) {
case AlcoholLevel.none:
case AlcoholLevel.light:
return 1.0;
case AlcoholLevel.moderate:
return 1.1;
case AlcoholLevel.heavy:
return 1.3;
case AlcoholLevel.veryHeavy:
return 1.6;
}
}
double getSleepHR(double hours, bool consistent) {
double hr;
if (hours >= 7 && hours <= 8) {
hr = 1.0;
} else if (hours >= 6 && hours < 7) {
hr = 1.05;
} else if (hours < 6) {
hr = 1.15;
} else {
// > 8 hours
hr = 1.10;
}
if (!consistent) {
hr *= 1.05;
}
return hr;
}
double getActivityHR(ActivityLevel level) {
switch (level) {
case ActivityLevel.high:
return 1.0;
case ActivityLevel.moderate:
return 1.05;
case ActivityLevel.light:
return 1.15;
case ActivityLevel.sedentary:
return 1.4;
}
}
double getBmiHR(double bmi) {
if (bmi >= 18.5 && bmi < 25) return 1.0;
if (bmi >= 25 && bmi < 30) return 1.1;
if (bmi >= 30 && bmi < 35) return 1.2;
if (bmi >= 35 && bmi < 40) return 1.4;
if (bmi >= 40) return 1.8;
return 1.15; // Underweight
}
double getDrivingHR(DrivingExposure level) {
switch (level) {
case DrivingExposure.low:
return 1.0;
case DrivingExposure.moderate:
return 1.02;
case DrivingExposure.high:
return 1.04;
case DrivingExposure.veryHigh:
return 1.08;
}
}
double getWorkHoursHR(WorkHoursLevel level) {
switch (level) {
case WorkHoursLevel.normal:
return 1.0;
case WorkHoursLevel.elevated:
return 1.05;
case WorkHoursLevel.high:
return 1.15;
case WorkHoursLevel.extreme:
return 1.3;
}
}
// --- 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;
for (final diagnosis in diagnoses) {
switch (diagnosis) {
case Diagnosis.cardiovascular:
hr *= 1.5;
break;
case Diagnosis.diabetes:
hr *= 1.4;
break;
case Diagnosis.cancer:
hr *= 2.0;
break;
case Diagnosis.copd:
hr *= 1.6;
break;
case Diagnosis.hypertension:
hr *= 1.2;
break;
}
}
return hr;
}
/// Confidence levels for each behavior based on evidence quality.
Confidence getConfidenceForBehavior(String behaviorKey) {
switch (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;
}
}
/// Display names for behaviors.
String getDisplayName(String behaviorKey) {
switch (behaviorKey) {
case 'smoking':
return 'Smoking';
case 'alcohol':
return 'Alcohol Consumption';
case 'sleep':
return 'Sleep';
case 'activity':
return 'Physical Activity';
case 'driving':
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,131 @@
import '../models/models.dart';
/// Simplified mortality groups with approximate life expectancy at birth.
/// Data approximated from WHO 2024 estimates.
enum MortalityGroup {
groupA, // High LE countries (~83-85)
groupB, // Upper-middle LE (~79-81)
groupC, // Middle LE (~72-76)
groupD, // Lower LE (~65-70)
}
/// Maps countries to their mortality group.
MortalityGroup getCountryGroup(String country) {
return _countryToGroup[country] ?? MortalityGroup.groupB;
}
/// Get baseline life expectancy at birth for a given country group and sex.
double getLifeExpectancyAtBirth(MortalityGroup group, Sex sex) {
final base = _groupBaseLE[group]!;
// Women live ~4-5 years longer on average
return sex == Sex.female ? base + 4.5 : base;
}
/// Get remaining life expectancy at current age.
/// Simplified model: as you age, remaining LE decreases but survivors
/// tend to live longer than birth LE suggests.
double getRemainingLifeExpectancy(int currentAge, Sex sex, String country) {
final group = getCountryGroup(country);
final leAtBirth = getLifeExpectancyAtBirth(group, sex);
if (currentAge >= leAtBirth) {
// Past average LE - use simplified survival model
// Each year survived past LE adds ~0.5-0.8 expected years
return 5.0 + (leAtBirth - currentAge) * 0.1;
}
// Simplified remaining LE calculation
// People who survive to age X have higher LE than birth cohort suggests
final survivorBonus = currentAge * 0.15; // ~0.15 years bonus per year survived
final rawRemaining = leAtBirth - currentAge;
return rawRemaining + survivorBonus.clamp(0, 5);
}
/// Base life expectancy by mortality group (male baseline).
const _groupBaseLE = {
MortalityGroup.groupA: 81.0,
MortalityGroup.groupB: 77.0,
MortalityGroup.groupC: 72.0,
MortalityGroup.groupD: 65.0,
};
/// Country to mortality group mapping.
const _countryToGroup = {
// Group A - High LE (83-85)
'Japan': MortalityGroup.groupA,
'Switzerland': MortalityGroup.groupA,
'Singapore': MortalityGroup.groupA,
'Spain': MortalityGroup.groupA,
'Italy': MortalityGroup.groupA,
'Australia': MortalityGroup.groupA,
'Iceland': MortalityGroup.groupA,
'Israel': MortalityGroup.groupA,
'Sweden': MortalityGroup.groupA,
'France': MortalityGroup.groupA,
'South Korea': MortalityGroup.groupA,
'Norway': MortalityGroup.groupA,
// Group B - Upper-middle LE (79-81)
'United States': MortalityGroup.groupB,
'United Kingdom': MortalityGroup.groupB,
'Germany': MortalityGroup.groupB,
'Canada': MortalityGroup.groupB,
'Netherlands': MortalityGroup.groupB,
'Belgium': MortalityGroup.groupB,
'Austria': MortalityGroup.groupB,
'Finland': MortalityGroup.groupB,
'Ireland': MortalityGroup.groupB,
'New Zealand': MortalityGroup.groupB,
'Denmark': MortalityGroup.groupB,
'Portugal': MortalityGroup.groupB,
'Czech Republic': MortalityGroup.groupB,
'Poland': MortalityGroup.groupB,
'Chile': MortalityGroup.groupB,
'Costa Rica': MortalityGroup.groupB,
'Cuba': MortalityGroup.groupB,
'United Arab Emirates': MortalityGroup.groupB,
'Qatar': MortalityGroup.groupB,
'Taiwan': MortalityGroup.groupB,
// Group C - Middle LE (72-76)
'China': MortalityGroup.groupC,
'Brazil': MortalityGroup.groupC,
'Mexico': MortalityGroup.groupC,
'Russia': MortalityGroup.groupC,
'Turkey': MortalityGroup.groupC,
'Argentina': MortalityGroup.groupC,
'Colombia': MortalityGroup.groupC,
'Thailand': MortalityGroup.groupC,
'Vietnam': MortalityGroup.groupC,
'Malaysia': MortalityGroup.groupC,
'Iran': MortalityGroup.groupC,
'Saudi Arabia': MortalityGroup.groupC,
'Egypt': MortalityGroup.groupC,
'Ukraine': MortalityGroup.groupC,
'Romania': MortalityGroup.groupC,
'Hungary': MortalityGroup.groupC,
'Peru': MortalityGroup.groupC,
'Philippines': MortalityGroup.groupC,
// Group D - Lower LE (65-70)
'India': MortalityGroup.groupD,
'Indonesia': MortalityGroup.groupD,
'South Africa': MortalityGroup.groupD,
'Pakistan': MortalityGroup.groupD,
'Bangladesh': MortalityGroup.groupD,
'Nigeria': MortalityGroup.groupD,
'Kenya': MortalityGroup.groupD,
'Ghana': MortalityGroup.groupD,
'Ethiopia': MortalityGroup.groupD,
'Myanmar': MortalityGroup.groupD,
'Nepal': MortalityGroup.groupD,
'Cambodia': MortalityGroup.groupD,
};
/// Get list of all supported countries, sorted alphabetically.
List<String> getSupportedCountries() {
final countries = _countryToGroup.keys.toList();
countries.sort();
return countries;
}

View file

@ -0,0 +1,3 @@
export 'hazard_ratios.dart';
export 'mortality_tables.dart';
export 'calculator.dart';

View file

@ -0,0 +1,196 @@
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 _helpUrl = 'https://addmonths.app/help';
static const String _privacyUrl = 'https://addmonths.app/privacy';
@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: 32),
// Links
_buildLinkButton(
context,
icon: Icons.help_outline,
label: 'How it works',
onTap: () => _launchUrl(_helpUrl),
),
const SizedBox(height: 12),
_buildLinkButton(
context,
icon: Icons.privacy_tip_outlined,
label: 'Privacy Policy',
onTap: () => _launchUrl(_privacyUrl),
),
const SizedBox(height: 32),
// Disclaimer
Text(
'Disclaimer',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'This app provides general information based on population-level '
'research and is not medical advice. Individual results vary widely. '
'Consult a healthcare provider for personalized guidance.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
);
}
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

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

View file

@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.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,
this.readOnly = false,
this.initialBehaviors,
});
@override
State<BehavioralScreen> createState() => _BehavioralScreenState();
}
class _BehavioralScreenState extends State<BehavioralScreen> {
SmokingStatus _smoking = SmokingStatus.never;
int _cigarettesPerDay = 0;
AlcoholLevel _alcohol = AlcoholLevel.none;
double _sleepHours = 7.5;
bool _sleepConsistent = true;
ActivityLevel _activity = ActivityLevel.moderate;
@override
void initState() {
super.initState();
_loadInitialData();
}
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) {
_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: 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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Daily Habits',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Substances, sleep, and activity levels.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
// Smoking
_buildSectionLabel('Smoking'),
const SizedBox(height: 12),
_buildSmokingSelector(),
if (_smoking == SmokingStatus.current) ...[
const SizedBox(height: 16),
_buildCigarettesSlider(),
],
const SizedBox(height: 28),
// Alcohol
_buildSectionLabel('Alcohol'),
const SizedBox(height: 12),
_buildAlcoholSelector(),
const SizedBox(height: 28),
// Sleep
_buildSectionLabel('Sleep'),
const SizedBox(height: 12),
_buildSleepSlider(),
const SizedBox(height: 12),
_buildSleepConsistentToggle(),
const SizedBox(height: 28),
// Physical Activity
_buildSectionLabel('Physical Activity'),
const SizedBox(height: 12),
_buildActivitySelector(),
const SizedBox(height: 40),
// 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),
],
),
),
);
}
Widget _buildSectionLabel(String label) {
return Text(
label,
style: Theme.of(context).textTheme.labelLarge,
);
}
Widget _buildSmokingSelector() {
return _buildSegmentedControl<SmokingStatus>(
value: _smoking,
options: [
(SmokingStatus.never, 'Never'),
(SmokingStatus.former, 'Former'),
(SmokingStatus.current, 'Current'),
],
onChanged: widget.readOnly ? null : (value) => setState(() => _smoking = value),
);
}
Widget _buildCigarettesSlider() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cigarettes per day',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _cigarettesPerDay.toDouble(),
min: 1,
max: 40,
divisions: 39,
onChanged: widget.readOnly
? null
: (value) {
if (value.round() == 20 && _cigarettesPerDay != 20) {
HapticFeedback.mediumImpact();
}
setState(() => _cigarettesPerDay = value.round());
},
),
),
SizedBox(
width: 50,
child: Text(
'$_cigarettesPerDay',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
),
],
),
Text(
_cigarettesPerDay <= 20
? '${(_cigarettesPerDay / 20).toStringAsFixed(1)} pack/day'
: '${(_cigarettesPerDay / 20).toStringAsFixed(1)} packs/day',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildAlcoholSelector() {
return _buildSegmentedControl<AlcoholLevel>(
value: _alcohol,
options: [
(AlcoholLevel.none, 'None'),
(AlcoholLevel.light, '1-7/wk'),
(AlcoholLevel.moderate, '8-14'),
(AlcoholLevel.heavy, '15-21'),
(AlcoholLevel.veryHeavy, '21+'),
],
onChanged: widget.readOnly ? null : (value) => setState(() => _alcohol = value),
);
}
Widget _buildSleepSlider() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Average hours per night',
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
_sleepHours.toStringAsFixed(1),
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
Slider(
value: _sleepHours,
min: 4,
max: 12,
divisions: 16,
onChanged: widget.readOnly
? null
: (value) => setState(() => _sleepHours = value),
),
],
);
}
Widget _buildSleepConsistentToggle() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Consistent schedule',
style: Theme.of(context).textTheme.bodyLarge,
),
Switch(
value: _sleepConsistent,
onChanged: widget.readOnly
? null
: (value) => setState(() => _sleepConsistent = value),
),
],
);
}
Widget _buildActivitySelector() {
return _buildSegmentedControl<ActivityLevel>(
value: _activity,
options: [
(ActivityLevel.sedentary, 'Sedentary'),
(ActivityLevel.light, 'Light'),
(ActivityLevel.moderate, 'Moderate'),
(ActivityLevel.high, 'High'),
],
onChanged: widget.readOnly ? null : (value) => setState(() => _activity = 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 _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,364 @@
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

@ -0,0 +1,265 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import 'baseline_screen.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _controller = PageController();
int _currentPage = 0;
final _slides = const [
_SlideData(
icon: Icons.person_outline,
title: 'Tell us your baseline',
description: 'Age, sex, country, and any existing health conditions.',
),
_SlideData(
icon: Icons.checklist_outlined,
title: 'Answer a few questions',
description: 'Simple inputs about sleep, activity, and daily habits.',
),
_SlideData(
icon: Icons.insights_outlined,
title: 'See your biggest lever',
description: 'Discover which single change could add the most months to your life.',
),
];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
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(index, _slides[index]),
),
),
// Bottom section
Padding(
padding: const EdgeInsets.fromLTRB(32, 0, 32, 32),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _onButtonPressed,
child: Text(_currentPage == _slides.length - 1
? 'Get Started'
: 'Next'),
),
),
const SizedBox(height: 12),
if (_currentPage < _slides.length - 1)
TextButton(
onPressed: _skip,
child: Text(
'Skip',
style: TextStyle(color: AppColors.textTertiary),
),
)
else
const SizedBox(height: 40),
],
),
),
],
),
),
);
}
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(
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: 48,
color: AppColors.primary,
),
),
const SizedBox(height: 32),
// Title
Text(
slide.title,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Description
Text(
slide.description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
);
}
void _onButtonPressed() {
if (_currentPage < _slides.length - 1) {
_controller.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
_navigateToBaseline();
}
}
void _skip() {
_navigateToBaseline();
}
void _navigateToBaseline() {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const BaselineScreen()),
);
}
}
class _SlideData {
final IconData icon;
final String title;
final String description;
const _SlideData({
required this.icon,
required this.title,
required this.description,
});
}

View file

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../risk_engine/calculator.dart';
import '../storage/local_storage.dart';
import '../theme.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;
final BehavioralInputs behaviors;
const ResultsScreen({
super.key,
required this.profile,
required this.behaviors,
});
@override
State<ResultsScreen> createState() => _ResultsScreenState();
}
class _ResultsScreenState extends State<ResultsScreen> {
late CalculationResult _result;
@override
void initState() {
super.initState();
_result = calculateRankedFactors(widget.profile, widget.behaviors);
_saveResult();
}
Future<void> _saveResult() async {
await LocalStorage.saveResult(_result);
}
@override
Widget build(BuildContext context) {
final dominant = _result.dominantFactor;
final secondary = _result.secondaryFactor;
return Scaffold(
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(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (dominant != null) ...[
// Dominant challenge card
_buildDominantCard(dominant),
const SizedBox(height: 24),
// Explanation
Text(
'Addressing this exposure would likely produce the largest increase in expected lifespan among available changes.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
// Secondary factor
if (secondary != null) ...[
Text(
'Secondary Factor',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
_buildSecondaryCard(secondary),
const SizedBox(height: 32),
],
// 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: 24),
],
] else ...[
// No factors to improve
_buildOptimalCard(),
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: _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(
onPressed: _showDeleteConfirmation,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
),
child: const Text('Delete All Data'),
),
),
const SizedBox(height: 16),
],
),
),
),
);
}
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(
'Your current behaviors are near optimal based on our model.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
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 _recalculate() {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const BaselineScreen()),
(route) => false,
);
}
void _showDeleteConfirmation() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete All Data'),
content: const Text(
'This will permanently delete all your data from this device. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final navigator = Navigator.of(context);
await LocalStorage.deleteAllData();
navigator.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const OnboardingScreen()),
(route) => false,
);
},
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Delete'),
),
],
),
);
}
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')),
);
}
}
}

10
lib/screens/screens.dart Normal file
View file

@ -0,0 +1,10 @@
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

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'baseline_screen.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
const Spacer(flex: 1),
// Main question - large and centered
Text(
'Simple questions.\nHonest answers.',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontSize: 32,
height: 1.3,
),
textAlign: TextAlign.center,
),
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 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: 24),
// Start button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _navigateToBaseline(context),
child: const Text('Start'),
),
),
const SizedBox(height: 16),
// About link
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
child: const Text('About'),
),
const SizedBox(height: 16),
],
),
),
),
);
}
void _navigateToBaseline(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const BaselineScreen()),
);
}
}

Some files were not shown because too many files have changed in this diff Show more