From 85e8e1a29070ca72d5baab80bf2d6bc0ca8ace3a Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sat, 21 Feb 2026 09:59:58 -0800 Subject: [PATCH] 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 --- lib/models/models.dart | 1 + lib/models/saved_run.dart | 80 ++++ lib/screens/about_screen.dart | 196 +++++++++ lib/screens/baseline_screen.dart | 154 +++++-- lib/screens/behavioral_screen.dart | 99 +++-- lib/screens/compare_runs_screen.dart | 350 ++++++++++++++++ lib/screens/lifestyle_screen.dart | 111 +++-- lib/screens/results_screen.dart | 79 +++- lib/screens/saved_run_detail_screen.dart | 495 +++++++++++++++++++++++ lib/screens/saved_runs_screen.dart | 196 +++++++++ lib/screens/screens.dart | 8 +- lib/screens/welcome_screen.dart | 12 +- lib/storage/local_storage.dart | 152 ++++++- pubspec.lock | 88 ++++ pubspec.yaml | 2 + 15 files changed, 1907 insertions(+), 116 deletions(-) create mode 100644 lib/models/saved_run.dart create mode 100644 lib/screens/about_screen.dart create mode 100644 lib/screens/compare_runs_screen.dart create mode 100644 lib/screens/saved_run_detail_screen.dart create mode 100644 lib/screens/saved_runs_screen.dart diff --git a/lib/models/models.dart b/lib/models/models.dart index bcc4dc0..28ba9e6 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -2,3 +2,4 @@ export 'enums.dart'; export 'user_profile.dart'; export 'behavioral_inputs.dart'; export 'result.dart'; +export 'saved_run.dart'; diff --git a/lib/models/saved_run.dart b/lib/models/saved_run.dart new file mode 100644 index 0000000..f542f32 --- /dev/null +++ b/lib/models/saved_run.dart @@ -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 toJson() => { + 'id': id, + 'label': label, + 'result': result.toJson(), + 'profile': profile.toJson(), + 'behaviors': behaviors.toJson(), + 'createdAt': createdAt.toIso8601String(), + }; + + factory SavedRun.fromJson(Map json) => SavedRun( + id: json['id'] as String, + label: json['label'] as String, + result: + CalculationResult.fromJson(json['result'] as Map), + profile: + UserProfile.fromJson(json['profile'] as Map), + behaviors: BehavioralInputs.fromJson( + json['behaviors'] as Map), + 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, + ); +} diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart new file mode 100644 index 0000000..7e5c8ce --- /dev/null +++ b/lib/screens/about_screen.dart @@ -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 _launchUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } +} diff --git a/lib/screens/baseline_screen.dart b/lib/screens/baseline_screen.dart index 296eaff..319cb0c 100644 --- a/lib/screens/baseline_screen.dart +++ b/lib/screens/baseline_screen.dart @@ -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 createState() => _BaselineScreenState(); @@ -19,6 +27,7 @@ class _BaselineScreenState extends State { double _heightCm = 170; double _weightKg = 70; final Set _diagnoses = {}; + bool _useMetric = false; late List _countries; @@ -26,24 +35,39 @@ class _BaselineScreenState extends State { void initState() { super.initState(); _countries = getSupportedCountries(); - _loadExistingProfile(); + _loadInitialData(); } - Future _loadExistingProfile() async { + Future _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 { 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 { _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 { 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 { ), 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 { 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 { 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 { 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 { 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 { 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 { 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 { return "$feet'$inches\""; } + Future _toggleUnits() async { + final newValue = !_useMetric; + await LocalStorage.setUseMetricUnits(newValue); + setState(() => _useMetric = newValue); + } + void _continue() async { final profile = UserProfile( age: _age, diff --git a/lib/screens/behavioral_screen.dart b/lib/screens/behavioral_screen.dart index bb97b43..b177725 100644 --- a/lib/screens/behavioral_screen.dart +++ b/lib/screens/behavioral_screen.dart @@ -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 createState() => _BehavioralScreenState(); @@ -25,32 +33,52 @@ class _BehavioralScreenState extends State { @override void initState() { super.initState(); - _loadExistingBehaviors(); + _loadInitialData(); } - Future _loadExistingBehaviors() async { + Future _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 { _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 { (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 { 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 { (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 { 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 { ), Switch( value: _sleepConsistent, - onChanged: (value) => setState(() => _sleepConsistent = value), + onChanged: widget.readOnly + ? null + : (value) => setState(() => _sleepConsistent = value), ), ], ); @@ -243,14 +278,14 @@ class _BehavioralScreenState extends State { (ActivityLevel.moderate, 'Moderate'), (ActivityLevel.high, 'High'), ], - onChanged: (value) => setState(() => _activity = value), + onChanged: widget.readOnly ? null : (value) => setState(() => _activity = value), ); } Widget _buildSegmentedControl({ required T value, required List<(T, String)> options, - required ValueChanged onChanged, + required ValueChanged? onChanged, }) { return Container( decoration: BoxDecoration( @@ -262,7 +297,7 @@ class _BehavioralScreenState extends State { 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( diff --git a/lib/screens/compare_runs_screen.dart b/lib/screens/compare_runs_screen.dart new file mode 100644 index 0000000..6d0d81e --- /dev/null +++ b/lib/screens/compare_runs_screen.dart @@ -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 createState() => _CompareRunsScreenState(); +} + +class _CompareRunsScreenState extends State { + List _allRuns = []; + SavedRun? _selectedRun; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadRuns(); + } + + Future _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 = []; + + 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/lifestyle_screen.dart b/lib/screens/lifestyle_screen.dart index 7af5288..9f2ae68 100644 --- a/lib/screens/lifestyle_screen.dart +++ b/lib/screens/lifestyle_screen.dart @@ -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 { StressLevel _stress = StressLevel.moderate; DrivingExposure _driving = DrivingExposure.low; WorkHoursLevel _workHours = WorkHoursLevel.normal; + bool _useMetric = false; @override void initState() { super.initState(); - _loadExistingBehaviors(); + _loadInitialData(); } - Future _loadExistingBehaviors() async { + Future _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 { 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 { _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 { (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 { (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 { (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 { (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 { (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( 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 { (WorkHoursLevel.high, '55-70'), (WorkHoursLevel.extreme, '70+'), ], - onChanged: (value) => setState(() => _workHours = value), + onChanged: widget.readOnly ? null : (value) => setState(() => _workHours = value), ); } Widget _buildSegmentedControl({ required T value, required List<(T, String)> options, - required ValueChanged onChanged, + required ValueChanged? onChanged, }) { return Container( decoration: BoxDecoration( @@ -266,7 +307,7 @@ class _LifestyleScreenState extends State { 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( diff --git a/lib/screens/results_screen.dart b/lib/screens/results_screen.dart index 0111eea..d8b0e0d 100644 --- a/lib/screens/results_screen.dart +++ b/lib/screens/results_screen.dart @@ -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 { 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 { 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 { ), ); } + + 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()), + ); + } } diff --git a/lib/screens/saved_run_detail_screen.dart b/lib/screens/saved_run_detail_screen.dart new file mode 100644 index 0000000..7026613 --- /dev/null +++ b/lib/screens/saved_run_detail_screen.dart @@ -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 createState() => _SavedRunDetailScreenState(); +} + +class _SavedRunDetailScreenState extends State { + late SavedRun _savedRun; + int _savedRunsCount = 0; + + @override + void initState() { + super.initState(); + _savedRun = widget.savedRun; + _loadSavedRunsCount(); + } + + Future _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), + ), + ); + } +} diff --git a/lib/screens/saved_runs_screen.dart b/lib/screens/saved_runs_screen.dart new file mode 100644 index 0000000..83e5584 --- /dev/null +++ b/lib/screens/saved_runs_screen.dart @@ -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 createState() => _SavedRunsScreenState(); +} + +class _SavedRunsScreenState extends State { + List _savedRuns = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadSavedRuns(); + } + + Future _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 _deleteRun(SavedRun run) async { + await LocalStorage.deleteSavedRun(run.id); + _loadSavedRuns(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Run deleted')), + ); + } + } +} diff --git a/lib/screens/screens.dart b/lib/screens/screens.dart index e01de27..def862a 100644 --- a/lib/screens/screens.dart +++ b/lib/screens/screens.dart @@ -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'; diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index b799900..6e90a64 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -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), ], ), ), diff --git a/lib/storage/local_storage.dart b/lib/storage/local_storage.dart index aec1c5c..100bd73 100644 --- a/lib/storage/local_storage.dart +++ b/lib/storage/local_storage.dart @@ -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 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 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 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> 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, + ), + profile: UserProfile.fromJson( + jsonDecode(row['profile'] as String) as Map, + ), + behaviors: BehavioralInputs.fromJson( + jsonDecode(row['behaviors'] as String) as Map, + ), + createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int), + )).toList(); + } + + static Future 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, + ), + profile: UserProfile.fromJson( + jsonDecode(row['profile'] as String) as Map, + ), + behaviors: BehavioralInputs.fromJson( + jsonDecode(row['behaviors'] as String) as Map, + ), + createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int), + ); + } + + static Future updateSavedRunLabel(String id, String newLabel) async { + final db = await database; + await db.update( + _savedRunsTable, + {'label': newLabel}, + where: 'id = ?', + whereArgs: [id], + ); + } + + static Future deleteSavedRun(String id) async { + final db = await database; + await db.delete( + _savedRunsTable, + where: 'id = ?', + whereArgs: [id], + ); + } + + static Future deleteAllSavedRuns() async { + final db = await database; + await db.delete(_savedRunsTable); + } + + static Future 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 setUseMetricUnits(bool useMetric) async { + await _put('useMetricUnits', {'value': useMetric}); + } + + static Future getUseMetricUnits() async { + final json = await _get('useMetricUnits'); + if (json == null) return false; // Default to imperial (US) + return json['value'] as bool; + } } diff --git a/pubspec.lock b/pubspec.lock index 3513c5d..f664e5d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -525,6 +533,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -541,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 995cfdd..91c10c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: