Add saved runs feature with history, comparison, and about screen (v1.2)

Features:
- Save calculation runs with custom labels for later retrieval
- View saved runs list with dominant factor summary
- View detailed results of any saved run
- View original inputs in read-only mode
- Use any saved run as starting point for new calculation
- Compare two saved runs side-by-side
- About screen with app info, help links, privacy policy
- Metric/Imperial unit toggle persisted across sessions
- Info icon in app bar on all screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-21 09:59:58 -08:00
parent 40275bcd0c
commit 85e8e1a290
15 changed files with 1907 additions and 116 deletions

View file

@ -2,3 +2,4 @@ export 'enums.dart';
export 'user_profile.dart';
export 'behavioral_inputs.dart';
export 'result.dart';
export 'saved_run.dart';

80
lib/models/saved_run.dart Normal file
View file

@ -0,0 +1,80 @@
import 'behavioral_inputs.dart';
import 'result.dart';
import 'user_profile.dart';
class SavedRun {
final String id;
final String label;
final CalculationResult result;
final UserProfile profile;
final BehavioralInputs behaviors;
final DateTime createdAt;
const SavedRun({
required this.id,
required this.label,
required this.result,
required this.profile,
required this.behaviors,
required this.createdAt,
});
String get displayDate {
final now = DateTime.now();
final diff = now.difference(createdAt);
if (diff.inDays == 0) {
return 'Today';
} else if (diff.inDays == 1) {
return 'Yesterday';
} else if (diff.inDays < 7) {
return '${diff.inDays} days ago';
} else {
return '${createdAt.month}/${createdAt.day}/${createdAt.year}';
}
}
String get dominantFactorSummary {
final factor = result.dominantFactor;
if (factor == null) return 'Optimal';
return '${factor.displayName}: ${factor.delta.rangeDisplay} mo';
}
Map<String, dynamic> toJson() => {
'id': id,
'label': label,
'result': result.toJson(),
'profile': profile.toJson(),
'behaviors': behaviors.toJson(),
'createdAt': createdAt.toIso8601String(),
};
factory SavedRun.fromJson(Map<String, dynamic> json) => SavedRun(
id: json['id'] as String,
label: json['label'] as String,
result:
CalculationResult.fromJson(json['result'] as Map<String, dynamic>),
profile:
UserProfile.fromJson(json['profile'] as Map<String, dynamic>),
behaviors: BehavioralInputs.fromJson(
json['behaviors'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['createdAt'] as String),
);
SavedRun copyWith({
String? id,
String? label,
CalculationResult? result,
UserProfile? profile,
BehavioralInputs? behaviors,
DateTime? createdAt,
}) =>
SavedRun(
id: id ?? this.id,
label: label ?? this.label,
result: result ?? this.result,
profile: profile ?? this.profile,
behaviors: behaviors ?? this.behaviors,
createdAt: createdAt ?? this.createdAt,
);
}

View file

@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
static const String _helpUrl = 'https://addmonths.app/help';
static const String _privacyUrl = 'https://addmonths.app/privacy';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('About'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App name and version
Center(
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.timeline,
size: 40,
color: AppColors.primary,
),
),
const SizedBox(height: 16),
Text(
'Add Months',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 4),
Text(
'Version 1.2',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(height: 32),
// Description
Text(
'What is Add Months?',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Add Months uses evidence-based hazard ratios from peer-reviewed '
'meta-analyses to identify which single lifestyle change could '
'have the biggest impact on your lifespan.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
'Answer simple questions about your demographics and habits, '
'and the app calculates which modifiable factor offers the '
'greatest potential benefit.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
// Privacy note
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(
Icons.lock_outline,
color: AppColors.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Your data stays on your device',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'No accounts, no cloud sync, no analytics. '
'Everything is stored locally.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
const SizedBox(height: 32),
// Links
_buildLinkButton(
context,
icon: Icons.help_outline,
label: 'How it works',
onTap: () => _launchUrl(_helpUrl),
),
const SizedBox(height: 12),
_buildLinkButton(
context,
icon: Icons.privacy_tip_outlined,
label: 'Privacy Policy',
onTap: () => _launchUrl(_privacyUrl),
),
const SizedBox(height: 32),
// Disclaimer
Text(
'Disclaimer',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'This app provides general information based on population-level '
'research and is not medical advice. Individual results vary widely. '
'Consult a healthcare provider for personalized guidance.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
);
}
Widget _buildLinkButton(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: AppColors.divider),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: AppColors.primary),
const SizedBox(width: 12),
Text(
label,
style: Theme.of(context).textTheme.bodyLarge,
),
const Spacer(),
const Icon(
Icons.open_in_new,
size: 18,
color: AppColors.textSecondary,
),
],
),
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}

View file

@ -3,10 +3,18 @@ import '../models/models.dart';
import '../risk_engine/mortality_tables.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'behavioral_screen.dart';
class BaselineScreen extends StatefulWidget {
const BaselineScreen({super.key});
final bool readOnly;
final UserProfile? initialProfile;
const BaselineScreen({
super.key,
this.readOnly = false,
this.initialProfile,
});
@override
State<BaselineScreen> createState() => _BaselineScreenState();
@ -19,6 +27,7 @@ class _BaselineScreenState extends State<BaselineScreen> {
double _heightCm = 170;
double _weightKg = 70;
final Set<Diagnosis> _diagnoses = {};
bool _useMetric = false;
late List<String> _countries;
@ -26,24 +35,39 @@ class _BaselineScreenState extends State<BaselineScreen> {
void initState() {
super.initState();
_countries = getSupportedCountries();
_loadExistingProfile();
_loadInitialData();
}
Future<void> _loadExistingProfile() async {
Future<void> _loadInitialData() async {
// Load unit preference
final useMetric = await LocalStorage.getUseMetricUnits();
setState(() => _useMetric = useMetric);
// If initial profile provided, use that
if (widget.initialProfile != null) {
_applyProfile(widget.initialProfile!);
return;
}
// Otherwise load from storage
final profile = await LocalStorage.getProfile();
if (profile != null) {
setState(() {
_age = profile.age;
_sex = profile.sex;
_country = profile.country;
_heightCm = profile.heightCm;
_weightKg = profile.weightKg;
_diagnoses.clear();
_diagnoses.addAll(profile.diagnoses);
});
_applyProfile(profile);
}
}
void _applyProfile(UserProfile profile) {
setState(() {
_age = profile.age;
_sex = profile.sex;
_country = profile.country;
_heightCm = profile.heightCm;
_weightKg = profile.weightKg;
_diagnoses.clear();
_diagnoses.addAll(profile.diagnoses);
});
}
double get _bmi => _weightKg / ((_heightCm / 100) * (_heightCm / 100));
String get _bmiCategory {
@ -59,11 +83,25 @@ class _BaselineScreenState extends State<BaselineScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Baseline'),
title: Text(widget.readOnly ? 'Baseline (View Only)' : 'Baseline'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(_useMetric ? Icons.straighten : Icons.square_foot),
tooltip: _useMetric ? 'Using Metric' : 'Using Imperial',
onPressed: widget.readOnly ? null : _toggleUnits,
),
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
@ -129,14 +167,15 @@ class _BaselineScreenState extends State<BaselineScreen> {
_buildDiagnosisCheckboxes(),
const SizedBox(height: 32),
// Continue button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _continue,
child: const Text('Continue'),
// Continue button (hidden in readOnly mode)
if (!widget.readOnly)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _continue,
child: const Text('Continue'),
),
),
),
const SizedBox(height: 16),
],
),
@ -162,7 +201,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: _age > 18 ? () => setState(() => _age--) : null,
onPressed: widget.readOnly || _age <= 18
? null
: () => setState(() => _age--),
),
Expanded(
child: Text(
@ -173,7 +214,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _age < 100 ? () => setState(() => _age++) : null,
onPressed: widget.readOnly || _age >= 100
? null
: () => setState(() => _age++),
),
],
),
@ -187,7 +230,7 @@ class _BaselineScreenState extends State<BaselineScreen> {
child: _buildToggleButton(
'Male',
_sex == Sex.male,
() => setState(() => _sex = Sex.male),
widget.readOnly ? null : () => setState(() => _sex = Sex.male),
),
),
const SizedBox(width: 12),
@ -195,14 +238,14 @@ class _BaselineScreenState extends State<BaselineScreen> {
child: _buildToggleButton(
'Female',
_sex == Sex.female,
() => setState(() => _sex = Sex.female),
widget.readOnly ? null : () => setState(() => _sex = Sex.female),
),
),
],
);
}
Widget _buildToggleButton(String label, bool selected, VoidCallback onTap) {
Widget _buildToggleButton(String label, bool selected, VoidCallback? onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
@ -242,26 +285,35 @@ class _BaselineScreenState extends State<BaselineScreen> {
child: Text(country),
);
}).toList(),
onChanged: (value) {
if (value != null) setState(() => _country = value);
},
onChanged: widget.readOnly
? null
: (value) {
if (value != null) setState(() => _country = value);
},
),
),
);
}
Widget _buildHeightSlider() {
final primaryText = _useMetric
? '${_heightCm.round()} cm'
: _cmToFeetInches(_heightCm);
final secondaryText = _useMetric
? _cmToFeetInches(_heightCm)
: '${_heightCm.round()} cm';
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_heightCm.round()} cm',
primaryText,
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
_cmToFeetInches(_heightCm),
secondaryText,
style: Theme.of(context).textTheme.bodyMedium,
),
],
@ -271,24 +323,30 @@ class _BaselineScreenState extends State<BaselineScreen> {
min: 120,
max: 220,
divisions: 100,
onChanged: (value) => setState(() => _heightCm = value),
onChanged: widget.readOnly
? null
: (value) => setState(() => _heightCm = value),
),
],
);
}
Widget _buildWeightSlider() {
final lbs = (_weightKg * 2.205).round();
final primaryText = _useMetric ? '${_weightKg.round()} kg' : '$lbs lbs';
final secondaryText = _useMetric ? '$lbs lbs' : '${_weightKg.round()} kg';
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_weightKg.round()} kg',
primaryText,
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'${(_weightKg * 2.205).round()} lbs',
secondaryText,
style: Theme.of(context).textTheme.bodyMedium,
),
],
@ -298,7 +356,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
min: 30,
max: 200,
divisions: 170,
onChanged: (value) => setState(() => _weightKg = value),
onChanged: widget.readOnly
? null
: (value) => setState(() => _weightKg = value),
),
],
);
@ -357,15 +417,17 @@ class _BaselineScreenState extends State<BaselineScreen> {
children: Diagnosis.values.map((diagnosis) {
return CheckboxListTile(
value: _diagnoses.contains(diagnosis),
onChanged: (checked) {
setState(() {
if (checked == true) {
_diagnoses.add(diagnosis);
} else {
_diagnoses.remove(diagnosis);
}
});
},
onChanged: widget.readOnly
? null
: (checked) {
setState(() {
if (checked == true) {
_diagnoses.add(diagnosis);
} else {
_diagnoses.remove(diagnosis);
}
});
},
title: Text(_getDiagnosisLabel(diagnosis)),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
@ -396,6 +458,12 @@ class _BaselineScreenState extends State<BaselineScreen> {
return "$feet'$inches\"";
}
Future<void> _toggleUnits() async {
final newValue = !_useMetric;
await LocalStorage.setUseMetricUnits(newValue);
setState(() => _useMetric = newValue);
}
void _continue() async {
final profile = UserProfile(
age: _age,

View file

@ -3,12 +3,20 @@ import 'package:flutter/services.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'lifestyle_screen.dart';
class BehavioralScreen extends StatefulWidget {
final UserProfile profile;
final bool readOnly;
final BehavioralInputs? initialBehaviors;
const BehavioralScreen({super.key, required this.profile});
const BehavioralScreen({
super.key,
required this.profile,
this.readOnly = false,
this.initialBehaviors,
});
@override
State<BehavioralScreen> createState() => _BehavioralScreenState();
@ -25,32 +33,52 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
@override
void initState() {
super.initState();
_loadExistingBehaviors();
_loadInitialData();
}
Future<void> _loadExistingBehaviors() async {
Future<void> _loadInitialData() async {
// If initial behaviors provided, use those
if (widget.initialBehaviors != null) {
_applyBehaviors(widget.initialBehaviors!);
return;
}
// Otherwise load from storage
final behaviors = await LocalStorage.getBehaviors();
if (behaviors != null) {
setState(() {
_smoking = behaviors.smoking;
_cigarettesPerDay = behaviors.cigarettesPerDay;
_alcohol = behaviors.alcohol;
_sleepHours = behaviors.sleepHours;
_sleepConsistent = behaviors.sleepConsistent;
_activity = behaviors.activity;
});
_applyBehaviors(behaviors);
}
}
void _applyBehaviors(BehavioralInputs behaviors) {
setState(() {
_smoking = behaviors.smoking;
_cigarettesPerDay = behaviors.cigarettesPerDay;
_alcohol = behaviors.alcohol;
_sleepHours = behaviors.sleepHours;
_sleepConsistent = behaviors.sleepConsistent;
_activity = behaviors.activity;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Habits'),
title: Text(widget.readOnly ? 'Habits (View Only)' : 'Habits'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
@ -98,14 +126,15 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
_buildActivitySelector(),
const SizedBox(height: 40),
// Continue button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _continue,
child: const Text('Continue'),
// Continue button (hidden in readOnly mode)
if (!widget.readOnly)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _continue,
child: const Text('Continue'),
),
),
),
const SizedBox(height: 16),
],
),
@ -128,7 +157,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
(SmokingStatus.former, 'Former'),
(SmokingStatus.current, 'Current'),
],
onChanged: (value) => setState(() => _smoking = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _smoking = value),
);
}
@ -149,12 +178,14 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
min: 1,
max: 40,
divisions: 39,
onChanged: (value) {
if (value.round() == 20 && _cigarettesPerDay != 20) {
HapticFeedback.mediumImpact();
}
setState(() => _cigarettesPerDay = value.round());
},
onChanged: widget.readOnly
? null
: (value) {
if (value.round() == 20 && _cigarettesPerDay != 20) {
HapticFeedback.mediumImpact();
}
setState(() => _cigarettesPerDay = value.round());
},
),
),
SizedBox(
@ -187,7 +218,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
(AlcoholLevel.heavy, '15-21'),
(AlcoholLevel.veryHeavy, '21+'),
],
onChanged: (value) => setState(() => _alcohol = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _alcohol = value),
);
}
@ -212,7 +243,9 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
min: 4,
max: 12,
divisions: 16,
onChanged: (value) => setState(() => _sleepHours = value),
onChanged: widget.readOnly
? null
: (value) => setState(() => _sleepHours = value),
),
],
);
@ -228,7 +261,9 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
),
Switch(
value: _sleepConsistent,
onChanged: (value) => setState(() => _sleepConsistent = value),
onChanged: widget.readOnly
? null
: (value) => setState(() => _sleepConsistent = value),
),
],
);
@ -243,14 +278,14 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
(ActivityLevel.moderate, 'Moderate'),
(ActivityLevel.high, 'High'),
],
onChanged: (value) => setState(() => _activity = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _activity = value),
);
}
Widget _buildSegmentedControl<T>({
required T value,
required List<(T, String)> options,
required ValueChanged<T> onChanged,
required ValueChanged<T>? onChanged,
}) {
return Container(
decoration: BoxDecoration(
@ -262,7 +297,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
final isSelected = value == option.$1;
return Expanded(
child: GestureDetector(
onTap: () => onChanged(option.$1),
onTap: onChanged == null ? null : () => onChanged(option.$1),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(

View file

@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
class CompareRunsScreen extends StatefulWidget {
final SavedRun initialRun;
const CompareRunsScreen({super.key, required this.initialRun});
@override
State<CompareRunsScreen> createState() => _CompareRunsScreenState();
}
class _CompareRunsScreenState extends State<CompareRunsScreen> {
List<SavedRun> _allRuns = [];
SavedRun? _selectedRun;
bool _loading = true;
@override
void initState() {
super.initState();
_loadRuns();
}
Future<void> _loadRuns() async {
final runs = await LocalStorage.getSavedRuns();
// Exclude the initial run from selection options
final otherRuns = runs.where((r) => r.id != widget.initialRun.id).toList();
setState(() {
_allRuns = otherRuns;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Compare Runs'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _buildContent(),
);
}
Widget _buildContent() {
if (_selectedRun == null) {
return _buildRunSelector();
}
return _buildComparison();
}
Widget _buildRunSelector() {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select a run to compare with',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Comparing: ${widget.initialRun.label}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.primary,
),
),
const SizedBox(height: 24),
Expanded(
child: ListView.builder(
itemCount: _allRuns.length,
itemBuilder: (context, index) {
final run = _allRuns[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(run.label),
subtitle: Text(run.displayDate),
trailing: const Icon(Icons.chevron_right),
onTap: () => setState(() => _selectedRun = run),
),
);
},
),
),
],
),
);
}
Widget _buildComparison() {
final runA = widget.initialRun;
final runB = _selectedRun!;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with run labels
Row(
children: [
Expanded(
child: _buildRunHeader(runA, 'Run A'),
),
const SizedBox(width: 16),
Expanded(
child: _buildRunHeader(runB, 'Run B'),
),
],
),
const SizedBox(height: 24),
// Dominant factors comparison
Text(
'Dominant Challenge',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_buildDominantComparison(runA, runB),
const SizedBox(height: 24),
// Profile changes
Text(
'Profile Changes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_buildProfileComparison(runA.profile, runB.profile),
const SizedBox(height: 32),
// Change comparison button
Center(
child: TextButton(
onPressed: () => setState(() => _selectedRun = null),
child: const Text('Compare with different run'),
),
),
],
),
);
}
Widget _buildRunHeader(SavedRun run, String tag) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tag,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
run.label,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
run.displayDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
);
}
Widget _buildDominantComparison(SavedRun runA, SavedRun runB) {
final dominantA = runA.result.dominantFactor;
final dominantB = runB.result.dominantFactor;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildCompactFactorCard(dominantA),
),
const SizedBox(width: 16),
Expanded(
child: _buildCompactFactorCard(dominantB),
),
],
);
}
Widget _buildCompactFactorCard(RankedFactor? factor) {
if (factor == null) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.success.withAlpha(26),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
const Icon(Icons.check_circle, color: AppColors.success),
const SizedBox(height: 8),
Text(
'Optimal',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: AppColors.success,
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.primary.withAlpha(26),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
factor.displayName,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'${factor.delta.rangeDisplay} mo',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.primary,
),
),
],
),
);
}
Widget _buildProfileComparison(UserProfile profileA, UserProfile profileB) {
final changes = <Widget>[];
if (profileA.age != profileB.age) {
changes.add(_buildChangeRow(
'Age',
'${profileA.age}',
'${profileB.age}',
));
}
if (profileA.weightKg != profileB.weightKg) {
changes.add(_buildChangeRow(
'Weight',
'${profileA.weightKg.round()} kg',
'${profileB.weightKg.round()} kg',
));
}
if (profileA.country != profileB.country) {
changes.add(_buildChangeRow(
'Country',
profileA.country,
profileB.country,
));
}
if (changes.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'No profile changes between runs',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
),
);
}
return Column(children: changes);
}
Widget _buildChangeRow(String label, String valueA, String valueB) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Expanded(
flex: 2,
child: Text(
valueA,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
const Icon(Icons.arrow_forward, size: 16, color: AppColors.textSecondary),
Expanded(
flex: 2,
child: Text(
valueB,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
),
],
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'results_screen.dart';
class LifestyleScreen extends StatefulWidget {
@ -12,6 +13,8 @@ class LifestyleScreen extends StatefulWidget {
final double sleepHours;
final bool sleepConsistent;
final ActivityLevel activity;
final bool readOnly;
final BehavioralInputs? initialBehaviors;
const LifestyleScreen({
super.key,
@ -22,6 +25,8 @@ class LifestyleScreen extends StatefulWidget {
required this.sleepHours,
required this.sleepConsistent,
required this.activity,
this.readOnly = false,
this.initialBehaviors,
});
@override
@ -36,37 +41,62 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
StressLevel _stress = StressLevel.moderate;
DrivingExposure _driving = DrivingExposure.low;
WorkHoursLevel _workHours = WorkHoursLevel.normal;
bool _useMetric = false;
@override
void initState() {
super.initState();
_loadExistingBehaviors();
_loadInitialData();
}
Future<void> _loadExistingBehaviors() async {
Future<void> _loadInitialData() async {
// Load unit preference
final useMetric = await LocalStorage.getUseMetricUnits();
setState(() => _useMetric = useMetric);
// If initial behaviors provided, use those
if (widget.initialBehaviors != null) {
_applyBehaviors(widget.initialBehaviors!);
return;
}
// Otherwise load from storage
final behaviors = await LocalStorage.getBehaviors();
if (behaviors != null) {
setState(() {
_diet = behaviors.diet;
_processedFood = behaviors.processedFood;
_drugUse = behaviors.drugUse;
_social = behaviors.social;
_stress = behaviors.stress;
_driving = behaviors.driving;
_workHours = behaviors.workHours;
});
_applyBehaviors(behaviors);
}
}
void _applyBehaviors(BehavioralInputs behaviors) {
setState(() {
_diet = behaviors.diet;
_processedFood = behaviors.processedFood;
_drugUse = behaviors.drugUse;
_social = behaviors.social;
_stress = behaviors.stress;
_driving = behaviors.driving;
_workHours = behaviors.workHours;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Lifestyle'),
title: Text(widget.readOnly ? 'Lifestyle (View Only)' : 'Lifestyle'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
@ -120,7 +150,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
const SizedBox(height: 28),
// Driving Exposure
_buildSectionLabel('Driving Exposure'),
_buildSectionLabel(_useMetric ? 'Driving (km/week)' : 'Driving (mi/week)'),
const SizedBox(height: 12),
_buildDrivingSelector(),
const SizedBox(height: 28),
@ -131,14 +161,15 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
_buildWorkHoursSelector(),
const SizedBox(height: 40),
// Calculate button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _calculate,
child: const Text('Calculate'),
// Calculate button (hidden in readOnly mode)
if (!widget.readOnly)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _calculate,
child: const Text('Calculate'),
),
),
),
const SizedBox(height: 16),
],
),
@ -169,7 +200,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
(DietQuality.good, 'Good'),
(DietQuality.excellent, 'Excellent'),
],
onChanged: (value) => setState(() => _diet = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _diet = value),
);
}
@ -182,7 +213,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
(ProcessedFoodLevel.occasional, 'Sometimes'),
(ProcessedFoodLevel.rarely, 'Rarely'),
],
onChanged: (value) => setState(() => _processedFood = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _processedFood = value),
);
}
@ -195,7 +226,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
(DrugUse.regular, 'Regular'),
(DrugUse.daily, 'Daily'),
],
onChanged: (value) => setState(() => _drugUse = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _drugUse = value),
);
}
@ -208,7 +239,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
(SocialConnection.moderate, 'Moderate'),
(SocialConnection.strong, 'Strong'),
],
onChanged: (value) => setState(() => _social = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _social = value),
);
}
@ -221,20 +252,30 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
(StressLevel.high, 'High'),
(StressLevel.chronic, 'Chronic'),
],
onChanged: (value) => setState(() => _stress = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _stress = value),
);
}
Widget _buildDrivingSelector() {
// Show metric (km) or imperial (mi) based on preference
final options = _useMetric
? [
(DrivingExposure.low, '<80 km'),
(DrivingExposure.moderate, '80-240'),
(DrivingExposure.high, '240-480'),
(DrivingExposure.veryHigh, '480+'),
]
: [
(DrivingExposure.low, '<50 mi'),
(DrivingExposure.moderate, '50-150'),
(DrivingExposure.high, '150-300'),
(DrivingExposure.veryHigh, '300+'),
];
return _buildSegmentedControl<DrivingExposure>(
value: _driving,
options: [
(DrivingExposure.low, '<50 mi/wk'),
(DrivingExposure.moderate, '50-150'),
(DrivingExposure.high, '150-300'),
(DrivingExposure.veryHigh, '300+'),
],
onChanged: (value) => setState(() => _driving = value),
options: options,
onChanged: widget.readOnly ? null : (value) => setState(() => _driving = value),
);
}
@ -247,14 +288,14 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
(WorkHoursLevel.high, '55-70'),
(WorkHoursLevel.extreme, '70+'),
],
onChanged: (value) => setState(() => _workHours = value),
onChanged: widget.readOnly ? null : (value) => setState(() => _workHours = value),
);
}
Widget _buildSegmentedControl<T>({
required T value,
required List<(T, String)> options,
required ValueChanged<T> onChanged,
required ValueChanged<T>? onChanged,
}) {
return Container(
decoration: BoxDecoration(
@ -266,7 +307,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
final isSelected = value == option.$1;
return Expanded(
child: GestureDetector(
onTap: () => onChanged(option.$1),
onTap: onChanged == null ? null : () => onChanged(option.$1),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(

View file

@ -3,8 +3,10 @@ import '../models/models.dart';
import '../risk_engine/calculator.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'onboarding_screen.dart';
import 'about_screen.dart';
import 'baseline_screen.dart';
import 'onboarding_screen.dart';
import 'saved_runs_screen.dart';
class ResultsScreen extends StatefulWidget {
final UserProfile profile;
@ -43,6 +45,15 @@ class _ResultsScreenState extends State<ResultsScreen> {
appBar: AppBar(
title: const Text('Results'),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
@ -107,11 +118,26 @@ class _ResultsScreenState extends State<ResultsScreen> {
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveRun,
child: const Text('Save Run'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _recalculate,
child: const Text('Recalculate'),
),
),
const SizedBox(height: 12),
Center(
child: TextButton(
onPressed: _viewSavedRuns,
child: const Text('View Saved Runs'),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: OutlinedButton(
@ -391,4 +417,55 @@ class _ResultsScreenState extends State<ResultsScreen> {
),
);
}
void _saveRun() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Save Run'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Enter a label for this run',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final label = controller.text.trim();
if (label.isNotEmpty) {
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
await LocalStorage.saveSavedRun(
label: label,
result: _result,
profile: widget.profile,
behaviors: widget.behaviors,
);
navigator.pop();
messenger.showSnackBar(
const SnackBar(content: Text('Run saved')),
);
}
},
child: const Text('Save'),
),
],
),
);
}
void _viewSavedRuns() {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SavedRunsScreen()),
);
}
}

View file

@ -0,0 +1,495 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'baseline_screen.dart';
import 'compare_runs_screen.dart';
class SavedRunDetailScreen extends StatefulWidget {
final SavedRun savedRun;
const SavedRunDetailScreen({super.key, required this.savedRun});
@override
State<SavedRunDetailScreen> createState() => _SavedRunDetailScreenState();
}
class _SavedRunDetailScreenState extends State<SavedRunDetailScreen> {
late SavedRun _savedRun;
int _savedRunsCount = 0;
@override
void initState() {
super.initState();
_savedRun = widget.savedRun;
_loadSavedRunsCount();
}
Future<void> _loadSavedRunsCount() async {
final count = await LocalStorage.getSavedRunsCount();
setState(() => _savedRunsCount = count);
}
@override
Widget build(BuildContext context) {
final result = _savedRun.result;
final dominant = result.dominantFactor;
final secondary = result.secondaryFactor;
return Scaffold(
appBar: AppBar(
title: const Text('Saved Run'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Label (editable)
_buildLabelRow(),
const SizedBox(height: 8),
Text(
_savedRun.displayDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 24),
if (dominant != null) ...[
// Dominant challenge card
_buildDominantCard(dominant),
const SizedBox(height: 24),
// Secondary factor
if (secondary != null) ...[
Text(
'Secondary Factor',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
_buildSecondaryCard(secondary),
const SizedBox(height: 24),
],
// All factors
if (result.rankedFactors.length > 2) ...[
Text(
'All Factors',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
...result.rankedFactors.skip(2).map((factor) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildFactorRow(factor),
);
}),
const SizedBox(height: 16),
],
] else ...[
_buildOptimalCard(),
const SizedBox(height: 24),
],
// Profile summary
_buildProfileSummary(),
const SizedBox(height: 24),
// Model version
Center(
child: Text(
'Model v${result.modelVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 32),
// Action buttons
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _viewInputs,
child: const Text('View Inputs'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _useAsStartingPoint,
child: const Text('Use as Starting Point'),
),
),
if (_savedRunsCount >= 2) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _compare,
child: const Text('Compare'),
),
),
],
const SizedBox(height: 16),
],
),
),
),
);
}
Widget _buildLabelRow() {
return Row(
children: [
Expanded(
child: Text(
_savedRun.label,
style: Theme.of(context).textTheme.headlineMedium,
),
),
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: _editLabel,
),
],
);
}
Widget _buildDominantCard(RankedFactor factor) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primary, AppColors.primaryDark],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'DOMINANT CHALLENGE',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white70,
letterSpacing: 1.2,
),
),
const SizedBox(height: 12),
Text(
factor.displayName,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: -0.5,
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ESTIMATED GAIN',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white60,
letterSpacing: 0.8,
),
),
const SizedBox(height: 4),
Text(
'${factor.delta.rangeDisplay} months',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withAlpha(51),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_getConfidenceLabel(factor.delta.confidence),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
],
),
);
}
Widget _buildSecondaryCard(RankedFactor factor) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.divider),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
factor.displayName,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
'${factor.delta.rangeDisplay} months',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
_buildConfidenceBadge(factor.delta.confidence),
],
),
);
}
Widget _buildFactorRow(RankedFactor factor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
factor.displayName,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'${factor.delta.rangeDisplay} mo',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildOptimalCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.success.withAlpha(26),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.success.withAlpha(77)),
),
child: Column(
children: [
const Icon(
Icons.check_circle_outline,
size: 48,
color: AppColors.success,
),
const SizedBox(height: 16),
Text(
'No significant factors identified',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.success,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Behaviors were near optimal at this time.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildProfileSummary() {
final profile = _savedRun.profile;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.person_outline, color: AppColors.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Text(
'${profile.age} years old, ${profile.sex.name}, ${profile.country}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
),
],
),
);
}
Widget _buildConfidenceBadge(Confidence confidence) {
Color color;
switch (confidence) {
case Confidence.high:
color = AppColors.success;
break;
case Confidence.moderate:
color = AppColors.warning;
break;
case Confidence.emerging:
color = AppColors.textTertiary;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(26),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_getConfidenceLabel(confidence),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
String _getConfidenceLabel(Confidence confidence) {
switch (confidence) {
case Confidence.high:
return 'High';
case Confidence.moderate:
return 'Moderate';
case Confidence.emerging:
return 'Emerging';
}
}
void _editLabel() {
final controller = TextEditingController(text: _savedRun.label);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit Label'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Enter a label',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final newLabel = controller.text.trim();
final navigator = Navigator.of(context);
if (newLabel.isNotEmpty) {
await LocalStorage.updateSavedRunLabel(_savedRun.id, newLabel);
setState(() {
_savedRun = _savedRun.copyWith(label: newLabel);
});
}
navigator.pop();
},
child: const Text('Save'),
),
],
),
);
}
void _viewInputs() {
// Navigate to read-only baseline screen with saved data
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BaselineScreen(
readOnly: true,
initialProfile: _savedRun.profile,
),
),
);
}
void _useAsStartingPoint() {
// Navigate to editable baseline screen with saved data pre-filled
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (_) => BaselineScreen(
initialProfile: _savedRun.profile,
),
),
(route) => false,
);
}
void _compare() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CompareRunsScreen(initialRun: _savedRun),
),
);
}
}

View file

@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'saved_run_detail_screen.dart';
class SavedRunsScreen extends StatefulWidget {
const SavedRunsScreen({super.key});
@override
State<SavedRunsScreen> createState() => _SavedRunsScreenState();
}
class _SavedRunsScreenState extends State<SavedRunsScreen> {
List<SavedRun> _savedRuns = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadSavedRuns();
}
Future<void> _loadSavedRuns() async {
final runs = await LocalStorage.getSavedRuns();
setState(() {
_savedRuns = runs;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Saved Runs'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _savedRuns.isEmpty
? _buildEmptyState()
: _buildRunsList(),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_outline,
size: 64,
color: AppColors.textSecondary.withAlpha(128),
),
const SizedBox(height: 16),
Text(
'No saved runs yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'Save a calculation from the results screen to compare runs over time.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildRunsList() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _savedRuns.length,
itemBuilder: (context, index) {
final run = _savedRuns[index];
return _buildRunCard(run);
},
);
}
Widget _buildRunCard(SavedRun run) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _openRunDetail(run),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
run.label,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
run.displayDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
run.dominantFactorSummary,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.primary,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
color: AppColors.error,
onPressed: () => _confirmDelete(run),
),
],
),
),
),
);
}
void _openRunDetail(SavedRun run) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SavedRunDetailScreen(savedRun: run),
),
);
// Refresh list in case label was edited
_loadSavedRuns();
}
void _confirmDelete(SavedRun run) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Run?'),
content: Text('Delete "${run.label}"? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
_deleteRun(run);
},
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Delete'),
),
],
),
);
}
Future<void> _deleteRun(SavedRun run) async {
await LocalStorage.deleteSavedRun(run.id);
_loadSavedRuns();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Run deleted')),
);
}
}
}

View file

@ -1,6 +1,10 @@
export 'onboarding_screen.dart';
export 'welcome_screen.dart';
export 'about_screen.dart';
export 'baseline_screen.dart';
export 'behavioral_screen.dart';
export 'compare_runs_screen.dart';
export 'lifestyle_screen.dart';
export 'onboarding_screen.dart';
export 'results_screen.dart';
export 'saved_run_detail_screen.dart';
export 'saved_runs_screen.dart';
export 'welcome_screen.dart';

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'baseline_screen.dart';
class WelcomeScreen extends StatelessWidget {
@ -59,7 +60,16 @@ class WelcomeScreen extends StatelessWidget {
child: const Text('Start'),
),
),
const SizedBox(height: 32),
const SizedBox(height: 16),
// About link
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
child: const Text('About'),
),
const SizedBox(height: 16),
],
),
),

View file

@ -1,14 +1,17 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:uuid/uuid.dart';
import '../models/models.dart';
class LocalStorage {
static const _dbName = 'add_months.db';
static const _tableName = 'user_data';
static const _version = 1;
static const _savedRunsTable = 'saved_runs';
static const _version = 2;
static Database? _database;
static const _uuid = Uuid();
static Future<Database> get database async {
if (_database != null) return _database!;
@ -31,6 +34,30 @@ class LocalStorage {
updated_at INTEGER NOT NULL
)
''');
await db.execute('''
CREATE TABLE $_savedRunsTable (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
result TEXT NOT NULL,
profile TEXT NOT NULL,
behaviors TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('''
CREATE TABLE $_savedRunsTable (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
result TEXT NOT NULL,
profile TEXT NOT NULL,
behaviors TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
}
},
);
}
@ -101,10 +128,11 @@ class LocalStorage {
return profile != null && behaviors != null;
}
// Delete all data
// Delete all data (including saved runs)
static Future<void> deleteAllData() async {
final db = await database;
await db.delete(_tableName);
await db.delete(_savedRunsTable);
}
// Get last updated timestamp
@ -122,4 +150,124 @@ class LocalStorage {
results.first['updated_at'] as int,
);
}
// ============================================
// Saved Runs operations
// ============================================
static Future<String> saveSavedRun({
required String label,
required CalculationResult result,
required UserProfile profile,
required BehavioralInputs behaviors,
}) async {
final db = await database;
final id = _uuid.v4();
final now = DateTime.now();
await db.insert(_savedRunsTable, {
'id': id,
'label': label,
'result': jsonEncode(result.toJson()),
'profile': jsonEncode(profile.toJson()),
'behaviors': jsonEncode(behaviors.toJson()),
'created_at': now.millisecondsSinceEpoch,
});
return id;
}
static Future<List<SavedRun>> getSavedRuns() async {
final db = await database;
final results = await db.query(
_savedRunsTable,
orderBy: 'created_at DESC',
);
return results.map((row) => SavedRun(
id: row['id'] as String,
label: row['label'] as String,
result: CalculationResult.fromJson(
jsonDecode(row['result'] as String) as Map<String, dynamic>,
),
profile: UserProfile.fromJson(
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
),
behaviors: BehavioralInputs.fromJson(
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
),
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
)).toList();
}
static Future<SavedRun?> getSavedRun(String id) async {
final db = await database;
final results = await db.query(
_savedRunsTable,
where: 'id = ?',
whereArgs: [id],
);
if (results.isEmpty) return null;
final row = results.first;
return SavedRun(
id: row['id'] as String,
label: row['label'] as String,
result: CalculationResult.fromJson(
jsonDecode(row['result'] as String) as Map<String, dynamic>,
),
profile: UserProfile.fromJson(
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
),
behaviors: BehavioralInputs.fromJson(
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
),
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
);
}
static Future<void> updateSavedRunLabel(String id, String newLabel) async {
final db = await database;
await db.update(
_savedRunsTable,
{'label': newLabel},
where: 'id = ?',
whereArgs: [id],
);
}
static Future<void> deleteSavedRun(String id) async {
final db = await database;
await db.delete(
_savedRunsTable,
where: 'id = ?',
whereArgs: [id],
);
}
static Future<void> deleteAllSavedRuns() async {
final db = await database;
await db.delete(_savedRunsTable);
}
static Future<int> getSavedRunsCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM $_savedRunsTable');
return result.first['count'] as int;
}
// ============================================
// Unit preference operations
// ============================================
static Future<void> setUseMetricUnits(bool useMetric) async {
await _put('useMetricUnits', {'value': useMetric});
}
static Future<bool> getUseMetricUnits() async {
final json = await _get('useMetricUnits');
if (json == null) return false; // Default to imperial (US)
return json['value'] as bool;
}
}

View file

@ -121,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@ -525,6 +533,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@ -541,6 +621,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:

View file

@ -34,6 +34,8 @@ dependencies:
sqflite: ^2.3.0
path: ^1.8.3
flutter_secure_storage: ^9.0.0
uuid: ^4.3.3
url_launcher: ^6.2.4
dev_dependencies:
flutter_test: