Initial commit: Add Months MVP

Local-first Flutter app that identifies the single behavioral change
most likely to extend lifespan using hazard-based modeling.

Features:
- Risk engine with hazard ratios from meta-analyses
- 50 countries mapped to 4 mortality groups
- 6 modifiable factors: smoking, alcohol, sleep, activity, driving, work hours
- SQLite local storage (no cloud, no accounts)
- Muted clinical UI theme
- 23 unit tests for risk engine

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-20 21:25:00 -08:00
commit 151106aa8e
99 changed files with 4578 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 WelcomeScreen();
},
);
}
return const WelcomeScreen();
}
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,78 @@
import 'enums.dart';
class BehavioralInputs {
final SmokingStatus smoking;
final int cigarettesPerDay; // only relevant if smoking == current
final AlcoholLevel alcohol;
final double sleepHours;
final bool sleepConsistent;
final ActivityLevel activity;
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.driving,
required this.workHours,
});
static const optimal = BehavioralInputs(
smoking: SmokingStatus.never,
cigarettesPerDay: 0,
alcohol: AlcoholLevel.none,
sleepHours: 7.5,
sleepConsistent: true,
activity: ActivityLevel.high,
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,
'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),
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,
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,
driving: driving ?? this.driving,
workHours: workHours ?? this.workHours,
);
}

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

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

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

@ -0,0 +1,4 @@
export 'enums.dart';
export 'user_profile.dart';
export 'behavioral_inputs.dart';
export 'result.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),
);
}

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,176 @@
import 'dart:math';
import '../models/models.dart';
import 'hazard_ratios.dart';
import 'mortality_tables.dart';
const String modelVersion = '1.0';
/// 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.
const double _lowMultiplier = 0.6;
const double _highMultiplier = 1.4;
/// Calculate combined hazard ratio from behavioral inputs.
double computeCombinedHazard(BehavioralInputs inputs, double bmi) {
double hr = 1.0;
hr *= getSmokingHR(inputs.smoking, inputs.cigarettesPerDay);
hr *= getAlcoholHR(inputs.alcohol);
hr *= getSleepHR(inputs.sleepHours, inputs.sleepConsistent);
hr *= getActivityHR(inputs.activity);
hr *= getBmiHR(bmi);
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) {
// No improvement possible or already optimal
return LifespanDelta(
lowMonths: 0,
highMonths: 0,
confidence: getConfidenceForBehavior(behaviorKey),
);
}
// Delta years baselineYears × (1 - modifiedHR/currentHR) × dampingFactor
final rawDeltaYears =
baselineYears * (1 - modifiedHR / currentHR) * _dampingFactor;
// Convert to months with uncertainty range
final midpointMonths = rawDeltaYears * 12;
final lowMonths = (midpointMonths * _lowMultiplier).round();
final highMonths = (midpointMonths * _highMultiplier).round();
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 '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 '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',
'driving',
'workHours',
];
/// Calculate ranked factors for a user profile and behavioral inputs.
CalculationResult calculateRankedFactors(
UserProfile profile,
BehavioralInputs inputs,
) {
// Get baseline remaining life expectancy
final baselineYears = getRemainingLifeExpectancy(
profile.age,
profile.sex,
profile.country,
);
// Apply existing condition modifiers (reduces baseline)
final conditionHR = getDiagnosisHR(profile.diagnoses);
final adjustedBaselineYears = baselineYears / conditionHR;
// Calculate current combined HR from behaviors
final currentHR = computeCombinedHazard(inputs, profile.bmi);
// Calculate delta for each modifiable behavior
final factors = <RankedFactor>[];
for (final behaviorKey in _modifiableBehaviors) {
// Skip if already optimal
if (_isOptimal(inputs, behaviorKey)) continue;
// Compute HR with this behavior set to optimal
final modifiedInputs = _setToOptimal(inputs, behaviorKey);
final modifiedHR = computeCombinedHazard(modifiedInputs, profile.bmi);
final delta = _computeDelta(
adjustedBaselineYears,
currentHR,
modifiedHR,
behaviorKey,
);
// Only include if there's meaningful gain (> 1 month)
if (delta.highMonths >= 1) {
factors.add(RankedFactor(
behaviorKey: behaviorKey,
displayName: getDisplayName(behaviorKey),
delta: delta,
));
}
}
// Sort by midpoint delta descending
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,163 @@
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;
}
// Inconsistent sleep schedule adds additional risk
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;
// Underweight
return 1.15;
}
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;
}
}
/// 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':
return Confidence.high;
case 'sleep':
case 'workHours':
return Confidence.moderate;
case 'driving':
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';
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,419 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../risk_engine/mortality_tables.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'behavioral_screen.dart';
class BaselineScreen extends StatefulWidget {
const BaselineScreen({super.key});
@override
State<BaselineScreen> createState() => _BaselineScreenState();
}
class _BaselineScreenState extends State<BaselineScreen> {
int _age = 35;
Sex _sex = Sex.male;
String _country = 'United States';
double _heightCm = 170;
double _weightKg = 70;
final Set<Diagnosis> _diagnoses = {};
late List<String> _countries;
@override
void initState() {
super.initState();
_countries = getSupportedCountries();
_loadExistingProfile();
}
Future<void> _loadExistingProfile() async {
final profile = await LocalStorage.getProfile();
if (profile != null) {
setState(() {
_age = profile.age;
_sex = profile.sex;
_country = profile.country;
_heightCm = profile.heightCm;
_weightKg = profile.weightKg;
_diagnoses.clear();
_diagnoses.addAll(profile.diagnoses);
});
}
}
double get _bmi => _weightKg / ((_heightCm / 100) * (_heightCm / 100));
String get _bmiCategory {
if (_bmi < 18.5) return 'Underweight';
if (_bmi < 25) return 'Normal';
if (_bmi < 30) return 'Overweight';
if (_bmi < 35) return 'Obese I';
if (_bmi < 40) return 'Obese II';
return 'Obese III';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Baseline'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Demographics',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'This information establishes your baseline life expectancy.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Age
_buildSectionLabel('Age'),
const SizedBox(height: 8),
_buildAgeSelector(),
const SizedBox(height: 24),
// Sex
_buildSectionLabel('Biological Sex'),
const SizedBox(height: 8),
_buildSexSelector(),
const SizedBox(height: 24),
// Country
_buildSectionLabel('Country'),
const SizedBox(height: 8),
_buildCountryDropdown(),
const SizedBox(height: 24),
// Height
_buildSectionLabel('Height'),
const SizedBox(height: 8),
_buildHeightSlider(),
const SizedBox(height: 24),
// Weight
_buildSectionLabel('Weight'),
const SizedBox(height: 8),
_buildWeightSlider(),
const SizedBox(height: 16),
// BMI display
_buildBmiDisplay(),
const SizedBox(height: 32),
// Existing conditions
Text(
'Existing Conditions',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Select any diagnosed conditions. These affect baseline calculations but are not modifiable factors.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
_buildDiagnosisCheckboxes(),
const SizedBox(height: 32),
// Continue button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _continue,
child: const Text('Continue'),
),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildSectionLabel(String label) {
return Text(
label,
style: Theme.of(context).textTheme.labelLarge,
);
}
Widget _buildAgeSelector() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: _age > 18 ? () => setState(() => _age--) : null,
),
Expanded(
child: Text(
'$_age years',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _age < 100 ? () => setState(() => _age++) : null,
),
],
),
);
}
Widget _buildSexSelector() {
return Row(
children: [
Expanded(
child: _buildToggleButton(
'Male',
_sex == Sex.male,
() => setState(() => _sex = Sex.male),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildToggleButton(
'Female',
_sex == Sex.female,
() => setState(() => _sex = Sex.female),
),
),
],
);
}
Widget _buildToggleButton(String label, bool selected, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: selected ? AppColors.primary : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColors.textSecondary,
),
),
),
);
}
Widget _buildCountryDropdown() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _country,
isExpanded: true,
icon: const Icon(Icons.keyboard_arrow_down),
items: _countries.map((country) {
return DropdownMenuItem(
value: country,
child: Text(country),
);
}).toList(),
onChanged: (value) {
if (value != null) setState(() => _country = value);
},
),
),
);
}
Widget _buildHeightSlider() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_heightCm.round()} cm',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
_cmToFeetInches(_heightCm),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Slider(
value: _heightCm,
min: 120,
max: 220,
divisions: 100,
onChanged: (value) => setState(() => _heightCm = value),
),
],
);
}
Widget _buildWeightSlider() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_weightKg.round()} kg',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'${(_weightKg * 2.205).round()} lbs',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Slider(
value: _weightKg,
min: 30,
max: 200,
divisions: 170,
onChanged: (value) => setState(() => _weightKg = value),
),
],
);
}
Widget _buildBmiDisplay() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'BMI',
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
_bmi.toStringAsFixed(1),
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getBmiColor(),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_bmiCategory,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Color _getBmiColor() {
if (_bmi < 18.5 || _bmi >= 30) return AppColors.warning;
if (_bmi >= 25) return AppColors.primary;
return AppColors.success;
}
Widget _buildDiagnosisCheckboxes() {
return Column(
children: Diagnosis.values.map((diagnosis) {
return CheckboxListTile(
value: _diagnoses.contains(diagnosis),
onChanged: (checked) {
setState(() {
if (checked == true) {
_diagnoses.add(diagnosis);
} else {
_diagnoses.remove(diagnosis);
}
});
},
title: Text(_getDiagnosisLabel(diagnosis)),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
);
}).toList(),
);
}
String _getDiagnosisLabel(Diagnosis diagnosis) {
switch (diagnosis) {
case Diagnosis.cardiovascular:
return 'Cardiovascular disease';
case Diagnosis.diabetes:
return 'Diabetes';
case Diagnosis.cancer:
return 'Cancer (active)';
case Diagnosis.copd:
return 'COPD';
case Diagnosis.hypertension:
return 'Hypertension';
}
}
String _cmToFeetInches(double cm) {
final totalInches = cm / 2.54;
final feet = (totalInches / 12).floor();
final inches = (totalInches % 12).round();
return "$feet'$inches\"";
}
void _continue() async {
final profile = UserProfile(
age: _age,
sex: _sex,
country: _country,
heightCm: _heightCm,
weightKg: _weightKg,
diagnoses: _diagnoses,
);
await LocalStorage.saveProfile(profile);
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BehavioralScreen(profile: profile),
),
);
}
}
}

View file

@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'results_screen.dart';
class BehavioralScreen extends StatefulWidget {
final UserProfile profile;
const BehavioralScreen({super.key, required this.profile});
@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;
DrivingExposure _driving = DrivingExposure.low;
WorkHoursLevel _workHours = WorkHoursLevel.normal;
@override
void initState() {
super.initState();
_loadExistingBehaviors();
}
Future<void> _loadExistingBehaviors() async {
final behaviors = await LocalStorage.getBehaviors();
if (behaviors != null) {
setState(() {
_smoking = behaviors.smoking;
_cigarettesPerDay = behaviors.cigarettesPerDay;
_alcohol = behaviors.alcohol;
_sleepHours = behaviors.sleepHours;
_sleepConsistent = behaviors.sleepConsistent;
_activity = behaviors.activity;
_driving = behaviors.driving;
_workHours = behaviors.workHours;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Behaviors'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Modifiable Factors',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'These behaviors can be changed. We\'ll identify which has the largest impact.',
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: 28),
// Driving Exposure
_buildSectionLabel('Driving Exposure'),
const SizedBox(height: 12),
_buildDrivingSelector(),
const SizedBox(height: 28),
// Work Hours
_buildSectionLabel('Work Hours'),
const SizedBox(height: 12),
_buildWorkHoursSelector(),
const SizedBox(height: 40),
// Calculate button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _calculate,
child: const Text('Calculate'),
),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildSectionLabel(String label) {
return Text(
label,
style: Theme.of(context).textTheme.labelLarge,
);
}
Widget _buildSmokingSelector() {
return _buildSegmentedControl<SmokingStatus>(
value: _smoking,
options: [
(SmokingStatus.never, 'Never'),
(SmokingStatus.former, 'Former'),
(SmokingStatus.current, 'Current'),
],
onChanged: (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: (value) {
// Haptic feedback at 20 (one pack)
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: (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: (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: (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: (value) => setState(() => _activity = value),
);
}
Widget _buildDrivingSelector() {
return _buildSegmentedControl<DrivingExposure>(
value: _driving,
options: [
(DrivingExposure.low, '<50 mi/wk'),
(DrivingExposure.moderate, '50-150'),
(DrivingExposure.high, '150-300'),
(DrivingExposure.veryHigh, '300+'),
],
onChanged: (value) => setState(() => _driving = value),
);
}
Widget _buildWorkHoursSelector() {
return _buildSegmentedControl<WorkHoursLevel>(
value: _workHours,
options: [
(WorkHoursLevel.normal, '<40'),
(WorkHoursLevel.elevated, '40-55'),
(WorkHoursLevel.high, '55-70'),
(WorkHoursLevel.extreme, '70+'),
],
onChanged: (value) => setState(() => _workHours = value),
);
}
Widget _buildSegmentedControl<T>({
required T value,
required List<(T, String)> options,
required ValueChanged<T> onChanged,
}) {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: options.map((option) {
final isSelected = value == option.$1;
return Expanded(
child: GestureDetector(
onTap: () => onChanged(option.$1),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Text(
option.$2,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : AppColors.textSecondary,
),
),
),
),
);
}).toList(),
),
);
}
void _calculate() async {
final behaviors = BehavioralInputs(
smoking: _smoking,
cigarettesPerDay: _cigarettesPerDay,
alcohol: _alcohol,
sleepHours: _sleepHours,
sleepConsistent: _sleepConsistent,
activity: _activity,
driving: _driving,
workHours: _workHours,
);
await LocalStorage.saveBehaviors(behaviors);
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ResultsScreen(
profile: widget.profile,
behaviors: behaviors,
),
),
);
}
}
}

View file

@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../risk_engine/calculator.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'welcome_screen.dart';
import 'baseline_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,
),
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: _recalculate,
child: const Text('Recalculate'),
),
),
const SizedBox(height: 12),
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 WelcomeScreen()),
(route) => false,
);
},
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Delete'),
),
],
),
);
}
}

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

@ -0,0 +1,4 @@
export 'welcome_screen.dart';
export 'baseline_screen.dart';
export 'behavioral_screen.dart';
export 'results_screen.dart';

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../theme.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: 2),
// Icon or logo area
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.timeline,
size: 40,
color: AppColors.primary,
),
),
const SizedBox(height: 32),
// Title
Text(
'Add Months',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
'Identify the single change most likely to extend your lifespan.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Features list
_buildFeatureItem(
context,
Icons.shield_outlined,
'Private',
'All data stays on your device',
),
const SizedBox(height: 16),
_buildFeatureItem(
context,
Icons.science_outlined,
'Evidence-based',
'Hazard ratios from meta-analyses',
),
const SizedBox(height: 16),
_buildFeatureItem(
context,
Icons.trending_up_outlined,
'Actionable',
'Focus on what matters most',
),
const Spacer(flex: 3),
// Start button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _navigateToBaseline(context),
child: const Text('Start'),
),
),
const SizedBox(height: 32),
],
),
),
),
);
}
Widget _buildFeatureItem(
BuildContext context,
IconData icon,
String title,
String description,
) {
return Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: AppColors.primary, size: 22),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelLarge,
),
Text(
description,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
);
}
void _navigateToBaseline(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const BaselineScreen()),
);
}
}

View file

@ -0,0 +1,125 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/models.dart';
class LocalStorage {
static const _dbName = 'add_months.db';
static const _tableName = 'user_data';
static const _version = 1;
static Database? _database;
static Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
static Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, _dbName);
return await openDatabase(
path,
version: _version,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE $_tableName (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
''');
},
);
}
// Generic key-value operations
static Future<void> _put(String key, Map<String, dynamic> value) async {
final db = await database;
await db.insert(
_tableName,
{
'key': key,
'value': jsonEncode(value),
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
static Future<Map<String, dynamic>?> _get(String key) async {
final db = await database;
final results = await db.query(
_tableName,
where: 'key = ?',
whereArgs: [key],
);
if (results.isEmpty) return null;
return jsonDecode(results.first['value'] as String) as Map<String, dynamic>;
}
// Profile operations
static Future<void> saveProfile(UserProfile profile) async {
await _put('profile', profile.toJson());
}
static Future<UserProfile?> getProfile() async {
final json = await _get('profile');
if (json == null) return null;
return UserProfile.fromJson(json);
}
// Behavioral inputs operations
static Future<void> saveBehaviors(BehavioralInputs behaviors) async {
await _put('behaviors', behaviors.toJson());
}
static Future<BehavioralInputs?> getBehaviors() async {
final json = await _get('behaviors');
if (json == null) return null;
return BehavioralInputs.fromJson(json);
}
// Result operations
static Future<void> saveResult(CalculationResult result) async {
await _put('lastResult', result.toJson());
}
static Future<CalculationResult?> getLastResult() async {
final json = await _get('lastResult');
if (json == null) return null;
return CalculationResult.fromJson(json);
}
// Check if user has completed setup
static Future<bool> hasCompletedSetup() async {
final profile = await getProfile();
final behaviors = await getBehaviors();
return profile != null && behaviors != null;
}
// Delete all data
static Future<void> deleteAllData() async {
final db = await database;
await db.delete(_tableName);
}
// Get last updated timestamp
static Future<DateTime?> getLastUpdated() async {
final db = await database;
final results = await db.query(
_tableName,
columns: ['updated_at'],
orderBy: 'updated_at DESC',
limit: 1,
);
if (results.isEmpty) return null;
return DateTime.fromMillisecondsSinceEpoch(
results.first['updated_at'] as int,
);
}
}

153
lib/theme.dart Normal file
View file

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
/// Muted clinical color palette.
class AppColors {
static const primary = Color(0xFF4A90A4); // Muted teal
static const primaryDark = Color(0xFF2D6073);
static const primaryLight = Color(0xFF7BB8CC);
static const surface = Color(0xFFF8FAFB);
static const surfaceVariant = Color(0xFFEEF2F4);
static const background = Color(0xFFFFFFFF);
static const textPrimary = Color(0xFF1A2B33);
static const textSecondary = Color(0xFF5A6B73);
static const textTertiary = Color(0xFF8A9BA3);
static const success = Color(0xFF4A9A7C);
static const warning = Color(0xFFB8934A);
static const error = Color(0xFFA45A5A);
static const divider = Color(0xFFDDE4E8);
}
/// App-wide theme.
ThemeData buildAppTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
secondary: AppColors.primaryLight,
surface: AppColors.surface,
onSurface: AppColors.textPrimary,
),
scaffoldBackgroundColor: AppColors.background,
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.background,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
fontFamily: 'SF Pro Display',
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: -0.3,
),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: -0.3,
height: 1.3,
),
headlineSmall: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: -0.2,
),
bodyLarge: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
height: 1.5,
),
bodyMedium: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
height: 1.5,
),
bodySmall: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
height: 1.4,
),
labelLarge: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: 0.1,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary, width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariant,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
labelStyle: const TextStyle(color: AppColors.textSecondary),
),
dividerTheme: const DividerThemeData(
color: AppColors.divider,
thickness: 1,
),
cardTheme: CardThemeData(
color: AppColors.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.divider, width: 1),
),
),
);
}

92
pubspec.yaml Normal file
View file

@ -0,0 +1,92 @@
name: add_months
description: "Identify the single change most likely to extend your lifespan."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.10.4
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
sqflite: ^2.3.0
path: ^1.8.3
flutter_secure_storage: ^9.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.1
flutter_launcher_icons:
android: true
ios: true
remove_alpha_ios: true
image_path: "assets/icon/app_icon.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View file

@ -0,0 +1,224 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:add_months/models/models.dart';
import 'package:add_months/risk_engine/risk_engine.dart';
void main() {
group('Hazard Ratios', () {
test('smoking current produces HR > 1.8', () {
expect(getSmokingHR(SmokingStatus.current, 10), greaterThanOrEqualTo(1.8));
});
test('smoking heavy (>20/day) produces highest HR', () {
final lightHR = getSmokingHR(SmokingStatus.current, 5);
final moderateHR = getSmokingHR(SmokingStatus.current, 15);
final heavyHR = getSmokingHR(SmokingStatus.current, 25);
expect(heavyHR, greaterThan(moderateHR));
expect(moderateHR, greaterThan(lightHR));
expect(heavyHR, equals(2.8));
});
test('never smoker has HR of 1.0', () {
expect(getSmokingHR(SmokingStatus.never, 0), equals(1.0));
});
test('sedentary activity has higher HR than active', () {
expect(
getActivityHR(ActivityLevel.sedentary),
greaterThan(getActivityHR(ActivityLevel.high)),
);
});
test('very heavy alcohol has highest HR', () {
expect(getAlcoholHR(AlcoholLevel.veryHeavy), equals(1.6));
expect(getAlcoholHR(AlcoholLevel.none), equals(1.0));
});
test('short sleep has higher HR than optimal', () {
expect(getSleepHR(5.0, true), greaterThan(getSleepHR(7.5, true)));
});
test('inconsistent sleep adds to HR', () {
expect(getSleepHR(7.0, false), greaterThan(getSleepHR(7.0, true)));
});
});
group('Mortality Tables', () {
test('Japan is in Group A', () {
expect(getCountryGroup('Japan'), equals(MortalityGroup.groupA));
});
test('United States is in Group B', () {
expect(getCountryGroup('United States'), equals(MortalityGroup.groupB));
});
test('female LE is higher than male', () {
final maleLE =
getLifeExpectancyAtBirth(MortalityGroup.groupB, Sex.male);
final femaleLE =
getLifeExpectancyAtBirth(MortalityGroup.groupB, Sex.female);
expect(femaleLE, greaterThan(maleLE));
});
test('remaining LE decreases with age', () {
final le30 = getRemainingLifeExpectancy(30, Sex.male, 'United States');
final le50 = getRemainingLifeExpectancy(50, Sex.male, 'United States');
expect(le30, greaterThan(le50));
});
test('getSupportedCountries returns sorted list', () {
final countries = getSupportedCountries();
expect(countries, isNotEmpty);
expect(countries.first, equals('Argentina'));
expect(countries, contains('United States'));
});
});
group('Calculator', () {
final healthyProfile = UserProfile(
age: 40,
sex: Sex.male,
country: 'United States',
heightCm: 175,
weightKg: 75,
);
final optimalInputs = BehavioralInputs.optimal;
final unhealthyInputs = BehavioralInputs(
smoking: SmokingStatus.current,
cigarettesPerDay: 20,
alcohol: AlcoholLevel.heavy,
sleepHours: 5.0,
sleepConsistent: false,
activity: ActivityLevel.sedentary,
driving: DrivingExposure.veryHigh,
workHours: WorkHoursLevel.extreme,
);
test('optimal inputs produce low combined HR', () {
final hr = computeCombinedHazard(optimalInputs, 22.0);
expect(hr, closeTo(1.0, 0.1));
});
test('unhealthy inputs produce high combined HR', () {
final hr = computeCombinedHazard(unhealthyInputs, 35.0);
expect(hr, greaterThan(3.0));
});
test('combined HR is capped at 4.0', () {
final hr = computeCombinedHazard(unhealthyInputs, 45.0);
expect(hr, lessThanOrEqualTo(4.0));
});
test('ranking puts smoking above driving for heavy smoker', () {
final smokerInputs = BehavioralInputs(
smoking: SmokingStatus.current,
cigarettesPerDay: 20,
alcohol: AlcoholLevel.none,
sleepHours: 7.5,
sleepConsistent: true,
activity: ActivityLevel.high,
driving: DrivingExposure.veryHigh,
workHours: WorkHoursLevel.normal,
);
final result = calculateRankedFactors(healthyProfile, smokerInputs);
expect(result.rankedFactors, isNotEmpty);
expect(result.dominantFactor?.behaviorKey, equals('smoking'));
});
test('optimal profile returns empty factors', () {
final result = calculateRankedFactors(healthyProfile, optimalInputs);
expect(result.rankedFactors, isEmpty);
});
test('result includes model version', () {
final result = calculateRankedFactors(healthyProfile, unhealthyInputs);
expect(result.modelVersion, equals('1.0'));
});
test('sedentary person sees activity as factor', () {
final sedentaryInputs = BehavioralInputs(
smoking: SmokingStatus.never,
cigarettesPerDay: 0,
alcohol: AlcoholLevel.none,
sleepHours: 7.5,
sleepConsistent: true,
activity: ActivityLevel.sedentary,
driving: DrivingExposure.low,
workHours: WorkHoursLevel.normal,
);
final result = calculateRankedFactors(healthyProfile, sedentaryInputs);
expect(result.rankedFactors, isNotEmpty);
expect(result.dominantFactor?.behaviorKey, equals('activity'));
});
test('delta ranges are reasonable', () {
final result = calculateRankedFactors(healthyProfile, unhealthyInputs);
for (final factor in result.rankedFactors) {
// Low should be less than or equal to high
expect(factor.delta.lowMonths, lessThanOrEqualTo(factor.delta.highMonths));
// Months should be reasonable (not hundreds of years)
expect(factor.delta.highMonths, lessThan(240)); // < 20 years
}
});
});
group('Existing Conditions', () {
test('cardiovascular disease increases diagnosis HR', () {
final hr = getDiagnosisHR({Diagnosis.cardiovascular});
expect(hr, equals(1.5));
});
test('multiple conditions compound', () {
final singleHR = getDiagnosisHR({Diagnosis.diabetes});
final multiHR = getDiagnosisHR({Diagnosis.diabetes, Diagnosis.hypertension});
expect(multiHR, greaterThan(singleHR));
});
test('conditions reduce effective baseline', () {
final healthyProfile = UserProfile(
age: 50,
sex: Sex.male,
country: 'United States',
heightCm: 175,
weightKg: 80,
diagnoses: {},
);
final unhealthyProfile = healthyProfile.copyWith(
diagnoses: {Diagnosis.cardiovascular, Diagnosis.diabetes},
);
final sedentaryInputs = BehavioralInputs(
smoking: SmokingStatus.never,
cigarettesPerDay: 0,
alcohol: AlcoholLevel.none,
sleepHours: 7.5,
sleepConsistent: true,
activity: ActivityLevel.sedentary,
driving: DrivingExposure.low,
workHours: WorkHoursLevel.normal,
);
final healthyResult = calculateRankedFactors(healthyProfile, sedentaryInputs);
final unhealthyResult = calculateRankedFactors(unhealthyProfile, sedentaryInputs);
// Unhealthy person has less to gain (lower baseline)
if (healthyResult.dominantFactor != null && unhealthyResult.dominantFactor != null) {
expect(
unhealthyResult.dominantFactor!.delta.highMonths,
lessThan(healthyResult.dominantFactor!.delta.highMonths),
);
}
});
});
}

7
test/widget_test.dart Normal file
View file

@ -0,0 +1,7 @@
// Widget tests require SQLite mocking which is complex.
// Core logic is covered by risk_engine tests.
// Integration testing should be done on device.
void main() {
// Run: flutter test test/risk_engine/ for unit tests
}

198
tool/generate_icon.dart Normal file
View file

@ -0,0 +1,198 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:math';
/// Generates a simple tree icon PNG
void main() async {
const size = 1024;
final pixels = Uint8List(size * size * 4);
// Fill with white background
for (var i = 0; i < pixels.length; i += 4) {
pixels[i] = 255; // R
pixels[i + 1] = 255; // G
pixels[i + 2] = 255; // B
pixels[i + 3] = 255; // A
}
// Tree colors (muted teal from our theme)
const treeR = 74; // 0x4A
const treeG = 144; // 0x90
const treeB = 164; // 0xA4
// Trunk color (darker)
const trunkR = 90;
const trunkG = 70;
const trunkB = 55;
// Draw tree trunk (rectangle)
final trunkLeft = (size * 0.44).round();
final trunkRight = (size * 0.56).round();
final trunkTop = (size * 0.65).round();
final trunkBottom = (size * 0.85).round();
for (var y = trunkTop; y < trunkBottom; y++) {
for (var x = trunkLeft; x < trunkRight; x++) {
final i = (y * size + x) * 4;
pixels[i] = trunkR;
pixels[i + 1] = trunkG;
pixels[i + 2] = trunkB;
pixels[i + 3] = 255;
}
}
// Draw tree canopy (three triangles stacked)
void drawTriangle(int centerX, int topY, int height, int baseWidth) {
for (var y = topY; y < topY + height; y++) {
final progress = (y - topY) / height;
final halfWidth = (baseWidth * progress / 2).round();
for (var x = centerX - halfWidth; x <= centerX + halfWidth; x++) {
if (x >= 0 && x < size && y >= 0 && y < size) {
final i = (y * size + x) * 4;
pixels[i] = treeR;
pixels[i + 1] = treeG;
pixels[i + 2] = treeB;
pixels[i + 3] = 255;
}
}
}
}
final centerX = size ~/ 2;
// Top triangle (smallest)
drawTriangle(centerX, (size * 0.15).round(), (size * 0.20).round(), (size * 0.35).round());
// Middle triangle
drawTriangle(centerX, (size * 0.28).round(), (size * 0.22).round(), (size * 0.48).round());
// Bottom triangle (largest)
drawTriangle(centerX, (size * 0.42).round(), (size * 0.26).round(), (size * 0.58).round());
// Encode as PNG
final png = encodePng(size, size, pixels);
// Write to file
final file = File('assets/icon/app_icon.png');
await file.writeAsBytes(png);
print('Generated app_icon.png');
// Also create foreground version (same but smaller for adaptive icons)
final foregroundFile = File('assets/icon/app_icon_foreground.png');
await foregroundFile.writeAsBytes(png);
print('Generated app_icon_foreground.png');
}
/// Simple PNG encoder (no compression for simplicity)
Uint8List encodePng(int width, int height, Uint8List rgba) {
final output = BytesBuilder();
// PNG signature
output.add([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
// IHDR chunk
final ihdr = BytesBuilder();
ihdr.add(_int32be(width));
ihdr.add(_int32be(height));
ihdr.addByte(8); // bit depth
ihdr.addByte(6); // color type (RGBA)
ihdr.addByte(0); // compression
ihdr.addByte(0); // filter
ihdr.addByte(0); // interlace
_writeChunk(output, 'IHDR', ihdr.toBytes());
// IDAT chunk (image data with zlib compression)
// For simplicity, we'll use store (no compression)
final rawData = BytesBuilder();
for (var y = 0; y < height; y++) {
rawData.addByte(0); // filter type: None
for (var x = 0; x < width; x++) {
final i = (y * width + x) * 4;
rawData.addByte(rgba[i]); // R
rawData.addByte(rgba[i + 1]); // G
rawData.addByte(rgba[i + 2]); // B
rawData.addByte(rgba[i + 3]); // A
}
}
final compressed = _deflateStore(rawData.toBytes());
_writeChunk(output, 'IDAT', compressed);
// IEND chunk
_writeChunk(output, 'IEND', Uint8List(0));
return output.toBytes();
}
Uint8List _int32be(int value) {
return Uint8List.fromList([
(value >> 24) & 0xFF,
(value >> 16) & 0xFF,
(value >> 8) & 0xFF,
value & 0xFF,
]);
}
void _writeChunk(BytesBuilder output, String type, Uint8List data) {
output.add(_int32be(data.length));
final typeBytes = type.codeUnits;
output.add(typeBytes);
output.add(data);
// CRC32 of type + data
final crcData = Uint8List(typeBytes.length + data.length);
crcData.setAll(0, typeBytes);
crcData.setAll(typeBytes.length, data);
output.add(_int32be(_crc32(crcData)));
}
/// Simple deflate with store (no compression)
Uint8List _deflateStore(Uint8List data) {
final output = BytesBuilder();
// zlib header
output.addByte(0x78); // CMF
output.addByte(0x01); // FLG (no dict, fastest)
// Split into blocks of max 65535 bytes
const maxBlock = 65535;
var offset = 0;
while (offset < data.length) {
final remaining = data.length - offset;
final blockSize = remaining > maxBlock ? maxBlock : remaining;
final isLast = offset + blockSize >= data.length;
output.addByte(isLast ? 0x01 : 0x00); // BFINAL + BTYPE (store)
output.addByte(blockSize & 0xFF);
output.addByte((blockSize >> 8) & 0xFF);
output.addByte((~blockSize) & 0xFF);
output.addByte(((~blockSize) >> 8) & 0xFF);
output.add(data.sublist(offset, offset + blockSize));
offset += blockSize;
}
// Adler-32 checksum
var s1 = 1;
var s2 = 0;
for (var i = 0; i < data.length; i++) {
s1 = (s1 + data[i]) % 65521;
s2 = (s2 + s1) % 65521;
}
final adler = (s2 << 16) | s1;
output.add(_int32be(adler));
return output.toBytes();
}
int _crc32(Uint8List data) {
var crc = 0xFFFFFFFF;
for (var byte in data) {
crc ^= byte;
for (var i = 0; i < 8; i++) {
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
}
}
return crc ^ 0xFFFFFFFF;
}