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:
parent
40275bcd0c
commit
85e8e1a290
15 changed files with 1907 additions and 116 deletions
|
|
@ -2,3 +2,4 @@ export 'enums.dart';
|
||||||
export 'user_profile.dart';
|
export 'user_profile.dart';
|
||||||
export 'behavioral_inputs.dart';
|
export 'behavioral_inputs.dart';
|
||||||
export 'result.dart';
|
export 'result.dart';
|
||||||
|
export 'saved_run.dart';
|
||||||
|
|
|
||||||
80
lib/models/saved_run.dart
Normal file
80
lib/models/saved_run.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
196
lib/screens/about_screen.dart
Normal file
196
lib/screens/about_screen.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,18 @@ import '../models/models.dart';
|
||||||
import '../risk_engine/mortality_tables.dart';
|
import '../risk_engine/mortality_tables.dart';
|
||||||
import '../storage/local_storage.dart';
|
import '../storage/local_storage.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
|
import 'about_screen.dart';
|
||||||
import 'behavioral_screen.dart';
|
import 'behavioral_screen.dart';
|
||||||
|
|
||||||
class BaselineScreen extends StatefulWidget {
|
class BaselineScreen extends StatefulWidget {
|
||||||
const BaselineScreen({super.key});
|
final bool readOnly;
|
||||||
|
final UserProfile? initialProfile;
|
||||||
|
|
||||||
|
const BaselineScreen({
|
||||||
|
super.key,
|
||||||
|
this.readOnly = false,
|
||||||
|
this.initialProfile,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BaselineScreen> createState() => _BaselineScreenState();
|
State<BaselineScreen> createState() => _BaselineScreenState();
|
||||||
|
|
@ -19,6 +27,7 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
double _heightCm = 170;
|
double _heightCm = 170;
|
||||||
double _weightKg = 70;
|
double _weightKg = 70;
|
||||||
final Set<Diagnosis> _diagnoses = {};
|
final Set<Diagnosis> _diagnoses = {};
|
||||||
|
bool _useMetric = false;
|
||||||
|
|
||||||
late List<String> _countries;
|
late List<String> _countries;
|
||||||
|
|
||||||
|
|
@ -26,24 +35,39 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_countries = getSupportedCountries();
|
_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();
|
final profile = await LocalStorage.getProfile();
|
||||||
if (profile != null) {
|
if (profile != null) {
|
||||||
setState(() {
|
_applyProfile(profile);
|
||||||
_age = profile.age;
|
|
||||||
_sex = profile.sex;
|
|
||||||
_country = profile.country;
|
|
||||||
_heightCm = profile.heightCm;
|
|
||||||
_weightKg = profile.weightKg;
|
|
||||||
_diagnoses.clear();
|
|
||||||
_diagnoses.addAll(profile.diagnoses);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
double get _bmi => _weightKg / ((_heightCm / 100) * (_heightCm / 100));
|
||||||
|
|
||||||
String get _bmiCategory {
|
String get _bmiCategory {
|
||||||
|
|
@ -59,11 +83,25 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Baseline'),
|
title: Text(widget.readOnly ? 'Baseline (View Only)' : 'Baseline'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => Navigator.pop(context),
|
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(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|
@ -129,14 +167,15 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
_buildDiagnosisCheckboxes(),
|
_buildDiagnosisCheckboxes(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Continue button
|
// Continue button (hidden in readOnly mode)
|
||||||
SizedBox(
|
if (!widget.readOnly)
|
||||||
width: double.infinity,
|
SizedBox(
|
||||||
child: ElevatedButton(
|
width: double.infinity,
|
||||||
onPressed: _continue,
|
child: ElevatedButton(
|
||||||
child: const Text('Continue'),
|
onPressed: _continue,
|
||||||
|
child: const Text('Continue'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -162,7 +201,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.remove),
|
icon: const Icon(Icons.remove),
|
||||||
onPressed: _age > 18 ? () => setState(() => _age--) : null,
|
onPressed: widget.readOnly || _age <= 18
|
||||||
|
? null
|
||||||
|
: () => setState(() => _age--),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -173,7 +214,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
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(
|
child: _buildToggleButton(
|
||||||
'Male',
|
'Male',
|
||||||
_sex == Sex.male,
|
_sex == Sex.male,
|
||||||
() => setState(() => _sex = Sex.male),
|
widget.readOnly ? null : () => setState(() => _sex = Sex.male),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -195,14 +238,14 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
child: _buildToggleButton(
|
child: _buildToggleButton(
|
||||||
'Female',
|
'Female',
|
||||||
_sex == Sex.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(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -242,26 +285,35 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
child: Text(country),
|
child: Text(country),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (value) {
|
onChanged: widget.readOnly
|
||||||
if (value != null) setState(() => _country = value);
|
? null
|
||||||
},
|
: (value) {
|
||||||
|
if (value != null) setState(() => _country = value);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeightSlider() {
|
Widget _buildHeightSlider() {
|
||||||
|
final primaryText = _useMetric
|
||||||
|
? '${_heightCm.round()} cm'
|
||||||
|
: _cmToFeetInches(_heightCm);
|
||||||
|
final secondaryText = _useMetric
|
||||||
|
? _cmToFeetInches(_heightCm)
|
||||||
|
: '${_heightCm.round()} cm';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_heightCm.round()} cm',
|
primaryText,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_cmToFeetInches(_heightCm),
|
secondaryText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -271,24 +323,30 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
min: 120,
|
min: 120,
|
||||||
max: 220,
|
max: 220,
|
||||||
divisions: 100,
|
divisions: 100,
|
||||||
onChanged: (value) => setState(() => _heightCm = value),
|
onChanged: widget.readOnly
|
||||||
|
? null
|
||||||
|
: (value) => setState(() => _heightCm = value),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildWeightSlider() {
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_weightKg.round()} kg',
|
primaryText,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${(_weightKg * 2.205).round()} lbs',
|
secondaryText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -298,7 +356,9 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
min: 30,
|
min: 30,
|
||||||
max: 200,
|
max: 200,
|
||||||
divisions: 170,
|
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) {
|
children: Diagnosis.values.map((diagnosis) {
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
value: _diagnoses.contains(diagnosis),
|
value: _diagnoses.contains(diagnosis),
|
||||||
onChanged: (checked) {
|
onChanged: widget.readOnly
|
||||||
setState(() {
|
? null
|
||||||
if (checked == true) {
|
: (checked) {
|
||||||
_diagnoses.add(diagnosis);
|
setState(() {
|
||||||
} else {
|
if (checked == true) {
|
||||||
_diagnoses.remove(diagnosis);
|
_diagnoses.add(diagnosis);
|
||||||
}
|
} else {
|
||||||
});
|
_diagnoses.remove(diagnosis);
|
||||||
},
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
title: Text(_getDiagnosisLabel(diagnosis)),
|
title: Text(_getDiagnosisLabel(diagnosis)),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|
@ -396,6 +458,12 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
return "$feet'$inches\"";
|
return "$feet'$inches\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleUnits() async {
|
||||||
|
final newValue = !_useMetric;
|
||||||
|
await LocalStorage.setUseMetricUnits(newValue);
|
||||||
|
setState(() => _useMetric = newValue);
|
||||||
|
}
|
||||||
|
|
||||||
void _continue() async {
|
void _continue() async {
|
||||||
final profile = UserProfile(
|
final profile = UserProfile(
|
||||||
age: _age,
|
age: _age,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,20 @@ import 'package:flutter/services.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../storage/local_storage.dart';
|
import '../storage/local_storage.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
|
import 'about_screen.dart';
|
||||||
import 'lifestyle_screen.dart';
|
import 'lifestyle_screen.dart';
|
||||||
|
|
||||||
class BehavioralScreen extends StatefulWidget {
|
class BehavioralScreen extends StatefulWidget {
|
||||||
final UserProfile profile;
|
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
|
@override
|
||||||
State<BehavioralScreen> createState() => _BehavioralScreenState();
|
State<BehavioralScreen> createState() => _BehavioralScreenState();
|
||||||
|
|
@ -25,32 +33,52 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
final behaviors = await LocalStorage.getBehaviors();
|
||||||
if (behaviors != null) {
|
if (behaviors != null) {
|
||||||
setState(() {
|
_applyBehaviors(behaviors);
|
||||||
_smoking = behaviors.smoking;
|
|
||||||
_cigarettesPerDay = behaviors.cigarettesPerDay;
|
|
||||||
_alcohol = behaviors.alcohol;
|
|
||||||
_sleepHours = behaviors.sleepHours;
|
|
||||||
_sleepConsistent = behaviors.sleepConsistent;
|
|
||||||
_activity = behaviors.activity;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyBehaviors(BehavioralInputs behaviors) {
|
||||||
|
setState(() {
|
||||||
|
_smoking = behaviors.smoking;
|
||||||
|
_cigarettesPerDay = behaviors.cigarettesPerDay;
|
||||||
|
_alcohol = behaviors.alcohol;
|
||||||
|
_sleepHours = behaviors.sleepHours;
|
||||||
|
_sleepConsistent = behaviors.sleepConsistent;
|
||||||
|
_activity = behaviors.activity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Habits'),
|
title: Text(widget.readOnly ? 'Habits (View Only)' : 'Habits'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline),
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|
@ -98,14 +126,15 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
||||||
_buildActivitySelector(),
|
_buildActivitySelector(),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
// Continue button
|
// Continue button (hidden in readOnly mode)
|
||||||
SizedBox(
|
if (!widget.readOnly)
|
||||||
width: double.infinity,
|
SizedBox(
|
||||||
child: ElevatedButton(
|
width: double.infinity,
|
||||||
onPressed: _continue,
|
child: ElevatedButton(
|
||||||
child: const Text('Continue'),
|
onPressed: _continue,
|
||||||
|
child: const Text('Continue'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -128,7 +157,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
||||||
(SmokingStatus.former, 'Former'),
|
(SmokingStatus.former, 'Former'),
|
||||||
(SmokingStatus.current, 'Current'),
|
(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,
|
min: 1,
|
||||||
max: 40,
|
max: 40,
|
||||||
divisions: 39,
|
divisions: 39,
|
||||||
onChanged: (value) {
|
onChanged: widget.readOnly
|
||||||
if (value.round() == 20 && _cigarettesPerDay != 20) {
|
? null
|
||||||
HapticFeedback.mediumImpact();
|
: (value) {
|
||||||
}
|
if (value.round() == 20 && _cigarettesPerDay != 20) {
|
||||||
setState(() => _cigarettesPerDay = value.round());
|
HapticFeedback.mediumImpact();
|
||||||
},
|
}
|
||||||
|
setState(() => _cigarettesPerDay = value.round());
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
@ -187,7 +218,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
||||||
(AlcoholLevel.heavy, '15-21'),
|
(AlcoholLevel.heavy, '15-21'),
|
||||||
(AlcoholLevel.veryHeavy, '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,
|
min: 4,
|
||||||
max: 12,
|
max: 12,
|
||||||
divisions: 16,
|
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(
|
Switch(
|
||||||
value: _sleepConsistent,
|
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.moderate, 'Moderate'),
|
||||||
(ActivityLevel.high, 'High'),
|
(ActivityLevel.high, 'High'),
|
||||||
],
|
],
|
||||||
onChanged: (value) => setState(() => _activity = value),
|
onChanged: widget.readOnly ? null : (value) => setState(() => _activity = value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSegmentedControl<T>({
|
Widget _buildSegmentedControl<T>({
|
||||||
required T value,
|
required T value,
|
||||||
required List<(T, String)> options,
|
required List<(T, String)> options,
|
||||||
required ValueChanged<T> onChanged,
|
required ValueChanged<T>? onChanged,
|
||||||
}) {
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -262,7 +297,7 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
||||||
final isSelected = value == option.$1;
|
final isSelected = value == option.$1;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => onChanged(option.$1),
|
onTap: onChanged == null ? null : () => onChanged(option.$1),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
350
lib/screens/compare_runs_screen.dart
Normal file
350
lib/screens/compare_runs_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../storage/local_storage.dart';
|
import '../storage/local_storage.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
|
import 'about_screen.dart';
|
||||||
import 'results_screen.dart';
|
import 'results_screen.dart';
|
||||||
|
|
||||||
class LifestyleScreen extends StatefulWidget {
|
class LifestyleScreen extends StatefulWidget {
|
||||||
|
|
@ -12,6 +13,8 @@ class LifestyleScreen extends StatefulWidget {
|
||||||
final double sleepHours;
|
final double sleepHours;
|
||||||
final bool sleepConsistent;
|
final bool sleepConsistent;
|
||||||
final ActivityLevel activity;
|
final ActivityLevel activity;
|
||||||
|
final bool readOnly;
|
||||||
|
final BehavioralInputs? initialBehaviors;
|
||||||
|
|
||||||
const LifestyleScreen({
|
const LifestyleScreen({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -22,6 +25,8 @@ class LifestyleScreen extends StatefulWidget {
|
||||||
required this.sleepHours,
|
required this.sleepHours,
|
||||||
required this.sleepConsistent,
|
required this.sleepConsistent,
|
||||||
required this.activity,
|
required this.activity,
|
||||||
|
this.readOnly = false,
|
||||||
|
this.initialBehaviors,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -36,37 +41,62 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
StressLevel _stress = StressLevel.moderate;
|
StressLevel _stress = StressLevel.moderate;
|
||||||
DrivingExposure _driving = DrivingExposure.low;
|
DrivingExposure _driving = DrivingExposure.low;
|
||||||
WorkHoursLevel _workHours = WorkHoursLevel.normal;
|
WorkHoursLevel _workHours = WorkHoursLevel.normal;
|
||||||
|
bool _useMetric = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
final behaviors = await LocalStorage.getBehaviors();
|
||||||
if (behaviors != null) {
|
if (behaviors != null) {
|
||||||
setState(() {
|
_applyBehaviors(behaviors);
|
||||||
_diet = behaviors.diet;
|
|
||||||
_processedFood = behaviors.processedFood;
|
|
||||||
_drugUse = behaviors.drugUse;
|
|
||||||
_social = behaviors.social;
|
|
||||||
_stress = behaviors.stress;
|
|
||||||
_driving = behaviors.driving;
|
|
||||||
_workHours = behaviors.workHours;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Lifestyle'),
|
title: Text(widget.readOnly ? 'Lifestyle (View Only)' : 'Lifestyle'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline),
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|
@ -120,7 +150,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
// Driving Exposure
|
// Driving Exposure
|
||||||
_buildSectionLabel('Driving Exposure'),
|
_buildSectionLabel(_useMetric ? 'Driving (km/week)' : 'Driving (mi/week)'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildDrivingSelector(),
|
_buildDrivingSelector(),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
@ -131,14 +161,15 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
_buildWorkHoursSelector(),
|
_buildWorkHoursSelector(),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
// Calculate button
|
// Calculate button (hidden in readOnly mode)
|
||||||
SizedBox(
|
if (!widget.readOnly)
|
||||||
width: double.infinity,
|
SizedBox(
|
||||||
child: ElevatedButton(
|
width: double.infinity,
|
||||||
onPressed: _calculate,
|
child: ElevatedButton(
|
||||||
child: const Text('Calculate'),
|
onPressed: _calculate,
|
||||||
|
child: const Text('Calculate'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -169,7 +200,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
(DietQuality.good, 'Good'),
|
(DietQuality.good, 'Good'),
|
||||||
(DietQuality.excellent, 'Excellent'),
|
(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.occasional, 'Sometimes'),
|
||||||
(ProcessedFoodLevel.rarely, 'Rarely'),
|
(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.regular, 'Regular'),
|
||||||
(DrugUse.daily, 'Daily'),
|
(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.moderate, 'Moderate'),
|
||||||
(SocialConnection.strong, 'Strong'),
|
(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.high, 'High'),
|
||||||
(StressLevel.chronic, 'Chronic'),
|
(StressLevel.chronic, 'Chronic'),
|
||||||
],
|
],
|
||||||
onChanged: (value) => setState(() => _stress = value),
|
onChanged: widget.readOnly ? null : (value) => setState(() => _stress = value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDrivingSelector() {
|
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>(
|
return _buildSegmentedControl<DrivingExposure>(
|
||||||
value: _driving,
|
value: _driving,
|
||||||
options: [
|
options: options,
|
||||||
(DrivingExposure.low, '<50 mi/wk'),
|
onChanged: widget.readOnly ? null : (value) => setState(() => _driving = value),
|
||||||
(DrivingExposure.moderate, '50-150'),
|
|
||||||
(DrivingExposure.high, '150-300'),
|
|
||||||
(DrivingExposure.veryHigh, '300+'),
|
|
||||||
],
|
|
||||||
onChanged: (value) => setState(() => _driving = value),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,14 +288,14 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
(WorkHoursLevel.high, '55-70'),
|
(WorkHoursLevel.high, '55-70'),
|
||||||
(WorkHoursLevel.extreme, '70+'),
|
(WorkHoursLevel.extreme, '70+'),
|
||||||
],
|
],
|
||||||
onChanged: (value) => setState(() => _workHours = value),
|
onChanged: widget.readOnly ? null : (value) => setState(() => _workHours = value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSegmentedControl<T>({
|
Widget _buildSegmentedControl<T>({
|
||||||
required T value,
|
required T value,
|
||||||
required List<(T, String)> options,
|
required List<(T, String)> options,
|
||||||
required ValueChanged<T> onChanged,
|
required ValueChanged<T>? onChanged,
|
||||||
}) {
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -266,7 +307,7 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
final isSelected = value == option.$1;
|
final isSelected = value == option.$1;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => onChanged(option.$1),
|
onTap: onChanged == null ? null : () => onChanged(option.$1),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import '../models/models.dart';
|
||||||
import '../risk_engine/calculator.dart';
|
import '../risk_engine/calculator.dart';
|
||||||
import '../storage/local_storage.dart';
|
import '../storage/local_storage.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import 'onboarding_screen.dart';
|
import 'about_screen.dart';
|
||||||
import 'baseline_screen.dart';
|
import 'baseline_screen.dart';
|
||||||
|
import 'onboarding_screen.dart';
|
||||||
|
import 'saved_runs_screen.dart';
|
||||||
|
|
||||||
class ResultsScreen extends StatefulWidget {
|
class ResultsScreen extends StatefulWidget {
|
||||||
final UserProfile profile;
|
final UserProfile profile;
|
||||||
|
|
@ -43,6 +45,15 @@ class _ResultsScreenState extends State<ResultsScreen> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Results'),
|
title: const Text('Results'),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline),
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
|
@ -107,11 +118,26 @@ class _ResultsScreenState extends State<ResultsScreen> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
|
onPressed: _saveRun,
|
||||||
|
child: const Text('Save Run'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
onPressed: _recalculate,
|
onPressed: _recalculate,
|
||||||
child: const Text('Recalculate'),
|
child: const Text('Recalculate'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _viewSavedRuns,
|
||||||
|
child: const Text('View Saved Runs'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton(
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
495
lib/screens/saved_run_detail_screen.dart
Normal file
495
lib/screens/saved_run_detail_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
lib/screens/saved_runs_screen.dart
Normal file
196
lib/screens/saved_runs_screen.dart
Normal 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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export 'onboarding_screen.dart';
|
export 'about_screen.dart';
|
||||||
export 'welcome_screen.dart';
|
|
||||||
export 'baseline_screen.dart';
|
export 'baseline_screen.dart';
|
||||||
export 'behavioral_screen.dart';
|
export 'behavioral_screen.dart';
|
||||||
|
export 'compare_runs_screen.dart';
|
||||||
export 'lifestyle_screen.dart';
|
export 'lifestyle_screen.dart';
|
||||||
|
export 'onboarding_screen.dart';
|
||||||
export 'results_screen.dart';
|
export 'results_screen.dart';
|
||||||
|
export 'saved_run_detail_screen.dart';
|
||||||
|
export 'saved_runs_screen.dart';
|
||||||
|
export 'welcome_screen.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
|
import 'about_screen.dart';
|
||||||
import 'baseline_screen.dart';
|
import 'baseline_screen.dart';
|
||||||
|
|
||||||
class WelcomeScreen extends StatelessWidget {
|
class WelcomeScreen extends StatelessWidget {
|
||||||
|
|
@ -59,7 +60,16 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
child: const Text('Start'),
|
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
|
|
||||||
class LocalStorage {
|
class LocalStorage {
|
||||||
static const _dbName = 'add_months.db';
|
static const _dbName = 'add_months.db';
|
||||||
static const _tableName = 'user_data';
|
static const _tableName = 'user_data';
|
||||||
static const _version = 1;
|
static const _savedRunsTable = 'saved_runs';
|
||||||
|
static const _version = 2;
|
||||||
|
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
static Future<Database> get database async {
|
static Future<Database> get database async {
|
||||||
if (_database != null) return _database!;
|
if (_database != null) return _database!;
|
||||||
|
|
@ -31,6 +34,30 @@ class LocalStorage {
|
||||||
updated_at INTEGER NOT NULL
|
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;
|
return profile != null && behaviors != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all data
|
// Delete all data (including saved runs)
|
||||||
static Future<void> deleteAllData() async {
|
static Future<void> deleteAllData() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete(_tableName);
|
await db.delete(_tableName);
|
||||||
|
await db.delete(_savedRunsTable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last updated timestamp
|
// Get last updated timestamp
|
||||||
|
|
@ -122,4 +150,124 @@ class LocalStorage {
|
||||||
results.first['updated_at'] as int,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
pubspec.lock
88
pubspec.lock
|
|
@ -121,6 +121,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -525,6 +533,78 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -541,6 +621,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ dependencies:
|
||||||
sqflite: ^2.3.0
|
sqflite: ^2.3.0
|
||||||
path: ^1.8.3
|
path: ^1.8.3
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^9.0.0
|
||||||
|
uuid: ^4.3.3
|
||||||
|
url_launcher: ^6.2.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue