From 05d491ff5378cfb20d9ae366f3b5cc1e5c5a7cac Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 6 May 2026 23:07:33 +0530 Subject: [PATCH 01/44] feat: add workout summary screen and enhance app theme colors - Implemented WorkoutSummaryScreen to display post-workout details including duration, volume, sets, and exercises. - Added visual elements such as trophy header and muscle groups trained section. - Refactored AppTheme to centralize color management through AppColors, improving maintainability and consistency across the app. - Updated theme properties for better visual coherence and modern styling. --- .../screens/add_custom_exercise_screen.dart | 517 ++--- .../lib/screens/analytics_screen.dart | 1254 +++--------- .../screens/edit_workout_session_screen.dart | 1256 +++--------- .../lib/screens/exercise_library_screen.dart | 1044 ++++------ .../lib/screens/history_screen.dart | 1012 +++------- workout-logger/lib/screens/home_screen.dart | 1075 +++++----- .../lib/screens/profile_screen.dart | 735 +------ .../lib/screens/routines_screen.dart | 822 ++------ .../screens/widgets/dashboard_widgets.dart | 260 +++ .../widgets/editable_exercise_card.dart | 623 ++++++ .../widgets/exercise_details_sheet.dart | 307 +++ .../widgets/exercise_input_section.dart | 851 ++++++++ .../widgets/exercise_progress_view.dart | 486 +++++ .../lib/screens/widgets/profile_sections.dart | 596 ++++++ .../lib/screens/widgets/rest_timer_view.dart | 139 ++ .../lib/screens/widgets/rf_cards.dart | 686 +++++++ .../lib/screens/widgets/rf_inputs.dart | 620 ++++++ .../lib/screens/widgets/rf_widgets.dart | 825 ++++++++ .../lib/screens/widgets/routine_creator.dart | 550 ++++++ .../widgets/session_details_sheet.dart | 455 +++++ .../lib/screens/widgets/targets_tab.dart | 327 +++ .../lib/screens/widgets/workout_header.dart | 279 +++ .../lib/screens/workout_flow_screen.dart | 1755 ++++------------- .../lib/screens/workout_summary_screen.dart | 293 +++ workout-logger/lib/theme/app_theme.dart | 230 ++- 25 files changed, 10141 insertions(+), 6856 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/dashboard_widgets.dart create mode 100644 workout-logger/lib/screens/widgets/editable_exercise_card.dart create mode 100644 workout-logger/lib/screens/widgets/exercise_details_sheet.dart create mode 100644 workout-logger/lib/screens/widgets/exercise_input_section.dart create mode 100644 workout-logger/lib/screens/widgets/exercise_progress_view.dart create mode 100644 workout-logger/lib/screens/widgets/profile_sections.dart create mode 100644 workout-logger/lib/screens/widgets/rest_timer_view.dart create mode 100644 workout-logger/lib/screens/widgets/rf_cards.dart create mode 100644 workout-logger/lib/screens/widgets/rf_inputs.dart create mode 100644 workout-logger/lib/screens/widgets/rf_widgets.dart create mode 100644 workout-logger/lib/screens/widgets/routine_creator.dart create mode 100644 workout-logger/lib/screens/widgets/session_details_sheet.dart create mode 100644 workout-logger/lib/screens/widgets/targets_tab.dart create mode 100644 workout-logger/lib/screens/widgets/workout_header.dart create mode 100644 workout-logger/lib/screens/workout_summary_screen.dart diff --git a/workout-logger/lib/screens/add_custom_exercise_screen.dart b/workout-logger/lib/screens/add_custom_exercise_screen.dart index ba75d83..d0be1d6 100644 --- a/workout-logger/lib/screens/add_custom_exercise_screen.dart +++ b/workout-logger/lib/screens/add_custom_exercise_screen.dart @@ -1,4 +1,4 @@ -// Add Custom Exercise Screen - Form for creating user-defined exercises +// add_custom_exercise_screen.dart — Form for creating a custom exercise import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../services/workout_provider.dart'; import '../data/exercise_database.dart'; import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; class AddCustomExerciseScreen extends StatefulWidget { const AddCustomExerciseScreen({super.key}); @@ -20,8 +21,8 @@ class _AddCustomExerciseScreenState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); - String _selectedCategory = 'compound'; - String? _selectedMuscleGroup; + String _category = 'compound'; + String? _muscleId; bool _isSubmitting = false; @override @@ -30,109 +31,116 @@ class _AddCustomExerciseScreenState extends State { super.dispose(); } - Future _saveExercise() async { + Future _save() async { if (!_formKey.currentState!.validate()) return; - if (_selectedMuscleGroup == null) { + if (_muscleId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please select a primary muscle group'), - backgroundColor: AppTheme.error, + backgroundColor: AppColors.error, ), ); return; } setState(() => _isSubmitting = true); - try { - final provider = context.read(); - await provider.addCustomExercise( - name: _nameController.text.trim(), - category: _selectedCategory, - primaryMuscleGroupId: _selectedMuscleGroup!, - ); - + await context.read().addCustomExercise( + name: _nameController.text.trim(), + category: _category, + primaryMuscleGroupId: _muscleId!, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle, color: AppTheme.success), - const SizedBox(width: 8), - Text('${_nameController.text.trim()} added successfully!'), - ], + content: Text('${_nameController.text.trim()} added!'), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), ), - backgroundColor: AppTheme.cardColor, ), ); - Navigator.of(context).pop(true); // Return success + Navigator.of(context).pop(true); } } catch (e) { debugPrint('Failed to save custom exercise: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Failed to save exercise. Please try again.'), - backgroundColor: AppTheme.error, + content: Text('Failed to save. Please try again.'), + backgroundColor: AppColors.error, ), ); } } finally { - if (mounted) { - setState(() => _isSubmitting = false); - } + if (mounted) setState(() => _isSubmitting = false); } } @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: AppColors.background, appBar: AppBar( - title: const Text('Add Custom Exercise'), + backgroundColor: AppColors.surface, + title: const Text( + 'New Exercise', + style: TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), actions: [ TextButton( - onPressed: _isSubmitting ? null : _saveExercise, + onPressed: _isSubmitting ? null : _save, child: _isSubmitting ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), ) - : const Text('Save'), + : const Text( + 'Save', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), ), ], ), body: SingleChildScrollView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Info Banner + // Info banner Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), + color: AppColors.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: AppTheme.primaryColor.withOpacity(0.3), + color: AppColors.primary.withValues(alpha: 0.2), ), ), - child: Row( + child: const Row( children: [ - Icon( - Icons.info_outline, - color: AppTheme.primaryColor, - size: 24, - ), - const SizedBox(width: AppSpacing.sm), + Icon(Icons.info_outline_rounded, + color: AppColors.primary, size: 18), + SizedBox(width: AppSpacing.sm), Expanded( child: Text( - 'Create a custom exercise to track workouts not in the built-in library.', + 'Create a custom exercise to track workouts ' + 'not in the built-in library.', style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, + color: AppColors.textSoft, + fontSize: 13, ), ), ), @@ -142,189 +150,117 @@ class _AddCustomExerciseScreenState extends State { const SizedBox(height: AppSpacing.lg), - // Exercise Name - Text( - 'Exercise Name', - style: Theme.of(context).textTheme.titleMedium, - ), + // Name + _label('EXERCISE NAME'), const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - hintText: 'e.g., Cable Lateral Raise', - prefixIcon: Icon(Icons.fitness_center), + Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextFormField( + controller: _nameController, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + ), + textCapitalization: TextCapitalization.words, + inputFormatters: [LengthLimitingTextInputFormatter(50)], + decoration: const InputDecoration( + hintText: 'e.g., Cable Lateral Raise', + hintStyle: TextStyle(color: AppColors.textMuted), + prefixIcon: Icon( + Icons.fitness_center_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Please enter an exercise name'; + } + if (v.trim().length < 3) { + return 'Name must be at least 3 characters'; + } + return null; + }, ), - textCapitalization: TextCapitalization.words, - inputFormatters: [LengthLimitingTextInputFormatter(50)], - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter an exercise name'; - } - if (value.trim().length < 3) { - return 'Name must be at least 3 characters'; - } - return null; - }, ), const SizedBox(height: AppSpacing.lg), - // Category Selection - Text( - 'Exercise Type', - style: Theme.of(context).textTheme.titleMedium, - ), + // Category toggle + _label('EXERCISE TYPE'), const SizedBox(height: AppSpacing.sm), - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'compound', - label: Text('Compound'), - icon: Icon(Icons.fitness_center), + Row( + children: [ + _CategoryTile( + label: 'Compound', + icon: Icons.fitness_center_rounded, + description: 'Multiple muscle groups', + selected: _category == 'compound', + onTap: () => setState(() => _category = 'compound'), ), - ButtonSegment( - value: 'isolation', - label: Text('Isolation'), - icon: Icon(Icons.accessibility_new), + const SizedBox(width: AppSpacing.sm), + _CategoryTile( + label: 'Isolation', + icon: Icons.accessibility_new_rounded, + description: 'Single muscle group', + selected: _category == 'isolation', + onTap: () => setState(() => _category = 'isolation'), ), ], - selected: {_selectedCategory}, - onSelectionChanged: (Set selection) { - setState(() => _selectedCategory = selection.first); - }, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppTheme.primaryColor.withOpacity(0.2); - } - return AppTheme.surfaceColor; - }), - ), - ), - - const SizedBox(height: AppSpacing.xs), - Text( - _selectedCategory == 'compound' - ? 'Works multiple muscle groups (e.g., squats, bench press)' - : 'Targets a single muscle group (e.g., bicep curls)', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), const SizedBox(height: AppSpacing.lg), - // Primary Muscle Group - Text( - 'Primary Muscle Group', - style: Theme.of(context).textTheme.titleMedium, - ), + // Muscle group grid + _label('PRIMARY MUSCLE GROUP'), const SizedBox(height: AppSpacing.sm), - - // Muscle Group Grid - Builder( - builder: (context) { - // Materialize keys once to avoid O(n²) lookup - final muscleKeys = MuscleGroups.names.keys.toList(); - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 2.2, - crossAxisSpacing: AppSpacing.sm, - mainAxisSpacing: AppSpacing.sm, - ), - itemCount: muscleKeys.length, - itemBuilder: (context, index) { - final muscleId = muscleKeys[index]; - final muscleName = MuscleGroups.names[muscleId]!; - final muscleColor = AppTheme.getMuscleColor(muscleId); - final isSelected = _selectedMuscleGroup == muscleId; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - setState(() => _selectedMuscleGroup = muscleId), - borderRadius: BorderRadius.circular(AppRadius.md), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: isSelected - ? muscleColor.withOpacity(0.3) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: isSelected - ? muscleColor - : AppTheme.cardColor, - width: 2, - ), - ), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (isSelected) ...[ - Icon( - Icons.check_circle, - size: 14, - color: muscleColor, - ), - const SizedBox(width: 4), - ], - Flexible( - child: Text( - muscleName, - style: TextStyle( - color: isSelected - ? muscleColor - : AppTheme.textSecondary, - fontSize: 11, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, + _MuscleGrid( + selected: _muscleId, + onSelect: (id) => setState(() => _muscleId = id), ), - if (_selectedMuscleGroup != null) ...[ + if (_muscleId != null) ...[ const SizedBox(height: AppSpacing.md), Container( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), decoration: BoxDecoration( - color: AppTheme.getMuscleColor( - _selectedMuscleGroup!, - ).withOpacity(0.1), - borderRadius: BorderRadius.circular(AppRadius.md), + color: AppColors.muscle(_muscleId!) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: AppColors.muscle(_muscleId!) + .withValues(alpha: 0.3), + ), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ Container( - width: 12, - height: 12, + width: 10, + height: 10, decoration: BoxDecoration( - color: AppTheme.getMuscleColor(_selectedMuscleGroup!), - borderRadius: BorderRadius.circular(6), + color: AppColors.muscle(_muscleId!), + shape: BoxShape.circle, ), ), - const SizedBox(width: AppSpacing.sm), + const SizedBox(width: 8), Text( - 'Primary: ${MuscleGroups.names[_selectedMuscleGroup]}', + 'Primary: ${MuscleGroups.names[_muscleId]}', style: TextStyle( - color: AppTheme.getMuscleColor(_selectedMuscleGroup!), + color: AppColors.muscle(_muscleId!), + fontSize: 13, fontWeight: FontWeight.w600, ), ), @@ -335,26 +271,11 @@ class _AddCustomExerciseScreenState extends State { const SizedBox(height: AppSpacing.xxl), - // Save Button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isSubmitting ? null : _saveExercise, - icon: _isSubmitting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.add), - label: Text(_isSubmitting ? 'Saving...' : 'Add Exercise'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), + GlowButton( + label: _isSubmitting ? 'Saving…' : 'Add Exercise', + icon: Icons.add_rounded, + onPressed: _isSubmitting ? null : _save, + fullWidth: true, ), const SizedBox(height: AppSpacing.lg), @@ -364,4 +285,160 @@ class _AddCustomExerciseScreenState extends State { ), ); } + + Widget _label(String text) { + return Text( + text, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ); + } +} + +// ── Category tile ───────────────────────────────────────────────────────────── +class _CategoryTile extends StatelessWidget { + const _CategoryTile({ + required this.label, + required this.icon, + required this.description, + required this.selected, + required this.onTap, + }); + + final String label; + final IconData icon; + final String description; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.12) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + color: selected ? AppColors.primary : AppColors.textMuted, + size: 20, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Text( + description, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Muscle group grid ───────────────────────────────────────────────────────── +class _MuscleGrid extends StatelessWidget { + const _MuscleGrid({required this.selected, required this.onSelect}); + final String? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final keys = MuscleGroups.names.keys.toList(); + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.3, + crossAxisSpacing: AppSpacing.sm, + mainAxisSpacing: AppSpacing.sm, + ), + itemCount: keys.length, + itemBuilder: (_, i) { + final id = keys[i]; + final name = MuscleGroups.names[id]!; + final color = AppColors.muscle(id); + final isSelected = selected == id; + + return GestureDetector( + onTap: () => onSelect(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: isSelected + ? color.withValues(alpha: 0.2) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: isSelected + ? color.withValues(alpha: 0.6) + : AppColors.glassBorder, + width: isSelected ? 1.5 : 1, + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isSelected) ...[ + Icon(Icons.check_rounded, size: 12, color: color), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + name, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? color : AppColors.textSoft, + fontSize: 11, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w400, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } } diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 8794068..59218f4 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -1,16 +1,16 @@ -// Analytics Screen - Visualize progress with charts +// analytics_screen.dart — Analytics screen with Overview, Exercises, Targets tabs import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; -import '../models/models.dart'; import '../services/workout_provider.dart'; -import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/exercise_progress_view.dart'; +import 'widgets/targets_tab.dart'; class AnalyticsScreen extends StatefulWidget { const AnalyticsScreen({super.key}); @@ -38,1075 +38,397 @@ class _AnalyticsScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Analytics'), - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Overview'), - Tab(text: 'Exercises'), - Tab(text: 'Targets'), + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + _AnalyticsHeader(tabController: _tabController), + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ + _OverviewTab(), + ExerciseProgressView(), + TargetsTab(), + ], + ), + ), ], ), ), - body: TabBarView( - controller: _tabController, - children: const [ - _OverviewTab(), - _ExercisesTab(), - _TargetsTab(), - ], - ), ); } } -// ==================== Overview Tab ==================== - -class _OverviewTab extends StatelessWidget { - const _OverviewTab(); +// ── Header with title + tab bar ─────────────────────────────────────────────── +class _AnalyticsHeader extends StatelessWidget { + const _AnalyticsHeader({required this.tabController}); + final TabController tabController; @override Widget build(BuildContext context) { - final provider = context.watch(); - - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildVolumeChart(context, provider), - const SizedBox(height: AppSpacing.lg), - _buildMuscleVolumeChart(context, provider), - const SizedBox(height: AppSpacing.lg), - _buildWorkoutFrequency(context, provider), - ], - ), - ); - } - - Widget _buildVolumeChart(BuildContext context, WorkoutProvider provider) { - final sessions = provider.sessions.take(14).toList().reversed.toList(); - - if (sessions.isEmpty) { - return _buildEmptyChart(context, 'Volume Progression'); - } - - final spots = sessions.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.totalVolume / 1000); - }).toList(); - return Container( - padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), + color: AppColors.surface, + border: Border(bottom: BorderSide(color: AppColors.glassBorder)), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Volume Progression (kg)', - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - 'Last ${sessions.length} workouts', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 200, - child: LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: 1, - getDrawingHorizontalLine: (value) => FlLine( - color: AppTheme.surfaceColor, - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - interval: 1, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index >= 0 && index < sessions.length) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - DateFormat('d/M').format(sessions[index].date), - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ); - } - return const Text(''); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) => Text( - '${value.toStringAsFixed(0)}k', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ), - ), + const Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.sm, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Analytics', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: AppTheme.primaryColor, - barWidth: 3, - isStrokeCapRound: true, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, barData, index) => - FlDotCirclePainter( - radius: 4, - color: AppTheme.primaryColor, - strokeWidth: 2, - strokeColor: AppTheme.cardColor, - ), - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.3), - AppTheme.primaryColor.withOpacity(0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ], ), ), ), - ], - ), - ); - } - - Widget _buildMuscleVolumeChart(BuildContext context, WorkoutProvider provider) { - final volumeByMuscle = provider.getWeeklyVolumeByMuscle(); - - if (volumeByMuscle.isEmpty) { - return _buildEmptyChart(context, 'Weekly Muscle Volume'); - } - - // Sort by volume and take top 8 - final sorted = volumeByMuscle.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = sorted.take(8).toList(); - final maxVolume = top.first.value; - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Weekly Muscle Volume', - style: Theme.of(context).textTheme.titleMedium, + TabBar( + controller: tabController, + indicatorColor: AppColors.primary, + indicatorWeight: 2, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textMuted, + labelStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + tabs: const [ + Tab(text: 'Overview'), + Tab(text: 'Exercises'), + Tab(text: 'Targets'), + ], ), - const SizedBox(height: AppSpacing.md), - ...top.map((entry) { - final muscleName = MuscleGroups.names[entry.key] ?? entry.key; - final color = AppTheme.getMuscleColor(entry.key); - final percentage = entry.value / maxVolume; - - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - muscleName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 13, - ), - ), - Text( - '${(entry.value / 1000).toStringAsFixed(1)}k kg', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - const SizedBox(height: 4), - LinearProgressIndicator( - value: percentage, - backgroundColor: AppTheme.surfaceColor, - valueColor: AlwaysStoppedAnimation(color), - borderRadius: BorderRadius.circular(4), - minHeight: 8, - ), - ], - ), - ); - }), ], ), ); } +} - Widget _buildWorkoutFrequency(BuildContext context, WorkoutProvider provider) { - // Calculate workouts per week for last 4 weeks - final now = DateTime.now(); - final weeks = {}; - - for (int i = 0; i < 4; i++) { - weeks[i] = 0; - } +// ── Overview Tab ────────────────────────────────────────────────────────────── +class _OverviewTab extends StatelessWidget { + const _OverviewTab(); - for (var session in provider.sessions) { - final weeksAgo = now.difference(session.date).inDays ~/ 7; - if (weeksAgo < 4) { - weeks[weeksAgo] = (weeks[weeksAgo] ?? 0) + 1; - } - } + @override + Widget build(BuildContext context) { + final provider = context.watch(); - return Container( + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Workout Frequency', - style: Theme.of(context).textTheme.titleMedium, - ), + _VolumeChart(provider: provider), const SizedBox(height: AppSpacing.md), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: weeks.entries.map((entry) { - final label = entry.key == 0 - ? 'This Week' - : '${entry.key} week${entry.key > 1 ? 's' : ''} ago'; - return Column( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity( - entry.value > 0 ? 0.2 + (entry.value * 0.15) : 0.1 - ), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - '${entry.value}', - style: TextStyle( - color: entry.value > 0 - ? AppTheme.primaryColor - : AppTheme.textMuted, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - ), - const SizedBox(height: 4), - Text( - entry.key == 0 ? 'This' : '-${entry.key}w', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ], - ); - }).toList(), - ), - ], - ), - ); - } - - Widget _buildEmptyChart(BuildContext context, String title) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.lg), - Icon( - Icons.show_chart, - size: 48, - color: AppTheme.textMuted, - ), + _MuscleVolumeChart(provider: provider), const SizedBox(height: AppSpacing.md), - const Text( - 'No data yet', - style: TextStyle(color: AppTheme.textSecondary), - ), - const Text( - 'Complete workouts to see your progress', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), + _FrequencyGrid(provider: provider), + const SizedBox(height: AppSpacing.xxl), ], ), ); } } -// ==================== Exercises Tab ==================== - -class _ExercisesTab extends StatefulWidget { - const _ExercisesTab(); - - @override - State<_ExercisesTab> createState() => _ExercisesTabState(); -} - -class _ExercisesTabState extends State<_ExercisesTab> { - String? _selectedExerciseId; - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - - // Get exercises that have been performed - final performedExercises = {}; - for (var session in provider.sessions) { - for (var log in session.exercises) { - performedExercises.add(log.exerciseId); - } - } - - if (performedExercises.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.fitness_center, - size: 64, - color: AppTheme.textMuted, - ), - const SizedBox(height: 16), - Text( - 'No Exercise Data', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - const Text( - 'Complete workouts to track exercises', - style: TextStyle(color: AppTheme.textSecondary), - ), - ], - ), - ); - } - - return Column( - children: [ - // Exercise selector - Container( - padding: const EdgeInsets.all(AppSpacing.md), - child: DropdownButtonFormField( - initialValue: _selectedExerciseId, - decoration: const InputDecoration( - labelText: 'Select Exercise', - prefixIcon: Icon(Icons.fitness_center), - ), - items: performedExercises.map((id) { - final name = provider.getExerciseName(id); - return DropdownMenuItem(value: id, child: Text(name)); - }).toList(), - onChanged: (value) => setState(() => _selectedExerciseId = value), - ), - ), - - // Exercise stats - if (_selectedExerciseId != null) - Expanded( - child: _ExerciseProgressView( - exerciseId: _selectedExerciseId!, - provider: provider, - ), - ), - ], - ); - } -} - -class _ExerciseProgressView extends StatelessWidget { - final String exerciseId; +// ── Volume progression line chart ───────────────────────────────────────────── +class _VolumeChart extends StatelessWidget { + const _VolumeChart({required this.provider}); final WorkoutProvider provider; - const _ExerciseProgressView({ - required this.exerciseId, - required this.provider, - }); - @override Widget build(BuildContext context) { - final progression = provider.getVolumeProgression(exerciseId); - final growthModel = provider.getGrowthModel(exerciseId); - final exercise = provider.getExercise(exerciseId); - - final settings = context.watch(); - final bestOneRM = provider.getBestOneRM(exerciseId); - - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1RM card - if (bestOneRM != null) - _buildOneRMCard(context, bestOneRM, settings), - - if (bestOneRM != null) const SizedBox(height: AppSpacing.md), - - // Growth rate card - if (growthModel != null) - _buildGrowthCard(context, growthModel), - - const SizedBox(height: AppSpacing.md), - - // Volume chart - _buildVolumeChart(context, progression), - - const SizedBox(height: AppSpacing.md), - - // Session history - _buildSessionHistory(context, progression), - ], - ), - ); - } + final sessions = provider.sessions.take(14).toList().reversed.toList(); - Widget _buildOneRMCard( - BuildContext context, - double bestOneRMkg, - SettingsProvider settings, - ) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.2), - AppTheme.primaryColor.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: const Icon( - Icons.emoji_events_rounded, - color: AppTheme.primaryColor, - size: 28, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Estimated 1RM', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - Text( - settings.formatWeight(bestOneRMkg), - style: const TextStyle( - color: AppTheme.primaryColor, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ], + return _ChartCard( + title: 'Volume Progression', + subtitle: 'Last ${sessions.length} workouts (tonnes)', + isEmpty: sessions.isEmpty, + child: SizedBox( + height: 180, + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 1, + getDrawingHorizontalLine: (_) => FlLine( + color: AppColors.glassBorder, + strokeWidth: 1, + ), ), - ), - const Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Epley formula', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, + titlesData: FlTitlesData( + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: 1, + getTitlesWidget: (v, _) { + final i = v.toInt(); + if (i < 0 || i >= sessions.length) return const Text(''); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + DateFormat('d/M').format(sessions[i].date), + style: const TextStyle(color: AppColors.textMuted, fontSize: 9), + ), + ); + }, ), ), - Text( - 'Best across all sets', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (v, _) => Text( + '${v.toStringAsFixed(0)}t', + style: const TextStyle(color: AppColors.textMuted, fontSize: 9), + ), ), ), - ], - ), - ], - ), - ); - } - - Widget _buildGrowthCard(BuildContext context, GrowthModel model) { - final isGrowing = model.slope > 0; - final slopeFormatted = model.slope.abs().toStringAsFixed(1); - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: isGrowing - ? [AppTheme.success.withOpacity(0.2), AppTheme.success.withOpacity(0.1)] - : [AppTheme.warning.withOpacity(0.2), AppTheme.warning.withOpacity(0.1)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Icon( - isGrowing ? Icons.trending_up : Icons.trending_down, - color: isGrowing ? AppTheme.success : AppTheme.warning, - size: 40, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isGrowing ? 'Growing!' : 'Plateau', - style: TextStyle( - color: isGrowing ? AppTheme.success : AppTheme.warning, - fontWeight: FontWeight.bold, - fontSize: 18, + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: sessions.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.totalVolume / 1000); + }).toList(), + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.primary, + barWidth: 2.5, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (_, __, ___, ____) => FlDotCirclePainter( + radius: 3.5, + color: AppColors.primary, + strokeWidth: 1.5, + strokeColor: AppColors.surface, ), ), - Text( - isGrowing - ? '+$slopeFormatted kg volume per session' - : 'Volume trend is flat or declining', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.25), + AppColors.primary.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'R² = ${(model.r2 * 100).toStringAsFixed(0)}%', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - Text( - 'Model Fit', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), ), ], ), - ], + ), ), ); } +} - Widget _buildVolumeChart( - BuildContext context, - List<({DateTime date, double volume})> data, - ) { - if (data.isEmpty) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: const Center( - child: Text('No data', style: TextStyle(color: AppTheme.textMuted)), - ), +// ── Muscle volume horizontal bars ───────────────────────────────────────────── +class _MuscleVolumeChart extends StatelessWidget { + const _MuscleVolumeChart({required this.provider}); + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final byMuscle = provider.getWeeklyVolumeByMuscle(); + + if (byMuscle.isEmpty) { + return _ChartCard( + title: 'Weekly Muscle Volume', + isEmpty: true, + child: const SizedBox.shrink(), ); } - final spots = data.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.volume / 100); - }).toList(); + final sorted = byMuscle.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top = sorted.take(8).toList(); + final maxVol = top.first.value; - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), + return _ChartCard( + title: 'Weekly Muscle Volume', child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Volume Progression', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 180, - child: LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (value) => FlLine( - color: AppTheme.surfaceColor, - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) => Text( - (value * 100).toStringAsFixed(0), - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), + children: top.map((entry) { + final name = MuscleGroups.names[entry.key] ?? entry.key; + final color = AppColors.muscle(entry.key); + final pct = entry.value / maxVol; + final volStr = entry.value >= 1000 + ? '${(entry.value / 1000).toStringAsFixed(1)}k' + : entry.value.toStringAsFixed(0); + + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + name, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, ), ), - ), - ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: AppTheme.secondaryColor, - barWidth: 3, - dotData: const FlDotData(show: true), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - AppTheme.secondaryColor.withOpacity(0.3), - AppTheme.secondaryColor.withOpacity(0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + Text( + '$volStr kg', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, ), ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildSessionHistory( - BuildContext context, - List<({DateTime date, double volume})> data, - ) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Session History', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.md), - Builder( - builder: (context) { - final settings = context.watch(); - return Column( - children: data.take(10).map((entry) { - final displayVolume = settings.toDisplay(entry.volume); - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - DateFormat('MMM d, yyyy').format(entry.date), - style: const TextStyle( - color: AppTheme.textSecondary, - ), - ), - Text( - '${displayVolume.toStringAsFixed(0)} ${settings.unitLabel}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - }).toList(), - ); - }, - ), - ], - ), - ); - } -} - -// ==================== Targets Tab ==================== - -class _TargetsTab extends StatelessWidget { - const _TargetsTab(); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final targets = provider.targets; - final activeTargets = targets.where((t) => !t.isCompleted).toList(); - final completedTargets = targets.where((t) => t.isCompleted).toList(); - - return Scaffold( - body: targets.isEmpty - ? _buildEmptyState(context) - : ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ - if (activeTargets.isNotEmpty) ...[ - Text( - 'Active Targets', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - ...activeTargets.map((t) => _TargetCard(target: t, provider: provider)), - ], - if (completedTargets.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - Text( - 'Completed', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - ...completedTargets.map((t) => _TargetCard(target: t, provider: provider)), - ], + ], + ), + const SizedBox(height: 4), + RFProgressBar(value: pct, color: color, height: 6, showGlow: false), ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreateTargetDialog(context), - icon: const Icon(Icons.add), - label: const Text('New Target'), + ); + }).toList(), ), ); } - - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.flag, - size: 64, - color: AppTheme.textMuted, - ), - const SizedBox(height: 16), - Text( - 'No Targets Set', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - const Text( - 'Set a target to track your progress', - style: TextStyle(color: AppTheme.textSecondary), - ), - ], - ), - ); - } - - void _showCreateTargetDialog(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => const _CreateTargetSheet(), - ); - } } -class _TargetCard extends StatelessWidget { - final Target target; +// ── Weekly frequency grid ────────────────────────────────────────────────────── +class _FrequencyGrid extends StatelessWidget { + const _FrequencyGrid({required this.provider}); final WorkoutProvider provider; - const _TargetCard({ - required this.target, - required this.provider, - }); - @override Widget build(BuildContext context) { - final exerciseName = provider.getExerciseName(target.exerciseId); - final progress = target.progressPercentage; - final isCompleted = target.isCompleted; + final now = DateTime.now(); + final weeks = {0: 0, 1: 0, 2: 0, 3: 0}; + for (final s in provider.sessions) { + final w = now.difference(s.date).inDays ~/ 7; + if (w < 4) weeks[w] = (weeks[w] ?? 0) + 1; + } - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - isCompleted ? Icons.check_circle : Icons.flag, - color: isCompleted ? AppTheme.success : AppTheme.warning, + return _ChartCard( + title: 'Workout Frequency', + subtitle: 'Sessions per week', + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: weeks.entries.map((e) { + final count = e.value; + final label = e.key == 0 ? 'This' : '-${e.key}w'; + final active = count > 0; + return Column( + children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.12 + count * 0.06) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: active + ? AppColors.primary.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), ), - const SizedBox(width: 8), - Expanded( + child: Center( child: Text( - exerciseName, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, + '$count', + style: TextStyle( + color: active ? AppColors.primary : AppColors.textMuted, + fontSize: 22, + fontWeight: FontWeight.w800, ), ), ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: () => provider.deleteTarget(target.id), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - Text( - '${target.targetType}: ${target.currentValue.toStringAsFixed(0)} / ${target.targetValue.toStringAsFixed(0)}', - style: const TextStyle(color: AppTheme.textSecondary), - ), - const SizedBox(height: AppSpacing.sm), - LinearProgressIndicator( - value: progress / 100, - backgroundColor: AppTheme.surfaceColor, - valueColor: AlwaysStoppedAnimation( - isCompleted ? AppTheme.success : AppTheme.primaryColor, ), - borderRadius: BorderRadius.circular(4), - minHeight: 8, - ), - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${progress.toStringAsFixed(0)}%', - style: TextStyle( - color: isCompleted ? AppTheme.success : AppTheme.primaryColor, - fontWeight: FontWeight.w600, - ), - ), - if (target.estimatedCompletionDate != null && !isCompleted) - Text( - 'Est: ${DateFormat('MMM d').format(target.estimatedCompletionDate!)}', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ], - ), - ], - ), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle(color: AppColors.textMuted, fontSize: 10), + ), + ], + ); + }).toList(), ), ); } } -class _CreateTargetSheet extends StatefulWidget { - const _CreateTargetSheet(); - - @override - State<_CreateTargetSheet> createState() => _CreateTargetSheetState(); -} - -class _CreateTargetSheetState extends State<_CreateTargetSheet> { - String? _selectedExerciseId; - String _targetType = 'reps'; - final _valueController = TextEditingController(); +// ── Reusable chart card wrapper ─────────────────────────────────────────────── +class _ChartCard extends StatelessWidget { + const _ChartCard({ + required this.title, + required this.child, + this.subtitle, + this.isEmpty = false, + }); - @override - void dispose() { - _valueController.dispose(); - super.dispose(); - } + final String title; + final String? subtitle; + final Widget child; + final bool isEmpty; @override Widget build(BuildContext context) { - final exercises = ExerciseDatabase.getAll(); - - return Padding( - padding: EdgeInsets.only( - left: AppSpacing.lg, - right: AppSpacing.lg, - top: AppSpacing.lg, - bottom: MediaQuery.of(context).viewInsets.bottom + AppSpacing.lg, + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), ), child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Create Target', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.lg), - - DropdownButtonFormField( - initialValue: _selectedExerciseId, - decoration: const InputDecoration( - labelText: 'Exercise', - ), - items: exercises.map((e) => DropdownMenuItem( - value: e.id, - child: Text(e.name), - )).toList(), - onChanged: (val) => setState(() => _selectedExerciseId = val), - ), - - const SizedBox(height: AppSpacing.md), - - DropdownButtonFormField( - initialValue: _targetType, - decoration: const InputDecoration( - labelText: 'Target Type', + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, ), - items: const [ - DropdownMenuItem(value: 'reps', child: Text('Max Reps')), - DropdownMenuItem(value: 'weight', child: Text('Max Weight (kg)')), - DropdownMenuItem(value: 'volume', child: Text('Total Volume (kg)')), - ], - onChanged: (val) => setState(() => _targetType = val!), ), - - const SizedBox(height: AppSpacing.md), - - TextField( - controller: _valueController, - decoration: const InputDecoration( - labelText: 'Target Value', + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: const TextStyle(color: AppColors.textMuted, fontSize: 11), ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), - ], - ), - - const SizedBox(height: AppSpacing.lg), - - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _createTarget, - child: const Text('Create Target'), + ], + if (isEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + Center( + child: RFEmptyState( + icon: Icons.show_chart_rounded, + title: 'No data yet', + subtitle: 'Complete workouts to see progress', + ), ), - ), + ] else ...[ + const SizedBox(height: AppSpacing.md), + child, + ], ], ), ); } - - void _createTarget() async { - if (_selectedExerciseId == null || _valueController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please fill all fields')), - ); - return; - } - - final value = double.tryParse(_valueController.text); - if (value == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Invalid target value')), - ); - return; - } - - await context.read().createTarget( - exerciseId: _selectedExerciseId!, - type: _targetType, - targetValue: value, - ); - - if (mounted) Navigator.pop(context); - } } diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index feea401..668fc2e 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -1,4 +1,4 @@ -// Edit Workout Session Screen - Modify recorded workout sessions +// edit_workout_session_screen.dart — Edit a recorded workout session import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,11 +8,12 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/editable_exercise_card.dart'; class EditWorkoutSessionScreen extends StatefulWidget { - final WorkoutSession session; - const EditWorkoutSessionScreen({super.key, required this.session}); + final WorkoutSession session; @override State createState() => @@ -22,9 +23,9 @@ class EditWorkoutSessionScreen extends StatefulWidget { class _EditWorkoutSessionScreenState extends State { late DateTime _selectedDate; late TimeOfDay _selectedTime; - late TextEditingController _notesController; - late TextEditingController _durationController; - late List<_EditableExerciseLog> _editableExercises; + late TextEditingController _notesCtrl; + late TextEditingController _durationCtrl; + late List _exercises; bool _isSubmitting = false; bool _hasChanges = false; @@ -33,24 +34,22 @@ class _EditWorkoutSessionScreenState extends State { super.initState(); _selectedDate = widget.session.date; _selectedTime = TimeOfDay.fromDateTime(widget.session.date); - _notesController = TextEditingController(text: widget.session.notes ?? ''); - _durationController = TextEditingController( + _notesCtrl = TextEditingController(text: widget.session.notes ?? ''); + _durationCtrl = TextEditingController( text: widget.session.duration.toString(), ); - - // Convert to editable structure, preserving all set metadata - _editableExercises = widget.session.exercises.map((log) { - return _EditableExerciseLog( + _exercises = widget.session.exercises.map((log) { + return EditableExerciseLog( exerciseId: log.exerciseId, sets: log.sets .map( - (set) => _EditableSet( - weight: set.weight, - reps: set.reps, - isDropset: set.isDropset, - drops: set.drops, - timeTaken: set.timeTaken, - timestamp: set.timestamp, + (s) => EditableSet( + weight: s.weight, + reps: s.reps, + isDropset: s.isDropset, + drops: s.drops, + timeTaken: s.timeTaken, + timestamp: s.timestamp, ), ) .toList(), @@ -61,15 +60,13 @@ class _EditWorkoutSessionScreenState extends State { @override void dispose() { - _notesController.dispose(); - _durationController.dispose(); + _notesCtrl.dispose(); + _durationCtrl.dispose(); super.dispose(); } void _markChanged() { - if (!_hasChanges) { - setState(() => _hasChanges = true); - } + if (!_hasChanges) setState(() => _hasChanges = true); } Future _selectDate() async { @@ -78,28 +75,24 @@ class _EditWorkoutSessionScreenState extends State { initialDate: _selectedDate, firstDate: DateTime(2020), lastDate: DateTime.now().add(const Duration(days: 1)), - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.dark( - primary: AppTheme.primaryColor, - surface: AppTheme.cardColor, - ), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + surface: AppColors.cardHigh, ), - child: child!, - ); - }, + ), + child: child!, + ), ); if (picked != null) { - setState(() { - _selectedDate = DateTime( - picked.year, - picked.month, - picked.day, - _selectedTime.hour, - _selectedTime.minute, - ); - }); + setState(() => _selectedDate = DateTime( + picked.year, + picked.month, + picked.day, + _selectedTime.hour, + _selectedTime.minute, + )); _markChanged(); } } @@ -108,17 +101,15 @@ class _EditWorkoutSessionScreenState extends State { final picked = await showTimePicker( context: context, initialTime: _selectedTime, - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.dark( - primary: AppTheme.primaryColor, - surface: AppTheme.cardColor, - ), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + surface: AppColors.cardHigh, ), - child: child!, - ); - }, + ), + child: child!, + ), ); if (picked != null) { setState(() { @@ -137,70 +128,43 @@ class _EditWorkoutSessionScreenState extends State { void _addSet(int exerciseIndex) { setState(() { - // Copy last set values or use defaults - final lastSet = _editableExercises[exerciseIndex].sets.isNotEmpty - ? _editableExercises[exerciseIndex].sets.last + final last = _exercises[exerciseIndex].sets.isNotEmpty + ? _exercises[exerciseIndex].sets.last : null; - _editableExercises[exerciseIndex].sets.add( - _EditableSet( - weight: lastSet?.weight ?? 0, - reps: lastSet?.reps ?? 0, - isDropset: false, - drops: null, - timeTaken: null, - timestamp: DateTime.now(), - ), - ); + _exercises[exerciseIndex].sets.add(EditableSet( + weight: last?.weight ?? 0, + reps: last?.reps ?? 0, + timestamp: DateTime.now(), + )); }); _markChanged(); } - void _deleteSet(int exerciseIndex, int setIndex) { - setState(() { - _editableExercises[exerciseIndex].sets.removeAt(setIndex); - }); + void _deleteSet(int exIdx, int setIdx) { + setState(() => _exercises[exIdx].sets.removeAt(setIdx)); _markChanged(); } - void _deleteExercise(int exerciseIndex) { - setState(() { - _editableExercises.removeAt(exerciseIndex); - }); + void _deleteExercise(int exIdx) { + setState(() => _exercises.removeAt(exIdx)); _markChanged(); } - Future _saveChanges() async { - // Validate duration - final duration = int.tryParse(_durationController.text); + Future _save() async { + final duration = int.tryParse(_durationCtrl.text); if (duration == null || duration < 0) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter a valid duration'), - backgroundColor: AppTheme.error, - ), - ); + _snack('Please enter a valid duration', isError: true); return; } - - // Validate that there's at least one exercise with sets - final exercisesWithSets = _editableExercises - .where((e) => e.sets.isNotEmpty) - .toList(); - if (exercisesWithSets.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Workout must have at least one exercise with sets'), - backgroundColor: AppTheme.error, - ), - ); + final withSets = _exercises.where((e) => e.sets.isNotEmpty).toList(); + if (withSets.isEmpty) { + _snack('Workout must have at least one exercise with sets', isError: true); return; } setState(() => _isSubmitting = true); - try { - // Convert editable exercises back to ExerciseLog, preserving metadata - final updatedExercises = exercisesWithSets.map((e) { + final updatedExercises = withSets.map((e) { return ExerciseLog( exerciseId: e.exerciseId, sets: e.sets @@ -219,69 +183,55 @@ class _EditWorkoutSessionScreenState extends State { ); }).toList(); - // Create updated session - final updatedSession = widget.session.copyWith( + final updated = widget.session.copyWith( date: _selectedDate, duration: duration, - notes: _notesController.text.isEmpty ? null : _notesController.text, + notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text, exercises: updatedExercises, ); - // Save via provider - final provider = context.read(); - await provider.updateWorkoutSession(updatedSession); + await context.read().updateWorkoutSession(updated); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: const [ - Icon(Icons.check_circle, color: AppTheme.success), - SizedBox(width: 8), - Text('Workout updated successfully'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); - Navigator.of(context).pop(true); // Return success + _snack('Workout updated'); + Navigator.of(context).pop(true); } } catch (e) { - debugPrint('Failed to save workout session: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to save workout. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); - } + debugPrint('Save failed: $e'); + if (mounted) _snack('Failed to save. Please try again.', isError: true); } finally { - if (mounted) { - setState(() => _isSubmitting = false); - } + if (mounted) setState(() => _isSubmitting = false); } } Future _onWillPop() async { if (!_hasChanges) return true; - final result = await showDialog( context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Discard Changes?'), + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Discard Changes?', + style: TextStyle(color: AppColors.textPrimary), + ), content: const Text( - 'You have unsaved changes. Are you sure you want to discard them?', + 'You have unsaved changes. Discard them?', + style: TextStyle(color: AppColors.textSoft), ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), ), TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), child: const Text('Discard'), ), ], @@ -290,418 +240,264 @@ class _EditWorkoutSessionScreenState extends State { return result ?? false; } + void _snack(String msg, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)), + backgroundColor: isError ? AppColors.error : AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } + @override Widget build(BuildContext context) { final provider = context.read(); - final dateFormat = DateFormat('EEEE, MMMM d, yyyy'); - final timeFormat = DateFormat('h:mm a'); return PopScope( canPop: !_hasChanges, - onPopInvokedWithResult: (didPop, result) async { + onPopInvokedWithResult: (didPop, _) async { if (didPop) return; - final shouldPop = await _onWillPop(); - if (shouldPop && context.mounted) { + if (await _onWillPop() && context.mounted) { Navigator.of(context).pop(); } }, child: Scaffold( + backgroundColor: AppColors.background, appBar: AppBar( - title: const Text('Edit Workout'), + backgroundColor: AppColors.surface, + title: const Text( + 'Edit Workout', + style: TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), actions: [ TextButton( - onPressed: _isSubmitting ? null : _saveChanges, + onPressed: _isSubmitting ? null : _save, child: _isSubmitting ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), ) - : const Text('Save'), + : const Text( + 'Save', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), ), ], ), body: ListView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), children: [ - // Date & Time Section - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.calendar_today, - size: 20, - color: AppTheme.primaryColor, - ), - const SizedBox(width: 8), - Text( - 'Date & Time', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: InkWell( - onTap: _selectDate, - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular( - AppRadius.md, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Date', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Text( - dateFormat.format(_selectedDate), - style: const TextStyle( - color: AppTheme.textPrimary, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - InkWell( - onTap: _selectTime, - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Time', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Text( - timeFormat.format(_selectedDate), - style: const TextStyle( - color: AppTheme.textPrimary, - ), - ), - ], - ), - ), + // Date & Time + _SectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(context, Icons.calendar_today_rounded, + AppColors.primary, 'Date & Time'), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: _TapField( + label: 'Date', + value: DateFormat('EEE, MMM d, yyyy') + .format(_selectedDate), + onTap: _selectDate, ), - ], - ), - ], - ), + ), + const SizedBox(width: AppSpacing.sm), + _TapField( + label: 'Time', + value: DateFormat('h:mm a').format(_selectedDate), + onTap: _selectTime, + ), + ], + ), + ], ), ), const SizedBox(height: AppSpacing.md), - // Duration & Notes Section - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.timer_outlined, - size: 20, - color: AppTheme.secondaryColor, - ), - const SizedBox(width: 8), - Text( - 'Duration (minutes)', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - TextField( - controller: _durationController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - hintText: 'Duration in minutes', - ), - onChanged: (_) => _markChanged(), - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - const Icon( - Icons.notes, - size: 20, - color: AppTheme.warning, - ), - const SizedBox(width: 8), - Text( - 'Notes', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - TextField( - controller: _notesController, - maxLines: 3, - decoration: const InputDecoration( - hintText: 'Optional workout notes...', - ), - onChanged: (_) => _markChanged(), - ), - ], - ), + // Duration & Notes + _SectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(context, Icons.timer_outlined, + AppColors.secondary, 'Duration (minutes)'), + const SizedBox(height: AppSpacing.sm), + _StyledField( + controller: _durationCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + hint: 'Minutes', + onChanged: (_) => _markChanged(), + ), + const SizedBox(height: AppSpacing.md), + _sectionLabel(context, Icons.notes_rounded, + AppColors.warning, 'Notes'), + const SizedBox(height: AppSpacing.sm), + _StyledField( + controller: _notesCtrl, + hint: 'Optional workout notes…', + maxLines: 3, + onChanged: (_) => _markChanged(), + ), + ], ), ), const SizedBox(height: AppSpacing.lg), - // Exercises Header Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + const RFSectionHeader('Exercises'), + const Spacer(), Text( - 'Exercises', - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - '${_editableExercises.length} exercises', - style: const TextStyle(color: AppTheme.textMuted), + '${_exercises.length} exercises', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), ), ], ), const SizedBox(height: AppSpacing.md), - // Exercise Cards - ..._editableExercises.asMap().entries.map((entry) { - final exerciseIndex = entry.key; - final editableLog = entry.value; - final exercise = provider.getExercise(editableLog.exerciseId); - - return _EditableExerciseCard( - key: ValueKey('exercise_$exerciseIndex'), - exerciseName: exercise?.name ?? 'Unknown Exercise', - editableLog: editableLog, - onSetChanged: (setIndex, weight, reps, isDropset, drops) { + ..._exercises.asMap().entries.map((entry) { + final i = entry.key; + final log = entry.value; + final ex = provider.getExercise(log.exerciseId); + return EditableExerciseCard( + key: ValueKey('exercise_$i'), + exerciseName: ex?.name ?? 'Unknown Exercise', + editableLog: log, + onSetChanged: (si, w, r, d, drops) { setState(() { - editableLog.sets[setIndex].weight = weight; - editableLog.sets[setIndex].reps = reps; - if (editableLog.sets[setIndex].isDropset != isDropset) { - editableLog.sets[setIndex].isDropset = isDropset; - if (isDropset && - editableLog.sets[setIndex].drops == null) { - editableLog.sets[setIndex].drops = []; - } - } - if (drops != null) { - editableLog.sets[setIndex].drops = drops; - } + log.sets[si].weight = w; + log.sets[si].reps = r; + log.sets[si].isDropset = d; + if (drops != null) log.sets[si].drops = drops; }); _markChanged(); }, - onAddSet: () => _addSet(exerciseIndex), - onDeleteSet: (setIndex) => _deleteSet(exerciseIndex, setIndex), - onDeleteExercise: () => _deleteExercise(exerciseIndex), + onAddSet: () => _addSet(i), + onDeleteSet: (si) => _deleteSet(i, si), + onDeleteExercise: () => _deleteExercise(i), ); }), - if (_editableExercises.isEmpty) - Container( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Center( - child: Column( - children: [ - const Icon( - Icons.fitness_center, - size: 48, - color: AppTheme.textMuted, - ), - const SizedBox(height: AppSpacing.md), - const Text( - 'No exercises in this workout', - style: TextStyle(color: AppTheme.textMuted), - ), - ], - ), - ), + if (_exercises.isEmpty) + RFEmptyState( + icon: Icons.fitness_center_rounded, + title: 'No exercises', + subtitle: 'All exercises have been removed', ), - const SizedBox(height: 80), // Space for bottom + const SizedBox(height: 80), ], ), ), ); } -} - -// Helper class for editable exercise data -class _EditableExerciseLog { - final String exerciseId; - final List<_EditableSet> sets; - final String? notes; - _EditableExerciseLog({ - required this.exerciseId, - required this.sets, - this.notes, - }); + Widget _sectionLabel( + BuildContext context, + IconData icon, + Color color, + String label, + ) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } } -class _EditableSet { - double weight; - int reps; - bool isDropset; - List? drops; - int? timeTaken; - DateTime timestamp; +// ── Section card ────────────────────────────────────────────────────────────── +class _SectionCard extends StatelessWidget { + const _SectionCard({required this.child}); + final Widget child; - _EditableSet({ - required this.weight, - required this.reps, - required this.timestamp, - this.isDropset = false, - this.drops, - this.timeTaken, - }); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: child, + ); + } } -// Editable Exercise Card Widget -class _EditableExerciseCard extends StatelessWidget { - final String exerciseName; - final _EditableExerciseLog editableLog; - final Function( - int setIndex, - double weight, - int reps, - bool isDropset, - List? drops, - ) - onSetChanged; - final VoidCallback onAddSet; - final Function(int setIndex) onDeleteSet; - final VoidCallback onDeleteExercise; - - const _EditableExerciseCard({ - super.key, - required this.exerciseName, - required this.editableLog, - required this.onSetChanged, - required this.onAddSet, - required this.onDeleteSet, - required this.onDeleteExercise, +// ── Tappable date/time display field ────────────────────────────────────────── +class _TapField extends StatelessWidget { + const _TapField({ + required this.label, + required this.value, + required this.onTap, }); + final String label; + final String value; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: Padding( + return GestureDetector( + onTap: onTap, + child: Container( padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header - Row( - children: [ - Expanded( - child: Text( - exerciseName, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - fontSize: 16, - ), - ), - ), - IconButton( - onPressed: () => _confirmDeleteExercise(context), - icon: const Icon(Icons.delete_outline, size: 20), - color: AppTheme.error, - tooltip: 'Remove exercise', - ), - ], + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ), - - const Divider(), - - // Sets - ...editableLog.sets.asMap().entries.map((entry) { - final setIndex = entry.key; - final set = entry.value; - - return _EditableSetRow( - setNumber: setIndex + 1, - weight: set.weight, - reps: set.reps, - isDropset: set.isDropset, - drops: set.drops, - onWeightChanged: (weight) => onSetChanged( - setIndex, - weight, - set.reps, - set.isDropset, - set.drops, - ), - onRepsChanged: (reps) => onSetChanged( - setIndex, - set.weight, - reps, - set.isDropset, - set.drops, - ), - onDropsChanged: (drops) => onSetChanged( - setIndex, - set.weight, - set.reps, - set.isDropset, - drops, - ), - onIsDropsetChanged: (val) => onSetChanged( - setIndex, - set.weight, - set.reps, - val, - set.drops, - ), - onDelete: () => onDeleteSet(setIndex), - ); - }), - - // Add Set Button - Center( - child: TextButton.icon( - onPressed: onAddSet, - icon: const Icon(Icons.add, size: 18), - label: const Text('Add Set'), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, ), ), ], @@ -709,484 +505,50 @@ class _EditableExerciseCard extends StatelessWidget { ), ); } - - void _confirmDeleteExercise(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Remove Exercise?'), - content: Text('Remove "$exerciseName" from this workout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - onDeleteExercise(); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Remove'), - ), - ], - ), - ); - } } -// Editable Set Row Widget -class _EditableSetRow extends StatefulWidget { - final int setNumber; - final double weight; - final int reps; - final bool isDropset; - final List? drops; - final Function(double) onWeightChanged; - final Function(int) onRepsChanged; - final Function(List) onDropsChanged; - final Function(bool) onIsDropsetChanged; - final VoidCallback onDelete; - - const _EditableSetRow({ - required this.setNumber, - required this.weight, - required this.reps, - this.isDropset = false, - this.drops, - required this.onWeightChanged, - required this.onRepsChanged, - required this.onDropsChanged, - required this.onIsDropsetChanged, - required this.onDelete, +// ── Styled text field ───────────────────────────────────────────────────────── +class _StyledField extends StatelessWidget { + const _StyledField({ + required this.controller, + required this.hint, + this.keyboardType, + this.inputFormatters, + this.maxLines = 1, + this.onChanged, }); - @override - State<_EditableSetRow> createState() => _EditableSetRowState(); -} - -class _EditableSetRowState extends State<_EditableSetRow> { - late TextEditingController _weightController; - late TextEditingController _repsController; - final FocusNode _weightFocus = FocusNode(); - final FocusNode _repsFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _weightController = TextEditingController(text: widget.weight.toString()); - _repsController = TextEditingController(text: widget.reps.toString()); - } - - @override - void didUpdateWidget(covariant _EditableSetRow oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { - if (double.tryParse(_weightController.text) != widget.weight) { - _weightController.text = widget.weight.toString(); - } - } - if (widget.reps != oldWidget.reps && !_repsFocus.hasFocus) { - if (int.tryParse(_repsController.text) != widget.reps) { - _repsController.text = widget.reps.toString(); - } - } - } - - @override - void dispose() { - _weightController.dispose(); - _repsController.dispose(); - _weightFocus.dispose(); - _repsFocus.dispose(); - super.dispose(); - } + final TextEditingController controller; + final String hint; + final TextInputType? keyboardType; + final List? inputFormatters; + final int maxLines; + final ValueChanged? onChanged; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), - child: Column( - children: [ - Row( - children: [ - // Set number - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(14), - ), - child: Center( - child: Text( - '${widget.setNumber}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.primaryColor, - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - - // Weight input - SizedBox( - width: 80, - child: TextField( - controller: _weightController, - focusNode: _weightFocus, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - suffixText: 'kg', - suffixStyle: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - filled: true, - fillColor: AppTheme.surfaceColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final weight = double.tryParse(value) ?? 0; - widget.onWeightChanged(weight); - }, - ), - ), - const SizedBox(width: AppSpacing.sm), - - // × symbol - const Text('×', style: TextStyle(color: AppTheme.textMuted)), - const SizedBox(width: AppSpacing.sm), - - // Reps input - SizedBox( - width: 70, - child: TextField( - controller: _repsController, - focusNode: _repsFocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - suffixText: 'reps', - suffixStyle: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - filled: true, - fillColor: AppTheme.surfaceColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final reps = int.tryParse(value) ?? 0; - widget.onRepsChanged(reps); - }, - ), - ), - - const Spacer(), - - // Toggle Dropset button - IconButton( - onPressed: () => widget.onIsDropsetChanged(!widget.isDropset), - icon: Icon( - widget.isDropset ? Icons.layers : Icons.layers_outlined, - size: 18, - ), - color: widget.isDropset - ? AppTheme.primaryColor - : AppTheme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: widget.isDropset - ? 'Remove drops' - : 'Convert to dropset', - ), - - // Delete button - IconButton( - onPressed: widget.onDelete, - icon: const Icon(Icons.close, size: 18), - color: AppTheme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: 'Delete set', - ), - ], - ), - - // Dropset Rows - if (widget.isDropset) ...[ - if (widget.drops != null) ...[ - const SizedBox(height: 4), - ...widget.drops!.asMap().entries.map((entry) { - final index = entry.key; - final drop = entry.value; - return _EditableDropRow( - key: ValueKey('drop_${widget.setNumber}_$index'), - dropNumber: index + 1, - weight: drop.weight, - reps: drop.reps, - onWeightChanged: (val) { - final newDrops = List.from(widget.drops!); - newDrops[index] = DropsetEntry( - weight: val, - reps: drop.reps, - ); - widget.onDropsChanged(newDrops); - }, - onRepsChanged: (val) { - final newDrops = List.from(widget.drops!); - newDrops[index] = DropsetEntry( - weight: drop.weight, - reps: val, - ); - widget.onDropsChanged(newDrops); - }, - onDelete: () { - final newDrops = List.from(widget.drops!) - ..removeAt(index); - widget.onDropsChanged(newDrops); - }, - ); - }), - ], - - // Add drop button - Padding( - padding: const EdgeInsets.only(left: 32, top: 4, bottom: 4), - child: InkWell( - onTap: () { - final newDrops = List.from(widget.drops ?? []); - // Default to 80% of last weight or current weight - double initialWeight = widget.weight * 0.8; - if (newDrops.isNotEmpty) { - initialWeight = newDrops.last.weight * 0.8; - } - // Round to nearest 0.5 - initialWeight = (initialWeight * 2).round() / 2; - - newDrops.add( - DropsetEntry(weight: initialWeight, reps: widget.reps), - ); - widget.onDropsChanged(newDrops); - }, - child: Row( - children: [ - Icon( - Icons.add_circle_outline, - size: 14, - color: AppTheme.primaryColor.withOpacity(0.7), - ), - const SizedBox(width: 4), - Text( - 'Add Drop', - style: TextStyle( - fontSize: 12, - color: AppTheme.primaryColor.withOpacity(0.7), - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ], + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), ), - ); - } -} - -class _EditableDropRow extends StatefulWidget { - final int dropNumber; - final double weight; - final int reps; - final Function(double) onWeightChanged; - final Function(int) onRepsChanged; - final VoidCallback onDelete; - - const _EditableDropRow({ - super.key, - required this.dropNumber, - required this.weight, - required this.reps, - required this.onWeightChanged, - required this.onRepsChanged, - required this.onDelete, - }); - - @override - State<_EditableDropRow> createState() => _EditableDropRowState(); -} - -class _EditableDropRowState extends State<_EditableDropRow> { - late TextEditingController _weightController; - late TextEditingController _repsController; - final FocusNode _weightFocus = FocusNode(); - final FocusNode _repsFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _weightController = TextEditingController(text: widget.weight.toString()); - _repsController = TextEditingController(text: widget.reps.toString()); - } - - @override - void didUpdateWidget(covariant _EditableDropRow oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { - if (double.tryParse(_weightController.text) != widget.weight) { - _weightController.text = widget.weight.toString(); - } - } - if (widget.reps != oldWidget.reps && !_repsFocus.hasFocus) { - if (int.tryParse(_repsController.text) != widget.reps) { - _repsController.text = widget.reps.toString(); - } - } - } - - @override - void dispose() { - _weightController.dispose(); - _repsController.dispose(); - _weightFocus.dispose(); - _repsFocus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 32, top: 4, bottom: 4), - child: Row( - children: [ - Icon( - Icons.subdirectory_arrow_right, - size: 16, - color: AppTheme.textMuted.withOpacity(0.5), - ), - const SizedBox(width: 8), - - Text( - 'Drop ${widget.dropNumber}', - style: const TextStyle(color: AppTheme.textMuted, fontSize: 12), + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + onChanged: onChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textMuted, fontSize: 14), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, ), - const SizedBox(width: AppSpacing.sm), - - // Weight input - SizedBox( - width: 70, - height: 32, - child: TextField( - controller: _weightController, - focusNode: _weightFocus, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 0, - ), - suffixText: 'kg', - suffixStyle: const TextStyle( - fontSize: 10, - color: AppTheme.textMuted, - ), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final weight = double.tryParse(value) ?? 0; - widget.onWeightChanged(weight); - }, - ), - ), - - const SizedBox(width: 8), - const Text( - '×', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), - const SizedBox(width: 8), - - // Reps input - SizedBox( - width: 60, - height: 32, - child: TextField( - controller: _repsController, - focusNode: _repsFocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 0, - ), - suffixText: 'reps', - suffixStyle: const TextStyle( - fontSize: 10, - color: AppTheme.textMuted, - ), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final reps = int.tryParse(value) ?? 0; - widget.onRepsChanged(reps); - }, - ), - ), - - const Spacer(), - - IconButton( - onPressed: widget.onDelete, - icon: const Icon(Icons.close, size: 16), - color: AppTheme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Remove drop', - ), - ], + ), ), ); } diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index ee80ea2..bf85f66 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -1,4 +1,4 @@ -// Exercise Library Screen - Browse and search exercises +// exercise_library_screen.dart — Browse and search the exercise library import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +8,9 @@ import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; import 'add_custom_exercise_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_cards.dart'; +import 'widgets/exercise_details_sheet.dart'; class ExerciseLibraryScreen extends StatefulWidget { const ExerciseLibraryScreen({super.key}); @@ -17,729 +20,454 @@ class ExerciseLibraryScreen extends StatefulWidget { } class _ExerciseLibraryScreenState extends State { - String _searchQuery = ''; - String? _selectedMuscleGroup; + String _query = ''; + String? _muscleFilter; @override Widget build(BuildContext context) { - // Use Provider's exercise list (includes custom exercises) - final allExercises = context.watch().allExercises; + final provider = context.watch(); + final all = provider.allExercises; + final customCount = all.where((e) => e.isCustom).length; + + final filtered = all.where((e) { + final matchQ = _query.isEmpty || + e.name.toLowerCase().contains(_query.toLowerCase()); + final matchM = _muscleFilter == null || + e.muscleActivations.any((m) => m.muscleGroupId == _muscleFilter); + return matchQ && matchM; + }).toList() + ..sort((a, b) { + if (a.isCustom && !b.isCustom) return -1; + if (!a.isCustom && b.isCustom) return 1; + return a.name.compareTo(b.name); + }); - // Filter exercises - var filteredExercises = allExercises.where((e) { - final matchesSearch = - _searchQuery.isEmpty || - e.name.toLowerCase().contains(_searchQuery.toLowerCase()); - final matchesMuscle = - _selectedMuscleGroup == null || - e.muscleActivations.any( - (m) => m.muscleGroupId == _selectedMuscleGroup, - ); - return matchesSearch && matchesMuscle; - }).toList(); - - // Sort: custom exercises first within each group for visibility - filteredExercises.sort((a, b) { - // First by custom status (custom first) - if (a.isCustom && !b.isCustom) return -1; - if (!a.isCustom && b.isCustom) return 1; - // Then alphabetically - return a.name.compareTo(b.name); - }); - - // Group by primary muscle final grouped = >{}; - for (var exercise in filteredExercises) { - final primary = exercise.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(exercise); + for (final ex in filtered) { + grouped.putIfAbsent(ex.primaryMuscle, () => []).add(ex); } - // Count custom exercises for display - final customCount = allExercises.where((e) => e.isCustom).length; - return Scaffold( - appBar: AppBar( - title: const Text('Exercise Library'), - actions: [ - if (customCount > 0) - Padding( - padding: const EdgeInsets.only(right: AppSpacing.md), - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '$customCount custom', - style: const TextStyle( - color: AppTheme.primaryColor, - fontSize: 12, - fontWeight: FontWeight.w600, + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + _Header(customCount: customCount), + _SearchBar( + query: _query, + onChanged: (v) => setState(() => _query = v), + ), + _MuscleFilterChips( + selected: _muscleFilter, + onSelected: (id) => setState(() => _muscleFilter = id), + ), + Expanded( + child: grouped.isEmpty + ? RFEmptyState( + icon: Icons.search_off_rounded, + title: 'No exercises found', + subtitle: 'Try a different search or filter', + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 100, + ), + itemCount: grouped.length, + itemBuilder: (_, i) { + final muscleId = grouped.keys.elementAt(i); + final exercises = grouped[muscleId]!; + return _MuscleGroup( + muscleId: muscleId, + exercises: exercises, + onTap: (ex) => _openDetails(context, ex, provider), + ); + }, ), - ), - ), - ), ), - ], + ], + ), ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () async { - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const AddCustomExerciseScreen(), - ), - ); - // No need to manually refresh - Provider will notify listeners - if (result == true && mounted) { - // Optional: Show a subtle confirmation - } - }, - icon: const Icon(Icons.add), - label: const Text('Add Exercise'), + floatingActionButton: FloatingActionButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + ), + backgroundColor: AppColors.primary, + elevation: 0, + child: const Icon(Icons.add_rounded, color: Colors.white), + ), + ); + } + + void _openDetails( + BuildContext context, + Exercise exercise, + WorkoutProvider provider, + ) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => ExerciseDetailsSheet( + exercise: exercise, + provider: provider, ), - body: Column( + ); + } +} + +// ── Header ───────────────────────────────────────────────────────────────────── +class _Header extends StatelessWidget { + const _Header({required this.customCount}); + final int customCount; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( children: [ - // Search bar - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: TextField( - decoration: InputDecoration( - hintText: 'Search exercises...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () => setState(() => _searchQuery = ''), - ) - : null, - ), - onChanged: (val) => setState(() => _searchQuery = val), + const Text( + 'Exercises', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ), ), - - // Muscle group filter chips - SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - children: [ - FilterChip( - label: const Text('All'), - selected: _selectedMuscleGroup == null, - onSelected: (_) => - setState(() => _selectedMuscleGroup = null), + const Spacer(), + if (customCount > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.3), ), - const SizedBox(width: 8), - ...MuscleGroups.names.entries.map( - (entry) => Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(entry.value), - selected: _selectedMuscleGroup == entry.key, - selectedColor: AppTheme.getMuscleColor( - entry.key, - ).withOpacity(0.3), - onSelected: (selected) => setState(() { - _selectedMuscleGroup = selected ? entry.key : null; - }), - ), - ), + ), + child: Text( + '$customCount custom', + style: const TextStyle( + color: AppColors.warning, + fontSize: 12, + fontWeight: FontWeight.w600, ), - ], + ), ), - ), + ], + ), + ); + } +} - const SizedBox(height: AppSpacing.sm), +// ── Search bar ───────────────────────────────────────────────────────────────── +class _SearchBar extends StatelessWidget { + const _SearchBar({required this.query, required this.onChanged}); + final String query; + final ValueChanged onChanged; - // Exercise list - Expanded( - child: grouped.isEmpty - ? _buildEmptyState() - : ListView.builder( - padding: const EdgeInsets.only( - left: AppSpacing.md, - right: AppSpacing.md, - top: AppSpacing.md, - bottom: 80, // Space for FAB + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + child: Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + onChanged: onChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: 'Search exercises…', + hintStyle: + const TextStyle(color: AppColors.textMuted, fontSize: 14), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 18, + ), + suffixIcon: query.isNotEmpty + ? GestureDetector( + onTap: () => onChanged(''), + child: const Icon( + Icons.close_rounded, + color: AppColors.textMuted, + size: 16, ), - itemCount: grouped.length, - itemBuilder: (context, index) { - final muscleId = grouped.keys.elementAt(index); - final exercises = grouped[muscleId]!; - final muscleName = - MuscleGroups.names[muscleId] ?? muscleId; - final muscleColor = AppTheme.getMuscleColor(muscleId); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.sm, - ), - child: Row( - children: [ - Container( - width: 4, - height: 20, - decoration: BoxDecoration( - color: muscleColor, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Text( - muscleName, - style: TextStyle( - color: muscleColor, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - Text( - '(${exercises.length})', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ], - ), - ), - ...exercises.map( - (exercise) => _ExerciseCard(exercise: exercise), - ), - const SizedBox(height: AppSpacing.md), - ], - ); - }, - ), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), ), - ], + ), ), ); } +} + +// ── Muscle filter chips ──────────────────────────────────────────────────────── +class _MuscleFilterChips extends StatelessWidget { + const _MuscleFilterChips({required this.selected, required this.onSelected}); + final String? selected; + final ValueChanged onSelected; - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), children: [ - Icon(Icons.search_off, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - const Text( - 'No exercises found', - style: TextStyle(color: AppTheme.textSecondary), + _Chip( + label: 'All', + isSelected: selected == null, + color: AppColors.primary, + onTap: () => onSelected(null), ), + const SizedBox(width: 6), + ...MuscleGroups.names.entries.map((e) { + final color = AppColors.muscle(e.key); + return Padding( + padding: const EdgeInsets.only(right: 6), + child: _Chip( + label: e.value, + isSelected: selected == e.key, + color: color, + onTap: () => onSelected(selected == e.key ? null : e.key), + ), + ); + }), ], ), ); } } -class _ExerciseCard extends StatelessWidget { - final Exercise exercise; - - const _ExerciseCard({required this.exercise}); +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.isSelected, + required this.color, + required this.onTap, + }); + final String label; + final bool isSelected; + final Color color; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: InkWell( - onTap: () => _showExerciseDetails(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - children: [ - // Icon with custom badge - Stack( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: exercise.isCustom - ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - exercise.category == 'compound' - ? Icons.fitness_center - : Icons.accessibility_new, - color: exercise.isCustom - ? AppTheme.warning - : AppTheme.primaryColor, - ), - ), - if (exercise.isCustom) - Positioned( - right: -2, - top: -2, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: AppTheme.warning, - borderRadius: BorderRadius.circular(6), - ), - child: const Icon( - Icons.star, - size: 10, - color: Colors.black, - ), - ), - ), - ], - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - exercise.name, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: exercise.category == 'compound' - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.secondaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - exercise.category.toUpperCase(), - style: TextStyle( - color: exercise.category == 'compound' - ? AppTheme.primaryColor - : AppTheme.secondaryColor, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - if (exercise.isCustom) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'CUSTOM', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - const SizedBox(width: 8), - Text( - '${exercise.muscleActivations.length} muscle${exercise.muscleActivations.length != 1 ? 's' : ''}', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ], - ), - ], - ), - ), - const Icon(Icons.chevron_right, color: AppTheme.textMuted), - ], + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? color.withValues(alpha: 0.15) : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: isSelected + ? color.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? color : AppColors.textMuted, + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, ), ), ), ); } - - void _showExerciseDetails(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => _ExerciseDetailsSheet(exercise: exercise), - ); - } } -class _ExerciseDetailsSheet extends StatelessWidget { - final Exercise exercise; - - const _ExerciseDetailsSheet({required this.exercise}); +// ── Muscle group section ─────────────────────────────────────────────────────── +class _MuscleGroup extends StatelessWidget { + const _MuscleGroup({ + required this.muscleId, + required this.exercises, + required this.onTap, + }); + final String muscleId; + final List exercises; + final ValueChanged onTap; @override Widget build(BuildContext context) { - final provider = context.read(); - final lastSession = provider.getLastSessionForExercise(exercise.id); - final growthModel = provider.getGrowthModel(exercise.id); + final color = AppColors.muscle(muscleId); + final name = MuscleGroups.names[muscleId] ?? muscleId; - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Handle - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - - // Header - Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Row( children: [ - Stack( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: exercise.isCustom - ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - exercise.category == 'compound' - ? Icons.fitness_center - : Icons.accessibility_new, - color: exercise.isCustom - ? AppTheme.warning - : AppTheme.primaryColor, - ), - ), - if (exercise.isCustom) - Positioned( - right: -2, - top: -2, - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: AppTheme.warning, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.star, - size: 10, - color: Colors.black, - ), - ), - ), - ], + Container( + width: 3, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - exercise.name, - style: Theme.of(context).textTheme.titleLarge, - ), - Row( - children: [ - Text( - exercise.category == 'compound' - ? 'Compound Exercise' - : 'Isolation Exercise', - style: const TextStyle(color: AppTheme.textSecondary), - ), - if (exercise.isCustom) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'CUSTOM', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - ], + const SizedBox(width: 8), + Text( + name, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, ), ), - // Delete button for custom exercises - if (exercise.isCustom) - IconButton( - onPressed: () => _confirmDelete(context, provider), - icon: const Icon(Icons.delete_outline), - color: AppTheme.error, - tooltip: 'Delete custom exercise', + const SizedBox(width: 6), + Text( + '${exercises.length}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, ), + ), ], ), - - const SizedBox(height: AppSpacing.lg), - - // Muscle activations - Text( - 'Muscle Activation', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - ...exercise.muscleActivations.map((activation) { - final muscleName = - MuscleGroups.names[activation.muscleGroupId] ?? - activation.muscleGroupId; - final color = AppTheme.getMuscleColor(activation.muscleGroupId); - - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(width: 8), - Expanded(child: Text(muscleName)), - Text( - '${activation.activationPercentage}%', - style: TextStyle(color: color, fontWeight: FontWeight.bold), - ), - ], - ), - ); - }), - - if (lastSession != null) ...[ - const SizedBox(height: AppSpacing.lg), - Text( - 'Last Session', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: lastSession.sets.asMap().entries.map((entry) { - final set = entry.value; - return Chip( - label: Text('${set.weight}kg × ${set.reps}'), - backgroundColor: AppTheme.surfaceColor, - ); - }).toList(), - ), - ], - - if (growthModel != null && growthModel.r2 > 0.2) ...[ - const SizedBox(height: AppSpacing.md), - Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.1), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - const Icon(Icons.trending_up, color: AppTheme.success), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Growing at +${growthModel.slope.toStringAsFixed(1)} kg volume/session', - style: const TextStyle(color: AppTheme.success), - ), - ), - ], - ), - ), - ], - - const SizedBox(height: AppSpacing.lg), - ], - ), - ); - } - - Future _confirmDelete( - BuildContext context, - WorkoutProvider provider, - ) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Delete Custom Exercise?'), - content: Text( - 'Are you sure you want to delete "${exercise.name}"? This action cannot be undone.', ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + ...exercises.map( + (ex) => ExerciseCard( + exercise: ex, + onTap: () => onTap(ex), ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Delete'), - ), - ], - ), + ), + const SizedBox(height: AppSpacing.sm), + ], ); - - if (confirmed == true && context.mounted) { - final success = await provider.deleteCustomExercise(exercise.id); - if (success && context.mounted) { - // Capture messenger before pop to avoid deactivated context - final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); // Close the bottom sheet - messenger.showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle, color: AppTheme.success), - const SizedBox(width: 8), - Text('"${exercise.name}" deleted'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); - } - } } } -// ==================== Exercise Selector Screen ==================== - +// ── Exercise Selector Screen (used by workout flow for quick start) ──────────── class ExerciseSelectorScreen extends StatefulWidget { - final bool selectionMode; - final Function(List)? onExercisesSelected; - const ExerciseSelectorScreen({ super.key, this.selectionMode = false, this.onExercisesSelected, }); + final bool selectionMode; + final void Function(List)? onExercisesSelected; + @override - State createState() => _ExerciseSelectorScreenState(); + State createState() => + _ExerciseSelectorScreenState(); } class _ExerciseSelectorScreenState extends State { final Set _selectedIds = {}; - String _searchQuery = ''; + String _query = ''; @override Widget build(BuildContext context) { - // Use Provider's exercise list (includes custom exercises) - final allExercises = context.watch().allExercises; - - var filteredExercises = allExercises.where((e) { - return _searchQuery.isEmpty || - e.name.toLowerCase().contains(_searchQuery.toLowerCase()); - }).toList(); - - // Sort alphabetically within groups - filteredExercises.sort((a, b) => a.name.compareTo(b.name)); + final all = context.watch().allExercises; + final filtered = all + .where( + (e) => _query.isEmpty || + e.name.toLowerCase().contains(_query.toLowerCase()), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); - // Group by primary muscle final grouped = >{}; - for (var exercise in filteredExercises) { - final primary = exercise.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(exercise); + for (final ex in filtered) { + grouped.putIfAbsent(ex.primaryMuscle, () => []).add(ex); } return Column( children: [ Padding( padding: const EdgeInsets.all(AppSpacing.md), - child: TextField( - decoration: const InputDecoration( - hintText: 'Search exercises...', - prefixIcon: Icon(Icons.search), + child: Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: + const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: const InputDecoration( + hintText: 'Search exercises…', + hintStyle: + TextStyle(color: AppColors.textMuted, fontSize: 14), + prefixIcon: Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + ), ), - onChanged: (val) => setState(() => _searchQuery = val), ), ), - if (widget.selectionMode && _selectedIds.isNotEmpty) - Container( + Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: Row( children: [ Text( '${_selectedIds.length} selected', style: const TextStyle( - color: AppTheme.primaryColor, + color: AppColors.primary, fontWeight: FontWeight.w600, + fontSize: 13, ), ), const Spacer(), - TextButton( - onPressed: () => setState(() => _selectedIds.clear()), - child: const Text('Clear'), + GestureDetector( + onTap: () => setState(() => _selectedIds.clear()), + child: const Text( + 'Clear', + style: TextStyle(color: AppColors.error, fontSize: 12), + ), ), ], ), ), - Expanded( child: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), itemCount: grouped.length, - itemBuilder: (context, index) { - final muscleId = grouped.keys.elementAt(index); + itemBuilder: (_, i) { + final muscleId = grouped.keys.elementAt(i); final exercises = grouped[muscleId]!; - final muscleName = MuscleGroups.names[muscleId] ?? muscleId; - + final name = MuscleGroups.names[muscleId] ?? muscleId; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -747,53 +475,20 @@ class _ExerciseSelectorScreenState extends State { padding: const EdgeInsets.symmetric( vertical: AppSpacing.sm, ), - child: Text( - muscleName, - style: TextStyle( - color: AppTheme.getMuscleColor(muscleId), - fontWeight: FontWeight.w600, - ), - ), + child: RFSectionHeader(name), ), - ...exercises.map((exercise) { - final isSelected = _selectedIds.contains(exercise.id); - return ListTile( - leading: widget.selectionMode - ? Checkbox( - value: isSelected, - onChanged: (val) { - setState(() { - if (val == true) { - _selectedIds.add(exercise.id); - } else { - _selectedIds.remove(exercise.id); - } - }); - }, - ) - : null, - title: Text(exercise.name), - subtitle: Text(exercise.category), - trailing: widget.selectionMode && isSelected - ? Text( - '${_selectedIds.toList().indexOf(exercise.id) + 1}', - style: const TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), - ) - : null, - onTap: widget.selectionMode - ? () { - setState(() { - if (isSelected) { - _selectedIds.remove(exercise.id); - } else { - _selectedIds.add(exercise.id); - } - }); - } - : null, + ...exercises.map((ex) { + final sel = _selectedIds.contains(ex.id); + return ExerciseCard( + exercise: ex, + selected: sel, + onTap: () => setState(() { + if (sel) { + _selectedIds.remove(ex.id); + } else { + _selectedIds.add(ex.id); + } + }), ); }), ], @@ -801,20 +496,17 @@ class _ExerciseSelectorScreenState extends State { }, ), ), - if (widget.selectionMode) - Container( + Padding( padding: const EdgeInsets.all(AppSpacing.md), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _selectedIds.isEmpty - ? null - : () { - widget.onExercisesSelected?.call(_selectedIds.toList()); - }, - child: Text('Start with ${_selectedIds.length} exercises'), - ), + child: GlowButton( + label: 'Start with ${_selectedIds.length} exercises', + icon: Icons.play_arrow_rounded, + onPressed: _selectedIds.isEmpty + ? null + : () => + widget.onExercisesSelected?.call(_selectedIds.toList()), + fullWidth: true, ), ), ], diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 0e55912..c07ebd6 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -1,4 +1,4 @@ -// History Screen - View past workout sessions +// history_screen.dart — Workout history with month grouping and search import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -10,586 +10,313 @@ import '../services/managers/history_manager.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'edit_workout_session_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_cards.dart'; +import 'widgets/session_details_sheet.dart'; -// Teal color shared by the HC badge and sync status indicators. -const Color _hcColor = Color(0xFF00BFA5); +const Color _hcColor = Color(0xFF4ECDC4); -class HistoryScreen extends StatelessWidget { +class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @override - Widget build(BuildContext context) { - // Watch HistoryManager so the list rebuilds when hcSyncedAt changes. - final historyManager = context.watch(); - final provider = context.read(); - final settings = context.watch(); - final sessions = historyManager.sessions; + State createState() => _HistoryScreenState(); +} - final hasUnsynced = settings.healthConnectEnabled && - sessions.any((s) => s.hcSyncedAt == null); +class _HistoryScreenState extends State { + final _searchController = TextEditingController(); + String _query = ''; - return Scaffold( - appBar: AppBar( - title: const Text('Workout History'), - actions: [ - if (hasUnsynced) - IconButton( - icon: const Icon(Icons.monitor_heart_outlined, color: _hcColor), - tooltip: 'Sync all to Health Connect', - onPressed: () { - historyManager.syncAllUnsynced(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Syncing all unsynced workouts…'), - backgroundColor: AppTheme.cardColor, - duration: Duration(seconds: 2), - ), - ); - }, - ), - ], - ), - body: sessions.isEmpty - ? _buildEmptyState(context) - : _buildSessionList(context, sessions, provider, historyManager), - ); + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.history, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - Text( - 'No Workout History', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Complete a workout to see it here', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ); + List _filtered(List sessions, WorkoutProvider provider) { + if (_query.isEmpty) return sessions; + final q = _query.toLowerCase(); + return sessions.where((s) { + final dateStr = DateFormat('EEEE MMM d yyyy').format(s.date).toLowerCase(); + if (dateStr.contains(q)) return true; + return s.exercises.any((e) { + final name = provider.getExerciseName(e.exerciseId).toLowerCase(); + return name.contains(q); + }); + }).toList(); } - Widget _buildSessionList( - BuildContext context, - List sessions, - WorkoutProvider provider, - HistoryManager historyManager, - ) { - // Group sessions by month - final groupedSessions = >{}; - for (var session in sessions) { - final monthKey = DateFormat('MMMM yyyy').format(session.date); - groupedSessions.putIfAbsent(monthKey, () => []).add(session); + Map> _group(List sessions) { + final map = >{}; + for (final s in sessions) { + final key = DateFormat('MMMM yyyy').format(s.date); + map.putIfAbsent(key, () => []).add(s); } + return map; + } - return ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: groupedSessions.length, - itemBuilder: (context, index) { - final month = groupedSessions.keys.elementAt(index); - final monthSessions = groupedSessions[month]!; + @override + Widget build(BuildContext context) { + final historyManager = context.watch(); + final provider = context.read(); + final settings = context.watch(); + final all = historyManager.sessions; + final filtered = _filtered(all, provider); + final grouped = _group(filtered); + final months = grouped.keys.toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - child: Text( - month, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 14, - ), + final hasUnsynced = settings.healthConnectEnabled && + all.any((s) => s.hcSyncedAt == null); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: _Header( + hasUnsynced: hasUnsynced, + onSyncAll: () { + historyManager.syncAllUnsynced(); + ScaffoldMessenger.of(context).showSnackBar( + _snackBar('Syncing all unsynced workouts…'), + ); + }, ), ), - ...monthSessions.map( - (session) => _SessionCard( - session: session, - provider: provider, - historyManager: historyManager, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + child: _SearchBar( + controller: _searchController, + onChanged: (v) => setState(() => _query = v), + ), ), ), + if (filtered.isEmpty) + SliverFillRemaining( + child: _query.isNotEmpty + ? RFEmptyState( + icon: Icons.search_off_rounded, + title: 'No results', + subtitle: 'Try a different search term', + ) + : RFEmptyState( + icon: Icons.history_rounded, + title: 'No Workout History', + subtitle: 'Complete a workout to see it here', + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.xxl, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final month = months[i]; + final sessions = grouped[month]!; + return _MonthGroup( + month: month, + sessions: sessions, + provider: provider, + historyManager: historyManager, + settings: settings, + ); + }, + childCount: months.length, + ), + ), + ), ], - ); - }, + ), + ), ); } } -class _SessionCard extends StatelessWidget { - final WorkoutSession session; - final WorkoutProvider provider; - final HistoryManager historyManager; - - const _SessionCard({ - required this.session, - required this.provider, - required this.historyManager, - }); +// ── Header ───────────────────────────────────────────────────────────────────── +class _Header extends StatelessWidget { + const _Header({required this.hasUnsynced, required this.onSyncAll}); + final bool hasUnsynced; + final VoidCallback onSyncAll; @override Widget build(BuildContext context) { - final dateFormat = DateFormat('EEEE, MMM d'); - final timeFormat = DateFormat('h:mm a'); - final settings = context.watch(); - final isSynced = session.hcSyncedAt != null; - final showSyncOption = !isSynced && settings.healthConnectEnabled; - - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: InkWell( - onTap: () => _showSessionDetails(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header row ──────────────────────────────────────── - Row( - children: [ - Expanded( - child: Text( - dateFormat.format(session.date), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - ), - // HC synced badge - if (isSynced) - Tooltip( - message: 'Synced to Health Connect', - child: Padding( - padding: const EdgeInsets.only(right: 6), - child: Icon( - Icons.monitor_heart, - color: _hcColor, - size: 16, - ), + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( + children: [ + const Text( + 'History', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + const Spacer(), + if (hasUnsynced) + GestureDetector( + onTap: onSyncAll, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 6, + ), + decoration: BoxDecoration( + color: _hcColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: _hcColor.withValues(alpha: 0.3)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.favorite_rounded, size: 13, color: _hcColor), + SizedBox(width: 5), + Text( + 'Sync All', + style: TextStyle( + color: _hcColor, + fontSize: 12, + fontWeight: FontWeight.w600, ), ), - Text( - timeFormat.format(session.date), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - // ⋮ popup menu - _SessionMenu( - session: session, - provider: provider, - historyManager: historyManager, - showSyncOption: showSyncOption, - onDetailRequested: () => _showSessionDetails(context), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - _buildStat( - Icons.fitness_center, - '${session.exercises.length} exercises', - ), - const SizedBox(width: AppSpacing.md), - _buildStat(Icons.timer_outlined, '${session.duration} min'), - const SizedBox(width: AppSpacing.md), - _buildStat( - Icons.trending_up, - '${(session.totalVolume / 1000).toStringAsFixed(1)}k kg', - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - const Divider(), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 4, - children: session.exercises.take(4).map((log) { - final exerciseName = provider.getExerciseName(log.exerciseId); - return Chip( - label: Text( - exerciseName, - style: const TextStyle(fontSize: 11), - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ); - }).toList(), - ), - if (session.exercises.length > 4) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '+${session.exercises.length - 4} more', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), + ], ), - ], - ), - ), + ), + ), + ], ), ); } +} - Widget _buildStat(IconData icon, String text) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text( - text, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - ], - ); - } +// ── Search bar ───────────────────────────────────────────────────────────────── +class _SearchBar extends StatelessWidget { + const _SearchBar({required this.controller, required this.onChanged}); + final TextEditingController controller; + final ValueChanged onChanged; - void _showSessionDetails(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + @override + Widget build(BuildContext context) { + return Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => _SessionDetailsSheet( - session: session, - provider: provider, - historyManager: historyManager, - scrollController: scrollController, + child: TextField( + controller: controller, + onChanged: onChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: const InputDecoration( + hintText: 'Search by date or exercise…', + hintStyle: TextStyle(color: AppColors.textMuted, fontSize: 14), + prefixIcon: Icon(Icons.search_rounded, color: AppColors.textMuted, size: 18), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), ), ), ); } } -// ── 3-button popup menu ──────────────────────────────────────────────────────── - -enum _SessionMenuAction { edit, syncHc, delete } - -class _SessionMenu extends StatelessWidget { - final WorkoutSession session; - final WorkoutProvider provider; - final HistoryManager historyManager; - final bool showSyncOption; - final VoidCallback onDetailRequested; - - const _SessionMenu({ - required this.session, +// ── Month group ──────────────────────────────────────────────────────────────── +class _MonthGroup extends StatelessWidget { + const _MonthGroup({ + required this.month, + required this.sessions, required this.provider, required this.historyManager, - required this.showSyncOption, - required this.onDetailRequested, + required this.settings, }); + final String month; + final List sessions; + final WorkoutProvider provider; + final HistoryManager historyManager; + final SettingsProvider settings; @override Widget build(BuildContext context) { - return PopupMenuButton<_SessionMenuAction>( - icon: const Icon(Icons.more_vert, color: AppTheme.textSecondary, size: 20), - color: AppTheme.cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - onSelected: (action) => _handleAction(context, action), - itemBuilder: (_) => [ - const PopupMenuItem( - value: _SessionMenuAction.edit, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.edit_outlined, color: AppTheme.primaryColor), - title: Text('Edit', style: TextStyle(color: AppTheme.textPrimary)), - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: RFSectionHeader(month), ), - if (showSyncOption) - const PopupMenuItem( - value: _SessionMenuAction.syncHc, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.monitor_heart_outlined, color: _hcColor), - title: Text( - 'Sync to Health Connect', - style: TextStyle(color: AppTheme.textPrimary), - ), - ), - ), - const PopupMenuItem( - value: _SessionMenuAction.delete, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.delete_outline, color: AppTheme.error), - title: Text( - 'Delete', - style: TextStyle(color: AppTheme.error), - ), + ...sessions.map( + (s) => _HistoryCard( + session: s, + provider: provider, + historyManager: historyManager, + showSync: settings.healthConnectEnabled && s.hcSyncedAt == null, ), ), ], ); } - - void _handleAction(BuildContext context, _SessionMenuAction action) { - switch (action) { - case _SessionMenuAction.edit: - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditWorkoutSessionScreen(session: session), - ), - ); - case _SessionMenuAction.syncHc: - historyManager.syncSession(session); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ), - SizedBox(width: 12), - Text('Syncing to Health Connect…'), - ], - ), - backgroundColor: AppTheme.cardColor, - duration: Duration(seconds: 2), - ), - ); - case _SessionMenuAction.delete: - _confirmDelete(context); - } - } - - Future _confirmDelete(BuildContext context) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Delete Workout?'), - content: Text( - 'Are you sure you want to delete this workout from ' - '${DateFormat('MMMM d, yyyy').format(session.date)}? ' - 'This action cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Delete'), - ), - ], - ), - ); - - if (confirmed == true && context.mounted) { - final messenger = ScaffoldMessenger.of(context); - try { - await provider.deleteWorkoutSession(session.id); - if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Row( - children: [ - Icon(Icons.check_circle, color: AppTheme.success), - SizedBox(width: 8), - Text('Workout deleted'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); - } - } catch (e) { - debugPrint('Failed to delete workout session: $e'); - if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Text('Failed to delete workout. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); - } - } - } - } } -// ── Detail bottom sheet ──────────────────────────────────────────────────────── - -class _SessionDetailsSheet extends StatelessWidget { - final WorkoutSession session; - final WorkoutProvider provider; - final HistoryManager historyManager; - final ScrollController scrollController; - - const _SessionDetailsSheet({ +// ── Per-session card with menu ────────────────────────────────────────────────── +class _HistoryCard extends StatelessWidget { + const _HistoryCard({ required this.session, required this.provider, required this.historyManager, - required this.scrollController, + required this.showSync, }); - @override - Widget build(BuildContext context) { - final dateFormat = DateFormat('EEEE, MMMM d, yyyy'); - final timeFormat = DateFormat('h:mm a'); - - return ListView( - controller: scrollController, - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - // Handle - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.md), - - // Action Buttons Row - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Edit Button - TextButton.icon( - onPressed: () => _editSession(context), - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Edit'), - style: TextButton.styleFrom( - foregroundColor: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 8), - // Delete Button - TextButton.icon( - onPressed: () => _confirmDelete(context), - icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Delete'), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - ), - ], - ), - - const SizedBox(height: AppSpacing.sm), - - // Header - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dateFormat.format(session.date), - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - '${timeFormat.format(session.date)} • ${session.duration} minutes', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), - if (session.hcSyncedAt != null) - Tooltip( - message: - 'Synced to Health Connect\n${DateFormat('MMM d, h:mm a').format(session.hcSyncedAt!)}', - child: const Icon(Icons.monitor_heart, color: _hcColor, size: 20), - ), - ], - ), - - const SizedBox(height: AppSpacing.lg), + final WorkoutSession session; + final WorkoutProvider provider; + final HistoryManager historyManager; + final bool showSync; - // Stats row - Row( - children: [ - Expanded( - child: _StatBox( - value: '${session.exercises.length}', - label: 'Exercises', - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatBox( - value: - '${session.exercises.fold(0, (sum, e) => sum + e.sets.length)}', - label: 'Total Sets', - color: AppTheme.secondaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatBox( - value: '${(session.totalVolume / 1000).toStringAsFixed(1)}k', - label: 'Volume (kg)', - color: AppTheme.success, + void _openDetails(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (ctx, sc) => SessionDetailsSheet( + session: session, + provider: provider, + scrollController: sc, + onEdit: () { + Navigator.of(ctx).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditWorkoutSessionScreen(session: session), ), - ), - ], - ), - - const SizedBox(height: AppSpacing.lg), - const Divider(), - const SizedBox(height: AppSpacing.md), - - // Exercises - ...session.exercises.map( - (log) => _ExerciseDetailCard(log: log, provider: provider), + ); + }, + onDelete: () => _confirmDelete(ctx), ), - - if (session.notes != null && session.notes!.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - Text('Notes', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.sm), - Text(session.notes!, style: Theme.of(context).textTheme.bodyMedium), - ], - ], - ); - } - - void _editSession(BuildContext context) { - final navigator = Navigator.of(context); - navigator.pop(); - navigator.push( - MaterialPageRoute( - builder: (context) => EditWorkoutSessionScreen(session: session), ), ); } @@ -597,20 +324,28 @@ class _SessionDetailsSheet extends StatelessWidget { Future _confirmDelete(BuildContext context) async { final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Delete Workout?'), + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Delete Workout?', + style: TextStyle(color: AppColors.textPrimary), + ), content: Text( - 'Are you sure you want to delete this workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? This action cannot be undone.', + 'Delete workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? ' + 'This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel', style: TextStyle(color: AppColors.textSoft)), ), TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), child: const Text('Delete'), ), ], @@ -618,198 +353,85 @@ class _SessionDetailsSheet extends StatelessWidget { ); if (confirmed == true && context.mounted) { - final navigator = Navigator.of(context); + final nav = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); try { await provider.deleteWorkoutSession(session.id); if (context.mounted) { - navigator.pop(); - messenger.showSnackBar( - const SnackBar( - content: Row( - children: [ - Icon(Icons.check_circle, color: AppTheme.success), - SizedBox(width: 8), - Text('Workout deleted'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); + nav.pop(); // close sheet if open + messenger.showSnackBar(_snackBar('Workout deleted')); } } catch (e) { - debugPrint('Failed to delete workout session: $e'); + debugPrint('Delete failed: $e'); if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Text('Failed to delete workout. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); + messenger.showSnackBar(_snackBar('Failed to delete workout', isError: true)); } } } } -} - -class _StatBox extends StatelessWidget { - final String value; - final String label; - final Color color; - const _StatBox({ - required this.value, - required this.label, - required this.color, - }); + void _handleMenu(BuildContext context, String value) { + if (value == 'edit') { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditWorkoutSessionScreen(session: session), + ), + ); + } else if (value == 'sync') { + historyManager.syncSession(session); + ScaffoldMessenger.of(context).showSnackBar( + _snackBar('Syncing to Health Connect…'), + ); + } else if (value == 'delete') { + _confirmDelete(context); + } + } @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - children: [ - Text( - value, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(height: 4), - Text( - label, - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), + return SessionCard( + session: session, + getExerciseName: provider.getExerciseName, + synced: session.hcSyncedAt != null, + onTap: () => _openDetails(context), + trailing: PopupMenuButton( + color: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + icon: const Icon(Icons.more_vert_rounded, color: AppColors.textMuted, size: 18), + onSelected: (v) => _handleMenu(context, v), + itemBuilder: (_) => [ + _menuItem('edit', Icons.edit_outlined, 'Edit', AppColors.primary), + if (showSync) + _menuItem('sync', Icons.favorite_outlined, 'Sync to Health Connect', _hcColor), + _menuItem('delete', Icons.delete_outline, 'Delete', AppColors.error), ], ), ); } -} -class _ExerciseDetailCard extends StatelessWidget { - final ExerciseLog log; - final WorkoutProvider provider; - - const _ExerciseDetailCard({required this.log, required this.provider}); - - @override - Widget build(BuildContext context) { - final exercise = provider.getExercise(log.exerciseId); - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + PopupMenuItem _menuItem(String value, IconData icon, String label, Color color) { + return PopupMenuItem( + value: value, + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - exercise?.name ?? 'Unknown Exercise', - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - Text( - '${log.sets.length} sets', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - ...log.sets.asMap().entries.map((entry) { - final index = entry.key; - final set = entry.value; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - '${index + 1}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.primaryColor, - ), - ), - ), - ), - const SizedBox(width: 12), - Text( - '${set.weight} kg × ${set.reps} reps', - style: const TextStyle(color: AppTheme.textPrimary), - ), - const Spacer(), - Text( - '${set.volume.toStringAsFixed(0)} kg', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - if (set.isDropset) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'DROP', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - ); - }), - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - 'Total: ${log.totalVolume.toStringAsFixed(0)} kg', - style: const TextStyle( - color: AppTheme.success, - fontWeight: FontWeight.w600, - ), - ), - ], - ), + Icon(icon, size: 16, color: color), + const SizedBox(width: 10), + Text(label, style: TextStyle(color: color, fontSize: 14)), ], ), ); } } + +// ── Helpers ──────────────────────────────────────────────────────────────────── +SnackBar _snackBar(String msg, {bool isError = false}) { + return SnackBar( + content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)), + backgroundColor: isError ? AppColors.error : AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), + duration: const Duration(seconds: 2), + ); +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index ba453a7..a6fd5c2 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -1,9 +1,11 @@ -// Home Screen - Dashboard with quick actions and stats +// home_screen.dart — Main navigation shell + Dashboard tab import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; +import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import 'workout_flow_screen.dart'; @@ -13,6 +15,10 @@ import 'analytics_screen.dart'; import 'exercise_library_screen.dart'; import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/dashboard_widgets.dart'; + +// ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -24,113 +30,302 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { int _currentIndex = 0; + void switchTab(int index) => setState(() => _currentIndex = index); + @override Widget build(BuildContext context) { + final provider = context.watch(); + return Scaffold( body: IndexedStack( index: _currentIndex, children: const [ - DashboardTab(), + _DashboardTab(), HistoryScreen(), RoutinesScreen(), AnalyticsScreen(), ProfileScreen(), ], ), - bottomNavigationBar: Container( + floatingActionButton: _buildFAB(context, provider), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: _BottomNavBar( + currentIndex: _currentIndex, + onTap: switchTab, + ), + ); + } + + Widget _buildFAB(BuildContext context, WorkoutProvider provider) { + final isActive = provider.hasActiveWorkout; + return GestureDetector( + onTap: () => isActive ? _resumeWorkout(context) : _startQuickWorkout(context), + child: Container( + width: 58, + height: 58, + margin: const EdgeInsets.only(bottom: 4), decoration: BoxDecoration( - color: AppTheme.surfaceColor, + shape: BoxShape.circle, + gradient: LinearGradient( + colors: isActive + ? [AppColors.warning, Color.lerp(AppColors.warning, Colors.white, 0.15)!] + : [AppColors.primary, Color.lerp(AppColors.primary, Colors.white, 0.15)!], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 10, - offset: const Offset(0, -2), + color: (isActive ? AppColors.warning : AppColors.primary) + .withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 4), ), ], ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildNavItem(0, Icons.home_rounded, 'Home'), - _buildNavItem(1, Icons.history_rounded, 'History'), - _buildNavItem(2, Icons.list_alt_rounded, 'Routines'), - _buildNavItem(3, Icons.analytics_rounded, 'Analytics'), - _buildNavItem(4, Icons.person_rounded, 'Profile'), - ], - ), + child: Icon( + isActive ? Icons.play_arrow_rounded : Icons.add_rounded, + color: Colors.white, + size: 30, + ), + ), + ); + } + + Future _resolveConflict( + BuildContext context, + WorkoutProvider provider, + ) async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + return action ?? StartWorkoutConflictAction.cancel; + } + + void _resumeWorkout(BuildContext context) { + Navigator.push( + context, + _slide(const WorkoutFlowScreen(isQuickStart: true)), + ); + } + + Future _startQuickWorkout(BuildContext context) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + exerciseIds: const [], + onConflict: () async { + conflictAction = await _resolveConflict(context, provider); + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + HapticFeedback.mediumImpact(); + Navigator.push(context, _slide(const WorkoutFlowScreen(isQuickStart: true))); + } + } + + Future startRoutineWorkout( + BuildContext context, + Routine routine, + ) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + routine: routine, + onConflict: () async { + conflictAction = await _resolveConflict(context, provider); + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + HapticFeedback.mediumImpact(); + Navigator.push(context, _slide(WorkoutFlowScreen(routine: routine))); + } + } + + void _showRoutineSelector(BuildContext context) { + final provider = context.read(); + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (sheetCtx) => _RoutineSelectorSheet( + routines: provider.routines, + onSelect: (r) { + Navigator.pop(sheetCtx); + startRoutineWorkout(context, r); + }, + ), + ); + } +} + +// ── Bottom Nav Bar ───────────────────────────────────────────────────────────── + +class _BottomNavBar extends StatelessWidget { + const _BottomNavBar({ + required this.currentIndex, + required this.onTap, + }); + + final int currentIndex; + final ValueChanged onTap; + + static const _items = [ + (Icons.home_rounded, Icons.home_outlined, 'Home'), + (Icons.history_rounded, Icons.history_outlined, 'History'), + (null, null, ''), // centre FAB placeholder + (Icons.analytics_rounded, Icons.analytics_outlined, 'Analytics'), + (Icons.person_rounded, Icons.person_outlined, 'Profile'), + ]; + + @override + Widget build(BuildContext context) { + return Container( + height: 72, + decoration: BoxDecoration( + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.glassBorder)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, -4), ), + ], + ), + child: SafeArea( + top: false, + child: Row( + children: [ + for (int i = 0; i < _items.length; i++) + if (_items[i].$1 == null) + const Spacer() // placeholder for FAB + else + Expanded(child: _NavItem( + activeIcon: _items[i].$1!, + inactiveIcon: _items[i].$2!, + label: _items[i].$3, + selected: currentIndex == (i < 2 ? i : i - 1), + onTap: () => onTap(i < 2 ? i : i - 1), + )), + ], ), ), ); } +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.activeIcon, + required this.inactiveIcon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData activeIcon; + final IconData inactiveIcon; + final String label; + final bool selected; + final VoidCallback onTap; - Widget _buildNavItem(int index, IconData icon, String label) { - final isSelected = _currentIndex == index; + @override + Widget build(BuildContext context) { return GestureDetector( - onTap: () => setState(() => _currentIndex = index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.primaryColor.withOpacity(0.2) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: isSelected - ? AppTheme.primaryColor - : AppTheme.textSecondary, - size: 24, + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.full), ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - color: isSelected - ? AppTheme.primaryColor - : AppTheme.textSecondary, - fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), + child: Icon( + selected ? activeIcon : inactiveIcon, + color: selected ? AppColors.primary : AppColors.textSoft, + size: 22, ), - ], - ), + ), + Text( + label, + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textMuted, + fontSize: 10, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + ), + ), + ], ), ); } } -class DashboardTab extends StatelessWidget { - const DashboardTab({super.key}); +// ── Dashboard Tab ────────────────────────────────────────────────────────────── + +class _DashboardTab extends StatelessWidget { + const _DashboardTab(); + + String _greeting() { + final h = DateTime.now().hour; + if (h < 12) return 'Good morning'; + if (h < 17) return 'Good afternoon'; + return 'Good evening'; + } @override Widget build(BuildContext context) { + final provider = context.watch(); + final homeState = context.findAncestorStateOfType<_HomeScreenState>(); + return SafeArea( child: CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 0, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeader(context), + _buildHeader(context, homeState), + const SizedBox(height: AppSpacing.lg), + _buildHeroCTA(context, provider, homeState), const SizedBox(height: AppSpacing.lg), - _buildQuickStartCard(context), + _buildStatsSection(context, provider), const SizedBox(height: AppSpacing.lg), - _buildStatsSection(context), + _buildWeekStrip(provider), const SizedBox(height: AppSpacing.lg), - _buildRecentWorkouts(context), + RecentWorkoutsSection( + sessions: provider.sessions.take(3).toList(), + getExerciseName: provider.getExerciseName, + onSeeAll: () => homeState?.switchTab(1), + onTap: (_) => homeState?.switchTab(1), + ), const SizedBox(height: AppSpacing.lg), - _buildQuickActions(context), + _buildQuickActions(context, homeState), + const SizedBox(height: AppSpacing.xxl), ], ), ), @@ -140,12 +335,8 @@ class DashboardTab extends StatelessWidget { ); } - Widget _buildHeader(BuildContext context) { - final now = DateTime.now(); - final greeting = now.hour < 12 - ? 'Good morning' - : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); - + Widget _buildHeader(BuildContext context, _HomeScreenState? homeState) { + final dateStr = DateFormat('EEE, MMM d').format(DateTime.now()); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -153,51 +344,151 @@ class DashboardTab extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - greeting, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + _greeting(), + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( - 'Ready to crush it? 💪', - style: Theme.of(context).textTheme.headlineMedium, + 'Let\'s get moving 💪', + style: Theme.of(context).textTheme.headlineSmall, ), ], ), - IconButton( - onPressed: () { - // Navigate to Profile tab (index 4) - final homeState = context - .findAncestorStateOfType<_HomeScreenState>(); - if (homeState != null) { - homeState.setState(() => homeState._currentIndex = 4); - } - }, - icon: const Icon(Icons.person_rounded), - color: AppTheme.textPrimary, + GestureDetector( + onTap: () => homeState?.switchTab(4), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.calendar_today_rounded, + size: 12, + color: AppColors.textMuted, + ), + const SizedBox(width: 4), + Text( + dateStr, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), ), ], ); } - Widget _buildQuickStartCard(BuildContext context) { - final provider = context.watch(); + Widget _buildHeroCTA( + BuildContext context, + WorkoutProvider provider, + _HomeScreenState? homeState, + ) { + final isActive = provider.hasActiveWorkout; + if (isActive) { + // Resume card + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.warning.withValues(alpha: 0.2), + AppColors.warning.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.fitness_center_rounded, + color: AppColors.warning, + size: 24, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Workout in progress', + style: TextStyle( + color: AppColors.warning, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + Text( + provider.activeRoutine?.name ?? 'Quick workout', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + ), + ), + ], + ), + ), + GlowButton( + label: 'Resume', + onPressed: () => Navigator.push( + context, + _slide(const WorkoutFlowScreen(isQuickStart: true)), + ), + color: AppColors.warning, + fullWidth: false, + small: true, + ), + ], + ), + ); + } + + // Default start card return Container( - width: double.infinity, padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primaryColor, Color(0xFF8B7FE8)], + gradient: LinearGradient( + colors: [ + AppColors.primary, + Color.lerp(AppColors.primary, const Color(0xFF4834D4), 0.6)!, + ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), - borderRadius: BorderRadius.circular(AppRadius.lg), + borderRadius: BorderRadius.circular(AppRadius.xl), boxShadow: [ BoxShadow( - color: AppTheme.primaryColor.withOpacity(0.4), - blurRadius: 20, + color: AppColors.primaryGlow(0.4), + blurRadius: 24, offset: const Offset(0, 8), ), ], @@ -210,16 +501,16 @@ class DashboardTab extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), ), child: const Icon( - Icons.play_arrow_rounded, + Icons.bolt_rounded, color: Colors.white, - size: 28, + size: 26, ), ), - const SizedBox(width: 16), + const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -229,16 +520,16 @@ class DashboardTab extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 20, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w800, ), ), Text( provider.routines.isEmpty - ? 'Quick start or create a routine' - : '${provider.routines.length} routines available', + ? 'Quick start or build a routine' + : '${provider.routines.length} routines ready', style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 14, + color: Colors.white.withValues(alpha: 0.75), + fontSize: 13, ), ), ], @@ -250,25 +541,64 @@ class DashboardTab extends StatelessWidget { Row( children: [ Expanded( - child: ElevatedButton( - onPressed: () => _startQuickWorkout(context), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: AppTheme.primaryColor, + child: GestureDetector( + onTap: () => homeState?._startQuickWorkout(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.flash_on_rounded, + color: AppColors.primary, size: 18), + SizedBox(width: 6), + Text( + 'Quick Start', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ], + ), ), - child: const Text('Quick Start'), ), ), if (provider.routines.isNotEmpty) ...[ - const SizedBox(width: 12), + const SizedBox(width: AppSpacing.sm), Expanded( - child: OutlinedButton( - onPressed: () => _showRoutineSelector(context), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white), + child: GestureDetector( + onTap: () => homeState?._showRoutineSelector(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.list_alt_rounded, + color: Colors.white, size: 18), + SizedBox(width: 6), + Text( + 'From Routine', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), ), - child: const Text('From Routine'), ), ), ], @@ -279,461 +609,182 @@ class DashboardTab extends StatelessWidget { ); } - Widget _buildStatsSection(BuildContext context) { - return FutureBuilder>( - future: context.read().getQuickStats(), - builder: (context, snapshot) { - final stats = - snapshot.data ?? - { + Widget _buildStatsSection(BuildContext context, WorkoutProvider provider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('This Week'), + FutureBuilder>( + future: provider.getQuickStats(), + builder: (context, snap) { + final stats = snap.data ?? { 'totalWorkouts': 0, 'weeklyWorkouts': 0, 'weeklyVolume': 0.0, 'exercisesThisWeek': 0, }; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('This Week', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: _StatCard( - icon: Icons.fitness_center, - value: '${stats['weeklyWorkouts']}', - label: 'Workouts', - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatCard( - icon: Icons.trending_up, - value: _formatVolume( - stats['weeklyVolume']?.toDouble() ?? 0, - ), - label: 'Volume (kg)', - color: AppTheme.success, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: _StatCard( - icon: Icons.list_alt, - value: '${stats['exercisesThisWeek']}', - label: 'Exercises', - color: AppTheme.secondaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatCard( - icon: Icons.all_inclusive, - value: '${stats['totalWorkouts']}', - label: 'Total Sessions', - color: AppTheme.warning, - ), - ), - ], - ), - ], - ); - }, + return StatGrid(stats: stats); + }, + ), + ], ); } - String _formatVolume(double volume) { - if (volume >= 1000) { - return '${(volume / 1000).toStringAsFixed(1)}k'; - } - return volume.toStringAsFixed(0); - } - - Widget _buildRecentWorkouts(BuildContext context) { - final provider = context.watch(); - final recentSessions = provider.sessions.take(3).toList(); - - if (recentSessions.isEmpty) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - children: [ - Icon(Icons.fitness_center, size: 48, color: AppTheme.textMuted), - const SizedBox(height: AppSpacing.md), - Text( - 'No workouts yet', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - 'Start your first workout to see it here', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ); - } - + Widget _buildWeekStrip(WorkoutProvider provider) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Recent Workouts', - style: Theme.of(context).textTheme.titleLarge, - ), - TextButton( - onPressed: () { - final homeState = context - .findAncestorStateOfType<_HomeScreenState>(); - if (homeState != null) { - homeState.setState(() => homeState._currentIndex = 1); - } - }, - child: const Text('See All'), - ), - ], - ), + const RFSectionHeader('This Week'), const SizedBox(height: AppSpacing.sm), - ...recentSessions.map( - (session) => _RecentWorkoutCard(session: session), - ), + WeekActivityStrip(sessions: provider.sessions), ], ); } - Widget _buildQuickActions(BuildContext context) { + Widget _buildQuickActions( + BuildContext context, + _HomeScreenState? homeState, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Quick Actions', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Quick Actions'), + const SizedBox(height: AppSpacing.sm), Row( children: [ Expanded( - child: _QuickActionCard( - icon: Icons.add_circle_outline, + child: QuickActionTile( + icon: Icons.add_circle_outline_rounded, label: 'New Routine', - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const RoutinesScreen()), - ), + color: AppColors.primary, + onTap: () => homeState?.switchTab(2), ), ), - const SizedBox(width: AppSpacing.md), + const SizedBox(width: AppSpacing.sm), Expanded( - child: _QuickActionCard( - icon: Icons.library_books_outlined, + child: QuickActionTile( + icon: Icons.library_books_rounded, label: 'Exercises', + color: AppColors.secondary, onTap: () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const ExerciseLibraryScreen(), - ), + _slide(const ExerciseLibraryScreen()), ), ), ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: QuickActionTile( + icon: Icons.analytics_outlined, + label: 'Analytics', + color: AppColors.success, + onTap: () => homeState?.switchTab(3), + ), + ), ], ), ], ); } +} - Future _resolveWorkoutConflict( - BuildContext context, - WorkoutProvider provider, - ) async { - final action = await showWorkoutConflictDialog( - context, - workoutStartTime: provider.workoutStartTime ?? DateTime.now(), - ); - return action ?? StartWorkoutConflictAction.cancel; - } +// ── Routine Selector Sheet ───────────────────────────────────────────────────── - Future _startQuickWorkout(BuildContext context) async { - final provider = context.read(); - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; +class _RoutineSelectorSheet extends StatelessWidget { + const _RoutineSelectorSheet({ + required this.routines, + required this.onSelect, + }); - final started = await provider.startWorkoutSafely( - exerciseIds: const [], - onConflict: () async { - conflictAction = await _resolveWorkoutConflict(context, provider); - return conflictAction; - }, - ); + final List routines; + final void Function(Routine) onSelect; - if (!context.mounted) return; - if (started || conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const WorkoutFlowScreen(isQuickStart: true), + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: AppSpacing.md, bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.textMuted, + borderRadius: BorderRadius.circular(AppRadius.full), + ), ), - ); - } - } - - void _showRoutineSelector(BuildContext context) { - final provider = context.read(); - - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Select Routine', - style: Theme.of(context).textTheme.titleLarge, + const Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + child: RFSectionHeader('Select Routine'), + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.lg, ), - const SizedBox(height: AppSpacing.md), - ...provider.routines.map( - (routine) => ListTile( + itemCount: routines.length, + itemBuilder: (_, i) { + final r = routines[i]; + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), ), child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, + Icons.fitness_center_rounded, + color: AppColors.primary, + size: 20, ), ), - title: Text(routine.name), - subtitle: Text('${routine.exerciseIds.length} exercises'), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - Navigator.pop(sheetContext); - - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; - final started = await provider.startWorkoutSafely( - routine: routine, - onConflict: () async { - conflictAction = await _resolveWorkoutConflict( - context, - provider, - ); - return conflictAction; - }, - ); - - if (!context.mounted) return; - if (started || - conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(routine: routine), - ), - ); - } - }, - ), - ), - const SizedBox(height: AppSpacing.md), - ], - ), - ), - ); - } -} - -class _StatCard extends StatelessWidget { - final IconData icon; - final String value; - final String label; - final Color color; - - const _StatCard({ - required this.icon, - required this.value, - required this.label, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _RecentWorkoutCard extends StatelessWidget { - final dynamic session; - - const _RecentWorkoutCard({required this.session}); - - @override - Widget build(BuildContext context) { - final provider = context.read(); - final dateFormat = DateFormat('MMM d, yyyy'); - final timeFormat = DateFormat('h:mm a'); - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dateFormat.format(session.date), + title: Text( + r.name, style: const TextStyle( + color: AppColors.textPrimary, fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, ), ), - const SizedBox(height: 2), - Text( - '${session.exercises.length} exercises • ${session.duration} min', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), + subtitle: Text( + '${r.exerciseIds.length} exercises', + style: const TextStyle(color: AppColors.textMuted), ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - timeFormat.format(session.date), - style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), - ), - const SizedBox(height: 2), - Text( - '${(session.totalVolume / 1000).toStringAsFixed(1)}k kg', - style: const TextStyle( - fontSize: 12, - color: AppTheme.success, - fontWeight: FontWeight.w600, + trailing: const Icon( + Icons.play_arrow_rounded, + color: AppColors.primary, ), - ), - ], + onTap: () => onSelect(r), + ); + }, ), - ], - ), + ), + ], ); } } -class _QuickActionCard extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - - const _QuickActionCard({ - required this.icon, - required this.label, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppTheme.surfaceColor), - ), - child: Column( - children: [ - Icon(icon, color: AppTheme.primaryColor, size: 28), - const SizedBox(height: 8), - Text( - label, - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); - } +// ── Route helper ────────────────────────────────────────────────────────────── + +PageRouteBuilder _slide(Widget page) { + return PageRouteBuilder( + pageBuilder: (_, __, ___) => page, + transitionsBuilder: (_, anim, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 300), + ); } diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 5f8fca8..293800a 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -1,4 +1,4 @@ -// Profile Screen - User preferences, data management, and about +// profile_screen.dart — User preferences, data management, and about import 'dart:convert'; import 'dart:io'; @@ -9,7 +9,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:intl/intl.dart'; - import 'package:package_info_plus/package_info_plus.dart'; import '../services/workout_provider.dart'; @@ -17,8 +16,7 @@ import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; import '../theme/app_theme.dart'; - -const String _createdBy = 'Devasy Patel'; +import 'widgets/profile_sections.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @@ -42,8 +40,9 @@ class _ProfileScreenState extends State PackageInfo.fromPlatform().then((info) { if (mounted) setState(() => _appVersion = info.version); }); - // Reconcile stored HC flag against runtime state on screen load. - WidgetsBinding.instance.addPostFrameCallback((_) => _reconcileHealthConnectState()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _reconcileHealthConnectState(), + ); } @override @@ -54,22 +53,14 @@ class _ProfileScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - // Re-check HC state when the user returns from background - // (e.g. after visiting Health Connect settings). if (state == AppLifecycleState.resumed) { _reconcileHealthConnectState(); } } - /// Reconciles the persisted [SettingsProvider.healthConnectEnabled] flag - /// with the actual runtime HC availability and permission state. - /// If HC is unavailable or permissions are revoked, the flag is cleared - /// so the toggle and status row reflect reality. Future _reconcileHealthConnectState() async { if (!mounted) return; final settings = context.read(); - // Only run the runtime checks when the flag is currently enabled — - // avoids unnecessary plugin calls when HC is already off. if (!settings.healthConnectEnabled) return; try { final hc = context.read(); @@ -83,7 +74,6 @@ class _ProfileScreenState extends State if (mounted) await settings.setHealthConnectEnabled(false); } } catch (e) { - // If we can't determine state, fail-safe: disable the flag. debugPrint('HC reconciliation error: $e'); if (mounted) await settings.setHealthConnectEnabled(false); } @@ -95,21 +85,20 @@ class _ProfileScreenState extends State final hc = context.read(); final available = await hc.isAvailable(); if (!available) { - if (mounted) _showSnack('Health Connect is not available on this device.', AppTheme.error); + if (mounted) { + _showSnack( + 'Health Connect is not available on this device.', + AppColors.error, + ); + } return; } - // Check if permissions were already granted (e.g. via HC settings). bool granted = await hc.hasPermissions(); - if (!granted) { - // Try to show the in-app permission dialog. try { granted = await hc.requestPermissions(); } catch (_) { - // requestPermissions can fail if the plugin loses its activity reference - // during the async gap (known issue with health_connector on some devices). - // Re-check hasPermissions in case the user already granted via HC settings. granted = await hc.hasPermissions(); } } @@ -118,43 +107,43 @@ class _ProfileScreenState extends State if (granted) { final settings = context.read(); await settings.setHealthConnectEnabled(true); - _showSnack('Health Connect connected!', AppTheme.success); + _showSnack('Health Connect connected!', AppColors.success); } else { _showSnack( 'Open Health Connect → App permissions → RepForge and enable Exercise.', - AppTheme.warning, + AppColors.warning, ); } } catch (e) { - if (mounted) _showSnack('Could not connect to Health Connect.', AppTheme.error); + if (mounted) { + _showSnack('Could not connect to Health Connect.', AppColors.error); + } } finally { if (mounted) setState(() => _isRequestingHcPermission = false); } } - // ==================== Data Actions ==================== - Future _exportToFile() async { setState(() => _isExporting = true); try { final provider = context.read(); final jsonString = await provider.exportAllData(); - final tempDir = await getTemporaryDirectory(); final dateStr = DateFormat('yyyy-MM-dd_HHmmss').format(DateTime.now()); final file = File('${tempDir.path}/repforge_backup_$dateStr.json'); await file.writeAsString(jsonString); - - final result = await Share.shareXFiles([XFile(file.path)], - subject: 'RepForge Backup'); - + // ignore: deprecated_member_use + final result = await Share.shareXFiles( + [XFile(file.path)], + subject: 'RepForge Backup', + ); if (!mounted) return; if (result.status == ShareResultStatus.success || result.status == ShareResultStatus.dismissed) { - _showSnack('Backup exported successfully!', AppTheme.success); + _showSnack('Backup exported successfully!', AppColors.success); } } catch (e) { - if (mounted) _showSnack('Export failed. Please try again.', AppTheme.error); + if (mounted) _showSnack('Export failed. Please try again.', AppColors.error); } finally { if (mounted) setState(() => _isExporting = false); } @@ -164,27 +153,30 @@ class _ProfileScreenState extends State final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Import Backup', - style: TextStyle(color: AppTheme.textPrimary)), + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Import Backup', + style: TextStyle(color: AppColors.textPrimary), + ), content: const Text( 'This will merge the backup with your existing data. ' 'Select a .json RepForge backup file to continue.', - style: TextStyle(color: AppTheme.textSecondary), + style: TextStyle(color: AppColors.textSoft), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: - const Text('Cancel', style: TextStyle(color: AppTheme.textSecondary)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), ), + ), + TextButton( onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: AppColors.primary), child: const Text('Choose File'), ), ], @@ -194,32 +186,33 @@ class _ProfileScreenState extends State setState(() => _isImporting = true); try { - final result = await FilePicker.platform - .pickFiles(type: FileType.custom, allowedExtensions: ['json']); + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); if (result == null || result.files.single.path == null) { if (mounted) setState(() => _isImporting = false); return; } - final file = File(result.files.single.path!); final jsonString = await file.readAsString(); final data = jsonDecode(jsonString) as Map; if (!data.containsKey('sessions') && !data.containsKey('routines')) { - if (mounted) _showSnack('Invalid backup file.', AppTheme.error); + if (mounted) _showSnack('Invalid backup file.', AppColors.error); return; } - + if (!mounted) return; final provider = context.read(); await provider.importData(jsonString); - if (!mounted) return; final sessionCount = (data['sessions'] as List?)?.length ?? 0; final routineCount = (data['routines'] as List?)?.length ?? 0; _showSnack( - 'Import complete! $sessionCount sessions, $routineCount routines.', - AppTheme.success); + 'Import complete! $sessionCount sessions, $routineCount routines.', + AppColors.success, + ); } catch (e) { - if (mounted) _showSnack('Import failed. Invalid backup file.', AppTheme.error); + if (mounted) _showSnack('Import failed. Invalid backup file.', AppColors.error); } finally { if (mounted) setState(() => _isImporting = false); } @@ -227,22 +220,20 @@ class _ProfileScreenState extends State Future _performCloudBackup() async { setState(() => _isBackingUp = true); + final provider = context.read(); + final api = context.read(); try { - final provider = context.read(); final jsonString = await provider.exportAllData(); final data = jsonDecode(jsonString) as Map; - - final api = context.read(); await api.trackEvent('backup_triggered').catchError((_) => null); final success = await api.backupData(data); - if (!mounted) return; _showSnack( success ? 'Cloud backup successful!' : 'Backup failed. Please try again.', - success ? AppTheme.success : AppTheme.error, + success ? AppColors.success : AppColors.error, ); } catch (_) { - if (mounted) _showSnack('Something went wrong.', AppTheme.error); + if (mounted) _showSnack('Something went wrong.', AppColors.error); } finally { if (mounted) setState(() => _isBackingUp = false); } @@ -250,34 +241,60 @@ class _ProfileScreenState extends State void _showSnack(String message, Color color) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: color), + SnackBar( + content: Text(message, style: const TextStyle(color: AppColors.textPrimary)), + backgroundColor: color, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ); } - // ==================== Build ==================== - @override Widget build(BuildContext context) { final settings = context.watch(); return Scaffold( - backgroundColor: AppTheme.backgroundColor, + backgroundColor: AppColors.background, body: CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ _buildAppBar(), SliverPadding( padding: const EdgeInsets.all(AppSpacing.md), sliver: SliverList( delegate: SliverChildListDelegate([ - _buildPreferencesSection(settings), + PreferencesSection( + settings: settings, + onHaptic: () => HapticFeedback.selectionClick(), + ), const SizedBox(height: AppSpacing.lg), - _buildHealthConnectSection(settings), + HealthConnectSection( + settings: settings, + isLoading: _isRequestingHcPermission, + onToggle: (value) async { + if (value) { + await _requestHealthConnectPermission(); + } else { + await settings.setHealthConnectEnabled(false); + } + }, + ), const SizedBox(height: AppSpacing.lg), - _buildDataSection(), + DataManagementSection( + isExporting: _isExporting, + isImporting: _isImporting, + isBackingUp: _isBackingUp, + onExport: _isExporting ? null : _exportToFile, + onImport: _isImporting ? null : _importFromFile, + onCloudBackup: _isBackingUp ? null : _performCloudBackup, + ), const SizedBox(height: AppSpacing.lg), - _buildCloudSyncSection(), + const CloudSyncSection(), const SizedBox(height: AppSpacing.lg), - _buildAboutSection(), + AboutSection(appVersion: _appVersion), const SizedBox(height: AppSpacing.xxl), ]), ), @@ -291,37 +308,39 @@ class _ProfileScreenState extends State return SliverAppBar( expandedHeight: 160, pinned: true, - backgroundColor: AppTheme.surfaceColor, + backgroundColor: AppColors.surface, flexibleSpace: FlexibleSpaceBar( background: Container( decoration: const BoxDecoration( gradient: LinearGradient( - colors: [AppTheme.primaryColor, Color(0xFF8B7FE8)], + colors: [AppColors.primary, Color(0xFF8B7FE8)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: SafeArea( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, ), child: Column( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 60, - height: 60, + width: 56, + height: 56, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(AppRadius.md), ), child: const Icon( - Icons.fitness_center, + Icons.fitness_center_rounded, color: Colors.white, - size: 32, + size: 28, ), ), const SizedBox(height: AppSpacing.sm), @@ -329,15 +348,16 @@ class _ProfileScreenState extends State 'RepForge', style: TextStyle( color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, ), ), Text( 'v$_appVersion', style: TextStyle( - color: Colors.white.withOpacity(0.75), - fontSize: 13, + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, ), ), ], @@ -348,551 +368,4 @@ class _ProfileScreenState extends State ), ); } - - // ==================== Preferences ==================== - - Widget _buildPreferencesSection(SettingsProvider settings) { - return _ProfileSection( - icon: Icons.tune_rounded, - iconColor: AppTheme.primaryColor, - title: 'Preferences', - subtitle: 'Customize weight display and input steps', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SettingsLabel('Weight Unit'), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - Expanded( - child: _UnitToggleButton( - label: 'kg', - selected: settings.weightUnit == WeightUnit.kg, - onTap: () => settings.setWeightUnit(WeightUnit.kg), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: _UnitToggleButton( - label: 'lbs', - selected: settings.weightUnit == WeightUnit.lbs, - onTap: () => settings.setWeightUnit(WeightUnit.lbs), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - _SettingsLabel('Weight Increment'), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: settings.availableIncrements.map((inc) { - final selected = settings.weightIncrement == inc; - final label = inc == inc.truncateToDouble() - ? '${inc.toStringAsFixed(0)} ${settings.unitLabel}' - : '${inc.toStringAsFixed(2).replaceAll(RegExp(r'0+$'), '')} ${settings.unitLabel}'; - return ChoiceChip( - label: Text(label), - selected: selected, - onSelected: (_) { - HapticFeedback.selectionClick(); - settings.setWeightIncrement(inc); - }, - selectedColor: AppTheme.primaryColor.withOpacity(0.25), - backgroundColor: AppTheme.surfaceColor, - labelStyle: TextStyle( - color: - selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - fontSize: 13, - ), - side: BorderSide( - color: selected ? AppTheme.primaryColor : Colors.transparent, - ), - padding: - const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - ); - }).toList(), - ), - ], - ), - ); - } - - // ==================== Health Connect ==================== - - Widget _buildHealthConnectSection(SettingsProvider settings) { - final enabled = settings.healthConnectEnabled; - return _ProfileSection( - icon: Icons.monitor_heart_outlined, - iconColor: const Color(0xFF00BFA5), - title: 'Health Connect', - subtitle: 'Sync workouts to Android Health Connect', - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sync workouts after finishing', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - ), - ), - Text( - 'Writes session + per-set reps to Health Connect', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - Switch( - value: enabled, - onChanged: _isRequestingHcPermission - ? null - : (value) async { - if (value) { - await _requestHealthConnectPermission(); - } else { - await settings.setHealthConnectEnabled(false); - } - }, - activeThumbColor: const Color(0xFF00BFA5), - activeTrackColor: const Color(0xFF00BFA5).withValues(alpha: 0.4), - ), - ], - ), - if (enabled) ...[ - const SizedBox(height: AppSpacing.sm), - const Divider(color: AppTheme.surfaceColor, height: 1), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - const Icon(Icons.check_circle_outline, - color: Color(0xFF00BFA5), size: 16), - const SizedBox(width: 8), - const Text( - 'Connected — syncing after each workout', - style: TextStyle( - color: Color(0xFF00BFA5), - fontSize: 12, - ), - ), - ], - ), - ], - ], - ), - ); - } - - // ==================== Data Management ==================== - - Widget _buildDataSection() { - return _ProfileSection( - icon: Icons.storage_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Data Management', - subtitle: 'Export, import, or backup your workout data', - child: Column( - children: [ - _ActionTile( - icon: Icons.upload_file_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Export Backup', - subtitle: 'Save a local .json backup file', - loading: _isExporting, - onTap: _isExporting ? null : _exportToFile, - ), - const _Divider(), - _ActionTile( - icon: Icons.download_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Import Backup', - subtitle: 'Merge data from a .json backup', - loading: _isImporting, - onTap: _isImporting ? null : _importFromFile, - ), - const _Divider(), - _ActionTile( - icon: Icons.cloud_upload_outlined, - iconColor: AppTheme.primaryColor, - title: 'Cloud Backup', - subtitle: 'Sync to RepForge cloud (requires account)', - loading: _isBackingUp, - onTap: _isBackingUp ? null : _performCloudBackup, - ), - ], - ), - ); - } - - // ==================== Cloud Sync (placeholder) ==================== - - Widget _buildCloudSyncSection() { - return _ProfileSection( - icon: Icons.sync_rounded, - iconColor: AppTheme.warning, - title: 'Cloud Sync', - subtitle: 'Sync your data across devices', - trailing: _ComingSoonBadge(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SettingsLabel('MongoDB Connection String'), - const SizedBox(height: AppSpacing.sm), - TextField( - enabled: false, - decoration: InputDecoration( - hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: const TextStyle( - color: AppTheme.textMuted, fontSize: 13), - prefixIcon: const Icon(Icons.link_rounded, - color: AppTheme.textMuted, size: 20), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Cloud sync with custom MongoDB will be available in a future update.', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ); - } - - // ==================== About ==================== - - Widget _buildAboutSection() { - return _ProfileSection( - icon: Icons.info_outline_rounded, - iconColor: AppTheme.textSecondary, - title: 'About', - subtitle: 'RepForge Workout Logger', - child: Column( - children: [ - _InfoTile( - label: 'Version', - value: _appVersion, - icon: Icons.tag_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Created by', - value: _createdBy, - icon: Icons.person_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Platform', - value: 'Android', - icon: Icons.phone_android_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Package', - value: 'com.devasy.repforge', - icon: Icons.inventory_2_outlined, - ), - ], - ), - ); - } -} - -// ==================== Reusable Widgets ==================== - -class _ProfileSection extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final Widget child; - final Widget? trailing; - - const _ProfileSection({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.child, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - Text( - subtitle, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - if (trailing != null) trailing!, - ], - ), - const SizedBox(height: AppSpacing.md), - const Divider(color: AppTheme.surfaceColor, height: 1), - const SizedBox(height: AppSpacing.md), - child, - ], - ), - ); - } -} - -class _SettingsLabel extends StatelessWidget { - final String text; - const _SettingsLabel(this.text); - - @override - Widget build(BuildContext context) { - return Text( - text, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ); - } -} - -class _UnitToggleButton extends StatelessWidget { - final String label; - final bool selected; - final VoidCallback onTap; - - const _UnitToggleButton({ - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - HapticFeedback.selectionClick(); - onTap(); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: selected - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all( - color: selected ? AppTheme.primaryColor : Colors.transparent, - width: 1.5, - ), - ), - child: Center( - child: Text( - label, - style: TextStyle( - color: - selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - fontSize: 15, - ), - ), - ), - ), - ); - } -} - -class _ActionTile extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final bool loading; - final VoidCallback? onTap; - - const _ActionTile({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.loading, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - title: Text( - title, - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14), - ), - subtitle: Text( - subtitle, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - trailing: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(AppTheme.primaryColor), - ), - ) - : const Icon( - Icons.chevron_right, - color: AppTheme.textMuted, - ), - onTap: onTap, - ); - } -} - -class _InfoTile extends StatelessWidget { - final String label; - final String value; - final IconData icon; - - const _InfoTile({ - required this.label, - required this.value, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon(icon, color: AppTheme.textMuted, size: 18), - const SizedBox(width: 12), - Text( - label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - const Spacer(), - Text( - value, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) { - return const Divider( - color: AppTheme.surfaceColor, - height: 1, - indent: 40, - ); - } -} - -class _ComingSoonBadge extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.15), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all( - color: AppTheme.warning.withOpacity(0.4), - ), - ), - child: const Text( - 'Coming Soon', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ); - } } diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 81380f4..1ae972c 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -1,4 +1,4 @@ -// Routines Screen - Manage workout routines and training programs +// routines_screen.dart — Routines + Programs tabs import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -6,38 +6,10 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; -import '../data/exercise_database.dart'; -import 'workout_flow_screen.dart'; import 'programs/programs_screen.dart'; -import 'widgets/workout_conflict_dialog.dart'; - -Future _startRoutineWorkoutFlow( - BuildContext context, - Routine routine, -) async { - final provider = context.read(); - StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; - - final started = await provider.startWorkoutSafely( - routine: routine, - onConflict: () async { - final action = await showWorkoutConflictDialog( - context, - workoutStartTime: provider.workoutStartTime ?? DateTime.now(), - ); - conflictAction = action ?? StartWorkoutConflictAction.cancel; - return conflictAction; - }, - ); - - if (!context.mounted) return; - if (started || conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => WorkoutFlowScreen(routine: routine)), - ); - } -} +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_cards.dart'; +import 'widgets/routine_creator.dart'; class RoutinesScreen extends StatelessWidget { const RoutinesScreen({super.key}); @@ -47,718 +19,176 @@ class RoutinesScreen extends StatelessWidget { return DefaultTabController( length: 2, child: Scaffold( - appBar: AppBar( - title: const Text('Routines'), - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.list_alt), text: 'Routines'), - Tab(icon: Icon(Icons.calendar_month), text: 'Programs'), + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + _RoutinesHeader(), + Expanded( + child: TabBarView( + children: [ + _RoutinesTab(), + const ProgramsScreen(), + ], + ), + ), ], ), ), - body: const TabBarView(children: [_RoutinesTab(), ProgramsScreen()]), ), ); } } -class _RoutinesTab extends StatelessWidget { - const _RoutinesTab(); - +// ── Header with title + tab bar ─────────────────────────────────────────────── +class _RoutinesHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); - final routines = provider.routines; - - return Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: routines.isEmpty - ? _buildEmptyState(context) - : _buildRoutineList(context, routines, provider), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreateRoutineDialog(context), - icon: const Icon(Icons.add), - label: const Text('New Routine'), + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + border: Border(bottom: BorderSide(color: AppColors.glassBorder)), ), - ); - } - - Widget _buildEmptyState(BuildContext context) { - return Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.list_alt, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - Text( - 'No Routines Yet', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Create a routine to organize your workouts', - style: Theme.of(context).textTheme.bodyMedium, + const Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.sm, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Routines', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + ), ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () => _showCreateRoutineDialog(context), - icon: const Icon(Icons.add), - label: const Text('Create Routine'), + TabBar( + indicatorColor: AppColors.primary, + indicatorWeight: 2, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textMuted, + labelStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + tabs: const [ + Tab(text: 'My Routines'), + Tab(text: 'Programs'), + ], ), ], ), ); } - - Widget _buildRoutineList( - BuildContext context, - List routines, - WorkoutProvider provider, - ) { - return ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: routines.length, - itemBuilder: (context, index) { - final routine = routines[index]; - return _RoutineCard(routine: routine, provider: provider); - }, - ); - } - - void _showCreateRoutineDialog(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const CreateRoutineScreen()), - ); - } } -class _RoutineCard extends StatelessWidget { - final Routine routine; - final WorkoutProvider provider; - - const _RoutineCard({required this.routine, required this.provider}); - +// ── Routines Tab ────────────────────────────────────────────────────────────── +class _RoutinesTab extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: InkWell( - onTap: () => _showRoutineDetails(context), - onLongPress: () => _showRoutineOptions(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - routine.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: AppTheme.textPrimary, - ), - ), - Text( - '${routine.exerciseIds.length} exercises', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.play_circle_fill), - color: AppTheme.primaryColor, - iconSize: 40, - onPressed: () => _startRoutineWorkoutFlow(context, routine), - ), - ], + final provider = context.watch(); + final routines = provider.routines; + + return Scaffold( + backgroundColor: AppColors.background, + body: routines.isEmpty + ? RFEmptyState( + icon: Icons.list_alt_rounded, + title: 'No Routines Yet', + subtitle: 'Create a routine to organize your workouts', + action: GlowButton( + label: 'Create Routine', + icon: Icons.add_rounded, + onPressed: () => _openCreate(context), ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: 8, - runSpacing: 4, - children: routine.exerciseIds.take(5).map((id) { - final name = provider.getExerciseName(id); - return Chip( - label: Text(name, style: const TextStyle(fontSize: 11)), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ); - }).toList(), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, ), - if (routine.exerciseIds.length > 5) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '+${routine.exerciseIds.length - 5} more', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ), - ], - ), - ), + physics: const BouncingScrollPhysics(), + itemCount: routines.length, + itemBuilder: (_, i) => RoutineCard( + routine: routines[i], + getExerciseName: provider.getExerciseName, + onStart: () => startRoutineWorkoutFlow(context, routines[i]), + onEdit: () => _openEdit(context, routines[i]), + onDelete: () => _confirmDelete(context, routines[i], provider), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _openCreate(context), + backgroundColor: AppColors.primary, + elevation: 0, + child: const Icon(Icons.add_rounded, color: Colors.white), ), ); } - void _showRoutineDetails(BuildContext context) { + void _openCreate(BuildContext context) { Navigator.push( context, - MaterialPageRoute(builder: (_) => RoutineDetailScreen(routine: routine)), + MaterialPageRoute(builder: (_) => const CreateRoutineScreen()), ); } - void _showRoutineOptions(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.edit), - title: const Text('Edit Routine'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CreateRoutineScreen(routine: routine), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.delete, color: AppTheme.error), - title: const Text( - 'Delete Routine', - style: TextStyle(color: AppTheme.error), - ), - onTap: () { - Navigator.pop(context); - _confirmDelete(context); - }, - ), - ], - ), - ), + void _openEdit(BuildContext context, Routine routine) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => CreateRoutineScreen(routine: routine)), ); } - void _confirmDelete(BuildContext context) { - showDialog( + void _confirmDelete( + BuildContext context, + Routine routine, + WorkoutProvider provider, + ) { + showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Routine?'), - content: Text('Are you sure you want to delete "${routine.name}"?'), + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Delete Routine?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Delete "${routine.name}"? This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), + ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - provider.deleteRoutine(routine.id); - Navigator.pop(context); - }, + onPressed: () => Navigator.of(ctx).pop(), child: const Text( - 'Delete', - style: TextStyle(color: AppTheme.error), - ), - ), - ], - ), - ); - } -} - -class CreateRoutineScreen extends StatefulWidget { - final Routine? routine; - - const CreateRoutineScreen({super.key, this.routine}); - - @override - State createState() => _CreateRoutineScreenState(); -} - -class _CreateRoutineScreenState extends State { - final _nameController = TextEditingController(); - final List _selectedExerciseIds = []; - - @override - void initState() { - super.initState(); - if (widget.routine != null) { - _nameController.text = widget.routine!.name; - _selectedExerciseIds.addAll(widget.routine!.exerciseIds); - } - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // Use provider's exercises list (includes custom exercises) - final provider = context.watch(); - final exercises = provider.allExercises; - - return Scaffold( - appBar: AppBar( - title: Text(widget.routine == null ? 'New Routine' : 'Edit Routine'), - actions: [ - TextButton(onPressed: _saveRoutine, child: const Text('Save')), - ], - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Routine Name', - hintText: 'e.g., Push Day, Leg Day', - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: Row( - children: [ - Text( - 'Exercises (${_selectedExerciseIds.length})', - style: Theme.of(context).textTheme.titleMedium, - ), - const Spacer(), - if (_selectedExerciseIds.isNotEmpty) - TextButton( - onPressed: () => - setState(() => _selectedExerciseIds.clear()), - child: const Text('Clear All'), - ), - ], - ), - ), - Expanded( - child: ReorderableListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: _selectedExerciseIds.length + 1, - onReorder: (oldIndex, newIndex) { - if (oldIndex >= _selectedExerciseIds.length || - newIndex >= _selectedExerciseIds.length + 1) { - return; - } - - setState(() { - if (newIndex > oldIndex) newIndex--; - final item = _selectedExerciseIds.removeAt(oldIndex); - _selectedExerciseIds.insert(newIndex, item); - }); - }, - itemBuilder: (context, index) { - if (index == _selectedExerciseIds.length) { - return Padding( - key: const ValueKey('add_button'), - padding: const EdgeInsets.only(top: AppSpacing.md), - child: OutlinedButton.icon( - onPressed: () => _showExercisePicker(exercises), - icon: const Icon(Icons.add), - label: const Text('Add Exercises'), - ), - ); - } - - final exerciseId = _selectedExerciseIds[index]; - final exercise = provider.getExercise(exerciseId); - - return Card( - key: ValueKey(exerciseId), - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - leading: ReorderableDragStartListener( - index: index, - child: const Icon( - Icons.drag_handle, - color: AppTheme.textMuted, - ), - ), - title: Text(exercise?.name ?? 'Unknown'), - subtitle: Text( - exercise?.category ?? '', - style: const TextStyle(fontSize: 12), - ), - trailing: IconButton( - icon: const Icon( - Icons.remove_circle_outline, - color: AppTheme.error, - ), - onPressed: () { - setState(() => _selectedExerciseIds.removeAt(index)); - }, - ), - ), - ); - }, + 'Cancel', + style: TextStyle(color: AppColors.textSoft), ), ), - ], - ), - ); - } - - void _showExercisePicker(List allExercises) { - // Local state for picker search - scoped to this modal only - String pickerSearchQuery = ''; - // Use List instead of Set to preserve selection order - final List tempSelectedIds = []; - - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => StatefulBuilder( - builder: (context, setModalState) { - // Filter exercises by search and exclude already selected - var filteredExercises = allExercises.where((ex) { - if (_selectedExerciseIds.contains(ex.id)) return false; - if (pickerSearchQuery.isEmpty) return true; - return ex.name.toLowerCase().contains( - pickerSearchQuery.toLowerCase(), - ); - }).toList(); - - // Group by muscle - final grouped = >{}; - for (var ex in filteredExercises) { - final primary = ex.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(ex); - } - - return DraggableScrollableSheet( - initialChildSize: 0.8, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Fixed header with search and done button - Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - Row( - children: [ - Expanded( - child: Text( - 'Add Exercises', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - if (tempSelectedIds.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - _selectedExerciseIds.addAll( - tempSelectedIds, - ); - }); - Navigator.pop(context); - }, - icon: const Icon(Icons.check), - label: Text('Add ${tempSelectedIds.length}'), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - // Search field - TextField( - decoration: InputDecoration( - hintText: 'Search exercises...', - prefixIcon: const Icon(Icons.search), - suffixIcon: pickerSearchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - setModalState( - () => pickerSearchQuery = '', - ); - }, - ) - : null, - isDense: true, - ), - onChanged: (val) { - setModalState(() => pickerSearchQuery = val); - }, - ), - if (tempSelectedIds.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.sm), - Text( - '${tempSelectedIds.length} exercise${tempSelectedIds.length > 1 ? 's' : ''} selected', - style: const TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - ), - // Scrollable exercise list - Expanded( - child: ListView( - controller: scrollController, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - ), - children: [ - ...grouped.entries.map((entry) { - final muscleName = - MuscleGroups.names[entry.key] ?? entry.key; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.sm, - ), - child: Text( - muscleName, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - ...entry.value.map((exercise) { - final isSelected = tempSelectedIds.contains( - exercise.id, - ); - return ListTile( - leading: Checkbox( - value: isSelected, - onChanged: (val) { - setModalState(() { - if (val == true) { - // Prevent duplicates - if (!tempSelectedIds.contains( - exercise.id, - )) { - tempSelectedIds.add(exercise.id); - } - } else { - tempSelectedIds.remove(exercise.id); - } - }); - }, - ), - title: Text( - exercise.name, - style: TextStyle( - color: isSelected - ? AppTheme.primaryColor - : null, - ), - ), - subtitle: exercise.isCustom - ? const Text( - 'Custom', - style: TextStyle( - color: AppTheme.primaryColor, - fontSize: 12, - ), - ) - : null, - trailing: isSelected - ? const Icon( - Icons.check_circle, - color: AppTheme.primaryColor, - ) - : const Icon( - Icons.add_circle_outline, - color: AppTheme.textMuted, - ), - onTap: () { - setModalState(() { - if (isSelected) { - tempSelectedIds.remove(exercise.id); - } else { - // Prevent duplicates - if (!tempSelectedIds.contains( - exercise.id, - )) { - tempSelectedIds.add(exercise.id); - } - } - }); - }, - ); - }), - ], - ); - }), - const SizedBox(height: AppSpacing.xl), - ], - ), - ), - ], - ); - }, - ); - }, - ), - ); - } - - void _saveRoutine() async { - if (_nameController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please enter a routine name')), - ); - return; - } - - if (_selectedExerciseIds.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please add at least one exercise')), - ); - return; - } - - final provider = context.read(); - - if (widget.routine != null) { - // Update existing - final updated = Routine( - id: widget.routine!.id, - name: _nameController.text, - exerciseIds: _selectedExerciseIds, - createdAt: widget.routine!.createdAt, - ); - await provider.updateRoutine(updated); - } else { - // Create new - await provider.createRoutine(_nameController.text, _selectedExerciseIds); - } - - if (mounted) Navigator.pop(context); - } -} - -class RoutineDetailScreen extends StatelessWidget { - final Routine routine; - - const RoutineDetailScreen({super.key, required this.routine}); - - @override - Widget build(BuildContext context) { - final provider = context.read(); - - return Scaffold( - appBar: AppBar( - title: Text(routine.name), - actions: [ - IconButton( - icon: const Icon(Icons.edit), + TextButton( onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => CreateRoutineScreen(routine: routine), - ), - ); + provider.deleteRoutine(routine.id); + Navigator.of(ctx).pop(); }, + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), ), ], ), - body: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: routine.exerciseIds.length, - itemBuilder: (context, index) { - final exerciseId = routine.exerciseIds[index]; - final exercise = provider.getExercise(exerciseId); - - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - leading: CircleAvatar( - backgroundColor: AppTheme.primaryColor.withOpacity(0.2), - child: Text( - '${index + 1}', - style: const TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), - ), - ), - title: Text(exercise?.name ?? 'Unknown'), - subtitle: exercise != null - ? Text( - '${exercise.category} • ${MuscleGroups.names[exercise.primaryMuscle] ?? ""}', - style: const TextStyle(fontSize: 12), - ) - : null, - ), - ); - }, - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - _startRoutineWorkoutFlow(context, routine); - }, - icon: const Icon(Icons.play_arrow), - label: const Text('Start Workout'), - ), ); } } diff --git a/workout-logger/lib/screens/widgets/dashboard_widgets.dart b/workout-logger/lib/screens/widgets/dashboard_widgets.dart new file mode 100644 index 0000000..b2004ed --- /dev/null +++ b/workout-logger/lib/screens/widgets/dashboard_widgets.dart @@ -0,0 +1,260 @@ +// dashboard_widgets.dart — Dashboard-specific helper widgets for home_screen. + +import 'package:flutter/material.dart'; +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import 'rf_cards.dart'; + +// ── WeekActivityStrip ───────────────────────────────────────────────────────── +// 7-dot strip showing which days this week had a workout. +class WeekActivityStrip extends StatelessWidget { + const WeekActivityStrip({super.key, required this.sessions}); + + final List sessions; + + @override + Widget build(BuildContext context) { + final today = DateTime.now(); + // Weekday 1=Mon … 7=Sun; align strip Mon→Sun + final startOfWeek = today.subtract(Duration(days: today.weekday - 1)); + final trainedDays = sessions + .where((s) => s.date.isAfter(startOfWeek.subtract(const Duration(days: 1)))) + .map((s) => s.date.weekday) + .toSet(); + + const labels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (i) { + final weekday = i + 1; + final trained = trainedDays.contains(weekday); + final isToday = weekday == today.weekday; + + return Column( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 30, + height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: trained + ? AppColors.primary + : isToday + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.card, + border: Border.all( + color: isToday + ? AppColors.primary + : trained + ? AppColors.primary + : AppColors.glassBorder, + width: isToday ? 2 : 1, + ), + boxShadow: trained + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 8, + ), + ] + : null, + ), + child: trained + ? const Icon(Icons.check_rounded, size: 14, color: Colors.white) + : null, + ), + const SizedBox(height: 4), + Text( + labels[i], + style: TextStyle( + color: isToday ? AppColors.primary : AppColors.textMuted, + fontSize: 10, + fontWeight: isToday ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ); + }), + ); + } +} + +// ── StatGrid ────────────────────────────────────────────────────────────────── +// 2×2 grid of StatGridCards from quick stats map. +class StatGrid extends StatelessWidget { + const StatGrid({super.key, required this.stats}); + + final Map stats; + + static String _formatVolume(double v) { + if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}k'; + return v.toStringAsFixed(0); + } + + @override + Widget build(BuildContext context) { + final weeklyWorkouts = stats['weeklyWorkouts'] ?? 0; + final weeklyVolume = (stats['weeklyVolume'] ?? 0.0).toDouble(); + final exercisesThisWeek = stats['exercisesThisWeek'] ?? 0; + final totalWorkouts = stats['totalWorkouts'] ?? 0; + + return Column( + children: [ + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.fitness_center_rounded, + value: '$weeklyWorkouts', + label: 'This Week', + color: AppColors.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.trending_up_rounded, + value: _formatVolume(weeklyVolume), + label: 'Volume (kg)', + color: AppColors.success, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.bar_chart_rounded, + value: '$exercisesThisWeek', + label: 'Exercises', + color: AppColors.secondary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.emoji_events_rounded, + value: '$totalWorkouts', + label: 'All Time', + color: AppColors.warning, + ), + ), + ], + ), + ], + ); + } +} + +// ── QuickActionTile ─────────────────────────────────────────────────────────── +class QuickActionTile extends StatelessWidget { + const QuickActionTile({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.color, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(icon, color: c, size: 22), + ), + const SizedBox(height: AppSpacing.sm), + Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +// ── RecentWorkoutsSection ───────────────────────────────────────────────────── +class RecentWorkoutsSection extends StatelessWidget { + const RecentWorkoutsSection({ + super.key, + required this.sessions, + required this.getExerciseName, + required this.onSeeAll, + required this.onTap, + }); + + final List sessions; + final String Function(String) getExerciseName; + final VoidCallback onSeeAll; + final void Function(WorkoutSession) onTap; + + @override + Widget build(BuildContext context) { + if (sessions.isEmpty) { + return RFEmptyState( + icon: Icons.fitness_center_outlined, + title: 'No workouts yet', + subtitle: 'Start your first workout to see it here', + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader( + 'Recent Workouts', + trailing: TextButton( + onPressed: onSeeAll, + child: const Text( + 'See All', + style: TextStyle(color: AppColors.primary, fontSize: 13), + ), + ), + ), + ...sessions.map( + (s) => RecentSessionTile( + session: s, + getExerciseName: getExerciseName, + onTap: () => onTap(s), + ), + ), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/editable_exercise_card.dart b/workout-logger/lib/screens/widgets/editable_exercise_card.dart new file mode 100644 index 0000000..5a61090 --- /dev/null +++ b/workout-logger/lib/screens/widgets/editable_exercise_card.dart @@ -0,0 +1,623 @@ +// editable_exercise_card.dart — Editable exercise card + set/drop rows for edit screen + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; + +// ── Shared mutable data classes ─────────────────────────────────────────────── +class EditableExerciseLog { + final String exerciseId; + final List sets; + final String? notes; + + EditableExerciseLog({ + required this.exerciseId, + required this.sets, + this.notes, + }); +} + +class EditableSet { + double weight; + int reps; + bool isDropset; + List? drops; + int? timeTaken; + DateTime timestamp; + + EditableSet({ + required this.weight, + required this.reps, + required this.timestamp, + this.isDropset = false, + this.drops, + this.timeTaken, + }); +} + +// ── Editable exercise card ──────────────────────────────────────────────────── +class EditableExerciseCard extends StatelessWidget { + const EditableExerciseCard({ + super.key, + required this.exerciseName, + required this.editableLog, + required this.onSetChanged, + required this.onAddSet, + required this.onDeleteSet, + required this.onDeleteExercise, + }); + + final String exerciseName; + final EditableExerciseLog editableLog; + final void Function( + int setIndex, + double weight, + int reps, + bool isDropset, + List? drops, + ) onSetChanged; + final VoidCallback onAddSet; + final void Function(int setIndex) onDeleteSet; + final VoidCallback onDeleteExercise; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Text( + exerciseName, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + GestureDetector( + onTap: () => _confirmDelete(context), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.delete_outline_rounded, + size: 16, + color: AppColors.error, + ), + ), + ), + ], + ), + ), + + const Divider(height: 1, color: AppColors.divider), + + // Set rows + Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + children: editableLog.sets.asMap().entries.map((entry) { + final i = entry.key; + final set = entry.value; + return EditableSetRow( + key: ValueKey('set_${exerciseName}_$i'), + setNumber: i + 1, + weight: set.weight, + reps: set.reps, + isDropset: set.isDropset, + drops: set.drops, + onWeightChanged: (w) => + onSetChanged(i, w, set.reps, set.isDropset, set.drops), + onRepsChanged: (r) => + onSetChanged(i, set.weight, r, set.isDropset, set.drops), + onIsDropsetChanged: (d) => + onSetChanged(i, set.weight, set.reps, d, set.drops), + onDropsChanged: (drops) => + onSetChanged(i, set.weight, set.reps, set.isDropset, drops), + onDelete: () => onDeleteSet(i), + ); + }).toList(), + ), + ), + + // Add set button + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: GestureDetector( + onTap: onAddSet, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.2), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_rounded, size: 14, color: AppColors.primary), + SizedBox(width: 4), + Text( + 'Add Set', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Remove Exercise?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Remove "$exerciseName" from this workout?', + style: const TextStyle(color: AppColors.textSoft), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), + ), + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + onDeleteExercise(); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Remove'), + ), + ], + ), + ); + } +} + +// ── Editable set row ────────────────────────────────────────────────────────── +class EditableSetRow extends StatefulWidget { + const EditableSetRow({ + super.key, + required this.setNumber, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onIsDropsetChanged, + required this.onDropsChanged, + required this.onDelete, + this.isDropset = false, + this.drops, + }); + + final int setNumber; + final double weight; + final int reps; + final bool isDropset; + final List? drops; + final void Function(double) onWeightChanged; + final void Function(int) onRepsChanged; + final void Function(bool) onIsDropsetChanged; + final void Function(List) onDropsChanged; + final VoidCallback onDelete; + + @override + State createState() => _EditableSetRowState(); +} + +class _EditableSetRowState extends State { + late TextEditingController _weightCtrl; + late TextEditingController _repsCtrl; + final _weightFocus = FocusNode(); + final _repsFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _weightCtrl = TextEditingController(text: widget.weight.toString()); + _repsCtrl = TextEditingController(text: widget.reps.toString()); + } + + @override + void didUpdateWidget(covariant EditableSetRow old) { + super.didUpdateWidget(old); + if (widget.weight != old.weight && !_weightFocus.hasFocus) { + if (double.tryParse(_weightCtrl.text) != widget.weight) { + _weightCtrl.text = widget.weight.toString(); + } + } + if (widget.reps != old.reps && !_repsFocus.hasFocus) { + if (int.tryParse(_repsCtrl.text) != widget.reps) { + _repsCtrl.text = widget.reps.toString(); + } + } + } + + @override + void dispose() { + _weightCtrl.dispose(); + _repsCtrl.dispose(); + _weightFocus.dispose(); + _repsFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Column( + children: [ + Row( + children: [ + // Set number badge + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${widget.setNumber}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + + // Weight + _NumField( + controller: _weightCtrl, + focusNode: _weightFocus, + suffix: 'kg', + decimal: true, + width: 78, + onChanged: (v) => + widget.onWeightChanged(double.tryParse(v) ?? 0), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('×', style: TextStyle(color: AppColors.textMuted)), + ), + + // Reps + _NumField( + controller: _repsCtrl, + focusNode: _repsFocus, + suffix: 'reps', + width: 72, + onChanged: (v) => + widget.onRepsChanged(int.tryParse(v) ?? 0), + ), + + const Spacer(), + + // Dropset toggle + GestureDetector( + onTap: () => + widget.onIsDropsetChanged(!widget.isDropset), + child: Icon( + widget.isDropset + ? Icons.layers_rounded + : Icons.layers_outlined, + size: 18, + color: widget.isDropset + ? AppColors.primary + : AppColors.textMuted, + ), + ), + const SizedBox(width: AppSpacing.sm), + + // Delete + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close_rounded, + size: 16, + color: AppColors.textMuted, + ), + ), + ], + ), + + // Drop rows + if (widget.isDropset && widget.drops != null) ...[ + ...widget.drops!.asMap().entries.map((e) { + final i = e.key; + final drop = e.value; + return EditableDropRow( + key: ValueKey('drop_${widget.setNumber}_$i'), + dropNumber: i + 1, + weight: drop.weight, + reps: drop.reps, + onWeightChanged: (w) { + final updated = List.from(widget.drops!); + updated[i] = DropsetEntry(weight: w, reps: drop.reps); + widget.onDropsChanged(updated); + }, + onRepsChanged: (r) { + final updated = List.from(widget.drops!); + updated[i] = DropsetEntry(weight: drop.weight, reps: r); + widget.onDropsChanged(updated); + }, + onDelete: () { + final updated = List.from(widget.drops!) + ..removeAt(i); + widget.onDropsChanged(updated); + }, + ); + }), + // Add drop + GestureDetector( + onTap: () { + final existing = widget.drops ?? []; + final initW = existing.isEmpty + ? widget.weight * 0.8 + : existing.last.weight * 0.8; + final rounded = (initW * 2).round() / 2; + widget.onDropsChanged([ + ...existing, + DropsetEntry(weight: rounded, reps: widget.reps), + ]); + }, + child: Padding( + padding: const EdgeInsets.only(left: 34, top: 4), + child: Row( + children: [ + Icon( + Icons.add_circle_outline_rounded, + size: 13, + color: AppColors.primary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + 'Add Drop', + style: TextStyle( + color: AppColors.primary.withValues(alpha: 0.7), + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} + +// ── Drop row ────────────────────────────────────────────────────────────────── +class EditableDropRow extends StatefulWidget { + const EditableDropRow({ + super.key, + required this.dropNumber, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onDelete, + }); + + final int dropNumber; + final double weight; + final int reps; + final void Function(double) onWeightChanged; + final void Function(int) onRepsChanged; + final VoidCallback onDelete; + + @override + State createState() => _EditableDropRowState(); +} + +class _EditableDropRowState extends State { + late TextEditingController _weightCtrl; + late TextEditingController _repsCtrl; + final _weightFocus = FocusNode(); + final _repsFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _weightCtrl = TextEditingController(text: widget.weight.toString()); + _repsCtrl = TextEditingController(text: widget.reps.toString()); + } + + @override + void didUpdateWidget(covariant EditableDropRow old) { + super.didUpdateWidget(old); + if (widget.weight != old.weight && !_weightFocus.hasFocus) { + if (double.tryParse(_weightCtrl.text) != widget.weight) { + _weightCtrl.text = widget.weight.toString(); + } + } + if (widget.reps != old.reps && !_repsFocus.hasFocus) { + if (int.tryParse(_repsCtrl.text) != widget.reps) { + _repsCtrl.text = widget.reps.toString(); + } + } + } + + @override + void dispose() { + _weightCtrl.dispose(); + _repsCtrl.dispose(); + _weightFocus.dispose(); + _repsFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 34, top: 4, bottom: 4), + child: Row( + children: [ + Icon( + Icons.subdirectory_arrow_right_rounded, + size: 14, + color: AppColors.textMuted.withValues(alpha: 0.5), + ), + const SizedBox(width: 6), + Text( + 'Drop ${widget.dropNumber}', + style: const TextStyle(color: AppColors.textMuted, fontSize: 11), + ), + const SizedBox(width: AppSpacing.sm), + _NumField( + controller: _weightCtrl, + focusNode: _weightFocus, + suffix: 'kg', + decimal: true, + width: 68, + height: 30, + onChanged: (v) => + widget.onWeightChanged(double.tryParse(v) ?? 0), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '×', + style: TextStyle(color: AppColors.textMuted, fontSize: 12), + ), + ), + _NumField( + controller: _repsCtrl, + focusNode: _repsFocus, + suffix: 'reps', + width: 60, + height: 30, + onChanged: (v) => widget.onRepsChanged(int.tryParse(v) ?? 0), + ), + const Spacer(), + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close_rounded, + size: 14, + color: AppColors.textMuted, + ), + ), + ], + ), + ); + } +} + +// ── Shared number text field ────────────────────────────────────────────────── +class _NumField extends StatelessWidget { + const _NumField({ + required this.controller, + required this.focusNode, + required this.suffix, + required this.onChanged, + this.decimal = false, + this.width = 80, + this.height = 36, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final String suffix; + final bool decimal; + final double width; + final double height; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: TextField( + controller: controller, + focusNode: focusNode, + keyboardType: decimal + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, + inputFormatters: decimal + ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))] + : [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + ), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 0), + suffixText: suffix, + suffixStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + ), + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: BorderSide.none, + ), + ), + onChanged: onChanged, + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart new file mode 100644 index 0000000..dad6545 --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart @@ -0,0 +1,307 @@ +// exercise_details_sheet.dart — Bottom sheet showing exercise detail & stats + +import 'package:flutter/material.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../data/exercise_database.dart'; +import 'rf_widgets.dart'; + +class ExerciseDetailsSheet extends StatelessWidget { + const ExerciseDetailsSheet({ + super.key, + required this.exercise, + required this.provider, + }); + + final Exercise exercise; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final lastSession = provider.getLastSessionForExercise(exercise.id); + final growthModel = provider.getGrowthModel(exercise.id); + final color = exercise.isCustom ? AppColors.warning : AppColors.primary; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + exercise.category == 'compound' + ? Icons.fitness_center_rounded + : Icons.accessibility_new_rounded, + color: color, + size: 26, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exercise.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + Row( + children: [ + RFChip( + label: exercise.category, + small: true, + color: AppColors.primary, + ), + if (exercise.isCustom) ...[ + const SizedBox(width: 4), + const RFChip( + label: 'Custom', + small: true, + color: AppColors.warning, + ), + ], + ], + ), + ], + ), + ), + if (exercise.isCustom) + GestureDetector( + onTap: () => _confirmDelete(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: AppColors.error, + size: 20, + ), + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.lg), + + // Muscle activations + const RFSectionHeader('Muscle Activation'), + const SizedBox(height: AppSpacing.sm), + ...exercise.muscleActivations.map((a) { + final muscleColor = AppColors.muscle(a.muscleGroupId); + final name = MuscleGroups.names[a.muscleGroupId] ?? a.muscleGroupId; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: muscleColor, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + name, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + ), + ), + ), + SizedBox( + width: 100, + child: RFProgressBar( + value: a.activationPercentage / 100, + color: muscleColor, + height: 6, + showGlow: false, + ), + ), + const SizedBox(width: AppSpacing.sm), + SizedBox( + width: 32, + child: Text( + '${a.activationPercentage}%', + textAlign: TextAlign.right, + style: TextStyle( + color: muscleColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + }), + + // Last session + if (lastSession != null) ...[ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Last Session'), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: lastSession.sets.map((s) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + '${s.weight}kg × ${s.reps}', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ], + + // Growth trend + if (growthModel != null && growthModel.r2 > 0.2) ...[ + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + const Icon( + Icons.trending_up_rounded, + color: AppColors.success, + size: 20, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '+${growthModel.slope.toStringAsFixed(1)} kg volume/session', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + 'R² ${(growthModel.r2 * 100).toStringAsFixed(0)}%', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Delete Exercise?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Delete "${exercise.name}"? This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final messenger = ScaffoldMessenger.of(context); + final nav = Navigator.of(context); + final success = await provider.deleteCustomExercise(exercise.id); + if (success && context.mounted) { + nav.pop(); + messenger.showSnackBar( + SnackBar( + content: Text('"${exercise.name}" deleted'), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } + } + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart new file mode 100644 index 0000000..5810e1b --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -0,0 +1,851 @@ +// exercise_input_section.dart — Set entry UI for WorkoutFlowScreen + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/models.dart'; +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── ExerciseInputSection ────────────────────────────────────────────────────── +// Renders: AI suggestion card, weight/reps inputs, dropset section, +// LOG SET button, previous sets, last session info, program metadata banner. +class ExerciseInputSection extends StatelessWidget { + const ExerciseInputSection({ + super.key, + required this.currentWeight, + required this.currentReps, + required this.isDropset, + required this.drops, + required this.mainWeightController, + required this.mainRepsController, + required this.dropWeightControllers, + required this.dropRepsControllers, + required this.recommendations, + required this.previousSets, + required this.lastSession, + required this.settings, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onDropsetToggled, + required this.onDropAdded, + required this.onDropRemoved, + required this.onDropWeightChanged, + required this.onDropRepsChanged, + required this.onLogSet, + required this.onApplyRecommendation, + this.programSlot, + this.programWeek, + this.exerciseId, + }); + + final double currentWeight; + final int currentReps; + final bool isDropset; + final List drops; + final TextEditingController mainWeightController; + final TextEditingController mainRepsController; + final List dropWeightControllers; + final List dropRepsControllers; + final List recommendations; + final List previousSets; + final ExerciseLog? lastSession; + final SettingsProvider settings; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final ValueChanged onDropsetToggled; + final VoidCallback onDropAdded; + final ValueChanged onDropRemoved; + final void Function(int index, double weight) onDropWeightChanged; + final void Function(int index, int reps) onDropRepsChanged; + final VoidCallback onLogSet; + final VoidCallback onApplyRecommendation; + final ProgramExerciseSlot? programSlot; + final ProgramWeek? programWeek; + final String? exerciseId; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Program metadata + if (programSlot != null && programWeek != null) + _ProgramMetaBanner(slot: programSlot!, week: programWeek!), + + // AI suggestion + if (recommendations.isNotEmpty) + _RecommendationCard( + rec: recommendations[previousSets.length < recommendations.length + ? previousSets.length + : recommendations.length - 1], + settings: settings, + onApply: onApplyRecommendation, + ), + + const SizedBox(height: AppSpacing.lg), + + // Weight + reps inputs + if (!isDropset) ...[ + _InputRow( + currentWeight: currentWeight, + currentReps: currentReps, + settings: settings, + exerciseId: exerciseId, + onWeightChanged: onWeightChanged, + onRepsChanged: onRepsChanged, + ), + const SizedBox(height: AppSpacing.md), + ], + + // Dropset section + _DropsetSection( + isDropset: isDropset, + drops: drops, + currentWeight: currentWeight, + currentReps: currentReps, + mainWeightController: mainWeightController, + mainRepsController: mainRepsController, + dropWeightControllers: dropWeightControllers, + dropRepsControllers: dropRepsControllers, + settings: settings, + onToggled: onDropsetToggled, + onDropAdded: onDropAdded, + onDropRemoved: onDropRemoved, + onDropWeightChanged: onDropWeightChanged, + onDropRepsChanged: onDropRepsChanged, + ), + + const SizedBox(height: AppSpacing.lg), + + // LOG SET button + GlowButton( + label: 'LOG SET', + icon: Icons.check_rounded, + onPressed: onLogSet, + ), + + // Previous sets + if (previousSets.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _PreviousSetsSection(sets: previousSets, settings: settings), + ], + + // Last session + const SizedBox(height: AppSpacing.lg), + _LastSessionSection(lastSession: lastSession, settings: settings), + ], + ); + } +} + +// ── Recommendation Card ──────────────────────────────────────────────────────── +class _RecommendationCard extends StatelessWidget { + const _RecommendationCard({ + required this.rec, + required this.settings, + required this.onApply, + }); + + final SetRecommendation rec; + final SettingsProvider settings; + final VoidCallback onApply; + + @override + Widget build(BuildContext context) { + final displayWeight = settings.toDisplay(rec.weight); + final weightStr = displayWeight == displayWeight.truncateToDouble() + ? displayWeight.toStringAsFixed(0) + : displayWeight.toStringAsFixed(1); + final confidenceColor = rec.confidence == 'high' + ? AppColors.success + : rec.confidence == 'medium' + ? AppColors.warning + : AppColors.textMuted; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.18), + AppColors.secondary.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(Icons.auto_awesome_rounded, + color: AppColors.primary, size: 18), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'AI Suggestion', + style: TextStyle( + color: AppColors.textSoft, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: confidenceColor, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '$weightStr ${settings.unitLabel} × ${rec.reps} reps', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + TextButton( + onPressed: () { + onApply(); + HapticFeedback.lightImpact(); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: const Text( + 'Apply', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} + +// ── Input Row ──────────────────────────────────────────────────────────────── +class _InputRow extends StatelessWidget { + const _InputRow({ + required this.currentWeight, + required this.currentReps, + required this.settings, + required this.onWeightChanged, + required this.onRepsChanged, + this.exerciseId, + }); + + final double currentWeight; + final int currentReps; + final SettingsProvider settings; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final String? exerciseId; + + @override + Widget build(BuildContext context) { + final isAssistedBW = + exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; + final weightLabel = + isAssistedBW ? 'Assist (${settings.unitLabel})' : settings.unitLabel; + final displayWeight = settings.toDisplay(currentWeight); + + return Row( + children: [ + Expanded( + child: _NumberInputCard( + label: weightLabel, + value: displayWeight, + step: settings.weightIncrement, + decimals: 1, + onChanged: (v) => onWeightChanged(settings.toStorage(v)), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: _NumberInputCard( + label: 'Reps', + value: currentReps.toDouble(), + step: 1, + decimals: 0, + onChanged: (v) => onRepsChanged(v.toInt()), + ), + ), + ], + ); + } +} + +// ── Number Input Card ───────────────────────────────────────────────────────── +class _NumberInputCard extends StatelessWidget { + const _NumberInputCard({ + required this.label, + required this.value, + required this.step, + required this.decimals, + required this.onChanged, + }); + + final String label; + final double value; + final double step; + final int decimals; + final ValueChanged onChanged; + + String _format() => decimals > 0 + ? value.toStringAsFixed(decimals) + : value.toInt().toString(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _StepBtn( + icon: Icons.remove_rounded, + onTap: () => onChanged((value - step).clamp(0, 999)), + ), + Expanded( + child: Text( + _format(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 36, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + textAlign: TextAlign.center, + ), + ), + _StepBtn( + icon: Icons.add_rounded, + onTap: () => onChanged((value + step).clamp(0, 999)), + ), + ], + ), + ], + ), + ); + } +} + +class _StepBtn extends StatelessWidget { + const _StepBtn({required this.icon, required this.onTap}); + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + onTap(); + HapticFeedback.selectionClick(); + }, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, size: 18, color: AppColors.primary), + ), + ); + } +} + +// ── Dropset Section ─────────────────────────────────────────────────────────── +class _DropsetSection extends StatelessWidget { + const _DropsetSection({ + required this.isDropset, + required this.drops, + required this.currentWeight, + required this.currentReps, + required this.mainWeightController, + required this.mainRepsController, + required this.dropWeightControllers, + required this.dropRepsControllers, + required this.settings, + required this.onToggled, + required this.onDropAdded, + required this.onDropRemoved, + required this.onDropWeightChanged, + required this.onDropRepsChanged, + }); + + final bool isDropset; + final List drops; + final double currentWeight; + final int currentReps; + final TextEditingController mainWeightController; + final TextEditingController mainRepsController; + final List dropWeightControllers; + final List dropRepsControllers; + final SettingsProvider settings; + final ValueChanged onToggled; + final VoidCallback onDropAdded; + final ValueChanged onDropRemoved; + final void Function(int, double) onDropWeightChanged; + final void Function(int, int) onDropRepsChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.trending_down_rounded, + color: AppColors.warning, + size: 18, + ), + const SizedBox(width: 8), + const Text( + 'Dropset', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Switch( + value: isDropset, + onChanged: onToggled, + activeThumbColor: AppColors.warning, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + if (isDropset) ...[ + const SizedBox(height: AppSpacing.md), + _DropRow( + label: 'Start', + weightController: mainWeightController, + repsController: mainRepsController, + unitLabel: settings.unitLabel, + onWeightChanged: (v) { + final parsed = double.tryParse(v); + if (parsed != null) onDropWeightChanged(-1, settings.toStorage(parsed)); + }, + onRepsChanged: (v) { + final parsed = int.tryParse(v); + if (parsed != null) onDropRepsChanged(-1, parsed); + }, + ), + ...drops.asMap().entries.map((e) => _DropRow( + label: 'Drop ${e.key + 1}', + weightController: dropWeightControllers[e.key], + repsController: dropRepsControllers[e.key], + unitLabel: settings.unitLabel, + onWeightChanged: (v) { + final parsed = double.tryParse(v); + if (parsed != null) onDropWeightChanged(e.key, settings.toStorage(parsed)); + }, + onRepsChanged: (v) { + final parsed = int.tryParse(v); + if (parsed != null) onDropRepsChanged(e.key, parsed); + }, + onDelete: () => onDropRemoved(e.key), + )), + TextButton.icon( + onPressed: onDropAdded, + icon: const Icon(Icons.add_rounded, size: 16), + label: const Text('Add Drop'), + style: TextButton.styleFrom(foregroundColor: AppColors.warning), + ), + ], + ], + ), + ); + } +} + +class _DropRow extends StatelessWidget { + const _DropRow({ + required this.label, + required this.weightController, + required this.repsController, + required this.unitLabel, + required this.onWeightChanged, + required this.onRepsChanged, + this.onDelete, + }); + + final String label; + final TextEditingController weightController; + final TextEditingController repsController; + final String unitLabel; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + SizedBox( + width: 52, + child: Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ), + SizedBox( + width: 64, + child: TextField( + controller: weightController, + decoration: InputDecoration( + hintText: unitLabel, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + isDense: true, + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + ], + onChanged: onWeightChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('×', style: TextStyle(color: AppColors.textMuted)), + ), + SizedBox( + width: 52, + child: TextField( + controller: repsController, + decoration: const InputDecoration( + hintText: 'reps', + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + isDense: true, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: onRepsChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + ), + if (onDelete != null) + IconButton( + icon: const Icon(Icons.close_rounded, size: 16), + color: AppColors.textMuted, + onPressed: onDelete, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ) + else + const SizedBox(width: 32), + ], + ), + ); + } +} + +// ── Previous Sets ───────────────────────────────────────────────────────────── +class _PreviousSetsSection extends StatelessWidget { + const _PreviousSetsSection({required this.sets, required this.settings}); + + final List sets; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'THIS SESSION', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: sets.asMap().entries.map((e) { + final i = e.key; + final s = e.value; + final dw = settings.toDisplay(s.weight); + final wStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${i + 1}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + const SizedBox(width: 4), + Text( + '$wStr × ${s.reps}', + style: const TextStyle( + color: AppColors.success, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + if (s.isDropset) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.trending_down_rounded, + size: 12, + color: AppColors.warning, + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ); + } +} + +// ── Last Session ────────────────────────────────────────────────────────────── +class _LastSessionSection extends StatelessWidget { + const _LastSessionSection({required this.lastSession, required this.settings}); + + final ExerciseLog? lastSession; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + if (lastSession == null) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Row( + children: [ + Icon(Icons.star_outline_rounded, + color: AppColors.textMuted, size: 16), + SizedBox(width: 8), + Text( + 'First time doing this exercise!', + style: TextStyle(color: AppColors.textMuted, fontSize: 13), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'LAST SESSION', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: lastSession!.sets.map((s) { + final dw = settings.toDisplay(s.weight); + final wStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + return Chip( + label: Text( + '$wStr × ${s.reps}', + style: const TextStyle(fontSize: 12, color: AppColors.textSoft), + ), + backgroundColor: AppColors.surface, + side: BorderSide(color: AppColors.glassBorder), + padding: const EdgeInsets.symmetric(horizontal: 4), + ); + }).toList(), + ), + ], + ); + } +} + +// ── Program Meta Banner ──────────────────────────────────────────────────────── +class _ProgramMetaBanner extends StatelessWidget { + const _ProgramMetaBanner({required this.slot, required this.week}); + + final ProgramExerciseSlot slot; + final ProgramWeek week; + + @override + Widget build(BuildContext context) { + final displaySets = week.isDeload + ? (slot.sets - week.deloadSetReduction).clamp(1, 99) + : slot.sets; + final repRange = slot.minReps == slot.maxReps + ? '${slot.minReps} reps' + : '${slot.minReps}–${slot.maxReps} reps'; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.4) + : AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (week.isDeload) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon(Icons.battery_charging_full_rounded, + size: 14, color: Colors.amber), + ), + Text( + 'Target: $displaySets × $repRange', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: AppSpacing.md, + runSpacing: 4, + children: [ + _metaChip( + Icons.timer_outlined, + '${slot.restSeconds}s rest', + AppColors.textSoft, + ), + if (slot.tempo != null) + _metaChip(Icons.speed_rounded, 'Tempo ${slot.tempo}', + AppColors.secondary), + if (slot.supersetGroupId != null) + _metaChip(Icons.link_rounded, 'Superset', AppColors.secondary), + ], + ), + if (slot.notes != null) ...[ + const SizedBox(height: 4), + Text( + slot.notes!, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontStyle: FontStyle.italic), + ), + ], + ], + ), + ); + } + + Widget _metaChip(IconData icon, String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 11, color: color)), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart new file mode 100644 index 0000000..62d9d3d --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -0,0 +1,486 @@ +// exercise_progress_view.dart — Analytics "Exercises" tab + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class ExerciseProgressView extends StatefulWidget { + const ExerciseProgressView({super.key}); + + @override + State createState() => _ExerciseProgressViewState(); +} + +class _ExerciseProgressViewState extends State { + String? _selectedId; + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final performed = { + for (final s in provider.sessions) + for (final e in s.exercises) e.exerciseId, + }; + + if (performed.isEmpty) { + return RFEmptyState( + icon: Icons.fitness_center_rounded, + title: 'No Exercise Data', + subtitle: 'Complete workouts to track exercises', + ); + } + + return Column( + children: [ + _ExerciseDropdown( + ids: performed, + selected: _selectedId, + getExerciseName: provider.getExerciseName, + onChanged: (id) => setState(() => _selectedId = id), + ), + if (_selectedId != null) + Expanded( + child: _ExerciseStats( + exerciseId: _selectedId!, + provider: provider, + ), + ) + else + Expanded( + child: Center( + child: Text( + 'Select an exercise above', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), + ), + ), + ), + ], + ); + } +} + +// ── Exercise dropdown selector ───────────────────────────────────────────────── +class _ExerciseDropdown extends StatelessWidget { + const _ExerciseDropdown({ + required this.ids, + required this.selected, + required this.getExerciseName, + required this.onChanged, + }); + + final Set ids; + final String? selected; + final String Function(String) getExerciseName; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: DropdownButton( + value: selected, + isExpanded: true, + underline: const SizedBox.shrink(), + dropdownColor: AppColors.cardHigh, + hint: const Text( + 'Select exercise…', + style: TextStyle(color: AppColors.textMuted, fontSize: 14), + ), + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + items: ids.map((id) { + return DropdownMenuItem( + value: id, + child: Text(getExerciseName(id)), + ); + }).toList(), + onChanged: onChanged, + ), + ), + ); + } +} + +// ── Stats view for a selected exercise ─────────────────────────────────────── +class _ExerciseStats extends StatelessWidget { + const _ExerciseStats({ + required this.exerciseId, + required this.provider, + }); + + final String exerciseId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final progression = provider.getVolumeProgression(exerciseId); + final growthModel = provider.getGrowthModel(exerciseId); + final bestOneRM = provider.getBestOneRM(exerciseId); + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (bestOneRM != null) ...[ + _OneRMCard(oneRM: bestOneRM, settings: settings), + const SizedBox(height: AppSpacing.sm), + ], + if (growthModel != null) ...[ + _GrowthCard(model: growthModel), + const SizedBox(height: AppSpacing.sm), + ], + _VolumeChart(progression: progression), + const SizedBox(height: AppSpacing.sm), + _SessionHistory(progression: progression, settings: settings), + ], + ), + ); + } +} + +// ── 1RM card ────────────────────────────────────────────────────────────────── +class _OneRMCard extends StatelessWidget { + const _OneRMCard({required this.oneRM, required this.settings}); + final double oneRM; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.18), + AppColors.primary.withValues(alpha: 0.06), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: const Icon( + Icons.emoji_events_rounded, + color: AppColors.primary, + size: 28, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Estimated 1RM', + style: TextStyle(color: AppColors.textMuted, fontSize: 11), + ), + Text( + settings.formatWeight(oneRM), + style: const TextStyle( + color: AppColors.primary, + fontSize: 30, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + const Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Epley formula', + style: TextStyle(color: AppColors.textMuted, fontSize: 10), + ), + Text( + 'Best across sets', + style: TextStyle(color: AppColors.textMuted, fontSize: 10), + ), + ], + ), + ], + ), + ); + } +} + +// ── Growth trend card ───────────────────────────────────────────────────────── +class _GrowthCard extends StatelessWidget { + const _GrowthCard({required this.model}); + final GrowthModel model; + + @override + Widget build(BuildContext context) { + final isGrowing = model.slope > 0; + final color = isGrowing ? AppColors.success : AppColors.warning; + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.18), + color.withValues(alpha: 0.06), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Icon( + isGrowing ? Icons.trending_up_rounded : Icons.trending_flat_rounded, + color: color, + size: 40, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isGrowing ? 'Growing!' : 'Plateau', + style: TextStyle( + color: color, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + Text( + isGrowing + ? '+${model.slope.abs().toStringAsFixed(1)} kg/session' + : 'Volume trend is flat', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'R² ${(model.r2 * 100).toStringAsFixed(0)}%', + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const Text( + 'model fit', + style: TextStyle(color: AppColors.textMuted, fontSize: 10), + ), + ], + ), + ], + ), + ); + } +} + +// ── Volume progression line chart ───────────────────────────────────────────── +class _VolumeChart extends StatelessWidget { + const _VolumeChart({required this.progression}); + final List<({DateTime date, double volume})> progression; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Volume Progression', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + if (progression.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Text( + 'No data', + style: TextStyle(color: AppColors.textMuted), + ), + ), + ) + else + SizedBox( + height: 160, + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), + ), + titlesData: FlTitlesData( + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 38, + getTitlesWidget: (v, _) => Text( + v.toStringAsFixed(0), + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 9, + ), + ), + ), + ), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: progression.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.volume); + }).toList(), + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.secondary, + barWidth: 2.5, + dotData: const FlDotData(show: true), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.secondary.withValues(alpha: 0.25), + AppColors.secondary.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Session history list ─────────────────────────────────────────────────────── +class _SessionHistory extends StatelessWidget { + const _SessionHistory({ + required this.progression, + required this.settings, + }); + + final List<({DateTime date, double volume})> progression; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + if (progression.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Session History', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + ...progression.take(10).map((entry) { + final displayVol = settings.toDisplay(entry.volume); + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat('MMM d, yyyy').format(entry.date), + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + ), + ), + Text( + '${displayVol.toStringAsFixed(0)} ${settings.unitLabel}', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart new file mode 100644 index 0000000..0878dbd --- /dev/null +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -0,0 +1,596 @@ +// profile_sections.dart — Section widgets for ProfileScreen + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; + +const String _createdBy = 'Devasy Patel'; + +// ── Section container ───────────────────────────────────────────────────────── +class _ProfileSection extends StatelessWidget { + const _ProfileSection({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.child, + this.trailing, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final Widget child; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 15, + ), + ), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: AppSpacing.md), + Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + child, + ], + ), + ); + } +} + +// ── Preferences section ─────────────────────────────────────────────────────── +class PreferencesSection extends StatelessWidget { + const PreferencesSection({ + super.key, + required this.settings, + required this.onHaptic, + }); + + final SettingsProvider settings; + final VoidCallback onHaptic; + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.tune_rounded, + iconColor: AppColors.primary, + title: 'Preferences', + subtitle: 'Customize weight display and input steps', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('WEIGHT UNIT'), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _UnitToggleButton( + label: 'kg', + selected: settings.weightUnit == WeightUnit.kg, + onTap: () { + onHaptic(); + settings.setWeightUnit(WeightUnit.kg); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _UnitToggleButton( + label: 'lbs', + selected: settings.weightUnit == WeightUnit.lbs, + onTap: () { + onHaptic(); + settings.setWeightUnit(WeightUnit.lbs); + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + const _SectionLabel('WEIGHT INCREMENT'), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 8, + runSpacing: 8, + children: settings.availableIncrements.map((inc) { + final selected = settings.weightIncrement == inc; + final label = inc == inc.truncateToDouble() + ? '${inc.toStringAsFixed(0)} ${settings.unitLabel}' + : '${inc.toStringAsFixed(2).replaceAll(RegExp(r'0+$'), '')} ${settings.unitLabel}'; + return GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + settings.setWeightIncrement(inc); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 7, + ), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textSoft, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontSize: 13, + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } +} + +// ── Health Connect section ──────────────────────────────────────────────────── +class HealthConnectSection extends StatelessWidget { + const HealthConnectSection({ + super.key, + required this.settings, + required this.isLoading, + required this.onToggle, + }); + + final SettingsProvider settings; + final bool isLoading; + final Future Function(bool) onToggle; + + static const _hcColor = Color(0xFF00BFA5); + + @override + Widget build(BuildContext context) { + final enabled = settings.healthConnectEnabled; + return _ProfileSection( + icon: Icons.monitor_heart_outlined, + iconColor: _hcColor, + title: 'Health Connect', + subtitle: 'Sync workouts to Android Health Connect', + child: Column( + children: [ + Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sync workouts after finishing', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + SizedBox(height: 2), + Text( + 'Writes session + per-set reps to Health Connect', + style: TextStyle( + color: AppColors.textSoft, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: enabled, + onChanged: isLoading ? null : (v) => onToggle(v), + activeThumbColor: _hcColor, + activeTrackColor: _hcColor.withValues(alpha: 0.35), + ), + ], + ), + if (enabled) ...[ + const SizedBox(height: AppSpacing.sm), + Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + const Row( + children: [ + Icon(Icons.check_circle_outline, color: _hcColor, size: 16), + SizedBox(width: 8), + Text( + 'Connected — syncing after each workout', + style: TextStyle(color: _hcColor, fontSize: 12), + ), + ], + ), + ], + ], + ), + ); + } +} + +// ── Data Management section ─────────────────────────────────────────────────── +class DataManagementSection extends StatelessWidget { + const DataManagementSection({ + super.key, + required this.isExporting, + required this.isImporting, + required this.isBackingUp, + required this.onExport, + required this.onImport, + required this.onCloudBackup, + }); + + final bool isExporting; + final bool isImporting; + final bool isBackingUp; + final VoidCallback? onExport; + final VoidCallback? onImport; + final VoidCallback? onCloudBackup; + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.storage_rounded, + iconColor: AppColors.secondary, + title: 'Data Management', + subtitle: 'Export, import, or backup your workout data', + child: Column( + children: [ + _ActionTile( + icon: Icons.upload_file_rounded, + iconColor: AppColors.secondary, + title: 'Export Backup', + subtitle: 'Save a local .json backup file', + loading: isExporting, + onTap: onExport, + ), + _SectionDivider(), + _ActionTile( + icon: Icons.download_rounded, + iconColor: AppColors.secondary, + title: 'Import Backup', + subtitle: 'Merge data from a .json backup', + loading: isImporting, + onTap: onImport, + ), + _SectionDivider(), + _ActionTile( + icon: Icons.cloud_upload_outlined, + iconColor: AppColors.primary, + title: 'Cloud Backup', + subtitle: 'Sync to RepForge cloud (requires account)', + loading: isBackingUp, + onTap: onCloudBackup, + ), + ], + ), + ); + } +} + +// ── Cloud Sync section (placeholder) ───────────────────────────────────────── +class CloudSyncSection extends StatelessWidget { + const CloudSyncSection({super.key}); + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.sync_rounded, + iconColor: AppColors.warning, + title: 'Cloud Sync', + subtitle: 'Sync your data across devices', + trailing: _ComingSoonBadge(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('MONGODB CONNECTION STRING'), + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + enabled: false, + style: const TextStyle(color: AppColors.textMuted, fontSize: 13), + decoration: const InputDecoration( + hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', + hintStyle: TextStyle(color: AppColors.textMuted, fontSize: 13), + prefixIcon: Icon( + Icons.link_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + const Text( + 'Cloud sync with custom MongoDB will be available in a future update.', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } +} + +// ── About section ───────────────────────────────────────────────────────────── +class AboutSection extends StatelessWidget { + const AboutSection({super.key, required this.appVersion}); + final String appVersion; + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.info_outline_rounded, + iconColor: AppColors.textSoft, + title: 'About', + subtitle: 'RepForge Workout Logger', + child: Column( + children: [ + _InfoTile(label: 'Version', value: appVersion, icon: Icons.tag_rounded), + _SectionDivider(), + _InfoTile(label: 'Created by', value: _createdBy, icon: Icons.person_rounded), + _SectionDivider(), + _InfoTile(label: 'Platform', value: 'Android', icon: Icons.phone_android_rounded), + _SectionDivider(), + _InfoTile( + label: 'Package', + value: 'com.devasy.repforge', + icon: Icons.inventory_2_outlined, + ), + ], + ), + ); + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ); + } +} + +class _UnitToggleButton extends StatelessWidget { + const _UnitToggleButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Center( + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textSoft, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontSize: 15, + ), + ), + ), + ), + ); + } +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.loading, + this.onTap, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final bool loading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + title: Text( + title, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: Text( + subtitle, + style: const TextStyle(color: AppColors.textSoft, fontSize: 12), + ), + trailing: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted), + onTap: onTap, + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, color: AppColors.textMuted, size: 18), + const SizedBox(width: 12), + Text( + label, + style: const TextStyle(color: AppColors.textSoft, fontSize: 13), + ), + const Spacer(), + Text( + value, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _SectionDivider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const Divider(color: AppColors.glassBorder, height: 1, indent: 40); + } +} + +class _ComingSoonBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.warning.withValues(alpha: 0.4)), + ), + child: const Text( + 'Coming Soon', + style: TextStyle( + color: AppColors.warning, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rest_timer_view.dart b/workout-logger/lib/screens/widgets/rest_timer_view.dart new file mode 100644 index 0000000..993c788 --- /dev/null +++ b/workout-logger/lib/screens/widgets/rest_timer_view.dart @@ -0,0 +1,139 @@ +// rest_timer_view.dart — Full-screen rest timer overlay for WorkoutFlowScreen + +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class RestTimerView extends StatelessWidget { + const RestTimerView({ + super.key, + required this.remainingSeconds, + required this.totalSeconds, + required this.onAdjust, + required this.onSkip, + this.nextExerciseName, + }); + + final int remainingSeconds; + final int totalSeconds; + final void Function(int delta) onAdjust; + final VoidCallback onSkip; + final String? nextExerciseName; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: AppColors.background, + child: SafeArea( + child: Column( + children: [ + // Top hint + Padding( + padding: const EdgeInsets.only(top: AppSpacing.lg), + child: Text( + 'REST', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2, + ), + ), + ), + // Ring + time fills most of the screen + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RestTimerRing( + remaining: remainingSeconds, + total: totalSeconds, + size: 220, + ), + const SizedBox(height: AppSpacing.xl), + // Adjust buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _AdjustButton(label: '−30s', onTap: () => onAdjust(-30)), + const SizedBox(width: AppSpacing.xl), + _AdjustButton(label: '+30s', onTap: () => onAdjust(30)), + ], + ), + if (nextExerciseName != null) ...[ + const SizedBox(height: AppSpacing.lg), + Text( + 'Next up', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 4), + Text( + nextExerciseName!, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + // Skip button + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.xl, + ), + child: OutlineGlowButton( + label: 'SKIP REST', + onPressed: onSkip, + color: AppColors.textSoft, + fullWidth: true, + ), + ), + ], + ), + ), + ); + } +} + +class _AdjustButton extends StatelessWidget { + const _AdjustButton({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm + 4, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_cards.dart b/workout-logger/lib/screens/widgets/rf_cards.dart new file mode 100644 index 0000000..d9dee18 --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_cards.dart @@ -0,0 +1,686 @@ +// rf_cards.dart — RepForge card widget variants +// Stateless cards that consume AppColors tokens and rf_widgets primitives. + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── SessionCard ─────────────────────────────────────────────────────────────── +// History list card: date column | main info | volume. +class SessionCard extends StatelessWidget { + const SessionCard({ + super.key, + required this.session, + required this.getExerciseName, + this.onTap, + this.trailing, + this.synced = false, + }); + + final WorkoutSession session; + final String Function(String) getExerciseName; + final VoidCallback? onTap; + final Widget? trailing; + final bool synced; + + @override + Widget build(BuildContext context) { + final day = DateFormat('d').format(session.date); + final month = DateFormat('MMM').format(session.date).toUpperCase(); + final weekday = DateFormat('EEE').format(session.date).toUpperCase(); + final volume = session.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + final exerciseNames = session.exercises + .take(3) + .map((e) => getExerciseName(e.exerciseId)) + .toList(); + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + // Date column + Container( + width: 56, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.08), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(AppRadius.lg), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + weekday, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + Text( + day, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + Text( + month, + style: const TextStyle( + color: AppColors.primary, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + // Main info + Expanded( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${session.exercises.length} exercises · ${session.duration} min', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + if (synced) ...[ + const SizedBox(width: 6), + const Icon( + Icons.favorite_rounded, + size: 12, + color: Color(0xFF4ECDC4), + ), + ], + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 4, + children: exerciseNames + .map( + (n) => RFChip(label: n, small: true), + ) + .toList(), + ), + ], + ), + ), + ), + // Volume + Padding( + padding: const EdgeInsets.only(right: AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$volStr kg', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── StatGridCard ────────────────────────────────────────────────────────────── +// 2×2 grid tile: icon + value + label. Used on dashboard and summary screen. +class StatGridCard extends StatelessWidget { + const StatGridCard({ + super.key, + required this.icon, + required this.value, + required this.label, + this.color, + this.animate = true, + }); + + final IconData icon; + final String value; + final String label; + final Color? color; + final bool animate; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final numericValue = double.tryParse( + value.replaceAll(RegExp(r'[^0-9.]'), ''), + ); + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(icon, color: c, size: 18), + ), + const SizedBox(height: AppSpacing.sm), + animate && numericValue != null + ? AnimatedCounter( + value: numericValue, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + suffix: value.replaceAll(RegExp(r'[0-9.]'), ''), + ) + : Text( + value, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +// ── RecentSessionTile ───────────────────────────────────────────────────────── +// Compact recent workout row for the dashboard. +class RecentSessionTile extends StatelessWidget { + const RecentSessionTile({ + super.key, + required this.session, + required this.getExerciseName, + this.onTap, + }); + + final WorkoutSession session; + final String Function(String) getExerciseName; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final dateStr = DateFormat('MMM d').format(session.date); + final timeStr = DateFormat('h:mm a').format(session.date); + final volume = session.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k kg' + : '${volume.toStringAsFixed(0)} kg'; + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.fitness_center_rounded, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateStr, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${session.exercises.length} exercises · $timeStr', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + Text( + volStr, + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} + +// ── RoutineCard ─────────────────────────────────────────────────────────────── +class RoutineCard extends StatelessWidget { + const RoutineCard({ + super.key, + required this.routine, + required this.getExerciseName, + required this.onStart, + this.onEdit, + this.onDelete, + }); + + final Routine routine; + final String Function(String) getExerciseName; + final VoidCallback onStart; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final names = routine.exerciseIds.take(3).map(getExerciseName).toList(); + final extra = routine.exerciseIds.length - names.length; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + routine.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${routine.exerciseIds.length} exercises', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + if (onEdit != null || onDelete != null) + PopupMenuButton( + color: AppColors.cardHigh, + icon: const Icon( + Icons.more_vert_rounded, + color: AppColors.textSoft, + ), + onSelected: (v) { + if (v == 'edit') onEdit?.call(); + if (v == 'delete') onDelete?.call(); + }, + itemBuilder: (_) => [ + if (onEdit != null) + const PopupMenuItem( + value: 'edit', + child: Text('Edit'), + ), + if (onDelete != null) + const PopupMenuItem( + value: 'delete', + child: Text( + 'Delete', + style: TextStyle(color: AppColors.error), + ), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( + children: [ + Expanded( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...names.map((n) => RFChip(label: n, small: true)), + if (extra > 0) + RFChip( + label: '+$extra more', + small: true, + color: AppColors.textSoft, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + GestureDetector( + onTap: onStart, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(AppRadius.md), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 22, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── TargetCard ──────────────────────────────────────────────────────────────── +class TargetCard extends StatelessWidget { + const TargetCard({ + super.key, + required this.target, + required this.exerciseName, + this.onDelete, + }); + + final Target target; + final String exerciseName; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final pct = (target.progressPercentage * 100).clamp(0, 100); + final etaStr = target.estimatedCompletionDate != null + ? DateFormat('MMM d, y').format(target.estimatedCompletionDate!) + : null; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + exerciseName, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + RFChip( + label: target.targetType, + small: true, + color: AppColors.secondary, + ), + if (onDelete != null) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: onDelete, + child: const Icon( + Icons.close_rounded, + size: 16, + color: AppColors.textMuted, + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${target.currentValue.toStringAsFixed(1)} / ${target.targetValue.toStringAsFixed(1)}', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + ), + ), + Text( + '${pct.toStringAsFixed(0)}%', + style: const TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + RFProgressBar(value: target.progressPercentage), + if (etaStr != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.schedule_rounded, size: 11, color: AppColors.textMuted), + const SizedBox(width: 4), + Text( + 'Est. $etaStr', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +// ── ExerciseCard ────────────────────────────────────────────────────────────── +class ExerciseCard extends StatelessWidget { + const ExerciseCard({ + super.key, + required this.exercise, + this.onTap, + this.selected = false, + }); + + final Exercise exercise; + final VoidCallback? onTap; + final bool selected; + + @override + Widget build(BuildContext context) { + final muscleColor = exercise.muscleActivations.isNotEmpty + ? AppColors.muscle(exercise.muscleActivations.first.muscleGroupId) + : AppColors.primary; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.12) + : Colors.transparent, + border: Border( + bottom: BorderSide(color: AppColors.divider), + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: AppSpacing.md), + decoration: BoxDecoration( + color: muscleColor, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exercise.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (exercise.muscleActivations.isNotEmpty) + Text( + exercise.primaryMuscle, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (exercise.isCustom) + const RFChip( + label: 'Custom', + small: true, + color: AppColors.accent, + ), + if (exercise.isCustom) const SizedBox(width: 4), + RFChip( + label: exercise.category, + small: true, + color: AppColors.textSoft, + ), + if (selected) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.check_circle_rounded, + size: 20, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_inputs.dart b/workout-logger/lib/screens/widgets/rf_inputs.dart new file mode 100644 index 0000000..db62216 --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_inputs.dart @@ -0,0 +1,620 @@ +// rf_inputs.dart — RepForge form input widgets + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── RFTextField ─────────────────────────────────────────────────────────────── +// Styled text input using AppColors tokens. +class RFTextField extends StatelessWidget { + const RFTextField({ + super.key, + this.controller, + this.hint, + this.label, + this.prefixIcon, + this.suffixIcon, + this.maxLines = 1, + this.minLines, + this.keyboardType, + this.inputFormatters, + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.enabled = true, + this.errorText, + this.textCapitalization = TextCapitalization.none, + }); + + final TextEditingController? controller; + final String? hint; + final String? label; + final IconData? prefixIcon; + final Widget? suffixIcon; + final int? maxLines; + final int? minLines; + final TextInputType? keyboardType; + final List? inputFormatters; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final bool autofocus; + final bool enabled; + final String? errorText; + final TextCapitalization textCapitalization; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + minLines: minLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + onChanged: onChanged, + onSubmitted: onSubmitted, + autofocus: autofocus, + enabled: enabled, + textCapitalization: textCapitalization, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + hintText: hint, + labelText: label, + labelStyle: const TextStyle(color: AppColors.textSoft), + errorText: errorText, + errorStyle: const TextStyle(color: AppColors.error, fontSize: 11), + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, color: AppColors.textMuted, size: 20) + : null, + suffixIcon: suffixIcon, + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.error), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ); + } +} + +// ── RFNumberField ───────────────────────────────────────────────────────────── +// Large monospace tap-to-edit number for weights and reps. +// Tap → shows inline text field. Long-press → continuous increment. +class RFNumberField extends StatefulWidget { + const RFNumberField({ + super.key, + required this.value, + required this.onChanged, + this.label, + this.step = 1.0, + this.min = 0.0, + this.max = 9999.0, + this.decimals = 0, + this.color, + }); + + final double value; + final ValueChanged onChanged; + final String? label; + final double step; + final double min; + final double max; + final int decimals; + final Color? color; + + @override + State createState() => _RFNumberFieldState(); +} + +class _RFNumberFieldState extends State { + bool _editing = false; + late final TextEditingController _ctrl; + Timer? _longPressTimer; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: _format(widget.value)); + } + + @override + void didUpdateWidget(RFNumberField old) { + super.didUpdateWidget(old); + if (!_editing && old.value != widget.value) { + _ctrl.text = _format(widget.value); + } + } + + @override + void dispose() { + _ctrl.dispose(); + _longPressTimer?.cancel(); + super.dispose(); + } + + String _format(double v) => + widget.decimals > 0 ? v.toStringAsFixed(widget.decimals) : v.toInt().toString(); + + void _startIncrement(double dir) { + _step(dir); + _longPressTimer = Timer.periodic(const Duration(milliseconds: 120), (_) { + _step(dir); + }); + } + + void _stopIncrement() { + _longPressTimer?.cancel(); + _longPressTimer = null; + } + + void _step(double dir) { + final next = (widget.value + dir * widget.step).clamp(widget.min, widget.max); + HapticFeedback.selectionClick(); + widget.onChanged(next); + } + + void _commitEdit() { + final parsed = double.tryParse(_ctrl.text); + if (parsed != null) { + widget.onChanged(parsed.clamp(widget.min, widget.max)); + } else { + _ctrl.text = _format(widget.value); + } + setState(() => _editing = false); + } + + @override + Widget build(BuildContext context) { + final c = widget.color ?? AppColors.textPrimary; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Decrement button + GestureDetector( + onTap: () => _step(-1), + onLongPressStart: (_) => _startIncrement(-1), + onLongPressEnd: (_) => _stopIncrement(), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.remove_rounded, + size: 18, + color: AppColors.textSoft, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + // Value display / edit + GestureDetector( + onTap: () { + setState(() => _editing = true); + _ctrl.text = _format(widget.value); + _ctrl.selection = TextSelection( + baseOffset: 0, + extentOffset: _ctrl.text.length, + ); + }, + child: Container( + width: 80, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: _editing ? AppColors.primary : AppColors.glassBorder, + width: _editing ? 2 : 1, + ), + ), + child: _editing + ? TextField( + controller: _ctrl, + autofocus: true, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + style: TextStyle( + color: c, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onSubmitted: (_) => _commitEdit(), + onEditingComplete: _commitEdit, + ) + : Text( + _format(widget.value), + style: TextStyle( + color: c, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + // Increment button + GestureDetector( + onTap: () => _step(1), + onLongPressStart: (_) => _startIncrement(1), + onLongPressEnd: (_) => _stopIncrement(), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.add_rounded, + size: 18, + color: AppColors.textSoft, + ), + ), + ), + ], + ), + if (widget.label != null) ...[ + const SizedBox(height: 4), + Text( + widget.label!, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ], + ], + ); + } +} + +// ── RFToggle ────────────────────────────────────────────────────────────────── +// Two-option segmented toggle (e.g. kg / lbs, Compound / Isolation). +class RFToggle extends StatelessWidget { + const RFToggle({ + super.key, + required this.options, + required this.selectedIndex, + required this.onChanged, + this.color, + }); + + final List options; + final int selectedIndex; + final ValueChanged onChanged; + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return Container( + height: 44, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(options.length, (i) { + final selected = i == selectedIndex; + return GestureDetector( + onTap: () => onChanged(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: selected ? c : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: selected + ? [ + BoxShadow( + color: c.withValues(alpha: 0.35), + blurRadius: 8, + ), + ] + : null, + ), + child: Text( + options[i], + style: TextStyle( + color: selected ? Colors.white : AppColors.textSoft, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }), + ), + ); + } +} + +// ── RFDropdown ──────────────────────────────────────────────────────────────── +class RFDropdown extends StatelessWidget { + const RFDropdown({ + super.key, + required this.value, + required this.items, + required this.onChanged, + this.hint, + this.labelBuilder, + }); + + final T? value; + final List items; + final ValueChanged onChanged; + final String? hint; + final String Function(T)? labelBuilder; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: DropdownButton( + value: value, + onChanged: onChanged, + isExpanded: true, + underline: const SizedBox.shrink(), + dropdownColor: AppColors.cardHigh, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + hint: hint != null + ? Text(hint!, style: const TextStyle(color: AppColors.textMuted)) + : null, + icon: const Icon(Icons.keyboard_arrow_down_rounded, + color: AppColors.textSoft), + items: items.map((item) { + final label = + labelBuilder != null ? labelBuilder!(item) : item.toString(); + return DropdownMenuItem(value: item, child: Text(label)); + }).toList(), + ), + ); + } +} + +// ── NumberPickerSheet ───────────────────────────────────────────────────────── +// Bottom sheet with large +/- stepper — extracted from workout_flow logic. +class NumberPickerSheet extends StatefulWidget { + const NumberPickerSheet({ + super.key, + required this.title, + required this.initial, + required this.step, + required this.min, + required this.max, + this.decimals = 0, + this.suffix = '', + }); + + final String title; + final double initial; + final double step; + final double min; + final double max; + final int decimals; + final String suffix; + + static Future show( + BuildContext context, { + required String title, + required double initial, + required double step, + required double min, + required double max, + int decimals = 0, + String suffix = '', + }) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => NumberPickerSheet( + title: title, + initial: initial, + step: step, + min: min, + max: max, + decimals: decimals, + suffix: suffix, + ), + ); + } + + @override + State createState() => _NumberPickerSheetState(); +} + +class _NumberPickerSheetState extends State { + late double _value; + + @override + void initState() { + super.initState(); + _value = widget.initial; + } + + void _step(double dir) { + setState(() { + _value = (_value + dir * widget.step).clamp(widget.min, widget.max); + }); + HapticFeedback.selectionClick(); + } + + String get _display => widget.decimals > 0 + ? _value.toStringAsFixed(widget.decimals) + : _value.toInt().toString(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.textMuted, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + Text( + widget.title, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _StepButton( + icon: Icons.remove_rounded, + onTap: () => _step(-1), + onLongPress: () { + Timer.periodic( + const Duration(milliseconds: 100), + (t) { + if (!mounted) { + t.cancel(); + return; + } + _step(-1); + }, + ); + }, + ), + const SizedBox(width: AppSpacing.xl), + Text( + '$_display${widget.suffix}', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 48, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: AppSpacing.xl), + _StepButton( + icon: Icons.add_rounded, + onTap: () => _step(1), + onLongPress: () { + Timer.periodic( + const Duration(milliseconds: 100), + (t) { + if (!mounted) { + t.cancel(); + return; + } + _step(1); + }, + ); + }, + ), + ], + ), + const SizedBox(height: AppSpacing.xl), + GlowButton( + label: 'Confirm', + onPressed: () => Navigator.pop(context, _value), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({ + required this.icon, + required this.onTap, + required this.onLongPress, + }); + + final IconData icon; + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.cardHigh, + shape: BoxShape.circle, + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, color: AppColors.textPrimary, size: 28), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart new file mode 100644 index 0000000..1493e4e --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -0,0 +1,825 @@ +// rf_widgets.dart — RepForge primitive widget library +// All widgets consume AppColors/AppSpacing/AppRadius tokens only. + +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; + +// ── GlassCard ─────────────────────────────────────────────────────────────── +// Frosted-glass container. Use for primary content cards. +class GlassCard extends StatelessWidget { + const GlassCard({ + super.key, + required this.child, + this.padding, + this.margin, + this.borderRadius, + this.glowColor, + this.borderColor, + this.onTap, + }); + + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final BorderRadius? borderRadius; + final Color? glowColor; + final Color? borderColor; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final radius = borderRadius ?? BorderRadius.circular(AppRadius.lg); + final border = Border.all( + color: borderColor ?? AppColors.glassBorder, + width: 1, + ); + final decoration = BoxDecoration( + color: AppColors.glass, + borderRadius: radius, + border: border, + boxShadow: glowColor != null + ? [ + BoxShadow( + color: glowColor!.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: -4, + ), + ] + : null, + ); + + final content = Container( + padding: padding ?? const EdgeInsets.all(AppSpacing.md), + margin: margin, + decoration: decoration, + child: child, + ); + + if (onTap == null) return content; + return GestureDetector( + onTap: onTap, + child: AnimatedScale( + scale: 1.0, + duration: const Duration(milliseconds: 120), + child: content, + ), + ); + } +} + +// ── GlowButton ────────────────────────────────────────────────────────────── +// Full-width primary action button with glow shadow + haptic feedback. +class GlowButton extends StatefulWidget { + const GlowButton({ + super.key, + required this.label, + required this.onPressed, + this.color, + this.icon, + this.fullWidth = true, + this.small = false, + }); + + final String label; + final VoidCallback? onPressed; + final Color? color; + final IconData? icon; + final bool fullWidth; + final bool small; + + @override + State createState() => _GlowButtonState(); +} + +class _GlowButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _scale; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 100), + reverseDuration: const Duration(milliseconds: 200), + lowerBound: 0.95, + upperBound: 1.0, + value: 1.0, + ); + _scale = _ctrl; + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _onTapDown(TapDownDetails _) async { + await _ctrl.reverse(); + } + + Future _onTapUp(TapUpDetails _) async { + HapticFeedback.heavyImpact(); + widget.onPressed?.call(); + await _ctrl.forward(); + } + + Future _onTapCancel() async { + await _ctrl.forward(); + } + + @override + Widget build(BuildContext context) { + final color = widget.color ?? AppColors.primary; + final disabled = widget.onPressed == null; + final vPad = widget.small ? 12.0 : 18.0; + + return AnimatedBuilder( + animation: _scale, + builder: (context, child) => Transform.scale( + scale: _scale.value, + child: child, + ), + child: GestureDetector( + onTapDown: disabled ? null : _onTapDown, + onTapUp: disabled ? null : _onTapUp, + onTapCancel: disabled ? null : _onTapCancel, + child: Container( + width: widget.fullWidth ? double.infinity : null, + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: vPad, + ), + decoration: BoxDecoration( + gradient: disabled + ? null + : LinearGradient( + colors: [color, Color.lerp(color, Colors.white, 0.15)!], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + color: disabled ? AppColors.card : null, + borderRadius: BorderRadius.circular(AppRadius.lg), + boxShadow: disabled + ? null + : [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisSize: + widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: disabled ? AppColors.textMuted : Colors.white, + size: widget.small ? 18 : 20, + ), + const SizedBox(width: AppSpacing.sm), + ], + Text( + widget.label, + style: TextStyle( + color: disabled ? AppColors.textMuted : Colors.white, + fontSize: widget.small ? 14 : 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── OutlineGlowButton ──────────────────────────────────────────────────────── +class OutlineGlowButton extends StatelessWidget { + const OutlineGlowButton({ + super.key, + required this.label, + required this.onPressed, + this.color, + this.icon, + this.fullWidth = false, + this.small = false, + }); + + final String label; + final VoidCallback? onPressed; + final Color? color; + final IconData? icon; + final bool fullWidth; + final bool small; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final vPad = small ? 10.0 : 14.0; + return SizedBox( + width: fullWidth ? double.infinity : null, + child: OutlinedButton.icon( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: c, + side: BorderSide(color: c, width: 1.5), + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: vPad, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + ), + icon: icon != null + ? Icon(icon, size: small ? 16 : 18) + : const SizedBox.shrink(), + label: Text( + label, + style: TextStyle( + fontSize: small ? 13 : 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + +// ── RFChip ─────────────────────────────────────────────────────────────────── +// Pill-shaped label chip — muscle tags, category badges, etc. +class RFChip extends StatelessWidget { + const RFChip({ + super.key, + required this.label, + this.color, + this.small = false, + }); + + final String label; + final Color? color; + final bool small; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return Container( + padding: EdgeInsets.symmetric( + horizontal: small ? 8 : 10, + vertical: small ? 3 : 5, + ), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: c.withValues(alpha: 0.4), width: 1), + ), + child: Text( + label, + style: TextStyle( + color: c, + fontSize: small ? 10 : 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ); + } +} + +// ── RFSectionHeader ────────────────────────────────────────────────────────── +class RFSectionHeader extends StatelessWidget { + const RFSectionHeader( + this.title, { + super.key, + this.trailing, + this.bottomPad = true, + }); + + final String title; + final Widget? trailing; + final bool bottomPad; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: bottomPad ? AppSpacing.sm : 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title.toUpperCase(), + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +// ── RFStatBox ──────────────────────────────────────────────────────────────── +class RFStatBox extends StatelessWidget { + const RFStatBox({ + super.key, + required this.value, + required this.label, + this.color, + this.delta, + }); + + final String value; + final String label; + final Color? color; + final double? delta; // positive = up, negative = down, null = no arrow + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.textPrimary; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + style: TextStyle( + color: c, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (delta != null) ...[ + const SizedBox(width: 4), + Icon( + delta! >= 0 + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 14, + color: delta! >= 0 ? AppColors.success : AppColors.error, + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + ], + ); + } +} + +// ── AnimatedCounter ────────────────────────────────────────────────────────── +// Smoothly animates a number from 0 to [value] on first build. +class AnimatedCounter extends StatelessWidget { + const AnimatedCounter({ + super.key, + required this.value, + this.style, + this.decimals = 0, + this.suffix = '', + this.duration = const Duration(milliseconds: 800), + }); + + final double value; + final TextStyle? style; + final int decimals; + final String suffix; + final Duration duration; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: value), + duration: duration, + curve: Curves.easeOutCubic, + builder: (context, v, _) { + final display = decimals > 0 + ? v.toStringAsFixed(decimals) + : v.toInt().toString(); + return Text( + '$display$suffix', + style: style ?? + const TextStyle( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ); + }, + ); + } +} + +// ── MetricHero ─────────────────────────────────────────────────────────────── +// Large monospace number + small label — for weights, reps, PRs. +class MetricHero extends StatelessWidget { + const MetricHero({ + super.key, + required this.value, + required this.unit, + this.color, + this.size = 48, + }); + + final String value; + final String unit; + final Color? color; + final double size; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: TextStyle( + color: color ?? AppColors.textPrimary, + fontSize: size, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + height: 1.0, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 6, left: 4), + child: Text( + unit, + style: TextStyle( + color: (color ?? AppColors.textPrimary).withValues(alpha: 0.6), + fontSize: size * 0.35, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} + +// ── RFDivider ──────────────────────────────────────────────────────────────── +class RFDivider extends StatelessWidget { + const RFDivider({super.key, this.indent = 0}); + final double indent; + + @override + Widget build(BuildContext context) { + return Divider( + color: AppColors.divider, + thickness: 1, + height: 1, + indent: indent, + ); + } +} + +// ── RFEmptyState ───────────────────────────────────────────────────────────── +class RFEmptyState extends StatelessWidget { + const RFEmptyState({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.action, + }); + + final IconData icon; + final String title; + final String? subtitle; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.card, + shape: BoxShape.circle, + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, size: 32, color: AppColors.textMuted), + ), + const SizedBox(height: AppSpacing.md), + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + subtitle!, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), + textAlign: TextAlign.center, + ), + ], + if (action != null) ...[ + const SizedBox(height: AppSpacing.lg), + action!, + ], + ], + ), + ), + ); + } +} + +// ── RFLoadingDots ───────────────────────────────────────────────────────────── +class RFLoadingDots extends StatefulWidget { + const RFLoadingDots({super.key, this.color}); + final Color? color; + + @override + State createState() => _RFLoadingDotsState(); +} + +class _RFLoadingDotsState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = widget.color ?? AppColors.primary; + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final phase = (_ctrl.value - i * 0.2).clamp(0.0, 1.0); + final opacity = math.sin(phase * math.pi).clamp(0.2, 1.0); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: c.withValues(alpha: opacity), + shape: BoxShape.circle, + ), + ), + ); + }), + ); + }, + ); + } +} + +// ── RFProgressBar ───────────────────────────────────────────────────────────── +class RFProgressBar extends StatelessWidget { + const RFProgressBar({ + super.key, + required this.value, // 0.0 – 1.0 + this.color, + this.height = 6, + this.showGlow = true, + }); + + final double value; + final Color? color; + final double height; + final bool showGlow; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final clamped = value.clamp(0.0, 1.0); + return Container( + height: height, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + width: constraints.maxWidth * clamped, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [c, Color.lerp(c, Colors.white, 0.2)!]), + borderRadius: BorderRadius.circular(AppRadius.full), + boxShadow: showGlow + ? [BoxShadow(color: c.withValues(alpha: 0.5), blurRadius: 8)] + : null, + ), + ), + ], + ); + }, + ), + ); + } +} + +// ── RestTimerRing ──────────────────────────────────────────────────────────── +// Circular countdown ring for rest timer. +class RestTimerRing extends StatelessWidget { + const RestTimerRing({ + super.key, + required this.remaining, + required this.total, + this.size = 200, + }); + + final int remaining; + final int total; + final double size; + + @override + Widget build(BuildContext context) { + final progress = total > 0 ? remaining / total : 0.0; + final mins = remaining ~/ 60; + final secs = remaining % 60; + final label = + mins > 0 ? '$mins:${secs.toString().padLeft(2, '0')}' : '$secs'; + + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _RingPainter(progress: progress), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + 'REST', + style: TextStyle( + color: AppColors.textMuted, + fontSize: size * 0.08, + fontWeight: FontWeight.w600, + letterSpacing: 2, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + const _RingPainter({required this.progress}); + final double progress; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 8; + const strokeWidth = 8.0; + + // Track + canvas.drawCircle( + center, + radius, + Paint() + ..color = AppColors.card + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + + // Progress arc + final sweep = 2 * math.pi * progress; + final paint = Paint() + ..color = AppColors.secondary + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweep, + false, + paint, + ); + } + + @override + bool shouldRepaint(_RingPainter old) => old.progress != progress; +} + +// ── SkeletonBox ────────────────────────────────────────────────────────────── +class SkeletonBox extends StatefulWidget { + const SkeletonBox({ + super.key, + required this.width, + required this.height, + this.borderRadius, + }); + + final double width; + final double height; + final double? borderRadius; + + @override + State createState() => _SkeletonBoxState(); +} + +class _SkeletonBoxState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: Color.lerp(AppColors.card, AppColors.cardHigh, _ctrl.value), + borderRadius: BorderRadius.circular( + widget.borderRadius ?? AppRadius.sm, + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/routine_creator.dart b/workout-logger/lib/screens/widgets/routine_creator.dart new file mode 100644 index 0000000..99f4e06 --- /dev/null +++ b/workout-logger/lib/screens/widgets/routine_creator.dart @@ -0,0 +1,550 @@ +// routine_creator.dart — CreateRoutineScreen, RoutineDetailScreen, start helper + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../data/exercise_database.dart'; +import '../workout_flow_screen.dart'; +import 'rf_widgets.dart'; +import 'rf_cards.dart'; +import 'workout_conflict_dialog.dart'; + +// ── Start routine workout (shared helper) ───────────────────────────────────── +Future startRoutineWorkoutFlow( + BuildContext context, + Routine routine, +) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + routine: routine, + onConflict: () async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + conflictAction = action ?? StartWorkoutConflictAction.cancel; + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => WorkoutFlowScreen(routine: routine)), + ); + } +} + +// ── Create / Edit Routine Screen ────────────────────────────────────────────── +class CreateRoutineScreen extends StatefulWidget { + const CreateRoutineScreen({super.key, this.routine}); + final Routine? routine; + + @override + State createState() => _CreateRoutineScreenState(); +} + +class _CreateRoutineScreenState extends State { + final _nameController = TextEditingController(); + final List _selectedIds = []; + + @override + void initState() { + super.initState(); + if (widget.routine != null) { + _nameController.text = widget.routine!.name; + _selectedIds.addAll(widget.routine!.exerciseIds); + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final isEditing = widget.routine != null; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.surface, + title: Text( + isEditing ? 'Edit Routine' : 'New Routine', + style: const TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), + actions: [ + TextButton( + onPressed: _save, + child: const Text( + 'Save', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: _nameController, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Routine name (e.g. Push Day)', + hintStyle: TextStyle(color: AppColors.textMuted), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + children: [ + Text( + 'Exercises (${_selectedIds.length})', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (_selectedIds.isNotEmpty) + GestureDetector( + onTap: () => setState(() => _selectedIds.clear()), + child: const Text( + 'Clear All', + style: TextStyle( + color: AppColors.error, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Expanded( + child: ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + itemCount: _selectedIds.length + 1, + onReorder: (old, next) { + if (old >= _selectedIds.length || + next >= _selectedIds.length + 1) { + return; + } + setState(() { + if (next > old) next--; + final item = _selectedIds.removeAt(old); + _selectedIds.insert(next, item); + }); + }, + itemBuilder: (_, i) { + if (i == _selectedIds.length) { + return Padding( + key: const ValueKey('add_btn'), + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: OutlineGlowButton( + label: 'Add Exercises', + onPressed: () => + _showPicker(context, provider.allExercises), + fullWidth: true, + ), + ); + } + final id = _selectedIds[i]; + final ex = provider.getExercise(id); + return Container( + key: ValueKey(id), + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + ReorderableDragStartListener( + index: i, + child: const Icon( + Icons.drag_handle_rounded, + color: AppColors.textMuted, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ex?.name ?? 'Unknown', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (ex != null) + Text( + ex.category, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => + setState(() => _selectedIds.removeAt(i)), + child: const Icon( + Icons.remove_circle_outline_rounded, + color: AppColors.error, + size: 20, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + void _showPicker(BuildContext context, List all) { + String query = ''; + final List temp = []; + + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModal) { + final filtered = all.where((ex) { + if (_selectedIds.contains(ex.id)) return false; + return query.isEmpty || + ex.name.toLowerCase().contains(query.toLowerCase()); + }).toList(); + + final grouped = >{}; + for (final ex in filtered) { + grouped.putIfAbsent(ex.primaryMuscle, () => []).add(ex); + } + + return DraggableScrollableSheet( + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (_, sc) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.md, + ), + child: Column( + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + const Expanded( + child: Text( + 'Add Exercises', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + if (temp.isNotEmpty) + GlowButton( + label: 'Add ${temp.length}', + icon: Icons.check_rounded, + onPressed: () { + setState(() => _selectedIds.addAll(temp)); + Navigator.of(ctx).pop(); + }, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Container( + height: 40, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + onChanged: (v) => setModal(() => query = v), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + decoration: const InputDecoration( + hintText: 'Search exercises…', + hintStyle: TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), + prefixIcon: Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: + EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + ), + Expanded( + child: ListView( + controller: sc, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + children: [ + ...grouped.entries.map((entry) { + final muscleName = + MuscleGroups.names[entry.key] ?? entry.key; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + child: RFSectionHeader(muscleName), + ), + ...entry.value.map((ex) { + final sel = temp.contains(ex.id); + return ExerciseCard( + exercise: ex, + selected: sel, + onTap: () => setModal(() { + if (sel) { + temp.remove(ex.id); + } else { + temp.add(ex.id); + } + }), + ); + }), + ], + ); + }), + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + Future _save() async { + if (_nameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a routine name')), + ); + return; + } + if (_selectedIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please add at least one exercise')), + ); + return; + } + + final provider = context.read(); + if (widget.routine != null) { + final updated = Routine( + id: widget.routine!.id, + name: _nameController.text.trim(), + exerciseIds: _selectedIds, + createdAt: widget.routine!.createdAt, + ); + await provider.updateRoutine(updated); + } else { + await provider.createRoutine( + _nameController.text.trim(), + _selectedIds, + ); + } + if (mounted) Navigator.of(context).pop(); + } +} + +// ── Routine Detail Screen ───────────────────────────────────────────────────── +class RoutineDetailScreen extends StatelessWidget { + const RoutineDetailScreen({super.key, required this.routine}); + final Routine routine; + + @override + Widget build(BuildContext context) { + final provider = context.read(); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.surface, + title: Text( + routine.name, + style: const TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined, color: AppColors.textSoft), + onPressed: () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => CreateRoutineScreen(routine: routine), + ), + ), + ), + ], + ), + body: ListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, + ), + itemCount: routine.exerciseIds.length, + itemBuilder: (_, i) { + final ex = provider.getExercise(routine.exerciseIds[i]); + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${i + 1}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ex?.name ?? 'Unknown', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (ex != null) + Text( + '${ex.category} · ${MuscleGroups.names[ex.primaryMuscle] ?? ex.primaryMuscle}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => startRoutineWorkoutFlow(context, routine), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.play_arrow_rounded, color: Colors.white), + label: const Text( + 'Start Workout', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/session_details_sheet.dart b/workout-logger/lib/screens/widgets/session_details_sheet.dart new file mode 100644 index 0000000..f51341a --- /dev/null +++ b/workout-logger/lib/screens/widgets/session_details_sheet.dart @@ -0,0 +1,455 @@ +// session_details_sheet.dart — Bottom sheet showing full workout session detail + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +const Color _hcColor = Color(0xFF4ECDC4); + +class SessionDetailsSheet extends StatelessWidget { + const SessionDetailsSheet({ + super.key, + required this.session, + required this.provider, + required this.scrollController, + required this.onEdit, + required this.onDelete, + }); + + final WorkoutSession session; + final WorkoutProvider provider; + final ScrollController scrollController; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final dateStr = DateFormat('EEEE, MMMM d, yyyy').format(session.date); + final timeStr = DateFormat('h:mm a').format(session.date); + final totalSets = session.exercises.fold(0, (s, e) => s + e.sets.length); + final volume = session.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Date + actions row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateStr, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + '$timeStr · ${session.duration} min', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + if (session.hcSyncedAt != null) ...[ + const SizedBox(width: 6), + Tooltip( + message: + 'Synced ${DateFormat('MMM d, h:mm a').format(session.hcSyncedAt!)}', + child: const Icon( + Icons.favorite_rounded, + size: 13, + color: _hcColor, + ), + ), + ], + ], + ), + ], + ), + ), + Row( + children: [ + _ActionChip( + icon: Icons.edit_outlined, + label: 'Edit', + color: AppColors.primary, + onTap: onEdit, + ), + const SizedBox(width: AppSpacing.sm), + _ActionChip( + icon: Icons.delete_outline, + label: 'Delete', + color: AppColors.error, + onTap: onDelete, + ), + ], + ), + ], + ), + + const SizedBox(height: AppSpacing.lg), + + // Stat banner + Row( + children: [ + Expanded( + child: _StatBannerBox( + value: '${session.exercises.length}', + label: 'Exercises', + color: AppColors.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatBannerBox( + value: '$totalSets', + label: 'Sets', + color: AppColors.secondary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatBannerBox( + value: volStr, + label: 'Volume kg', + color: AppColors.success, + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.lg), + const RFSectionHeader('Exercises'), + const SizedBox(height: AppSpacing.sm), + + ...session.exercises.map( + (log) => _ExerciseDetailCard(log: log, provider: provider), + ), + + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Notes'), + const SizedBox(height: AppSpacing.sm), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + session.notes!, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ], + ); + } +} + +// ── Stat banner box ─────────────────────────────────────────────────────────── +class _StatBannerBox extends StatelessWidget { + const _StatBannerBox({ + required this.value, + required this.label, + required this.color, + }); + + final String value; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + value, + style: TextStyle( + color: color, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +// ── Action chip button ──────────────────────────────────────────────────────── +class _ActionChip extends StatelessWidget { + const _ActionChip({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm + 2, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +// ── Exercise detail card ─────────────────────────────────────────────────────── +class _ExerciseDetailCard extends StatelessWidget { + const _ExerciseDetailCard({required this.log, required this.provider}); + + final ExerciseLog log; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final exercise = provider.getExercise(log.exerciseId); + final name = exercise?.name ?? 'Unknown Exercise'; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Exercise name header + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Text( + name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${log.sets.length} sets', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + const Divider(height: 1, color: AppColors.divider), + // Set rows + Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + children: log.sets.asMap().entries.map((entry) { + return _SetRow(index: entry.key, set: entry.value); + }).toList(), + ), + ), + // Total + Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md, + ), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.05), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(AppRadius.md), + bottomRight: Radius.circular(AppRadius.md), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text( + 'Total ', + style: TextStyle(color: AppColors.textMuted, fontSize: 12), + ), + Text( + '${log.totalVolume.toStringAsFixed(0)} kg', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Individual set row ──────────────────────────────────────────────────────── +class _SetRow extends StatelessWidget { + const _SetRow({required this.index, required this.set}); + final int index; + final WorkoutSet set; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '${set.weight} kg × ${set.reps} reps', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + ), + ), + ), + if (set.isDropset) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'DROP', + style: TextStyle( + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), + ), + ), + Text( + '${set.volume.toStringAsFixed(0)} kg', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart new file mode 100644 index 0000000..bd6352e --- /dev/null +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -0,0 +1,327 @@ +// targets_tab.dart — Analytics "Targets" tab with create/manage targets + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../data/exercise_database.dart'; +import 'rf_widgets.dart'; +import 'rf_cards.dart'; + +class TargetsTab extends StatelessWidget { + const TargetsTab({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final targets = provider.targets; + final active = targets.where((t) => !t.isCompleted).toList(); + final completed = targets.where((t) => t.isCompleted).toList(); + + return Scaffold( + backgroundColor: AppColors.background, + body: targets.isEmpty + ? RFEmptyState( + icon: Icons.flag_rounded, + title: 'No Targets Set', + subtitle: 'Set a goal to track your progress', + ) + : SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (active.isNotEmpty) ...[ + const RFSectionHeader('Active'), + const SizedBox(height: AppSpacing.sm), + ...active.map( + (t) => TargetCard( + target: t, + exerciseName: provider.getExerciseName(t.exerciseId), + onDelete: () => provider.deleteTarget(t.id), + ), + ), + ], + if (completed.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Completed'), + const SizedBox(height: AppSpacing.sm), + ...completed.map( + (t) => TargetCard( + target: t, + exerciseName: provider.getExerciseName(t.exerciseId), + onDelete: () => provider.deleteTarget(t.id), + ), + ), + ], + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showCreateSheet(context), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.add_rounded, color: Colors.white), + label: const Text( + 'New Target', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + void _showCreateSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => const _CreateTargetSheet(), + ); + } +} + +// ── Create target bottom sheet ───────────────────────────────────────────────── +class _CreateTargetSheet extends StatefulWidget { + const _CreateTargetSheet(); + + @override + State<_CreateTargetSheet> createState() => _CreateTargetSheetState(); +} + +class _CreateTargetSheetState extends State<_CreateTargetSheet> { + String? _selectedExerciseId; + String _targetType = 'weight'; + final _valueController = TextEditingController(); + + static const _types = [ + ('weight', 'Max Weight (kg)'), + ('reps', 'Max Reps'), + ('volume', 'Total Volume (kg)'), + ]; + + @override + void dispose() { + _valueController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final exercises = ExerciseDatabase.getAll(); + final bottom = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + bottom + AppSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const Text( + 'New Target', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Exercise picker + const Text( + 'EXERCISE', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: DropdownButton( + value: _selectedExerciseId, + isExpanded: true, + underline: const SizedBox.shrink(), + dropdownColor: AppColors.cardHigh, + hint: const Text( + 'Select exercise…', + style: TextStyle(color: AppColors.textMuted, fontSize: 14), + ), + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + items: exercises + .map((e) => DropdownMenuItem(value: e.id, child: Text(e.name))) + .toList(), + onChanged: (v) => setState(() => _selectedExerciseId = v), + ), + ), + + const SizedBox(height: AppSpacing.md), + + // Target type + const Text( + 'TARGET TYPE', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: _types.map((t) { + final selected = _targetType == t.$1; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _targetType = t.$1), + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Text( + t.$2, + textAlign: TextAlign.center, + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textMuted, + fontSize: 11, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + ), + ); + }).toList(), + ), + + const SizedBox(height: AppSpacing.md), + + // Value input + const Text( + 'TARGET VALUE', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: _valueController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + ], + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + ), + decoration: const InputDecoration( + hintText: 'e.g. 100', + hintStyle: TextStyle(color: AppColors.textMuted), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ), + ), + + const SizedBox(height: AppSpacing.lg), + + GlowButton( + label: 'Create Target', + icon: Icons.flag_rounded, + onPressed: _submit, + fullWidth: true, + ), + ], + ), + ); + } + + Future _submit() async { + if (_selectedExerciseId == null || _valueController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please fill all fields'), + backgroundColor: AppColors.cardHigh, + ), + ); + return; + } + final value = double.tryParse(_valueController.text); + if (value == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid target value'), + backgroundColor: AppColors.error, + ), + ); + return; + } + + await context.read().createTarget( + exerciseId: _selectedExerciseId!, + type: _targetType, + targetValue: value, + ); + + if (mounted) Navigator.of(context).pop(); + } +} diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart new file mode 100644 index 0000000..8d8d6d8 --- /dev/null +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -0,0 +1,279 @@ +// workout_header.dart — Header bar for WorkoutFlowScreen + +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── WorkoutHeader ───────────────────────────────────────────────────────────── +// Shows exercise name, set/exercise progress, elapsed timer, and nav actions. +class WorkoutHeader extends StatefulWidget { + const WorkoutHeader({ + super.key, + required this.exerciseName, + required this.currentExerciseIndex, + required this.totalExercises, + required this.setNumber, + required this.workoutStartTime, + required this.progress, + required this.isFirst, + required this.isLast, + required this.onClose, + required this.onPrevious, + required this.onNext, + required this.onFinish, + required this.onRemoveLastSet, + required this.onSetRestTime, + }); + + final String exerciseName; + final int currentExerciseIndex; + final int totalExercises; + final int setNumber; + final DateTime? workoutStartTime; + final double progress; + final bool isFirst; + final bool isLast; + final VoidCallback onClose; + final VoidCallback onPrevious; + final VoidCallback onNext; + final VoidCallback onFinish; + final VoidCallback onRemoveLastSet; + final void Function(int seconds) onSetRestTime; + + @override + State createState() => _WorkoutHeaderState(); +} + +class _WorkoutHeaderState extends State { + late Timer _ticker; + int _elapsedSeconds = 0; + + @override + void initState() { + super.initState(); + _updateElapsed(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _updateElapsed()); + } + + void _updateElapsed() { + if (widget.workoutStartTime == null) return; + setState(() { + _elapsedSeconds = + DateTime.now().difference(widget.workoutStartTime!).inSeconds; + }); + } + + @override + void dispose() { + _ticker.cancel(); + super.dispose(); + } + + String get _elapsedLabel { + final m = _elapsedSeconds ~/ 60; + final s = _elapsedSeconds % 60; + return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + border: Border(bottom: BorderSide(color: AppColors.glassBorder)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), + child: Row( + children: [ + // Close button + IconButton( + icon: const Icon(Icons.close_rounded, size: 22), + color: AppColors.textSoft, + onPressed: widget.onClose, + ), + // Exercise info + Expanded( + child: Column( + children: [ + Text( + widget.exerciseName, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${widget.currentExerciseIndex + 1}/${widget.totalExercises}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Container( + width: 3, + height: 3, + decoration: const BoxDecoration( + color: AppColors.textMuted, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + 'Set ${widget.setNumber}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + // Timer chip + menu + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.timer_outlined, + size: 12, + color: AppColors.textMuted, + ), + const SizedBox(width: 4), + Text( + _elapsedLabel, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + _OptionsMenu( + restSeconds: 90, + onRemoveLastSet: widget.onRemoveLastSet, + onSetRestTime: widget.onSetRestTime, + onFinish: widget.onFinish, + ), + ], + ), + ], + ), + ), + // Progress bar + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: RFProgressBar( + value: widget.progress, + height: 4, + showGlow: false, + ), + ), + ], + ), + ), + ); + } +} + +// ── Options Menu ────────────────────────────────────────────────────────────── +class _OptionsMenu extends StatelessWidget { + const _OptionsMenu({ + required this.restSeconds, + required this.onRemoveLastSet, + required this.onSetRestTime, + required this.onFinish, + }); + + final int restSeconds; + final VoidCallback onRemoveLastSet; + final void Function(int) onSetRestTime; + final VoidCallback onFinish; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + color: AppColors.cardHigh, + icon: const Icon( + Icons.more_vert_rounded, + color: AppColors.textSoft, + size: 22, + ), + onSelected: (v) { + if (v == 'remove') onRemoveLastSet(); + if (v == 'finish') onFinish(); + if (v.startsWith('rest_')) { + onSetRestTime(int.parse(v.substring(5))); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'remove', child: Text('Remove Last Set')), + const PopupMenuDivider(), + for (final s in [30, 60, 90, 120, 180]) + PopupMenuItem( + value: 'rest_$s', + child: Row( + children: [ + const Icon(Icons.timer_outlined, size: 16), + const SizedBox(width: 8), + Text('Rest: ${s}s'), + if (restSeconds == s) ...[ + const Spacer(), + const Icon(Icons.check_rounded, size: 14), + ], + ], + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'finish', + child: Text('Finish Workout', + style: TextStyle(color: AppColors.success)), + ), + ], + ); + } +} diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index d2c9f22..2986a50 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -1,4 +1,4 @@ -// Workout Flow Screen - Samsung Health-style minimal workout interface +// workout_flow_screen.dart — Active workout session screen import 'dart:async'; import 'package:flutter/material.dart'; @@ -10,6 +10,10 @@ import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'exercise_library_screen.dart'; +import 'workout_summary_screen.dart'; +import 'widgets/workout_header.dart'; +import 'widgets/exercise_input_section.dart'; +import 'widgets/rest_timer_view.dart'; class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; @@ -32,139 +36,125 @@ class WorkoutFlowScreen extends StatefulWidget { enum _LeaveAction { discard, keep, cancel } class _WorkoutFlowScreenState extends State { - // Rest timer state + // Rest timer bool _isResting = false; - int _restSeconds = 90; // Default rest time + int _restSeconds = 90; Timer? _restTimer; int _remainingSeconds = 0; - - // Superset cycling: index to return to after rest (null = no return) int? _supersetReturnIndex; - // Input controllers + // Set entry state double _currentWeight = 20; int _currentReps = 10; bool _isDropset = false; final List _drops = []; - // TextEditingControllers for dropset fields (following Flutter best practices) - final TextEditingController _mainWeightController = TextEditingController(); - final TextEditingController _mainRepsController = TextEditingController(); - final List _dropWeightControllers = []; - final List _dropRepsControllers = []; + // Text controllers + final TextEditingController _mainWeightCtrl = TextEditingController(); + final TextEditingController _mainRepsCtrl = TextEditingController(); + final List _dropWeightCtrls = []; + final List _dropRepsCtrls = []; + + // ── Lifecycle ─────────────────────────────────────────────────────────────── @override void initState() { super.initState(); - // Defer initialization until after the first frame to avoid - // calling notifyListeners() during build - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeWorkout(); - }); + WidgetsBinding.instance.addPostFrameCallback((_) => _initializeWorkout()); + } + + @override + void dispose() { + _restTimer?.cancel(); + _mainWeightCtrl.dispose(); + _mainRepsCtrl.dispose(); + for (final c in _dropWeightCtrls) { + c.dispose(); + } + for (final c in _dropRepsCtrls) { + c.dispose(); + } + super.dispose(); } - ProgramDay? _resolvedProgramDay(WorkoutProvider provider) => - widget.programDay ?? provider.activeProgramDay; + // ── Program helpers ───────────────────────────────────────────────────────── - ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) => - widget.programWeek ?? provider.activeProgramWeek; + ProgramDay? _resolvedDay(WorkoutProvider p) => + widget.programDay ?? p.activeProgramDay; - ProgramExerciseSlot? _slotForIndex(int idx, {WorkoutProvider? provider}) { - final resolvedProvider = provider ?? context.read(); - final day = _resolvedProgramDay(resolvedProvider); + ProgramWeek? _resolvedWeek(WorkoutProvider p) => + widget.programWeek ?? p.activeProgramWeek; + + ProgramExerciseSlot? _slot(int idx, {WorkoutProvider? p}) { + final provider = p ?? context.read(); + final day = _resolvedDay(provider); if (day == null) return null; - final slots = day.exercises; - return idx < slots.length ? slots[idx] : null; + return idx < day.exercises.length ? day.exercises[idx] : null; } - /// Finds the index of the first exercise in the same superset group, scanning - /// backward from [fromIdx]. - int _supersetGroupStart( - int fromIdx, - String groupId, { - WorkoutProvider? provider, - }) { - int start = fromIdx; + int _supersetGroupStart(int from, String groupId, {WorkoutProvider? p}) { + int start = from; while (start > 0 && - _slotForIndex(start - 1, provider: provider)?.supersetGroupId == - groupId) { + _slot(start - 1, p: p)?.supersetGroupId == groupId) { start--; } return start; } - /// Returns true if any exercise in [startIdx..endIdx] still has fewer sets - /// logged than its target (deload-adjusted). bool _supersetNeedsMoreSets({ required int startIdx, required int endIdx, - required WorkoutProvider provider, + required WorkoutProvider p, }) { - final week = _resolvedProgramWeek(provider); + final week = _resolvedWeek(p); for (int i = startIdx; i <= endIdx; i++) { - final slot = _slotForIndex(i, provider: provider); - if (slot == null) continue; - if (i >= provider.currentExerciseLogs.length) continue; - final targetSets = (week?.isDeload == true) - ? (slot.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) - : slot.sets; - final logged = provider.currentExerciseLogs[i].sets.length; - if (logged < targetSets) return true; + final s = _slot(i, p: p); + if (s == null || i >= p.currentExerciseLogs.length) continue; + final target = week?.isDeload == true + ? (s.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) + : s.sets; + if (p.currentExerciseLogs[i].sets.length < target) return true; } return false; } - /// Returns true if the single slot at [index] still needs more sets. - bool _slotNeedsMoreSets({ - required int index, - required WorkoutProvider provider, - }) { - final slot = _slotForIndex(index, provider: provider); - if (slot == null) return false; - if (index >= provider.currentExerciseLogs.length) return false; - final week = _resolvedProgramWeek(provider); - final targetSets = (week?.isDeload == true) - ? (slot.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) - : slot.sets; - final logged = provider.currentExerciseLogs[index].sets.length; - return logged < targetSets; + bool _slotNeedsMoreSets({required int index, required WorkoutProvider p}) { + final s = _slot(index, p: p); + if (s == null || index >= p.currentExerciseLogs.length) return false; + final week = _resolvedWeek(p); + final target = week?.isDeload == true + ? (s.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) + : s.sets; + return p.currentExerciseLogs[index].sets.length < target; } + // ── Init / data load ──────────────────────────────────────────────────────── + void _initializeWorkout() { final provider = context.read(); - final programDay = _resolvedProgramDay(provider); - final programWeek = _resolvedProgramWeek(provider); + final day = _resolvedDay(provider); + final week = _resolvedWeek(provider); if (provider.hasActiveWorkout) { - final slot = _slotForIndex( - provider.currentExerciseIndex, - provider: provider, - ); - if (slot != null) { - _restSeconds = slot.restSeconds; - } + final s = _slot(provider.currentExerciseIndex, p: provider); + if (s != null) _restSeconds = s.restSeconds; _loadLastSessionData(); return; } - if (programDay != null) { - final exerciseIds = programDay.exercises - .map((s) => s.exerciseId) - .toList(); + if (day != null) { provider.startWorkout( - exerciseIds: exerciseIds, - programDay: programDay, - programWeek: programWeek, + exerciseIds: day.exercises.map((s) => s.exerciseId).toList(), + programDay: day, + programWeek: week, ); - // Set initial rest time from first slot - final firstSlot = _slotForIndex(0, provider: provider); - if (firstSlot != null) _restSeconds = firstSlot.restSeconds; + final first = _slot(0, p: provider); + if (first != null) _restSeconds = first.restSeconds; _loadLastSessionData(); } else if (widget.routine != null) { provider.startWorkout(routine: widget.routine); _loadLastSessionData(); } else if (widget.isQuickStart) { - // Will add exercises as we go provider.startWorkout(exerciseIds: []); } } @@ -172,50 +162,34 @@ class _WorkoutFlowScreenState extends State { void _loadLastSessionData() { final provider = context.read(); final settings = context.read(); - final currentExercise = provider.currentExercise; - if (currentExercise == null) return; + final exercise = provider.currentExercise; + if (exercise == null) return; - final lastSession = provider.getLastSessionForExercise(currentExercise.id); - if (lastSession != null && lastSession.sets.isNotEmpty) { - final lastSet = lastSession.sets.last; + final last = provider.getLastSessionForExercise(exercise.id); + if (last != null && last.sets.isNotEmpty) { + final lastSet = last.sets.last; setState(() { - _currentWeight = lastSet.weight; // always stored in kg + _currentWeight = lastSet.weight; _currentReps = lastSet.reps; - // Sync controllers using display unit - final displayWeight = settings.toDisplay(_currentWeight); - _mainWeightController.text = - displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); - _mainRepsController.text = _currentReps.toString(); + final dw = settings.toDisplay(_currentWeight); + _mainWeightCtrl.text = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _mainRepsCtrl.text = _currentReps.toString(); }); } } - @override - void dispose() { - _restTimer?.cancel(); - // Dispose TextEditingControllers to prevent memory leaks (Flutter best practice) - _mainWeightController.dispose(); - _mainRepsController.dispose(); - for (var controller in _dropWeightControllers) { - controller.dispose(); - } - for (var controller in _dropRepsControllers) { - controller.dispose(); - } - super.dispose(); - } + // ── Build ─────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final provider = context.watch(); if (!provider.hasActiveWorkout) { - return const Scaffold(body: Center(child: Text('No active workout'))); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } - // If no exercises yet (quick start), show exercise selector if (provider.currentExerciseLogs.isEmpty) { return _buildExerciseSelector(); } @@ -224,13 +198,11 @@ class _WorkoutFlowScreenState extends State { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - unawaited(_handleSystemBack()); + unawaited(_handleBack()); }, child: Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: SafeArea( - child: _isResting ? _buildRestTimerView() : _buildWorkoutView(), - ), + backgroundColor: AppColors.background, + body: _isResting ? _buildRestView(provider) : _buildWorkoutView(provider), ), ); } @@ -240,827 +212,156 @@ class _WorkoutFlowScreenState extends State { appBar: AppBar( title: const Text('Select Exercises'), leading: IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.close_rounded), onPressed: _showCancelDialog, ), ), body: ExerciseSelectorScreen( selectionMode: true, - onExercisesSelected: _startWithSelectedExercises, + onExercisesSelected: _startWithSelected, ), ); } - Future _startWithSelectedExercises(List exerciseIds) async { - if (exerciseIds.isEmpty) return; - + Future _startWithSelected(List ids) async { + if (ids.isEmpty) return; final provider = context.read(); - // Restart workout with selected exercises await provider.cancelWorkout(); - provider.startWorkout(exerciseIds: exerciseIds); + provider.startWorkout(exerciseIds: ids); } - Widget _buildWorkoutView() { - final provider = context.watch(); - final currentExercise = provider.currentExercise; - final currentLog = provider.currentExerciseLog; - final recommendations = currentExercise != null - ? provider.getRecommendations(currentExercise.id) + Widget _buildWorkoutView(WorkoutProvider provider) { + final exercise = provider.currentExercise; + final log = provider.currentExerciseLog; + final settings = context.watch(); + final totalExercises = provider.currentExerciseLogs.length; + final idx = provider.currentExerciseIndex; + final isFirst = idx == 0; + final isLast = idx >= totalExercises - 1; + + final recommendations = exercise != null + ? provider.getRecommendations(exercise.id) : []; + final lastSession = exercise != null + ? provider.getLastSessionForExercise(exercise.id) + : null; + return Column( children: [ - // Header - _buildHeader(provider, currentExercise), - - // Main content + WorkoutHeader( + exerciseName: exercise?.name ?? 'Workout', + currentExerciseIndex: idx, + totalExercises: totalExercises, + setNumber: (log?.sets.length ?? 0) + 1, + workoutStartTime: provider.workoutStartTime, + progress: totalExercises > 0 ? (idx + 1) / totalExercises : 0, + isFirst: isFirst, + isLast: isLast, + onClose: _showCancelDialog, + onPrevious: () { + provider.previousExercise(); + _loadLastSessionData(); + }, + onNext: () { + provider.nextExercise(); + _loadLastSessionData(); + }, + onFinish: _finishWorkout, + onRemoveLastSet: provider.removeLastSet, + onSetRestTime: (s) => setState(() => _restSeconds = s), + ), Expanded( child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Program metadata banner (shown only in program-mode) - _buildProgramMetaBanner(provider), - - // Recommendation card - if (recommendations.isNotEmpty && currentLog != null) - _buildRecommendationCard( - recommendations, - currentLog.sets.length, - ), - - const SizedBox(height: AppSpacing.lg), - - // Weight and reps input - if (!_isDropset) _buildInputSection(provider), - - if (!_isDropset) const SizedBox(height: AppSpacing.md), - - // Dropset toggle - _buildDropsetSection(), - - const SizedBox(height: AppSpacing.lg), - - // Set done button - _buildSetDoneButton(), - - const SizedBox(height: AppSpacing.lg), - - // Previous sets - if (currentLog != null && currentLog.sets.isNotEmpty) - _buildPreviousSets(currentLog.sets), - - const SizedBox(height: AppSpacing.lg), - - // Last session info - if (currentExercise != null) - _buildLastSessionInfo(currentExercise.id), - ], + child: ExerciseInputSection( + currentWeight: _currentWeight, + currentReps: _currentReps, + isDropset: _isDropset, + drops: _drops, + mainWeightController: _mainWeightCtrl, + mainRepsController: _mainRepsCtrl, + dropWeightControllers: _dropWeightCtrls, + dropRepsControllers: _dropRepsCtrls, + recommendations: recommendations, + previousSets: log?.sets ?? [], + lastSession: lastSession, + settings: settings, + exerciseId: exercise?.id, + programSlot: _slot(idx, p: provider), + programWeek: _resolvedWeek(provider), + onWeightChanged: (v) => setState(() => _currentWeight = v), + onRepsChanged: (v) => setState(() => _currentReps = v), + onDropsetToggled: _toggleDropset, + onDropAdded: _addDrop, + onDropRemoved: _removeDrop, + onDropWeightChanged: (i, w) { + if (i == -1) { + _currentWeight = w; + } else if (i < _drops.length) { + _drops[i] = DropsetEntry(weight: w, reps: _drops[i].reps); + } + }, + onDropRepsChanged: (i, r) { + if (i == -1) { + _currentReps = r; + } else if (i < _drops.length) { + _drops[i] = DropsetEntry(weight: _drops[i].weight, reps: r); + } + }, + onLogSet: _completeSet, + onApplyRecommendation: () { + if (recommendations.isEmpty) return; + final setIdx = (log?.sets.length ?? 0) + .clamp(0, recommendations.length - 1); + final rec = recommendations[setIdx]; + setState(() { + _currentWeight = rec.weight; + _currentReps = rec.reps; + final settings = context.read(); + final dw = settings.toDisplay(rec.weight); + _mainWeightCtrl.text = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _mainRepsCtrl.text = rec.reps.toString(); + }); + }, ), ), ), - - // Bottom actions - _buildBottomActions(provider), + _buildBottomNav(provider, isFirst, isLast), ], ); } - Widget _buildHeader(WorkoutProvider provider, Exercise? exercise) { - final totalExercises = provider.currentExerciseLogs.length; - final currentIndex = provider.currentExerciseIndex + 1; - final currentLog = provider.currentExerciseLog; - final setNumber = (currentLog?.sets.length ?? 0) + 1; - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: _showCancelDialog, - ), - Expanded( - child: Column( - children: [ - Text( - exercise?.name ?? 'Select Exercise', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - 'Exercise $currentIndex of $totalExercises • Set $setNumber', - style: const TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.more_vert), - onPressed: _showOptionsMenu, - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - // Progress bar - LinearProgressIndicator( - value: currentIndex / totalExercises, - backgroundColor: AppTheme.cardColor, - valueColor: const AlwaysStoppedAnimation(AppTheme.primaryColor), - borderRadius: BorderRadius.circular(4), - ), - ], - ), - ); - } - - Widget _buildRecommendationCard( - List recommendations, - int currentSetIndex, - ) { - if (currentSetIndex >= recommendations.length) return const SizedBox(); - - final rec = recommendations[currentSetIndex]; - final settings = context.watch(); - final displayWeight = settings.toDisplay(rec.weight); - final displayWeightText = displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); - final confidenceColor = rec.confidence == 'high' - ? AppTheme.success - : (rec.confidence == 'medium' ? AppTheme.warning : AppTheme.textMuted); - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.2), - AppTheme.secondaryColor.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.lightbulb_outline, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Suggested', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - Text( - '$displayWeightText ${settings.unitLabel} × ${rec.reps} reps', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - TextButton( - onPressed: () { - setState(() { - _currentWeight = rec.weight; - _currentReps = rec.reps; - }); - HapticFeedback.lightImpact(); - }, - child: const Text('Apply'), - ), - ], - ), - ); - } - - Widget _buildInputSection(WorkoutProvider provider) { - final settings = context.watch(); - final exerciseId = provider.currentExercise?.id ?? ''; - final isAssistedBodyweight = - exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; - final weightLabel = isAssistedBodyweight - ? 'Assist (${settings.unitLabel})' - : 'Weight (${settings.unitLabel})'; - - final displayWeight = settings.toDisplay(_currentWeight); - - return Row( - children: [ - // Weight input - Expanded( - child: _buildNumberInput( - label: weightLabel, - value: displayWeight, - onChanged: (val) => - setState(() => _currentWeight = settings.toStorage(val)), - step: settings.weightIncrement, - decimals: 1, - ), - ), - const SizedBox(width: AppSpacing.md), - // Reps input - Expanded( - child: _buildNumberInput( - label: 'Reps', - value: _currentReps.toDouble(), - onChanged: (val) => setState(() => _currentReps = val.toInt()), - step: 1, - decimals: 0, - ), - ), - ], - ); - } + Widget _buildRestView(WorkoutProvider provider) { + final idx = provider.currentExerciseIndex; + final nextExercise = idx + 1 < provider.currentExerciseLogs.length + ? provider.getExerciseName( + provider.currentExerciseLogs[idx + 1].exerciseId) + : null; - Widget _buildNumberInput({ - required String label, - required double value, - required Function(double) onChanged, - required double step, - required int decimals, - }) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - children: [ - Text( - label, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 14), - ), - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildCircleButton( - icon: Icons.remove, - onPressed: () { - onChanged((value - step).clamp(0, 999)); - HapticFeedback.selectionClick(); - }, - ), - Expanded( - child: GestureDetector( - onTap: () => _showNumberPicker(value, decimals, onChanged), - child: Text( - decimals == 0 - ? value.toInt().toString() - : value.toStringAsFixed(decimals), - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - textAlign: TextAlign.center, - ), - ), - ), - _buildCircleButton( - icon: Icons.add, - onPressed: () { - onChanged((value + step).clamp(0, 999)); - HapticFeedback.selectionClick(); - }, - ), - ], - ), - ], - ), - ); - } - - Widget _buildCircleButton({ - required IconData icon, - required VoidCallback onPressed, - }) { - return Material( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(20), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(20), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.5)), - ), - child: Icon(icon, color: AppTheme.primaryColor), - ), - ), - ); - } - - Widget _buildDropsetSection() { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - children: [ - Row( - children: [ - const Icon(Icons.trending_down, color: AppTheme.warning), - const SizedBox(width: 8), - const Text( - 'Dropset', - style: TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - ), - const Spacer(), - Switch( - value: _isDropset, - onChanged: (val) { - setState(() { - _isDropset = val; - if (!val) { - // Dispose all drop controllers when turning off dropset mode - for (var controller in _dropWeightControllers) { - controller.dispose(); - } - for (var controller in _dropRepsControllers) { - controller.dispose(); - } - _dropWeightControllers.clear(); - _dropRepsControllers.clear(); - _drops.clear(); - } else { - // Sync main controllers when enabling dropset to match current state values - _mainWeightController.text = _currentWeight.toString(); - _mainRepsController.text = _currentReps.toString(); - } - }); - }, - activeThumbColor: AppTheme.warning, - ), - ], - ), - if (_isDropset) ...[ - const SizedBox(height: AppSpacing.md), - _buildMainSetEntry(), - ..._drops.asMap().entries.map( - (entry) => _buildDropEntry(entry.key), - ), - TextButton.icon( - onPressed: _addDrop, - icon: const Icon(Icons.add), - label: const Text('Add Drop'), - ), - ], - ], - ), - ); - } - - Widget _buildMainSetEntry() { - final settings = context.read(); - // Controllers are initialized in _loadLastSessionData and updated via onChanged - // No controller.text assignments in build to avoid cursor jumps - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - children: [ - const Text('Start:', style: TextStyle(color: AppTheme.textSecondary)), - const SizedBox(width: 8), - Expanded( - child: Row( - children: [ - SizedBox( - width: 60, - child: TextFormField( - controller: _mainWeightController, - decoration: InputDecoration( - hintText: settings.unitLabel, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), - ], - onChanged: (val) { - final parsed = double.tryParse(val); - if (parsed != null) { - // Convert from display unit to kg for storage - _currentWeight = settings.toStorage(parsed); - } - }, - ), - ), - const Text( - ' × ', - style: TextStyle(color: AppTheme.textSecondary), - ), - SizedBox( - width: 50, - child: TextFormField( - controller: _mainRepsController, - decoration: const InputDecoration( - hintText: 'reps', - contentPadding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - ), - keyboardType: const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (val) { - final parsed = int.tryParse(val); - if (parsed != null) { - _currentReps = parsed; - } - }, - ), - ), - ], - ), - ), - const SizedBox(width: 48), // Align with delete button - ], - ), - ); - } - - Widget _buildDropEntry(int index) { - // Controllers are created and initialized in _addDrop() - // Build method only READS from controllers, never creates or modifies them - // This prevents cursor jumps and duplicate controller creation - - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - children: [ - Text( - 'Drop ${index + 1}:', - style: const TextStyle(color: AppTheme.textSecondary), - ), - const SizedBox(width: 8), - Expanded( - child: Row( - children: [ - SizedBox( - width: 60, - child: TextFormField( - controller: _dropWeightControllers[index], - decoration: InputDecoration( - hintText: context.read().unitLabel, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), - ], - onChanged: (val) { - final parsed = double.tryParse(val); - if (parsed != null) { - final settings = context.read(); - _drops[index] = DropsetEntry( - weight: settings.toStorage(parsed), - reps: _drops[index].reps, - ); - } - }, - ), - ), - const Text( - ' × ', - style: TextStyle(color: AppTheme.textSecondary), - ), - SizedBox( - width: 50, - child: TextFormField( - controller: _dropRepsControllers[index], - decoration: const InputDecoration( - hintText: 'reps', - contentPadding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - ), - keyboardType: const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (val) { - final parsed = int.tryParse(val); - if (parsed != null) { - _drops[index] = DropsetEntry( - weight: _drops[index].weight, - reps: parsed, - ); - } - }, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: () { - // Dispose controllers for this drop before removing - if (index < _dropWeightControllers.length) { - _dropWeightControllers[index].dispose(); - _dropWeightControllers.removeAt(index); - } - if (index < _dropRepsControllers.length) { - _dropRepsControllers[index].dispose(); - _dropRepsControllers.removeAt(index); - } - setState(() => _drops.removeAt(index)); - }, - ), - ], - ), - ); - } - - void _addDrop() { - setState(() { - final lastWeight = _drops.isEmpty ? _currentWeight : _drops.last.weight; - final newWeight = (lastWeight * 0.8).roundToDouble(); - - _drops.add(DropsetEntry(weight: newWeight, reps: _currentReps)); - - // Create controllers for the new drop (Flutter best practice) - _dropWeightControllers.add( - TextEditingController(text: newWeight.toString()), - ); - _dropRepsControllers.add( - TextEditingController(text: _currentReps.toString()), - ); - }); - } - - Widget _buildSetDoneButton() { - return SizedBox( - width: double.infinity, - height: 60, - child: ElevatedButton( - onPressed: _completeSet, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.success, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - ), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check_circle, size: 28), - SizedBox(width: 12), - Text( - 'SET DONE', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], - ), - ), - ); - } - - Widget _buildPreviousSets(List sets) { - final settings = context.watch(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'This Session', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.sm), - ...sets.asMap().entries.map((entry) { - final index = entry.key; - final set = entry.value; - final oneRM = WorkoutProvider.estimateOneRM(set.weight, set.reps); - final displayWeight = settings.toDisplay(set.weight); - final weightStr = displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.2), - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.check, - color: AppTheme.success, - size: 16, - ), - ), - const SizedBox(width: 12), - Text( - 'Set ${index + 1}', - style: const TextStyle(color: AppTheme.textSecondary), - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$weightStr ${settings.unitLabel} × ${set.reps}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - if (set.reps > 1) - Text( - '~${settings.formatWeight(oneRM)} 1RM', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 11, - ), - ), - ], - ), - if (set.isDropset) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'DROP', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - ); - }), - ], + return RestTimerView( + remainingSeconds: _remainingSeconds, + totalSeconds: _restSeconds, + onAdjust: _adjustRest, + onSkip: _skipRest, + nextExerciseName: nextExercise, ); } - Widget _buildLastSessionInfo(String exerciseId) { - final provider = context.read(); - final lastSession = provider.getLastSessionForExercise(exerciseId); - - if (lastSession == null) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Row( - children: [ - Icon(Icons.info_outline, color: AppTheme.textMuted), - SizedBox(width: 12), - Expanded( - child: Text( - 'First time doing this exercise!', - style: TextStyle(color: AppTheme.textSecondary), - ), - ), - ], - ), - ); - } - + Widget _buildBottomNav(WorkoutProvider provider, bool isFirst, bool isLast) { return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Last Session', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: lastSession.sets.asMap().entries.map((entry) { - final set = entry.value; - final settings = context.read(); - return Chip( - label: Text( - '${settings.formatWeight(set.weight)} × ${set.reps}', - style: const TextStyle(fontSize: 12), - ), - backgroundColor: AppTheme.surfaceColor, - ); - }).toList(), - ), - ], + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm + MediaQuery.of(context).padding.bottom, ), - ); - } - - Widget _buildBottomActions(WorkoutProvider provider) { - final isFirst = provider.currentExerciseIndex == 0; - final isLast = - provider.currentExerciseIndex >= - provider.currentExerciseLogs.length - 1; - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.glassBorder)), ), child: Row( children: [ @@ -1071,14 +372,23 @@ class _WorkoutFlowScreenState extends State { provider.previousExercise(); _loadLastSessionData(); }, - icon: const Icon(Icons.arrow_back), + icon: const Icon(Icons.arrow_back_rounded, size: 18), label: const Text('Previous'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSoft, + side: BorderSide(color: AppColors.glassBorder), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ), ) else const Spacer(), - const SizedBox(width: AppSpacing.md), + const SizedBox(width: AppSpacing.sm), Expanded( + flex: 2, child: ElevatedButton.icon( onPressed: isLast ? _finishWorkout @@ -1086,245 +396,78 @@ class _WorkoutFlowScreenState extends State { provider.nextExercise(); _loadLastSessionData(); }, - icon: Icon(isLast ? Icons.check : Icons.arrow_forward), - label: Text(isLast ? 'Finish' : 'Next'), - ), - ), - ], - ), - ); - } - - // ==================== Program Meta Banner ==================== - - Widget _buildProgramMetaBanner(WorkoutProvider provider) { - final week = _resolvedProgramWeek(provider); - if (_resolvedProgramDay(provider) == null || week == null) { - return const SizedBox.shrink(); - } - final slot = _slotForIndex( - provider.currentExerciseIndex, - provider: provider, - ); - if (slot == null) return const SizedBox.shrink(); - - final displaySets = week.isDeload - ? (slot.sets - week.deloadSetReduction).clamp(1, 99) - : slot.sets; - final repRange = slot.minReps == slot.maxReps - ? '${slot.minReps} reps' - : '${slot.minReps}–${slot.maxReps} reps'; - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: week.isDeload - ? Colors.amber.withOpacity(0.4) - : AppTheme.primaryColor.withOpacity(0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (week.isDeload) ...[ - const Icon( - Icons.battery_charging_full, - size: 14, - color: Colors.amber, - ), - const SizedBox(width: 4), - const Text( - 'DELOAD ', - style: TextStyle( - fontSize: 11, - color: Colors.amber, - fontWeight: FontWeight.bold, - letterSpacing: 0.6, - ), - ), - ], - Text( - 'Target: $displaySets × $repRange', - style: const TextStyle( - fontSize: 13, - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), + icon: Icon( + isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, + size: 18, ), - ], - ), - const SizedBox(height: 6), - Wrap( - spacing: AppSpacing.md, - runSpacing: 4, - children: [ - _programChip( - icon: Icons.timer_outlined, - label: '${slot.restSeconds}s rest', - color: AppTheme.textSecondary, - ), - if (slot.tempo != null) - _programChip( - icon: Icons.speed, - label: 'Tempo ${slot.tempo}', - color: AppTheme.secondaryColor, - ), - if (slot.weightPercentage != null) - _programChip( - icon: Icons.fitness_center, - label: week.isDeload - ? '${(slot.weightPercentage! * week.deloadIntensityFactor).toStringAsFixed(0)}% 1RM' - : '${slot.weightPercentage!.toStringAsFixed(0)}% 1RM', - color: AppTheme.primaryColor, - ), - if (slot.supersetGroupId != null) - _programChip( - icon: Icons.link, - label: 'Superset', - color: AppTheme.secondaryColor, + label: Text(isLast ? 'Finish' : 'Next'), + style: ElevatedButton.styleFrom( + backgroundColor: isLast ? AppColors.success : AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), ), - ], - ), - if (slot.notes != null) ...[ - const SizedBox(height: 4), - Text( - slot.notes!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - fontStyle: FontStyle.italic, ), ), - ], + ), ], ), ); } - Widget _programChip({ - required IconData icon, - required String label, - required Color color, - }) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 11, color: color), - const SizedBox(width: 3), - Text(label, style: TextStyle(fontSize: 11, color: color)), - ], - ); - } - - // ==================== Rest Timer View ==================== + // ── Dropset helpers ───────────────────────────────────────────────────────── - Widget _buildRestTimerView() { - final minutes = _remainingSeconds ~/ 60; - final seconds = _remainingSeconds % 60; + void _toggleDropset(bool val) { + setState(() { + _isDropset = val; + if (!val) { + for (final c in _dropWeightCtrls) { + c.dispose(); + } + for (final c in _dropRepsCtrls) { + c.dispose(); + } + _dropWeightCtrls.clear(); + _dropRepsCtrls.clear(); + _drops.clear(); + } else { + _mainWeightCtrl.text = _currentWeight.toString(); + _mainRepsCtrl.text = _currentReps.toString(); + } + }); + } - return Container( - width: double.infinity, - color: AppTheme.backgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'REST TIME', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - letterSpacing: 2, - ), - ), - const SizedBox(height: AppSpacing.lg), - Text( - '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 72, - fontWeight: FontWeight.w200, - ), - ), - const SizedBox(height: AppSpacing.xl), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildTimerButton( - icon: Icons.remove_circle_outline, - label: '-30s', - onPressed: () => _adjustRestTime(-30), - ), - const SizedBox(width: AppSpacing.lg), - _buildTimerButton( - icon: Icons.add_circle_outline, - label: '+30s', - onPressed: () => _adjustRestTime(30), - ), - ], - ), - const SizedBox(height: AppSpacing.xxl), - SizedBox( - width: 200, - child: ElevatedButton( - onPressed: _skipRest, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - 'SKIP', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ), - ], - ), - ); + void _addDrop() { + setState(() { + final lastWeight = _drops.isEmpty ? _currentWeight : _drops.last.weight; + final newWeight = (lastWeight * 0.8).roundToDouble(); + _drops.add(DropsetEntry(weight: newWeight, reps: _currentReps)); + _dropWeightCtrls.add(TextEditingController(text: newWeight.toString())); + _dropRepsCtrls.add( + TextEditingController(text: _currentReps.toString()), + ); + }); } - Widget _buildTimerButton({ - required IconData icon, - required String label, - required VoidCallback onPressed, - }) { - return GestureDetector( - onTap: onPressed, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Icon(icon, color: AppTheme.textSecondary), - const SizedBox(width: 8), - Text( - label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); + void _removeDrop(int index) { + if (index < _dropWeightCtrls.length) { + _dropWeightCtrls[index].dispose(); + _dropWeightCtrls.removeAt(index); + } + if (index < _dropRepsCtrls.length) { + _dropRepsCtrls[index].dispose(); + _dropRepsCtrls.removeAt(index); + } + setState(() => _drops.removeAt(index)); } - // ==================== Actions ==================== + // ── Set completion ────────────────────────────────────────────────────────── void _completeSet() { final provider = context.read(); - final currentIdx = provider.currentExerciseIndex; - final currentSlot = _slotForIndex(currentIdx, provider: provider); - final nextSlot = _slotForIndex(currentIdx + 1, provider: provider); + final idx = provider.currentExerciseIndex; + final currentSlot = _slot(idx, p: provider); + final nextSlot = _slot(idx + 1, p: provider); final set = WorkoutSet( weight: _currentWeight, @@ -1336,56 +479,40 @@ class _WorkoutFlowScreenState extends State { provider.addSet(set); HapticFeedback.heavyImpact(); - // Update rest time from current slot - if (currentSlot != null) { - _restSeconds = currentSlot.restSeconds; - } + if (currentSlot != null) _restSeconds = currentSlot.restSeconds; - // Reset dropset state and dispose controllers to prevent memory leaks setState(() { _isDropset = false; - // Dispose all drop controllers before clearing - for (var controller in _dropWeightControllers) { - controller.dispose(); + for (final c in _dropWeightCtrls) { + c.dispose(); } - for (var controller in _dropRepsControllers) { - controller.dispose(); + for (final c in _dropRepsCtrls) { + c.dispose(); } - _dropWeightControllers.clear(); - _dropRepsControllers.clear(); + _dropWeightCtrls.clear(); + _dropRepsCtrls.clear(); _drops.clear(); }); - // Superset auto-advance: if next exercise is in the same superset group - // AND the next slot still needs more sets, advance immediately. - final isSupersetPair = - currentSlot?.supersetGroupId != null && + // Superset auto-advance + final isSupersetPair = currentSlot?.supersetGroupId != null && nextSlot?.supersetGroupId == currentSlot?.supersetGroupId; if (isSupersetPair && - _slotNeedsMoreSets(index: currentIdx + 1, provider: provider)) { + _slotNeedsMoreSets(index: idx + 1, p: provider)) { provider.nextExercise(); _loadLastSessionData(); - // Apply the next slot's rest time so the subsequent rest is correct - final newSlot = _slotForIndex( - provider.currentExerciseIndex, - provider: provider, - ); + final newSlot = _slot(provider.currentExerciseIndex, p: provider); if (newSlot != null) setState(() => _restSeconds = newSlot.restSeconds); } else { - // Detect if we just finished the last exercise in a superset group. - // If the group still needs more sets, schedule a return after rest. final groupId = currentSlot?.supersetGroupId; if (groupId != null) { - final groupStart = _supersetGroupStart( - currentIdx, - groupId, - provider: provider, - ); + final groupStart = + _supersetGroupStart(idx, groupId, p: provider); if (_supersetNeedsMoreSets( startIdx: groupStart, - endIdx: currentIdx, - provider: provider, + endIdx: idx, + p: provider, )) { _supersetReturnIndex = groupStart; } @@ -1394,13 +521,14 @@ class _WorkoutFlowScreenState extends State { } } + // ── Rest timer ────────────────────────────────────────────────────────────── + void _startRestTimer() { setState(() { _isResting = true; _remainingSeconds = _restSeconds; }); - - _restTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _restTimer = Timer.periodic(const Duration(seconds: 1), (t) { if (_remainingSeconds <= 0) { _skipRest(); } else { @@ -1417,182 +545,49 @@ class _WorkoutFlowScreenState extends State { _remainingSeconds = 0; _supersetReturnIndex = null; }); - - // If in a superset cycle, auto-return to the first exercise in the group if (returnIdx != null) { final provider = context.read(); provider.goToExercise(returnIdx); _loadLastSessionData(); - final slot = _slotForIndex(returnIdx, provider: provider); - if (slot != null) setState(() => _restSeconds = slot.restSeconds); + final s = _slot(returnIdx, p: provider); + if (s != null) setState(() => _restSeconds = s.restSeconds); } - HapticFeedback.lightImpact(); } - void _adjustRestTime(int seconds) { + void _adjustRest(int delta) { setState(() { - _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 600).toInt(); - _restSeconds = (_restSeconds + seconds).clamp(30, 600).toInt(); + _remainingSeconds = (_remainingSeconds + delta).clamp(0, 600); + _restSeconds = (_restSeconds + delta).clamp(30, 600); }); HapticFeedback.selectionClick(); } - void _showNumberPicker( - double currentValue, - int decimals, - Function(double) onChanged, - ) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => _NumberPickerContent( - initialValue: currentValue, - decimals: decimals, - onChanged: onChanged, - ), - ); - } - - void _showOptionsMenu() { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Set Rest Timer'), - subtitle: Text('Currently: ${_restSeconds}s'), - onTap: () { - Navigator.pop(context); - _showRestTimerSettings(); - }, - ), - ListTile( - leading: const Icon(Icons.note_add), - title: const Text('Add Notes'), - onTap: () { - Navigator.pop(context); - // Show notes dialog - }, - ), - ListTile( - leading: const Icon(Icons.undo), - title: const Text('Remove Last Set'), - onTap: () { - context.read().removeLastSet(); - Navigator.pop(context); - }, - ), - ], - ), - ), - ); - } - - void _showRestTimerSettings() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Default Rest Time'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [30, 60, 90, 120, 150, 180].map((seconds) { - return ListTile( - title: Text('$seconds seconds'), - trailing: _restSeconds == seconds - ? const Icon(Icons.check, color: AppTheme.primaryColor) - : null, - onTap: () { - setState(() => _restSeconds = seconds); - Navigator.pop(context); - }, - ); - }).toList(), - ), - ), - ); - } - - Future _handleSystemBack() async { - final action = await _showLeaveDialog(); - if (!mounted) return; - - if (action == _LeaveAction.discard) { - await context.read().cancelWorkout(); - if (mounted) { - Navigator.of(context).pop(); - } - return; - } - - if (action == _LeaveAction.keep) { - Navigator.of(context).pop(); - } - } - - Future<_LeaveAction> _showLeaveDialog() async { - final action = await showDialog<_LeaveAction>( - context: context, - builder: (context) => AlertDialog( - title: const Text('Leave workout?'), - content: const Text( - 'Your progress is saved. You can resume it next time you start a workout.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(_LeaveAction.discard), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Discard workout'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_LeaveAction.keep), - child: const Text('Keep & exit'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_LeaveAction.cancel), - child: const Text('Cancel'), - ), - ], - ), - ); - - return action ?? _LeaveAction.cancel; - } + // ── Dialogs ───────────────────────────────────────────────────────────────── void _showCancelDialog() { showDialog( context: context, - builder: (dialogContext) => AlertDialog( + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, title: const Text('Cancel Workout?'), content: const Text('Your progress will not be saved.'), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Continue Workout'), + onPressed: () => Navigator.pop(ctx), + child: const Text('Continue'), ), TextButton( onPressed: () async { + final nav = Navigator.of(context); + final ctxNav = Navigator.of(ctx); await context.read().cancelWorkout(); if (!mounted) return; - Navigator.pop(dialogContext); // Close dialog - Navigator.pop(context); // Close workout screen + ctxNav.pop(); + nav.pop(); }, - child: const Text( - 'Cancel Workout', - style: TextStyle(color: AppTheme.error), - ), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Discard'), ), ], ), @@ -1602,126 +597,70 @@ class _WorkoutFlowScreenState extends State { void _finishWorkout() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, title: const Text('Finish Workout?'), - content: const Text('Save this workout session?'), + content: const Text('Ready to save this session?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(ctx), child: const Text('Continue'), ), ElevatedButton( onPressed: () async { - Navigator.pop(context); // Close dialog - await context.read().finishWorkout(); - if (mounted) { - Navigator.pop(context); // Close workout screen - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Workout saved! Great job! 💪'), - backgroundColor: AppTheme.success, - ), - ); - } + final nav = Navigator.of(context); + Navigator.of(ctx).pop(); + final session = + await context.read().finishWorkout(); + if (!mounted) return; + nav.pop(); + nav.push(MaterialPageRoute( + builder: (_) => WorkoutSummaryScreen(session: session), + )); }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + ), child: const Text('Save & Finish'), ), ], ), ); } -} - -/// A StatefulWidget for the number picker content that properly manages -/// its TextEditingController lifecycle to avoid memory leaks. -class _NumberPickerContent extends StatefulWidget { - final double initialValue; - final int decimals; - final Function(double) onChanged; - - const _NumberPickerContent({ - required this.initialValue, - required this.decimals, - required this.onChanged, - }); - - @override - State<_NumberPickerContent> createState() => _NumberPickerContentState(); -} - -class _NumberPickerContentState extends State<_NumberPickerContent> { - late final TextEditingController _controller; - late final FocusNode _focusNode; - - @override - void initState() { - super.initState(); - _controller = TextEditingController( - text: widget.decimals == 0 - ? widget.initialValue.toInt().toString() - : widget.initialValue.toStringAsFixed(widget.decimals), - ); - _focusNode = FocusNode(); - // Request focus after the bottom sheet is fully rendered - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - void _submit() { - final parsed = double.tryParse(_controller.text); - if (parsed != null) { - widget.onChanged(parsed); - } - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { - // Pad bottom so content shifts up above the keyboard - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg + bottomInset, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _controller, - focusNode: _focusNode, - keyboardType: widget.decimals > 0 - ? const TextInputType.numberWithOptions(decimal: true) - : const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputFormatters: widget.decimals > 0 - ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))] - : [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration(labelText: 'Enter value'), - onSubmitted: (_) => _submit(), + Future _handleBack() async { + final action = await showDialog<_LeaveAction>( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + title: const Text('Leave workout?'), + content: const Text( + 'Progress is saved. You can resume next time.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, _LeaveAction.discard), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Discard'), ), - const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _submit, - child: const Text('Done'), - ), + TextButton( + onPressed: () => Navigator.pop(ctx, _LeaveAction.keep), + child: const Text('Keep & exit'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, _LeaveAction.cancel), + child: const Text('Cancel'), ), ], ), ); + + if (!mounted) return; + if (action == _LeaveAction.discard) { + await context.read().cancelWorkout(); + if (mounted) Navigator.pop(context); + } else if (action == _LeaveAction.keep) { + Navigator.pop(context); + } } } diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart new file mode 100644 index 0000000..8d736f1 --- /dev/null +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -0,0 +1,293 @@ +// workout_summary_screen.dart — Post-workout celebration & summary screen + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../models/models.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_cards.dart'; + +class WorkoutSummaryScreen extends StatelessWidget { + const WorkoutSummaryScreen({super.key, required this.session}); + + final WorkoutSession session; + + @override + Widget build(BuildContext context) { + final provider = context.read(); + final totalSets = session.exercises.fold( + 0, + (sum, e) => sum + e.sets.length, + ); + final volume = session.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + // Collect unique muscles from all exercises + final muscles = {}; + for (final log in session.exercises) { + final exercise = provider.getExercise(log.exerciseId); + if (exercise != null) { + for (final activation in exercise.muscleActivations) { + muscles.add(activation.muscleGroupId); + } + } + } + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: AppSpacing.lg), + _buildTrophyHeader(context), + const SizedBox(height: AppSpacing.xl), + _buildStatGrid( + session.duration, + volStr, + totalSets, + session.exercises.length, + ), + if (muscles.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _buildMusclesSection(muscles, provider), + ], + const SizedBox(height: AppSpacing.lg), + _buildExerciseSummary(session, provider), + const SizedBox(height: AppSpacing.xl), + GlowButton( + label: 'Done', + icon: Icons.check_rounded, + onPressed: () => Navigator.of(context) + .popUntil((r) => r.isFirst), + ), + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTrophyHeader(BuildContext context) { + final dateStr = DateFormat('EEEE, MMM d').format(session.date); + return Column( + children: [ + // Glowing trophy icon + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.warning.withValues(alpha: 0.3), + AppColors.warning.withValues(alpha: 0.05), + ], + ), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.warning.withValues(alpha: 0.4), + blurRadius: 32, + spreadRadius: 4, + ), + ], + ), + child: const Icon( + Icons.emoji_events_rounded, + size: 48, + color: AppColors.warning, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + 'Workout Complete!', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + dateStr, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ], + ); + } + + Widget _buildStatGrid( + int duration, + String volume, + int sets, + int exercises, + ) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.timer_outlined, + value: '${duration}m', + label: 'Duration', + color: AppColors.secondary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.trending_up_rounded, + value: volume, + label: 'Volume (kg)', + color: AppColors.success, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.repeat_rounded, + value: '$sets', + label: 'Sets Logged', + color: AppColors.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.fitness_center_rounded, + value: '$exercises', + label: 'Exercises', + color: AppColors.warning, + ), + ), + ], + ), + ], + ); + } + + Widget _buildMusclesSection(Set muscles, WorkoutProvider provider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('Muscles Trained'), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: muscles.map((m) { + final name = provider.getMuscleGroupName(m); + return RFChip( + label: name, + color: AppColors.muscle(m), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildExerciseSummary( + WorkoutSession session, + WorkoutProvider provider, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('Exercise Breakdown'), + const SizedBox(height: AppSpacing.sm), + ...session.exercises.map( + (log) => _ExerciseSummaryRow(log: log, provider: provider), + ), + ], + ); + } +} + +class _ExerciseSummaryRow extends StatelessWidget { + const _ExerciseSummaryRow({ + required this.log, + required this.provider, + }); + + final ExerciseLog log; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final name = provider.getExerciseName(log.exerciseId); + final volume = log.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '${log.sets.length} sets', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Text( + '$volStr kg', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index e2f8694..90da855 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -1,30 +1,49 @@ -// App Theme - Dark theme with modern styling - import 'package:flutter/material.dart'; -class AppTheme { - // Primary colors - static const Color primaryColor = Color(0xFF6C5CE7); - static const Color secondaryColor = Color(0xFF00D9FF); - static const Color accentColor = Color(0xFFFF6B6B); - - // Background colors - static const Color backgroundColor = Color(0xFF0D1117); - static const Color surfaceColor = Color(0xFF161B22); - static const Color cardColor = Color(0xFF21262D); - - // Text colors - static const Color textPrimary = Color(0xFFE6EDF3); - static const Color textSecondary = Color(0xFF8B949E); - static const Color textMuted = Color(0xFF484F58); - - // Status colors - static const Color success = Color(0xFF00D26A); - static const Color warning = Color(0xFFFFB800); - static const Color error = Color(0xFFFF4757); - - // Muscle group colors - static const Map muscleColors = { +// ── AppColors ────────────────────────────────────────────────────────────── +// Single source of truth for all colour tokens. Never use hex literals in +// widget files — always reference AppColors or AppTheme aliases below. +class AppColors { + const AppColors._(); + + // Backgrounds + static const background = Color(0xFF080B10); + static const surface = Color(0xFF0F1318); + static const card = Color(0xFF161B22); + static const cardHigh = Color(0xFF1C2333); + + // Glassmorphism + static const glass = Color(0x0AFFFFFF); // 4 % white + static const glassBorder = Color(0x14FFFFFF); // 8 % white + static const divider = Color(0x0FFFFFFF); // 6 % white + + // Brand + static const primary = Color(0xFF6C5CE7); + static const secondary = Color(0xFF00D9FF); + static const accent = Color(0xFFFF6B6B); + + // Semantic + static const success = Color(0xFF00D26A); + static const warning = Color(0xFFFFB800); + static const error = Color(0xFFFF4757); + + // Text + static const textPrimary = Color(0xFFE6EDF3); + static const textSoft = Color(0xFF8B949E); + static const textMuted = Color(0xFF484F58); + + // Glow helpers (use in BoxShadow) + static Color primaryGlow([double opacity = 0.35]) => + primary.withValues(alpha: opacity); + static Color secondaryGlow([double opacity = 0.35]) => + secondary.withValues(alpha: opacity); + static Color accentGlow([double opacity = 0.35]) => + accent.withValues(alpha: opacity); + + // Muscle group palette + static Color muscle(String id) => _muscleColors[id] ?? primary; + + static const Map _muscleColors = { 'chest': Color(0xFFFF6B6B), 'upper_chest': Color(0xFFFF8E8E), 'back': Color(0xFF4ECDC4), @@ -44,193 +63,193 @@ class AppTheme { 'core': Color(0xFFFD79A8), 'traps': Color(0xFFE17055), }; +} - static Color getMuscleColor(String muscleId) { - return muscleColors[muscleId] ?? primaryColor; - } +// ── AppTheme ─────────────────────────────────────────────────────────────── +// Backward-compat aliases + ThemeData builder. +class AppTheme { + const AppTheme._(); + + // Aliases (keep existing callsites compiling during migration) + static const Color primaryColor = AppColors.primary; + static const Color secondaryColor = AppColors.secondary; + static const Color accentColor = AppColors.accent; + static const Color backgroundColor = AppColors.background; + static const Color surfaceColor = AppColors.surface; + static const Color cardColor = AppColors.card; + static const Color textPrimary = AppColors.textPrimary; + static const Color textSecondary = AppColors.textSoft; + static const Color textMuted = AppColors.textMuted; + static const Color success = AppColors.success; + static const Color warning = AppColors.warning; + static const Color error = AppColors.error; + static const Map muscleColors = AppColors._muscleColors; + + static Color getMuscleColor(String id) => AppColors.muscle(id); static ThemeData get darkTheme { return ThemeData( useMaterial3: true, brightness: Brightness.dark, - scaffoldBackgroundColor: backgroundColor, - + scaffoldBackgroundColor: AppColors.background, colorScheme: const ColorScheme.dark( - primary: primaryColor, - secondary: secondaryColor, - surface: surfaceColor, - error: error, + primary: AppColors.primary, + secondary: AppColors.secondary, + surface: AppColors.surface, + error: AppColors.error, onPrimary: Colors.white, onSecondary: Colors.black, - onSurface: textPrimary, + onSurface: AppColors.textPrimary, onError: Colors.white, ), - appBarTheme: const AppBarTheme( - backgroundColor: backgroundColor, - foregroundColor: textPrimary, + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, titleTextStyle: TextStyle( - color: textPrimary, - fontSize: 24, - fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, ), ), - cardTheme: CardThemeData( - color: cardColor, + color: AppColors.card, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), - elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: primaryColor, + backgroundColor: AppColors.primary, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), textStyle: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), ), ), - outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - foregroundColor: primaryColor, - side: const BorderSide(color: primaryColor), + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: primaryColor, - ), + style: TextButton.styleFrom(foregroundColor: AppColors.primary), ), - inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: surfaceColor, + fillColor: AppColors.surface, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + borderSide: const BorderSide(color: AppColors.glassBorder), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: primaryColor, width: 2), + borderSide: const BorderSide(color: AppColors.primary, width: 2), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - hintStyle: const TextStyle(color: textMuted), + hintStyle: const TextStyle(color: AppColors.textMuted), ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: surfaceColor, - selectedItemColor: primaryColor, - unselectedItemColor: textSecondary, + backgroundColor: AppColors.surface, + selectedItemColor: AppColors.primary, + unselectedItemColor: AppColors.textSoft, type: BottomNavigationBarType.fixed, elevation: 0, ), - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: primaryColor, + backgroundColor: AppColors.primary, foregroundColor: Colors.white, - elevation: 4, + elevation: 0, ), - dividerTheme: const DividerThemeData( - color: cardColor, + color: AppColors.divider, thickness: 1, ), - chipTheme: ChipThemeData( - backgroundColor: cardColor, - selectedColor: primaryColor.withOpacity(0.3), - labelStyle: const TextStyle(color: textPrimary), + backgroundColor: AppColors.card, + selectedColor: Color(0x4D6C5CE7), + labelStyle: const TextStyle(color: AppColors.textPrimary), side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), - snackBarTheme: SnackBarThemeData( - backgroundColor: cardColor, - contentTextStyle: const TextStyle(color: textPrimary), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + backgroundColor: AppColors.cardHigh, + contentTextStyle: const TextStyle(color: AppColors.textPrimary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), behavior: SnackBarBehavior.floating, ), - textTheme: const TextTheme( headlineLarge: TextStyle( - color: textPrimary, + color: AppColors.textPrimary, fontSize: 32, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ), headlineMedium: TextStyle( - color: textPrimary, + color: AppColors.textPrimary, fontSize: 24, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, ), headlineSmall: TextStyle( - color: textPrimary, + color: AppColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w600, ), titleLarge: TextStyle( - color: textPrimary, + color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w600, ), titleMedium: TextStyle( - color: textPrimary, + color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w500, ), titleSmall: TextStyle( - color: textSecondary, + color: AppColors.textSoft, fontSize: 14, fontWeight: FontWeight.w500, ), - bodyLarge: TextStyle( - color: textPrimary, - fontSize: 16, - ), - bodyMedium: TextStyle( - color: textSecondary, - fontSize: 14, - ), - bodySmall: TextStyle( - color: textMuted, - fontSize: 12, - ), + bodyLarge: TextStyle(color: AppColors.textPrimary, fontSize: 16), + bodyMedium: TextStyle(color: AppColors.textSoft, fontSize: 14), + bodySmall: TextStyle(color: AppColors.textMuted, fontSize: 12), labelLarge: TextStyle( - color: textPrimary, + color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, + letterSpacing: 0.4, ), ), ); } } -// Common UI Constants +// ── Spacing & Radius ──────────────────────────────────────────────────────── class AppSpacing { + const AppSpacing._(); static const double xs = 4; static const double sm = 8; static const double md = 16; @@ -240,6 +259,7 @@ class AppSpacing { } class AppRadius { + const AppRadius._(); static const double sm = 8; static const double md = 12; static const double lg = 16; From 90426bc1032f54d83739481797e4a0ec50c07fc6 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 10 May 2026 18:13:47 +0530 Subject: [PATCH 02/44] feat: add predictive back page transitions for Android in AppTheme --- workout-logger/lib/theme/app_theme.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index 90da855..9b0ff4d 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -91,6 +91,11 @@ class AppTheme { return ThemeData( useMaterial3: true, brightness: Brightness.dark, + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + }, + ), scaffoldBackgroundColor: AppColors.background, colorScheme: const ColorScheme.dark( primary: AppColors.primary, From 6fe15db85402b03d1c59c2f2c89748c1b693d55c Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 10 May 2026 18:27:20 +0530 Subject: [PATCH 03/44] Refactor Programs Screen and add Week Structure Editor - Updated ProgramsScreen to improve UI elements and replace AppTheme with AppColors. - Enhanced FloatingActionButton styles and added new RFWidgets for better consistency. - Implemented a new ProgramWeekEditorStep widget for editing week structures in training programs. - Introduced ProgramWeekTile for displaying collapsible week cards in ProgramDetailScreen. - Added functionality for managing deload weeks and intensity factors within the week editor. - Improved overall code organization and readability across the modified files. --- .../programs/program_detail_screen.dart | 662 ++++-------------- .../lib/screens/programs/programs_screen.dart | 279 ++++---- .../screens/widgets/program_week_editor.dart | 409 +++++++++++ .../screens/widgets/program_week_tile.dart | 441 ++++++++++++ 4 files changed, 1110 insertions(+), 681 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/program_week_editor.dart create mode 100644 workout-logger/lib/screens/widgets/program_week_tile.dart diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart index aeb71d6..36758c0 100644 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -1,7 +1,4 @@ -// Program Detail Screen -// -// Shows a full training program: phase timeline, week list with deload badges, -// and per-day exercise details (sets, rep range, rest, tempo, weight%, notes). +// program_detail_screen.dart — Full training program view import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,11 +9,11 @@ import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; import '../workout_flow_screen.dart'; import '../widgets/workout_conflict_dialog.dart'; +import '../widgets/program_week_tile.dart'; class ProgramDetailScreen extends StatefulWidget { - final TrainingProgram program; - const ProgramDetailScreen({super.key, required this.program}); + final TrainingProgram program; @override State createState() => _ProgramDetailScreenState(); @@ -24,7 +21,6 @@ class ProgramDetailScreen extends StatefulWidget { class _ProgramDetailScreenState extends State { late TrainingProgram _program; - int? _expandedWeekIndex; @override void initState() { @@ -34,24 +30,42 @@ class _ProgramDetailScreenState extends State { @override Widget build(BuildContext context) { + final provider = context.read(); + return Scaffold( - backgroundColor: AppTheme.backgroundColor, + backgroundColor: AppColors.background, appBar: AppBar( - title: Text(_program.name), + backgroundColor: AppColors.surface, + iconTheme: const IconThemeData(color: AppColors.textSoft), + title: Text( + _program.name, + style: const TextStyle(color: AppColors.textPrimary), + ), actions: [ PopupMenuButton( + color: AppColors.cardHigh, onSelected: _handleMenuAction, itemBuilder: (_) => [ - const PopupMenuItem(value: 'export', child: Text('Export JSON')), + const PopupMenuItem( + value: 'export', + child: Text( + 'Export JSON', + style: TextStyle(color: AppColors.textPrimary), + ), + ), const PopupMenuItem( value: 'delete', - child: Text('Delete', style: TextStyle(color: AppTheme.error)), + child: Text( + 'Delete', + style: TextStyle(color: AppColors.error), + ), ), ], ), ], ), body: CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ SliverToBoxAdapter(child: _buildHeader()), if (_program.phases.isNotEmpty) @@ -64,10 +78,12 @@ class _ProgramDetailScreenState extends State { AppSpacing.md, AppSpacing.sm, ), - child: Text( + child: const Text( 'WEEKS', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textMuted, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, letterSpacing: 1.2, ), ), @@ -75,7 +91,14 @@ class _ProgramDetailScreenState extends State { ), SliverList( delegate: SliverChildBuilderDelegate( - (context, index) => _buildWeekTile(index), + (context, index) => ProgramWeekTile( + key: ValueKey('week_$index'), + week: _program.weeks[index], + weekIndex: index, + program: _program, + provider: provider, + onStartDay: _startProgramDayWorkout, + ), childCount: _program.weeks.length, ), ), @@ -87,8 +110,7 @@ class _ProgramDetailScreenState extends State { Future _startProgramDayWorkout(ProgramDay day, ProgramWeek week) async { final provider = context.read(); - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; final started = await provider.startWorkoutSafely( exerciseIds: day.exercises.map((slot) => slot.exerciseId).toList(), @@ -106,26 +128,23 @@ class _ProgramDetailScreenState extends State { if (!mounted) return; if (started || conflictAction == StartWorkoutConflictAction.resume) { - final resumeProgramDay = provider.hasActiveWorkout - ? provider.activeProgramDay - : day; - final resumeProgramWeek = provider.hasActiveWorkout - ? provider.activeProgramWeek - : week; - + final resumeDay = + provider.hasActiveWorkout ? provider.activeProgramDay : day; + final resumeWeek = + provider.hasActiveWorkout ? provider.activeProgramWeek : week; Navigator.push( context, MaterialPageRoute( builder: (_) => WorkoutFlowScreen( - programDay: resumeProgramDay, - programWeek: resumeProgramWeek, + programDay: resumeDay, + programWeek: resumeWeek, ), ), ); } } - // ── Header ────────────────────────────────────────────────────────────── + // ── Header ─────────────────────────────────────────────────────────────────── Widget _buildHeader() { final deloadCount = _program.weeks.where((w) => w.isDeload).length; @@ -134,12 +153,9 @@ class _ProgramDetailScreenState extends State { margin: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( - color: AppTheme.cardColor, + color: AppColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all( - color: AppTheme.primaryColor.withOpacity(0.3), - width: 1, - ), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.25)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -147,29 +163,27 @@ class _ProgramDetailScreenState extends State { if (_program.description != null) ...[ Text( _program.description!, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + style: const TextStyle(color: AppColors.textSoft, fontSize: 13), ), const SizedBox(height: AppSpacing.md), ], - Row( + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, children: [ _statChip( - icon: Icons.calendar_today, + icon: Icons.calendar_today_rounded, label: '${_program.totalWeeks} weeks', - color: AppTheme.primaryColor, + color: AppColors.primary, ), - const SizedBox(width: AppSpacing.sm), _statChip( - icon: Icons.bolt, + icon: Icons.bolt_rounded, label: '${_program.phases.length} phases', - color: AppTheme.secondaryColor, + color: AppColors.secondary, ), - const SizedBox(width: AppSpacing.sm), if (deloadCount > 0) _statChip( - icon: Icons.battery_charging_full, + icon: Icons.battery_charging_full_rounded, label: '$deloadCount deload${deloadCount > 1 ? 's' : ''}', color: Colors.amber, ), @@ -179,22 +193,18 @@ class _ProgramDetailScreenState extends State { const SizedBox(height: AppSpacing.sm), Text( 'by ${_program.author}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textMuted), + style: const TextStyle(color: AppColors.textMuted, fontSize: 12), ), ], if (_program.isImported) ...[ const SizedBox(height: AppSpacing.xs), - Row( + const Row( children: [ - const Icon(Icons.download, size: 12, color: AppTheme.textMuted), - const SizedBox(width: 4), + Icon(Icons.download_done_rounded, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), Text( 'Imported', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textMuted), + style: TextStyle(color: AppColors.textMuted, fontSize: 12), ), ], ), @@ -212,7 +222,7 @@ class _ProgramDetailScreenState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: color.withOpacity(0.15), + color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppRadius.full), ), child: Row( @@ -233,15 +243,7 @@ class _ProgramDetailScreenState extends State { ); } - // ── Phase Timeline ──────────────────────────────────────────────────── - - static const List _phaseColors = [ - AppTheme.primaryColor, - AppTheme.secondaryColor, - Colors.orange, - Colors.pink, - Colors.green, - ]; + // ── Phase Timeline ─────────────────────────────────────────────────────────── Widget _buildPhaseTimeline() { return Container( @@ -253,21 +255,23 @@ class _ProgramDetailScreenState extends State { ), padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.cardColor, + color: AppColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + const Text( 'PHASES', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textMuted, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, letterSpacing: 1.2, ), ), const SizedBox(height: AppSpacing.sm), - // Visual timeline bar SizedBox( height: 8, child: Row( @@ -275,7 +279,8 @@ class _ProgramDetailScreenState extends State { final phase = entry.value; final fraction = (phase.endWeek - phase.startWeek + 1) / _program.totalWeeks; - final color = _phaseColors[entry.key % _phaseColors.length]; + final color = + kProgramPhaseColors[entry.key % kProgramPhaseColors.length]; return Expanded( flex: ((fraction * 100).round()).clamp(1, 100), child: Container( @@ -295,7 +300,8 @@ class _ProgramDetailScreenState extends State { runSpacing: 4, children: _program.phases.asMap().entries.map((entry) { final phase = entry.value; - final color = _phaseColors[entry.key % _phaseColors.length]; + final color = + kProgramPhaseColors[entry.key % kProgramPhaseColors.length]; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -310,9 +316,9 @@ class _ProgramDetailScreenState extends State { const SizedBox(width: 4), Text( '${phase.name} (W${phase.startWeek}–${phase.endWeek})', - style: TextStyle( + style: const TextStyle( fontSize: 12, - color: AppTheme.textSecondary, + color: AppColors.textSoft, ), ), ], @@ -324,440 +330,7 @@ class _ProgramDetailScreenState extends State { ); } - // ── Week Tile ──────────────────────────────────────────────────────── - - Widget _buildWeekTile(int index) { - final week = _program.weeks[index]; - final isExpanded = _expandedWeekIndex == index; - final phase = _program.phaseForWeek(week.weekNumber); - final phaseIdx = phase == null - ? 0 - : _program.phases.indexWhere((p) => p.id == phase.id); - final phaseColor = phaseIdx >= 0 - ? _phaseColors[phaseIdx % _phaseColors.length] - : AppTheme.primaryColor; - - return Container( - margin: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.sm, - ), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: week.isDeload - ? Border.all(color: Colors.amber.withOpacity(0.5), width: 1) - : null, - ), - child: Column( - children: [ - InkWell( - onTap: () => setState(() { - _expandedWeekIndex = isExpanded ? null : index; - }), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: phaseColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - alignment: Alignment.center, - child: Text( - 'W${week.weekNumber}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: phaseColor, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (week.isDeload) ...[ - const Icon( - Icons.battery_charging_full, - size: 14, - color: Colors.amber, - ), - const SizedBox(width: 4), - const Text( - 'DELOAD ', - style: TextStyle( - fontSize: 11, - color: Colors.amber, - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - ), - ), - ], - if (phase != null) - Text( - phase.name, - style: TextStyle( - fontSize: 12, - color: phaseColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - Text( - '${week.days.length} day${week.days.length != 1 ? 's' : ''}', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - if (week.isDeload) - Padding( - padding: const EdgeInsets.only(right: AppSpacing.sm), - child: Text( - '${((week.deloadIntensityFactor) * 100).round()}%', - style: const TextStyle( - fontSize: 12, - color: Colors.amber, - fontWeight: FontWeight.bold, - ), - ), - ), - Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: AppTheme.textMuted, - size: 20, - ), - ], - ), - ), - ), - if (isExpanded) ...[ - const Divider( - height: 1, - color: AppTheme.surfaceColor, - indent: AppSpacing.md, - endIndent: AppSpacing.md, - ), - ...week.days.map((day) => _buildDaySection(day, week)), - if (week.notes != null) - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md, - ), - child: Row( - children: [ - const Icon( - Icons.info_outline, - size: 14, - color: AppTheme.textMuted, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - week.notes!, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - } - - // ── Day Section ───────────────────────────────────────────────────── - - Widget _buildDaySection(ProgramDay day, ProgramWeek week) { - final provider = context.read(); - - // Group exercises into contiguous runs by supersetGroupId - // to preserve original order (a map would collapse non-contiguous groups). - final runs = >[]; - String? currentRunKey; - List currentRun = []; - for (final slot in day.exercises) { - final key = slot.supersetGroupId; - if (key == null) { - // Flush any open superset run - if (currentRun.isNotEmpty) { - runs.add(currentRun); - currentRun = []; - currentRunKey = null; - } - // Standalone exercise - runs.add([slot]); - } else if (key == currentRunKey) { - currentRun.add(slot); - } else { - // Flush previous run and start new - if (currentRun.isNotEmpty) { - runs.add(currentRun); - } - currentRunKey = key; - currentRun = [slot]; - } - } - if (currentRun.isNotEmpty) { - runs.add(currentRun); - } - - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.md, - AppSpacing.md, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - day.name.toUpperCase(), - style: const TextStyle( - fontSize: 11, - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - ), - ), - ), - if (day.dayOfWeek != null) ...[ - const SizedBox(width: AppSpacing.sm), - Text( - _dayName(day.dayOfWeek!), - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - ), - ), - ], - ], - ), - const SizedBox(height: AppSpacing.sm), - // Render standalone exercises and superset groups - ...runs.map((slots) { - final isSuperset = - slots.length > 1 || slots.first.supersetGroupId != null; - if (isSuperset) { - return _buildSupersetGroup( - slots: slots, - provider: provider, - week: week, - ); - } - return _buildExerciseRow( - slot: slots.first, - provider: provider, - week: week, - ); - }), - const SizedBox(height: AppSpacing.sm), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => _startProgramDayWorkout(day, week), - icon: const Icon(Icons.play_arrow, size: 18), - label: Text('Start ${day.name}'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - padding: const EdgeInsets.symmetric(vertical: 10), - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - ], - ), - ); - } - - Widget _buildSupersetGroup({ - required List slots, - required WorkoutProvider provider, - required ProgramWeek week, - }) { - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: AppTheme.secondaryColor.withOpacity(0.6), - width: 3, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: AppSpacing.sm, bottom: 2), - child: Text( - 'SUPERSET', - style: TextStyle( - fontSize: 10, - color: AppTheme.secondaryColor, - fontWeight: FontWeight.bold, - letterSpacing: 0.6, - ), - ), - ), - ...slots.map( - (slot) => _buildExerciseRow( - slot: slot, - provider: provider, - week: week, - indent: true, - ), - ), - ], - ), - ); - } - - Widget _buildExerciseRow({ - required ProgramExerciseSlot slot, - required WorkoutProvider provider, - required ProgramWeek week, - bool indent = false, - }) { - final exercise = provider.getExercise(slot.exerciseId); - final name = exercise?.name ?? slot.exerciseId; - - // Apply deload adjustments for display - final displaySets = week.isDeload - ? (slot.sets - week.deloadSetReduction).clamp(1, 99) - : slot.sets; - final displayIntensity = week.isDeload ? week.deloadIntensityFactor : 1.0; - - final repRange = slot.minReps == slot.maxReps - ? '${slot.minReps}' - : '${slot.minReps}–${slot.maxReps}'; - - return Padding( - padding: EdgeInsets.only( - left: indent ? AppSpacing.md : 0, - bottom: AppSpacing.sm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Sets × Reps badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - '$displaySets × $repRange', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 2), - Wrap( - spacing: AppSpacing.sm, - runSpacing: 2, - children: [ - _infoChip( - Icons.timer_outlined, - '${slot.restSeconds}s rest', - ), - if (slot.tempo != null) _infoChip(Icons.speed, slot.tempo!), - if (slot.weightPercentage != null) - _infoChip( - Icons.fitness_center, - week.isDeload - ? '${(slot.weightPercentage! * displayIntensity).toStringAsFixed(0)}%' - : '${slot.weightPercentage!.toStringAsFixed(0)}%', - ), - ], - ), - if (slot.notes != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - slot.notes!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _infoChip(IconData icon, String label) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 11, color: AppTheme.textMuted), - const SizedBox(width: 2), - Text( - label, - style: const TextStyle(fontSize: 11, color: AppTheme.textMuted), - ), - ], - ); - } - - // ── Actions ────────────────────────────────────────────────────────── + // ── Actions ────────────────────────────────────────────────────────────────── void _handleMenuAction(String action) { switch (action) { @@ -776,7 +349,14 @@ class _ProgramDetailScreenState extends State { showDialog( context: context, builder: (_) => AlertDialog( - title: const Text('Export Program'), + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Export Program', + style: TextStyle(color: AppColors.textPrimary), + ), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -785,7 +365,7 @@ class _ProgramDetailScreenState extends State { style: const TextStyle( fontFamily: 'monospace', fontSize: 10, - color: AppTheme.textSecondary, + color: AppColors.textSoft, ), ), ), @@ -796,14 +376,30 @@ class _ProgramDetailScreenState extends State { Clipboard.setData(ClipboardData(text: json)); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('JSON copied to clipboard')), + SnackBar( + content: const Text( + 'JSON copied to clipboard', + style: TextStyle(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ); }, - child: const Text('Copy to Clipboard'), + child: const Text( + 'Copy', + style: TextStyle(color: AppColors.primary), + ), ), TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Close'), + child: const Text( + 'Close', + style: TextStyle(color: AppColors.textSoft), + ), ), ], ), @@ -814,44 +410,40 @@ class _ProgramDetailScreenState extends State { showDialog( context: context, builder: (_) => AlertDialog( - title: const Text('Delete Program?'), - content: Text('Delete "${_program.name}"? This cannot be undone.'), + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Delete Program?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Delete "${_program.name}"? This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), ), TextButton( onPressed: () async { final provider = context.read(); await provider.programManager.deleteProgram(_program.id); if (mounted) { - Navigator.pop(context); // close dialog - Navigator.pop(context); // go back to list + Navigator.pop(context); + Navigator.pop(context); } }, - child: const Text( - 'Delete', - style: TextStyle(color: AppTheme.error), - ), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), ), ], ), ); } - - // ── Utils ──────────────────────────────────────────────────────────── - - static const _dayNames = [ - '', - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - 'Sun', - ]; - - String _dayName(int dow) => dow >= 1 && dow <= 7 ? _dayNames[dow] : ''; -} +} \ No newline at end of file diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index 328bab1..e23ae6d 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -1,9 +1,4 @@ -// Programs Screen -// -// Shows the list of training programs and provides entry points for: -// - Viewing program details -// - Creating a new program -// - Importing a program from JSON (full-screen ImportProgramScreen) +// programs_screen.dart — Training programs list import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -11,6 +6,7 @@ import 'package:provider/provider.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; +import '../widgets/rf_widgets.dart'; import 'program_detail_screen.dart'; import 'program_designer_screen.dart'; import 'import_program_screen.dart'; @@ -27,7 +23,7 @@ class ProgramsScreen extends StatelessWidget { context.read().programManager.programs; return Scaffold( - backgroundColor: AppTheme.backgroundColor, + backgroundColor: AppColors.background, body: programs.isEmpty ? _buildEmptyState(context) : _buildList(context, programs), @@ -38,15 +34,24 @@ class ProgramsScreen extends StatelessWidget { FloatingActionButton.small( heroTag: 'import_json', onPressed: () => _openImport(context), - backgroundColor: AppTheme.surfaceColor, - child: const Icon(Icons.download, color: AppTheme.secondaryColor), + backgroundColor: AppColors.card, + elevation: 0, + child: const Icon( + Icons.download_rounded, + color: AppColors.secondary, + ), ), const SizedBox(height: AppSpacing.sm), FloatingActionButton.extended( heroTag: 'new_program', onPressed: () => _openDesigner(context, null), - icon: const Icon(Icons.add), - label: const Text('New Program'), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.add_rounded, color: Colors.white), + label: const Text( + 'New Program', + style: TextStyle(color: Colors.white), + ), ), ], ), @@ -55,42 +60,31 @@ class ProgramsScreen extends StatelessWidget { ); } - // ── Empty State ────────────────────────────────────────────────────── - Widget _buildEmptyState(BuildContext context) { - return Center( + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.calendar_month, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - Text( - 'No Training Programs', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Create a structured multi-week program\nor import one from JSON', - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: AppTheme.textSecondary), + RFEmptyState( + icon: Icons.calendar_month_rounded, + title: 'No Training Programs', + subtitle: 'Create a structured multi-week program\nor import one from JSON', ), - const SizedBox(height: 24), + const SizedBox(height: AppSpacing.lg), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton.icon( + GlowButton( + label: 'Create', + icon: Icons.add_rounded, onPressed: () => _openDesigner(context, null), - icon: const Icon(Icons.add), - label: const Text('Create'), ), const SizedBox(width: AppSpacing.md), - OutlinedButton.icon( + OutlineGlowButton( + label: 'Import JSON', + icon: Icons.download_rounded, onPressed: () => _openImport(context), - icon: const Icon(Icons.download), - label: const Text('Import JSON'), ), ], ), @@ -99,24 +93,20 @@ class ProgramsScreen extends StatelessWidget { ); } - // ── Program List ───────────────────────────────────────────────────── - Widget _buildList(BuildContext context, List programs) { return ListView.builder( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.md, AppSpacing.md, - 100, // FAB clearance + 100, ), itemCount: programs.length, - itemBuilder: (context, index) => - _ProgramCard(program: programs[index]), + itemBuilder: (context, index) => _ProgramCard(program: programs[index]), ); } - // ── Actions ────────────────────────────────────────────────────────── - void _openDesigner(BuildContext context, TrainingProgram? existing) { Navigator.push( context, @@ -133,141 +123,138 @@ class ProgramsScreen extends StatelessWidget { ); if (result == true && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Program imported successfully!')), + SnackBar( + content: const Text( + 'Program imported successfully!', + style: TextStyle(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ); } } } -// ── Program Card ────────────────────────────────────────────────────────── - +// ── Program Card ────────────────────────────────────────────────────────────── class _ProgramCard extends StatelessWidget { + const _ProgramCard({required this.program}); final TrainingProgram program; - const _ProgramCard({required this.program}); + static const _phaseColors = [ + AppColors.primary, + AppColors.secondary, + Colors.orange, + Colors.pink, + Colors.green, + ]; @override Widget build(BuildContext context) { final deloadCount = program.weeks.where((w) => w.isDeload).length; - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProgramDetailScreen(program: program), - ), + return GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProgramDetailScreen(program: program), ), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Icon( - Icons.calendar_month, - color: AppTheme.primaryColor, - size: 20, - ), + ), + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: const Icon( + Icons.calendar_month_rounded, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + program.name, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: AppColors.textPrimary, + ), + ), + if (program.author != null) Text( - program.name, + program.author!, style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: AppTheme.textPrimary, + fontSize: 12, + color: AppColors.textMuted, ), ), - if (program.author != null) - Text( - program.author!, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textMuted, - ), - ), - ], - ), + ], ), - if (program.isImported) - const Padding( - padding: EdgeInsets.only(right: 4), - child: Icon( - Icons.download_done, - size: 16, - color: AppTheme.textMuted, - ), + ), + if (program.isImported) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon( + Icons.download_done_rounded, + size: 16, + color: AppColors.textMuted, ), - const Icon( - Icons.chevron_right, - color: AppTheme.textMuted, ), - ], - ), - const SizedBox(height: AppSpacing.md), - if (program.description != null) ...[ - Text( - program.description!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: AppSpacing.sm), + const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted), ], - // Stats row - Row( - children: [ - _badge( - '${program.totalWeeks}w', - AppTheme.primaryColor, - ), + ), + if (program.description != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + program.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: AppColors.textSoft), + ), + ], + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + _badge('${program.totalWeeks}w', AppColors.primary), + const SizedBox(width: AppSpacing.xs), + _badge('${program.phases.length} phases', AppColors.secondary), + if (deloadCount > 0) ...[ const SizedBox(width: AppSpacing.xs), - _badge( - '${program.phases.length} phases', - AppTheme.secondaryColor, - ), - if (deloadCount > 0) ...[ - const SizedBox(width: AppSpacing.xs), - _badge('$deloadCount deload', Colors.amber), - ], + _badge('$deloadCount deload', Colors.amber), ], - ), - if (program.phases.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.sm), - _buildMiniTimeline(), ], + ), + if (program.phases.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + _buildMiniTimeline(), ], - ), + ], ), ), ); } - static const _phaseColors = [ - AppTheme.primaryColor, - AppTheme.secondaryColor, - Colors.orange, - Colors.pink, - Colors.green, - ]; - Widget _buildMiniTimeline() { return SizedBox( height: 4, @@ -296,7 +283,7 @@ class _ProgramCard extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: color.withOpacity(0.15), + color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(AppRadius.full), ), child: Text( diff --git a/workout-logger/lib/screens/widgets/program_week_editor.dart b/workout-logger/lib/screens/widgets/program_week_editor.dart new file mode 100644 index 0000000..ff67ed8 --- /dev/null +++ b/workout-logger/lib/screens/widgets/program_week_editor.dart @@ -0,0 +1,409 @@ +// program_week_editor.dart — Step 2 week structure editor + shared stepper widget + +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; + +// ── Shared primitive: number stepper ───────────────────────────────────────── +class ProgramNumberStepper extends StatelessWidget { + const ProgramNumberStepper({ + super.key, + required this.label, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.step = 1, + }); + + final String label; + final int value; + final int min; + final int max; + final int step; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 13, color: AppColors.textSoft), + ), + ), + IconButton( + icon: const Icon(Icons.remove_rounded, size: 18), + color: AppColors.textSoft, + onPressed: value > min + ? () => onChanged((value - step).clamp(min, max)) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + SizedBox( + width: 40, + child: Text( + '$value', + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_rounded, size: 18), + color: AppColors.textSoft, + onPressed: value < max + ? () => onChanged((value + step).clamp(min, max)) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ); + } +} + +// ── Shared primitive: section header ───────────────────────────────────────── +class ProgramSectionHeader extends StatelessWidget { + const ProgramSectionHeader(this.text, {super.key}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + text.toUpperCase(), + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ); + } +} + +// ── Step 2: Week structure editor ───────────────────────────────────────────── +class ProgramWeekEditorStep extends StatefulWidget { + const ProgramWeekEditorStep({ + super.key, + required this.weeks, + required this.onWeeksChanged, + }); + + final List weeks; + final void Function(List) onWeeksChanged; + + @override + State createState() => _ProgramWeekEditorStepState(); +} + +class _ProgramWeekEditorStepState extends State { + final _uuid = const Uuid(); + late List _weeks; + + @override + void initState() { + super.initState(); + _weeks = List.from(widget.weeks); + } + + @override + void didUpdateWidget(ProgramWeekEditorStep old) { + super.didUpdateWidget(old); + if (widget.weeks != old.weeks) { + _weeks = List.from(widget.weeks); + } + } + + void _update(List weeks) { + setState(() => _weeks = weeks); + widget.onWeeksChanged(weeks); + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: _weeks.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const ProgramSectionHeader('Weeks & Days'); + return _buildWeekEditor(index - 1, _weeks[index - 1]); + }, + ); + } + + Widget _buildWeekEditor(int idx, ProgramWeek week) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.35) + : AppColors.glassBorder, + ), + ), + child: ExpansionTile( + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.15) + : AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Text( + 'W${week.weekNumber}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: week.isDeload ? Colors.amber : AppColors.primary, + ), + ), + ), + title: Text( + week.isDeload + ? 'Week ${week.weekNumber} — Deload' + : 'Week ${week.weekNumber}', + style: TextStyle( + fontWeight: FontWeight.w600, + color: week.isDeload ? Colors.amber : AppColors.textPrimary, + fontSize: 14, + ), + ), + subtitle: Text( + '${week.days.length} day${week.days.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 12, color: AppColors.textSoft), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile.adaptive( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text( + 'Deload Week', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13), + ), + value: week.isDeload, + activeColor: Colors.amber, + onChanged: (v) { + final updated = List.from(_weeks); + updated[idx] = week.copyWith(isDeload: v); + _update(updated); + }, + ), + if (week.isDeload) ...[ + ProgramNumberStepper( + label: 'Intensity factor (%)', + value: (week.deloadIntensityFactor * 100).round(), + min: 50, + max: 95, + onChanged: (v) { + final updated = List.from(_weeks); + updated[idx] = week.copyWith( + deloadIntensityFactor: v / 100.0, + ); + _update(updated); + }, + ), + ProgramNumberStepper( + label: 'Sets reduced by', + value: week.deloadSetReduction, + min: 0, + max: 3, + onChanged: (v) { + final updated = List.from(_weeks); + updated[idx] = week.copyWith(deloadSetReduction: v); + _update(updated); + }, + ), + ], + Divider(color: AppColors.glassBorder), + ...week.days.asMap().entries.map( + (e) => _buildDayChip(idx, e.key, e.value), + ), + OutlinedButton.icon( + onPressed: () => _addDay(idx), + icon: const Icon(Icons.add_rounded, size: 16), + label: const Text('Add Day'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDayChip(int weekIdx, int dayIdx, ProgramDay day) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.drag_handle_rounded, color: AppColors.textMuted), + title: Text( + day.name, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + ), + subtitle: Text( + '${day.exercises.length} exercise${day.exercises.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 11, color: AppColors.textSoft), + ), + trailing: IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: AppColors.error, + size: 18, + ), + onPressed: () { + final days = List.from(_weeks[weekIdx].days) + ..removeAt(dayIdx); + final updated = List.from(_weeks); + updated[weekIdx] = updated[weekIdx].copyWith(days: days); + _update(updated); + }, + ), + ); + } + + void _addDay(int weekIdx) { + showDialog( + context: context, + builder: (_) { + final nameCtrl = TextEditingController(); + int? dow; + return StatefulBuilder( + builder: (ctx, setDlg) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Add Day', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _styledField(nameCtrl, 'Day Name', hint: 'e.g. Push, Pull, Legs'), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Day of Week (optional)', + labelStyle: TextStyle(color: AppColors.textSoft), + ), + dropdownColor: AppColors.cardHigh, + style: const TextStyle(color: AppColors.textPrimary), + initialValue: dow, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Unscheduled'), + ), + ...List.generate(7, (i) => i + 1).map( + (d) => DropdownMenuItem( + value: d, + child: Text( + ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d - 1], + ), + ), + ), + ], + onChanged: (v) => setDlg(() => dow = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), + ), + TextButton( + onPressed: () { + final newDay = ProgramDay( + id: _uuid.v4(), + name: nameCtrl.text.isEmpty ? 'Day' : nameCtrl.text, + dayOfWeek: dow, + exercises: [], + ); + final days = List.from(_weeks[weekIdx].days) + ..add(newDay); + final updated = List.from(_weeks); + updated[weekIdx] = updated[weekIdx].copyWith(days: days); + _update(updated); + Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.primary), + child: const Text('Add'), + ), + ], + ), + ); + }, + ); + } + + Widget _styledField( + TextEditingController ctrl, + String label, { + String? hint, + int maxLines = 1, + }) { + return TextField( + controller: ctrl, + maxLines: maxLines, + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + labelText: label, + hintText: hint, + labelStyle: const TextStyle(color: AppColors.textSoft), + hintStyle: const TextStyle(color: AppColors.textMuted), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.primary), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/program_week_tile.dart b/workout-logger/lib/screens/widgets/program_week_tile.dart new file mode 100644 index 0000000..462742b --- /dev/null +++ b/workout-logger/lib/screens/widgets/program_week_tile.dart @@ -0,0 +1,441 @@ +// program_week_tile.dart — Collapsible week card for ProgramDetailScreen + +import 'package:flutter/material.dart'; +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; + +// Phase colors shared across programs UI +const List kProgramPhaseColors = [ + AppColors.primary, + AppColors.secondary, + Colors.orange, + Colors.pink, + Colors.green, +]; + +class ProgramWeekTile extends StatefulWidget { + const ProgramWeekTile({ + super.key, + required this.week, + required this.weekIndex, + required this.program, + required this.provider, + required this.onStartDay, + }); + + final ProgramWeek week; + final int weekIndex; + final TrainingProgram program; + final WorkoutProvider provider; + final void Function(ProgramDay, ProgramWeek) onStartDay; + + @override + State createState() => _ProgramWeekTileState(); +} + +class _ProgramWeekTileState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final week = widget.week; + final phase = widget.program.phaseForWeek(week.weekNumber); + final phaseIdx = phase == null + ? 0 + : widget.program.phases.indexWhere((p) => p.id == phase.id); + final phaseColor = phaseIdx >= 0 + ? kProgramPhaseColors[phaseIdx % kProgramPhaseColors.length] + : AppColors.primary; + + return Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), + ), + child: Column( + children: [ + _buildHeader(week, phase, phaseColor), + if (_expanded) ...[ + Divider(color: AppColors.glassBorder, height: 1), + ...week.days.map((day) => _buildDaySection(day, week)), + if (week.notes != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( + children: [ + const Icon( + Icons.info_outline_rounded, + size: 14, + color: AppColors.textMuted, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + week.notes!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSoft, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildHeader( + ProgramWeek week, + TrainingPhase? phase, + Color phaseColor, + ) { + return InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: phaseColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Text( + 'W${week.weekNumber}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: phaseColor, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (week.isDeload) ...[ + const Icon( + Icons.battery_charging_full_rounded, + size: 14, + color: Colors.amber, + ), + const SizedBox(width: 4), + const Text( + 'DELOAD ', + style: TextStyle( + fontSize: 11, + color: Colors.amber, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ], + if (phase != null) + Text( + phase.name, + style: TextStyle( + fontSize: 12, + color: phaseColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '${week.days.length} day${week.days.length != 1 ? 's' : ''}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSoft, + ), + ), + ], + ), + ), + if (week.isDeload) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Text( + '${((week.deloadIntensityFactor) * 100).round()}%', + style: const TextStyle( + fontSize: 12, + color: Colors.amber, + fontWeight: FontWeight.w700, + ), + ), + ), + Icon( + _expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, + color: AppColors.textMuted, + size: 20, + ), + ], + ), + ), + ); + } + + Widget _buildDaySection(ProgramDay day, ProgramWeek week) { + final runs = >[]; + String? currentRunKey; + List currentRun = []; + for (final slot in day.exercises) { + final key = slot.supersetGroupId; + if (key == null) { + if (currentRun.isNotEmpty) { + runs.add(currentRun); + currentRun = []; + currentRunKey = null; + } + runs.add([slot]); + } else if (key == currentRunKey) { + currentRun.add(slot); + } else { + if (currentRun.isNotEmpty) runs.add(currentRun); + currentRunKey = key; + currentRun = [slot]; + } + } + if (currentRun.isNotEmpty) runs.add(currentRun); + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + day.name.toUpperCase(), + style: const TextStyle( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ), + if (day.dayOfWeek != null) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + _dayName(day.dayOfWeek!), + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + ...runs.map((slots) { + final isSuperset = + slots.length > 1 || slots.first.supersetGroupId != null; + if (isSuperset) { + return _buildSupersetGroup(slots: slots, week: week); + } + return _buildExerciseRow(slot: slots.first, week: week); + }), + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => widget.onStartDay(day, week), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text('Start ${day.name}'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ); + } + + Widget _buildSupersetGroup({ + required List slots, + required ProgramWeek week, + }) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: AppColors.secondary.withValues(alpha: 0.5), + width: 3, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: AppSpacing.sm, bottom: 2), + child: Text( + 'SUPERSET', + style: TextStyle( + fontSize: 10, + color: AppColors.secondary, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + ), + ...slots.map( + (slot) => _buildExerciseRow(slot: slot, week: week, indent: true), + ), + ], + ), + ); + } + + Widget _buildExerciseRow({ + required ProgramExerciseSlot slot, + required ProgramWeek week, + bool indent = false, + }) { + final exercise = widget.provider.getExercise(slot.exerciseId); + final name = exercise?.name ?? slot.exerciseId; + final displaySets = + week.isDeload ? (slot.sets - week.deloadSetReduction).clamp(1, 99) : slot.sets; + final displayIntensity = week.isDeload ? week.deloadIntensityFactor : 1.0; + final repRange = slot.minReps == slot.maxReps + ? '${slot.minReps}' + : '${slot.minReps}–${slot.maxReps}'; + + return Padding( + padding: EdgeInsets.only( + left: indent ? AppSpacing.md : 0, + bottom: AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + '$displaySets × $repRange', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Wrap( + spacing: AppSpacing.sm, + runSpacing: 2, + children: [ + _infoChip( + Icons.timer_outlined, + '${slot.restSeconds}s rest', + ), + if (slot.tempo != null) _infoChip(Icons.speed, slot.tempo!), + if (slot.weightPercentage != null) + _infoChip( + Icons.fitness_center_rounded, + week.isDeload + ? '${(slot.weightPercentage! * displayIntensity).toStringAsFixed(0)}%' + : '${slot.weightPercentage!.toStringAsFixed(0)}%', + ), + ], + ), + if (slot.notes != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + slot.notes!, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _infoChip(IconData icon, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: AppColors.textMuted), + const SizedBox(width: 2), + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)), + ], + ); + } + + static const _dayNames = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + static String _dayName(int dow) => dow >= 1 && dow <= 7 ? _dayNames[dow] : ''; +} From 36ea4692a04dc040e692f2f103a3a6be4679d41a Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 11 May 2026 00:15:46 +0530 Subject: [PATCH 04/44] feat: Enhance UI with new glassmorphic components and charts - Introduced `GlassCard` with gradient background and accent border option. - Added `AmbientGlow` for decorative ambient effects. - Implemented `RFNavBar` for a custom bottom navigation bar with glassmorphism. - Created `Sparkline` and `VolumeChart` widgets for data visualization. - Developed `WheelPickerField` for weight and reps input with haptic feedback. - Updated `WorkoutHeader` with gradient background and improved text styles. - Refined `AppTheme` with new color definitions and text styles using Google Fonts. - Added Google Fonts dependency for enhanced typography. --- .../lib/screens/analytics_screen.dart | 288 ++-- .../lib/screens/history_screen.dart | 606 ++++++-- workout-logger/lib/screens/home_screen.dart | 1285 ++++++++++------- .../lib/screens/routines_screen.dart | 623 ++++++-- .../lib/screens/widgets/activity_heatmap.dart | 101 ++ .../lib/screens/widgets/body_heatmap.dart | 150 ++ .../lib/screens/widgets/calendar_grid.dart | 245 ++++ .../lib/screens/widgets/rf_widgets.dart | 242 +++- .../screens/widgets/sparkline_painter.dart | 91 ++ .../lib/screens/widgets/volume_chart.dart | 148 ++ .../lib/screens/widgets/wheel_picker.dart | 283 ++++ .../lib/screens/widgets/workout_header.dart | 52 +- workout-logger/lib/theme/app_theme.dart | 226 +-- workout-logger/pubspec.yaml | 3 + 14 files changed, 3275 insertions(+), 1068 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/activity_heatmap.dart create mode 100644 workout-logger/lib/screens/widgets/body_heatmap.dart create mode 100644 workout-logger/lib/screens/widgets/calendar_grid.dart create mode 100644 workout-logger/lib/screens/widgets/sparkline_painter.dart create mode 100644 workout-logger/lib/screens/widgets/volume_chart.dart create mode 100644 workout-logger/lib/screens/widgets/wheel_picker.dart diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 59218f4..5bc5e6e 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -1,13 +1,14 @@ -// analytics_screen.dart — Analytics screen with Overview, Exercises, Targets tabs +// analytics_screen.dart — Analytics: Overview / Exercises / Targets import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../services/workout_provider.dart'; -import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; +import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/exercise_progress_view.dart'; import 'widgets/targets_tab.dart'; @@ -19,130 +20,160 @@ class AnalyticsScreen extends StatefulWidget { State createState() => _AnalyticsScreenState(); } -class _AnalyticsScreenState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; +class _AnalyticsScreenState extends State { + int _tab = 0; - @override - void initState() { - super.initState(); - _tabController = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } + static const _tabs = ['Overview', 'Exercises', 'Targets']; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - body: SafeArea( - child: Column( - children: [ - _AnalyticsHeader(tabController: _tabController), - Expanded( - child: TabBarView( - controller: _tabController, - children: const [ - _OverviewTab(), - ExerciseProgressView(), - TargetsTab(), - ], - ), + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Column( + children: [ + _buildHeader(), + _buildPillTabBar(), + Expanded(child: _buildTabView()), + ], ), - ], - ), + ), + ], ), ); } -} - -// ── Header with title + tab bar ─────────────────────────────────────────────── -class _AnalyticsHeader extends StatelessWidget { - const _AnalyticsHeader({required this.tabController}); - final TabController tabController; - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.surface, - border: Border(bottom: BorderSide(color: AppColors.glassBorder)), - ), - child: Column( + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Padding( - padding: EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.lg, - AppSpacing.md, - AppSpacing.sm, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Analytics', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 28, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'INSIGHTS', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, + ), ), - ), - ), - ), - TabBar( - controller: tabController, - indicatorColor: AppColors.primary, - indicatorWeight: 2, - labelColor: AppColors.primary, - unselectedLabelColor: AppColors.textMuted, - labelStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, + const SizedBox(height: 2), + Text( + 'Analytics', + style: GoogleFonts.geist( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.6, + ), + ), + ], ), - tabs: const [ - Tab(text: 'Overview'), - Tab(text: 'Exercises'), - Tab(text: 'Targets'), - ], ), ], ), ); } + + Widget _buildPillTabBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: List.generate(_tabs.length, (i) { + final active = i == _tab; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _tab = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: active ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(11), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 12, + ), + ] + : null, + ), + child: Text( + _tabs[i], + textAlign: TextAlign.center, + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: active ? Colors.white : AppColors.textMuted, + ), + ), + ), + ), + ); + }), + ), + ), + ); + } + + Widget _buildTabView() { + switch (_tab) { + case 0: + return const _OverviewTab(); + case 1: + return const ExerciseProgressView(); + case 2: + return const TargetsTab(); + default: + return const SizedBox.shrink(); + } + } } // ── Overview Tab ────────────────────────────────────────────────────────────── + class _OverviewTab extends StatelessWidget { const _OverviewTab(); @override Widget build(BuildContext context) { final provider = context.watch(); - return SingleChildScrollView( physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _VolumeChart(provider: provider), - const SizedBox(height: AppSpacing.md), + const SizedBox(height: 12), _MuscleVolumeChart(provider: provider), - const SizedBox(height: AppSpacing.md), + const SizedBox(height: 12), _FrequencyGrid(provider: provider), - const SizedBox(height: AppSpacing.xxl), ], ), ); } } -// ── Volume progression line chart ───────────────────────────────────────────── +// ── Volume progression chart ─────────────────────────────────────────────────── + class _VolumeChart extends StatelessWidget { const _VolumeChart({required this.provider}); final WorkoutProvider provider; @@ -183,7 +214,7 @@ class _VolumeChart extends StatelessWidget { padding: const EdgeInsets.only(top: 6), child: Text( DateFormat('d/M').format(sessions[i].date), - style: const TextStyle(color: AppColors.textMuted, fontSize: 9), + style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9), ), ); }, @@ -195,7 +226,7 @@ class _VolumeChart extends StatelessWidget { reservedSize: 36, getTitlesWidget: (v, _) => Text( '${v.toStringAsFixed(0)}t', - style: const TextStyle(color: AppColors.textMuted, fontSize: 9), + style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9), ), ), ), @@ -240,7 +271,8 @@ class _VolumeChart extends StatelessWidget { } } -// ── Muscle volume horizontal bars ───────────────────────────────────────────── +// ── Muscle volume bars ──────────────────────────────────────────────────────── + class _MuscleVolumeChart extends StatelessWidget { const _MuscleVolumeChart({required this.provider}); final WorkoutProvider provider; @@ -274,32 +306,19 @@ class _MuscleVolumeChart extends StatelessWidget { : entry.value.toStringAsFixed(0); return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.only(bottom: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - name, - style: const TextStyle( - color: AppColors.textSoft, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - Text( - '$volStr kg', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - ), - ), + Text(name, style: GoogleFonts.geist(color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w500)), + Text('$volStr kg', style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 11)), ], ), const SizedBox(height: 4), - RFProgressBar(value: pct, color: color, height: 6, showGlow: false), + RFProgressBar(value: pct, color: color, height: 6, showGlow: true), ], ), ); @@ -310,6 +329,7 @@ class _MuscleVolumeChart extends StatelessWidget { } // ── Weekly frequency grid ────────────────────────────────────────────────────── + class _FrequencyGrid extends StatelessWidget { const _FrequencyGrid({required this.provider}); final WorkoutProvider provider; @@ -340,30 +360,35 @@ class _FrequencyGrid extends StatelessWidget { decoration: BoxDecoration( color: active ? AppColors.primary.withValues(alpha: 0.12 + count * 0.06) - : AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.md), + : AppColors.glass2, + borderRadius: BorderRadius.circular(14), border: Border.all( color: active ? AppColors.primary.withValues(alpha: 0.4) : AppColors.glassBorder, ), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.2), + blurRadius: 12, + ) + ] + : null, ), child: Center( child: Text( '$count', - style: TextStyle( + style: GoogleFonts.geistMono( color: active ? AppColors.primary : AppColors.textMuted, fontSize: 22, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, ), ), ), ), const SizedBox(height: 6), - Text( - label, - style: const TextStyle(color: AppColors.textMuted, fontSize: 10), - ), + Text(label, style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 10)), ], ); }).toList(), @@ -372,7 +397,8 @@ class _FrequencyGrid extends StatelessWidget { } } -// ── Reusable chart card wrapper ─────────────────────────────────────────────── +// ── Reusable chart card ──────────────────────────────────────────────────────── + class _ChartCard extends StatelessWidget { const _ChartCard({ required this.title, @@ -388,43 +414,37 @@ class _ChartCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.glassBorder), - ), + return GlassCard( + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w700, + fontSize: 14, + fontWeight: FontWeight.w600, ), ), if (subtitle != null) ...[ const SizedBox(height: 2), - Text( - subtitle!, - style: const TextStyle(color: AppColors.textMuted, fontSize: 11), - ), + Text(subtitle!, style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11)), ], if (isEmpty) ...[ - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: 24), Center( - child: RFEmptyState( - icon: Icons.show_chart_rounded, - title: 'No data yet', - subtitle: 'Complete workouts to see progress', + child: Column( + children: [ + const Icon(Icons.show_chart_rounded, size: 32, color: AppColors.textFaint), + const SizedBox(height: 8), + Text('No data yet', style: GoogleFonts.geist(fontSize: 13, color: AppColors.textMuted)), + Text('Complete workouts to see progress', style: GoogleFonts.geist(fontSize: 11, color: AppColors.textFaint)), + ], ), ), ] else ...[ - const SizedBox(height: AppSpacing.md), + const SizedBox(height: 14), child, ], ], diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index c07ebd6..8e56e2e 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -1,8 +1,9 @@ -// history_screen.dart — Workout history with month grouping and search +// history_screen.dart — Workout history with calendar + session list import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; @@ -11,8 +12,8 @@ import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'edit_workout_session_screen.dart'; import 'widgets/rf_widgets.dart'; -import 'widgets/rf_cards.dart'; import 'widgets/session_details_sheet.dart'; +import 'widgets/calendar_grid.dart'; const Color _hcColor = Color(0xFF4ECDC4); @@ -26,6 +27,11 @@ class HistoryScreen extends StatefulWidget { class _HistoryScreenState extends State { final _searchController = TextEditingController(); String _query = ''; + bool _showSearch = false; + + // Calendar state + DateTime _calendarMonth = DateTime(DateTime.now().year, DateTime.now().month); + int? _selectedDay; @override void dispose() { @@ -55,6 +61,18 @@ class _HistoryScreenState extends State { return map; } + Map _buildCalendarData(List sessions) { + final map = {}; + final monthSessions = sessions.where((s) => + s.date.year == _calendarMonth.year && s.date.month == _calendarMonth.month); + for (final s in monthSessions) { + final vol = s.totalVolume; + final intensity = vol > 15000 ? 3 : vol > 5000 ? 2 : 1; + map[s.date.day] = CalendarDayData(intensity: intensity); + } + return map; + } + @override Widget build(BuildContext context) { final historyManager = context.watch(); @@ -65,121 +83,139 @@ class _HistoryScreenState extends State { final grouped = _group(filtered); final months = grouped.keys.toList(); - final hasUnsynced = settings.healthConnectEnabled && - all.any((s) => s.hcSyncedAt == null); + final totalVolume = all.fold(0, (s, e) => s + e.totalVolume); + final hasUnsynced = settings.healthConnectEnabled && all.any((s) => s.hcSyncedAt == null); return Scaffold( backgroundColor: AppColors.background, - body: SafeArea( - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: _Header( - hasUnsynced: hasUnsynced, - onSyncAll: () { - historyManager.syncAllUnsynced(); - ScaffoldMessenger.of(context).showSnackBar( - _snackBar('Syncing all unsynced workouts…'), - ); - }, - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.sm, - ), - child: _SearchBar( - controller: _searchController, - onChanged: (v) => setState(() => _query = v), + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + // Header + SliverToBoxAdapter( + child: _buildHeader(context, hasUnsynced, historyManager), ), - ), - ), - if (filtered.isEmpty) - SliverFillRemaining( - child: _query.isNotEmpty - ? RFEmptyState( - icon: Icons.search_off_rounded, - title: 'No results', - subtitle: 'Try a different search term', - ) - : RFEmptyState( - icon: Icons.history_rounded, - title: 'No Workout History', - subtitle: 'Complete a workout to see it here', + + // Search bar (animated) + if (_showSearch) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _SearchBar( + controller: _searchController, + onChanged: (v) => setState(() => _query = v), ), - ) - else - SliverPadding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.xxl, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) { - final month = months[i]; - final sessions = grouped[month]!; - return _MonthGroup( - month: month, - sessions: sessions, - provider: provider, - historyManager: historyManager, - settings: settings, - ); - }, - childCount: months.length, + ), ), + + // Lifetime summary + SliverToBoxAdapter( + child: _buildSummaryCard(all, totalVolume), ), - ), - ], - ), + + // Calendar card + if (_query.isEmpty) + SliverToBoxAdapter( + child: _buildCalendarCard(all), + ), + + // Session list + if (filtered.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history_rounded, size: 48, color: AppColors.textFaint), + const SizedBox(height: 12), + Text( + _query.isNotEmpty ? 'No results' : 'No Workout History', + style: GoogleFonts.geist(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textMuted), + ), + const SizedBox(height: 4), + Text( + _query.isNotEmpty ? 'Try a different search term' : 'Complete a workout to see it here', + style: GoogleFonts.geist(fontSize: 13, color: AppColors.textFaint), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final month = months[i]; + final sessions = grouped[month]!; + return _MonthGroup( + month: month, + sessions: sessions, + provider: provider, + historyManager: historyManager, + settings: settings, + ); + }, + childCount: months.length, + ), + ), + ), + ], + ), + ), + ], ), ); } -} - -// ── Header ───────────────────────────────────────────────────────────────────── -class _Header extends StatelessWidget { - const _Header({required this.hasUnsynced, required this.onSyncAll}); - final bool hasUnsynced; - final VoidCallback onSyncAll; - @override - Widget build(BuildContext context) { + Widget _buildHeader(BuildContext context, bool hasUnsynced, HistoryManager historyManager) { return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.lg, - AppSpacing.md, - AppSpacing.md, - ), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Text( - 'History', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 28, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'LOG', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 2), + Text( + 'History', + style: GoogleFonts.geist( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.6, + ), + ), + ], ), ), - const Spacer(), if (hasUnsynced) GestureDetector( - onTap: onSyncAll, + onTap: () { + historyManager.syncAllUnsynced(); + ScaffoldMessenger.of(context).showSnackBar( + _snackBar('Syncing all unsynced workouts…'), + ); + }, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( color: _hcColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppRadius.full), @@ -190,25 +226,199 @@ class _Header extends StatelessWidget { children: [ Icon(Icons.favorite_rounded, size: 13, color: _hcColor), SizedBox(width: 5), - Text( - 'Sync All', - style: TextStyle( - color: _hcColor, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), + Text('Sync', style: TextStyle(color: _hcColor, fontSize: 12, fontWeight: FontWeight.w600)), ], ), ), ), + GestureDetector( + onTap: () => setState(() { + _showSearch = !_showSearch; + if (!_showSearch) { + _query = ''; + _searchController.clear(); + } + }), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _showSearch ? AppColors.primary.withValues(alpha: 0.15) : AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: _showSearch ? AppColors.primary.withValues(alpha: 0.4) : AppColors.glassBorder, + ), + ), + child: Icon( + _showSearch ? Icons.close_rounded : Icons.search_rounded, + size: 16, + color: _showSearch ? AppColors.primary : AppColors.textMuted, + ), + ), + ), ], ), ); } + + Widget _buildSummaryCard(List all, double totalVolume) { + final volStr = totalVolume >= 1000000 + ? '${(totalVolume / 1000000).toStringAsFixed(1)}M' + : totalVolume >= 1000 + ? '${(totalVolume / 1000).toStringAsFixed(0)}k' + : totalVolume.toStringAsFixed(0); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GlassCard( + padding: const EdgeInsets.symmetric(vertical: 16), + child: IntrinsicHeight( + child: Row( + children: [ + _SummaryCell(label: 'WORKOUTS', value: '${all.length}', unit: 'total'), + _VertDivider(), + _SummaryCell(label: 'VOLUME', value: volStr, unit: 'kg'), + _VertDivider(), + _SummaryCell(label: 'THIS MONTH', value: '${all.where((s) => s.date.month == DateTime.now().month && s.date.year == DateTime.now().year).length}', unit: 'sessions'), + ], + ), + ), + ), + ); + } + + Widget _buildCalendarCard(List sessions) { + final monthLabel = DateFormat('MMMM yyyy').format(_calendarMonth); + final monthSessions = sessions.where((s) => + s.date.year == _calendarMonth.year && s.date.month == _calendarMonth.month).length; + final calData = _buildCalendarData(sessions); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + GestureDetector( + onTap: () => setState(() { + _calendarMonth = DateTime(_calendarMonth.year, _calendarMonth.month - 1); + _selectedDay = null; + }), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.chevron_left_rounded, size: 18, color: AppColors.textMuted), + ), + ), + Expanded( + child: Column( + children: [ + Text( + monthLabel, + style: GoogleFonts.geist( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + Text( + '$monthSessions session${monthSessions == 1 ? '' : 's'}', + style: GoogleFonts.geist(fontSize: 11, color: AppColors.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ), + GestureDetector( + onTap: () => setState(() { + final next = DateTime(_calendarMonth.year, _calendarMonth.month + 1); + if (next.isBefore(DateTime.now()) || next.month == DateTime.now().month) { + _calendarMonth = next; + _selectedDay = null; + } + }), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textMuted), + ), + ), + ], + ), + const SizedBox(height: 16), + CalendarMonthGrid( + year: _calendarMonth.year, + month: _calendarMonth.month, + workoutDays: calData, + selectedDay: _selectedDay, + onDayTap: (day) => setState(() => _selectedDay = _selectedDay == day ? null : day), + ), + ], + ), + ), + ); + } +} + +// ── Summary cell ─────────────────────────────────────────────────────────────── + +class _SummaryCell extends StatelessWidget { + const _SummaryCell({required this.label, required this.value, required this.unit}); + final String label; + final String value; + final String unit; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + Text( + label, + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: GoogleFonts.geistMono( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + unit, + style: GoogleFonts.geist(fontSize: 10, color: AppColors.textMuted), + ), + ], + ), + ); + } +} + +class _VertDivider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container(width: 1, color: AppColors.glassBorder); + } } // ── Search bar ───────────────────────────────────────────────────────────────── + class _SearchBar extends StatelessWidget { const _SearchBar({required this.controller, required this.onChanged}); final TextEditingController controller; @@ -219,20 +429,21 @@ class _SearchBar extends StatelessWidget { return Container( height: 44, decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.full), + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.glassBorder), ), child: TextField( controller: controller, onChanged: onChanged, - style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), - decoration: const InputDecoration( + autofocus: true, + style: GoogleFonts.geist(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( hintText: 'Search by date or exercise…', - hintStyle: TextStyle(color: AppColors.textMuted, fontSize: 14), - prefixIcon: Icon(Icons.search_rounded, color: AppColors.textMuted, size: 18), + hintStyle: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 14), + prefixIcon: const Icon(Icons.search_rounded, color: AppColors.textMuted, size: 18), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: 12), + contentPadding: const EdgeInsets.symmetric(vertical: 12), ), ), ); @@ -240,6 +451,7 @@ class _SearchBar extends StatelessWidget { } // ── Month group ──────────────────────────────────────────────────────────────── + class _MonthGroup extends StatelessWidget { const _MonthGroup({ required this.month, @@ -260,8 +472,26 @@ class _MonthGroup extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - child: RFSectionHeader(month), + padding: const EdgeInsets.only(top: 20, bottom: 10), + child: Row( + children: [ + Text( + month, + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSoft, + ), + ), + const SizedBox(width: 10), + Expanded(child: Container(height: 1, color: AppColors.glassBorder)), + const SizedBox(width: 10), + Text( + '${sessions.length}', + style: GoogleFonts.geistMono(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), ), ...sessions.map( (s) => _HistoryCard( @@ -276,7 +506,8 @@ class _MonthGroup extends StatelessWidget { } } -// ── Per-session card with menu ────────────────────────────────────────────────── +// ── Per-session card ──────────────────────────────────────────────────────────── + class _HistoryCard extends StatelessWidget { const _HistoryCard({ required this.session, @@ -310,9 +541,7 @@ class _HistoryCard extends StatelessWidget { onEdit: () { Navigator.of(ctx).pop(); Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditWorkoutSessionScreen(session: session), - ), + MaterialPageRoute(builder: (_) => EditWorkoutSessionScreen(session: session)), ); }, onDelete: () => _confirmDelete(ctx), @@ -326,16 +555,10 @@ class _HistoryCard extends StatelessWidget { context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.cardHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - title: const Text( - 'Delete Workout?', - style: TextStyle(color: AppColors.textPrimary), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), + title: const Text('Delete Workout?', style: TextStyle(color: AppColors.textPrimary)), content: Text( - 'Delete workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? ' - 'This cannot be undone.', + 'Delete workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? This cannot be undone.', style: const TextStyle(color: AppColors.textSoft), ), actions: [ @@ -358,11 +581,10 @@ class _HistoryCard extends StatelessWidget { try { await provider.deleteWorkoutSession(session.id); if (context.mounted) { - nav.pop(); // close sheet if open + nav.pop(); messenger.showSnackBar(_snackBar('Workout deleted')); } } catch (e) { - debugPrint('Delete failed: $e'); if (context.mounted) { messenger.showSnackBar(_snackBar('Failed to delete workout', isError: true)); } @@ -373,15 +595,11 @@ class _HistoryCard extends StatelessWidget { void _handleMenu(BuildContext context, String value) { if (value == 'edit') { Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditWorkoutSessionScreen(session: session), - ), + MaterialPageRoute(builder: (_) => EditWorkoutSessionScreen(session: session)), ); } else if (value == 'sync') { historyManager.syncSession(session); - ScaffoldMessenger.of(context).showSnackBar( - _snackBar('Syncing to Health Connect…'), - ); + ScaffoldMessenger.of(context).showSnackBar(_snackBar('Syncing to Health Connect…')); } else if (value == 'delete') { _confirmDelete(context); } @@ -389,24 +607,107 @@ class _HistoryCard extends StatelessWidget { @override Widget build(BuildContext context) { - return SessionCard( - session: session, - getExerciseName: provider.getExerciseName, - synced: session.hcSyncedAt != null, + final dayAbbr = DateFormat('EEE').format(session.date); + final dayNum = session.date.day; + final exCount = session.exercises.length; + final setCount = session.exercises.fold(0, (s, e) => s + e.sets.length); + final vol = session.totalVolume; + final volStr = vol >= 1000 ? '${(vol / 1000).toStringAsFixed(1)}k' : vol.toStringAsFixed(0); + final duration = session.duration; + final routineName = session.routineId != null + ? provider.routines.cast().firstWhere( + (r) => r?.id == session.routineId, orElse: () => null)?.name ?? 'Workout' + : 'Quick Workout'; + + return GestureDetector( onTap: () => _openDetails(context), - trailing: PopupMenuButton( - color: AppColors.cardHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GlassCard( + padding: EdgeInsets.zero, + child: Row( + children: [ + // Date column + Container( + width: 56, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: const BorderRadius.horizontal(left: Radius.circular(18)), + ), + child: Column( + children: [ + Text( + dayAbbr.toUpperCase(), + style: GoogleFonts.geist(fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textMuted, letterSpacing: 0.6), + ), + const SizedBox(height: 2), + Text( + '$dayNum', + style: GoogleFonts.geistMono(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + ], + ), + ), + // Vertical divider + Container(width: 1, height: 56, color: AppColors.glassBorder), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + routineName, + style: GoogleFonts.geist(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Text( + '$exCount exercises · $setCount sets${duration > 0 ? ' · ${duration}m' : ''}', + style: GoogleFonts.geist(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + ), + // Volume + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + volStr, + style: GoogleFonts.geistMono( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.secondary, + ), + ), + Text('kg', style: GoogleFonts.geist(fontSize: 10, color: AppColors.textMuted)), + ], + ), + ), + // Menu + PopupMenuButton( + color: AppColors.cardHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), + icon: const Icon(Icons.more_vert_rounded, color: AppColors.textMuted, size: 18), + onSelected: (v) => _handleMenu(context, v), + itemBuilder: (_) => [ + _menuItem('edit', Icons.edit_outlined, 'Edit', AppColors.primary), + if (showSync) + _menuItem('sync', Icons.favorite_outlined, 'Sync to Health Connect', _hcColor), + _menuItem('delete', Icons.delete_outline, 'Delete', AppColors.error), + ], + ), + ], + ), ), - icon: const Icon(Icons.more_vert_rounded, color: AppColors.textMuted, size: 18), - onSelected: (v) => _handleMenu(context, v), - itemBuilder: (_) => [ - _menuItem('edit', Icons.edit_outlined, 'Edit', AppColors.primary), - if (showSync) - _menuItem('sync', Icons.favorite_outlined, 'Sync to Health Connect', _hcColor), - _menuItem('delete', Icons.delete_outline, 'Delete', AppColors.error), - ], ), ); } @@ -426,6 +727,7 @@ class _HistoryCard extends StatelessWidget { } // ── Helpers ──────────────────────────────────────────────────────────────────── + SnackBar _snackBar(String msg, {bool isError = false}) { return SnackBar( content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)), diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index a6fd5c2..b44d0d8 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -1,9 +1,10 @@ -// home_screen.dart — Main navigation shell + Dashboard tab +// home_screen.dart — Navigation shell + Dashboard tab (soft-futurist redesign) import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; @@ -12,11 +13,12 @@ import 'workout_flow_screen.dart'; import 'history_screen.dart'; import 'routines_screen.dart'; import 'analytics_screen.dart'; -import 'exercise_library_screen.dart'; import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; import 'widgets/rf_widgets.dart'; -import 'widgets/dashboard_widgets.dart'; +import 'widgets/sparkline_painter.dart'; +import 'widgets/activity_heatmap.dart'; +import 'widgets/body_heatmap.dart'; // ── HomeScreen ──────────────────────────────────────────────────────────────── @@ -30,63 +32,32 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { int _currentIndex = 0; + static const _navItems = [ + RFNavItem(icon: Icons.home_rounded, label: 'Home'), + RFNavItem(icon: Icons.layers_rounded, label: 'Routines'), + RFNavItem(icon: Icons.history_rounded, label: 'History'), + RFNavItem(icon: Icons.bar_chart_rounded, label: 'Stats'), + ]; + void switchTab(int index) => setState(() => _currentIndex = index); @override Widget build(BuildContext context) { - final provider = context.watch(); - return Scaffold( + backgroundColor: AppColors.background, body: IndexedStack( index: _currentIndex, children: const [ _DashboardTab(), - HistoryScreen(), RoutinesScreen(), + HistoryScreen(), AnalyticsScreen(), - ProfileScreen(), ], ), - floatingActionButton: _buildFAB(context, provider), - floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, - bottomNavigationBar: _BottomNavBar( + bottomNavigationBar: RFNavBar( currentIndex: _currentIndex, onTap: switchTab, - ), - ); - } - - Widget _buildFAB(BuildContext context, WorkoutProvider provider) { - final isActive = provider.hasActiveWorkout; - return GestureDetector( - onTap: () => isActive ? _resumeWorkout(context) : _startQuickWorkout(context), - child: Container( - width: 58, - height: 58, - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: isActive - ? [AppColors.warning, Color.lerp(AppColors.warning, Colors.white, 0.15)!] - : [AppColors.primary, Color.lerp(AppColors.primary, Colors.white, 0.15)!], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: (isActive ? AppColors.warning : AppColors.primary) - .withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 4), - ), - ], - ), - child: Icon( - isActive ? Icons.play_arrow_rounded : Icons.add_rounded, - color: Colors.white, - size: 30, - ), + items: _navItems, ), ); } @@ -103,10 +74,7 @@ class _HomeScreenState extends State { } void _resumeWorkout(BuildContext context) { - Navigator.push( - context, - _slide(const WorkoutFlowScreen(isQuickStart: true)), - ); + Navigator.push(context, _slide(const WorkoutFlowScreen(isQuickStart: true))); } Future _startQuickWorkout(BuildContext context) async { @@ -128,10 +96,7 @@ class _HomeScreenState extends State { } } - Future startRoutineWorkout( - BuildContext context, - Routine routine, - ) async { + Future startRoutineWorkout(BuildContext context, Routine routine) async { final provider = context.read(); StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; @@ -164,129 +129,42 @@ class _HomeScreenState extends State { Navigator.pop(sheetCtx); startRoutineWorkout(context, r); }, + onQuickStart: () { + Navigator.pop(sheetCtx); + _startQuickWorkout(context); + }, ), ); } } -// ── Bottom Nav Bar ───────────────────────────────────────────────────────────── - -class _BottomNavBar extends StatelessWidget { - const _BottomNavBar({ - required this.currentIndex, - required this.onTap, - }); - - final int currentIndex; - final ValueChanged onTap; - - static const _items = [ - (Icons.home_rounded, Icons.home_outlined, 'Home'), - (Icons.history_rounded, Icons.history_outlined, 'History'), - (null, null, ''), // centre FAB placeholder - (Icons.analytics_rounded, Icons.analytics_outlined, 'Analytics'), - (Icons.person_rounded, Icons.person_outlined, 'Profile'), - ]; - - @override - Widget build(BuildContext context) { - return Container( - height: 72, - decoration: BoxDecoration( - color: AppColors.surface, - border: Border(top: BorderSide(color: AppColors.glassBorder)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 16, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Row( - children: [ - for (int i = 0; i < _items.length; i++) - if (_items[i].$1 == null) - const Spacer() // placeholder for FAB - else - Expanded(child: _NavItem( - activeIcon: _items[i].$1!, - inactiveIcon: _items[i].$2!, - label: _items[i].$3, - selected: currentIndex == (i < 2 ? i : i - 1), - onTap: () => onTap(i < 2 ? i : i - 1), - )), - ], - ), - ), - ); - } -} - -class _NavItem extends StatelessWidget { - const _NavItem({ - required this.activeIcon, - required this.inactiveIcon, - required this.label, - required this.selected, - required this.onTap, - }); - - final IconData activeIcon; - final IconData inactiveIcon; - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: selected - ? AppColors.primary.withValues(alpha: 0.15) - : Colors.transparent, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Icon( - selected ? activeIcon : inactiveIcon, - color: selected ? AppColors.primary : AppColors.textSoft, - size: 22, - ), - ), - Text( - label, - style: TextStyle( - color: selected ? AppColors.primary : AppColors.textMuted, - fontSize: 10, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - ), - ), - ], - ), - ); - } -} - -// ── Dashboard Tab ────────────────────────────────────────────────────────────── +// ── Dashboard Tab ───────────────────────────────────────────────────────────── class _DashboardTab extends StatelessWidget { const _DashboardTab(); String _greeting() { final h = DateTime.now().hour; - if (h < 12) return 'Good morning'; - if (h < 17) return 'Good afternoon'; - return 'Good evening'; + if (h < 12) return 'Good morning,'; + if (h < 17) return 'Good afternoon,'; + return 'Good evening,'; + } + + // Generate deterministic 14-week heatmap (98 cells, col-major) + List _buildHeatmapData(List sessions) { + final now = DateTime.now(); + final data = List.filled(98, 0); + for (final s in sessions) { + final diff = now.difference(s.date).inDays; + if (diff < 0 || diff >= 98) continue; + final col = (97 - diff) ~/ 7; + final row = (97 - diff) % 7; + final idx = col * 7 + row; + if (idx >= 0 && idx < 98) { + data[idx] = (data[idx] + 1).clamp(0, 4); + } + } + return data; } @override @@ -294,100 +172,99 @@ class _DashboardTab extends StatelessWidget { final provider = context.watch(); final homeState = context.findAncestorStateOfType<_HomeScreenState>(); - return SafeArea( - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.md, - AppSpacing.md, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context, homeState), - const SizedBox(height: AppSpacing.lg), - _buildHeroCTA(context, provider, homeState), - const SizedBox(height: AppSpacing.lg), - _buildStatsSection(context, provider), - const SizedBox(height: AppSpacing.lg), - _buildWeekStrip(provider), - const SizedBox(height: AppSpacing.lg), - RecentWorkoutsSection( - sessions: provider.sessions.take(3).toList(), - getExerciseName: provider.getExerciseName, - onSeeAll: () => homeState?.switchTab(1), - onTap: (_) => homeState?.switchTab(1), + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, homeState), + const SizedBox(height: 24), + _buildStreakHero(context, provider, homeState), + const SizedBox(height: 16), + _buildStatsGrid(context, provider), + const SizedBox(height: 16), + _buildHeatmapCard(context, provider), + const SizedBox(height: 16), + _buildMuscleVolumeCard(context, provider), + const SizedBox(height: 16), + _buildRecentWorkouts(context, provider, homeState), + const SizedBox(height: 100), + ], ), - const SizedBox(height: AppSpacing.lg), - _buildQuickActions(context, homeState), - const SizedBox(height: AppSpacing.xxl), - ], + ), ), - ), + ], ), - ], - ), + ), + ], ); } Widget _buildHeader(BuildContext context, _HomeScreenState? homeState) { - final dateStr = DateFormat('EEE, MMM d').format(DateTime.now()); + final now = DateTime.now(); + final dateStr = DateFormat('EEEE · MMM d').format(now); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _greeting(), - style: const TextStyle( - color: AppColors.textSoft, - fontSize: 14, + dateStr.toUpperCase(), + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.textMuted, fontWeight: FontWeight.w500, + letterSpacing: 0.3, ), ), - const SizedBox(height: 2), - Text( - 'Let\'s get moving 💪', - style: Theme.of(context).textTheme.headlineSmall, + const SizedBox(height: 4), + RichText( + text: TextSpan( + style: GoogleFonts.geist( + fontSize: 28, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -1.12, + height: 1.05, + ), + children: [ + TextSpan(text: '${_greeting()}\n'), + TextSpan( + text: 'You.', + style: TextStyle(color: AppColors.textMuted), + ), + ], + ), ), ], ), GestureDetector( - onTap: () => homeState?.switchTab(4), + onTap: () => Navigator.push( + context, + _slide(const ProfileScreen()), + ), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 4, - ), + width: 40, + height: 40, decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.full), + borderRadius: BorderRadius.circular(12), + color: AppColors.glass2, border: Border.all(color: AppColors.glassBorder), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.calendar_today_rounded, - size: 12, - color: AppColors.textMuted, - ), - const SizedBox(width: 4), - Text( - dateStr, - style: const TextStyle( - color: AppColors.textSoft, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], + child: const Icon( + Icons.person_outline_rounded, + size: 18, + color: AppColors.textSoft, ), ), ), @@ -395,309 +272,731 @@ class _DashboardTab extends StatelessWidget { ); } - Widget _buildHeroCTA( + Widget _buildStreakHero( BuildContext context, WorkoutProvider provider, _HomeScreenState? homeState, ) { + final sessions = provider.sessions; + // Calculate current streak + int streak = 0; + final today = DateTime.now(); + for (int i = 0; i < 60; i++) { + final d = today.subtract(Duration(days: i)); + final hasWorkout = sessions.any((s) => + s.date.year == d.year && + s.date.month == d.month && + s.date.day == d.day); + if (hasWorkout) { + streak++; + } else if (i > 0) { + break; + } + } + + // Week dots (Mon–Sun) + final weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + final weekStart = today.subtract(Duration(days: today.weekday - 1)); + final hasWorkoutDays = List.generate(7, (i) { + final d = weekStart.add(Duration(days: i)); + return sessions.any((s) => + s.date.year == d.year && + s.date.month == d.month && + s.date.day == d.day); + }); + final todayWeekday = today.weekday - 1; // 0=Mon + final isActive = provider.hasActiveWorkout; - if (isActive) { - // Resume card - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.warning.withValues(alpha: 0.2), - AppColors.warning.withValues(alpha: 0.05), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all( - color: AppColors.warning.withValues(alpha: 0.4), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.fitness_center_rounded, - color: AppColors.warning, - size: 24, + return GlassCard( + padding: const EdgeInsets.all(20), + child: Stack( + clipBehavior: Clip.none, + children: [ + // Ambient blob top-right + Positioned( + top: -40, + right: -40, + child: IgnorePointer( + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.15), + Colors.transparent, + ], + ), + ), ), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Workout in progress', - style: TextStyle( - color: AppColors.warning, - fontSize: 15, - fontWeight: FontWeight.w700, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.local_fire_department_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + 'STREAK', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$streak', + style: GoogleFonts.geistMono( + fontSize: 56, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -2.24, + height: 1, + ), + ), + const SizedBox(width: 6), + Text( + 'days', + style: GoogleFonts.geist( + fontSize: 16, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + streak == 0 + ? 'Start your streak today' + : 'Keep it going — you\'re on a roll', + style: GoogleFonts.geist( + fontSize: 13, + color: AppColors.textMuted, + ), + ), + ], ), ), - Text( - provider.activeRoutine?.name ?? 'Quick workout', - style: const TextStyle( - color: AppColors.textSoft, - fontSize: 13, + const SizedBox(width: 12), + GestureDetector( + onTap: isActive + ? () => homeState?._resumeWorkout(context) + : () => homeState?._showRoutineSelector(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isActive ? AppColors.warning : AppColors.primary, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + ), + boxShadow: [ + BoxShadow( + color: (isActive ? AppColors.warning : AppColors.primary) + .withValues(alpha: 0.35), + blurRadius: 16, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isActive + ? Icons.play_arrow_rounded + : Icons.flash_on_rounded, + size: 12, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + isActive ? 'Resume' : 'Start', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), ), ), ], ), - ), - GlowButton( - label: 'Resume', - onPressed: () => Navigator.push( - context, - _slide(const WorkoutFlowScreen(isQuickStart: true)), + const SizedBox(height: 18), + // Week dots + Row( + children: List.generate(7, (i) { + final done = hasWorkoutDays[i]; + final isToday = i == todayWeekday; + final isFuture = i > todayWeekday; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + children: [ + Text( + weekDays[i], + style: GoogleFonts.geist( + fontSize: 10, + color: AppColors.textFaint, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: done + ? AppColors.primary + : isToday + ? AppColors.primary.withValues(alpha: 0.3) + : isFuture + ? const Color(0x0FFFFFFF) + : const Color(0x0FFFFFFF), + border: isToday + ? Border.all( + color: AppColors.primary, + width: 1, + ) + : null, + boxShadow: done + ? [ + BoxShadow( + color: AppColors.primary + .withValues(alpha: 0.4), + blurRadius: 6, + ), + ] + : null, + ), + ), + ], + ), + ), + ); + }), ), - color: AppColors.warning, - fullWidth: false, - small: true, - ), - ], - ), - ); - } + ], + ), + ], + ), + ); + } - // Default start card - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.primary, - Color.lerp(AppColors.primary, const Color(0xFF4834D4), 0.6)!, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.4), - blurRadius: 24, - offset: const Offset(0, 8), + Widget _buildStatsGrid(BuildContext context, WorkoutProvider provider) { + final sessions = provider.sessions; + final now = DateTime.now(); + final weekStart = now.subtract(Duration(days: now.weekday - 1)); + final weekSessions = sessions + .where((s) => s.date.isAfter(weekStart.subtract(const Duration(days: 1)))) + .toList(); + + final weekVol = weekSessions.fold(0, (s, e) => s + e.totalVolume); + final weekSets = + weekSessions.fold(0, (s, e) => s + e.exercises.fold(0, (a, ex) => a + ex.sets.length)); + final avgDuration = sessions.isEmpty + ? 0 + : sessions.take(7).fold(0, (s, e) => s + e.duration) ~/ + sessions.take(7).length; + + // Sparkline data (last 7 weeks, workouts per week) + List weeklyWorkouts = List.generate(7, (i) { + final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + return sessions.where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)).length.toDouble(); + }); + List weeklyVolumes = List.generate(7, (i) { + final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + return sessions + .where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)) + .fold(0, (s, e) => s + e.totalVolume); + }); + + final stats = [ + _StatItem( + label: 'This week', + value: '${weekSessions.length}', + unit: '/ 5 goal', + color: AppColors.primary, + spark: weeklyWorkouts, + ), + _StatItem( + label: 'Volume', + value: weekVol >= 1000 + ? '${(weekVol / 1000).toStringAsFixed(1)}k' + : weekVol.toStringAsFixed(0), + unit: 'kg', + color: AppColors.secondary, + spark: weeklyVolumes, + ), + _StatItem( + label: 'Sets', + value: '$weekSets', + unit: 'this week', + color: AppColors.success, + spark: List.generate(7, (i) => (weekSets * (0.5 + i * 0.07)).clamp(0, weekSets + 10).toDouble()), + ), + _StatItem( + label: 'Avg time', + value: '$avgDuration', + unit: 'min', + color: AppColors.warning, + spark: List.generate(7, (i) => (avgDuration * (0.8 + i * 0.04)).toDouble()), + ), + ]; + + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.4, + ), + itemCount: stats.length, + itemBuilder: (_, i) => _StatCard(item: stats[i]), + ); + } + + Widget _buildHeatmapCard(BuildContext context, WorkoutProvider provider) { + final data = _buildHeatmapData(provider.sessions); + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Activity', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + Text( + 'Last 14 weeks', + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], ), + const SizedBox(height: 14), + ActivityHeatmap(data: data), ], ), + ); + } + + Widget _buildMuscleVolumeCard(BuildContext context, WorkoutProvider provider) { + final now = DateTime.now(); + final weekStart = now.subtract(Duration(days: now.weekday - 1)); + final weekSessions = provider.sessions + .where((s) => s.date.isAfter(weekStart.subtract(const Duration(days: 1)))) + .toList(); + + final muscleVols = {}; + for (final s in weekSessions) { + for (final el in s.exercises) { + final ex = provider.getExercise(el.exerciseId); + if (ex == null) continue; + final vol = el.totalVolume; + for (final ma in ex.muscleActivations) { + muscleVols[ma.muscleGroupId] = + (muscleVols[ma.muscleGroupId] ?? 0) + vol * ma.activationPercentage / 100; + } + } + } + + final maxVol = muscleVols.values.isEmpty ? 1.0 : muscleVols.values.reduce((a, b) => a > b ? a : b); + final muscleList = muscleVols.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final topMuscles = muscleList.take(5).toList(); + + final normalizedVols = { + for (final e in muscleVols.entries) e.key: e.value / maxVol + }; + + return GlassCard( + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(AppRadius.md), + Text( + 'Weekly muscle volume', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), - child: const Icon( - Icons.bolt_rounded, - color: Colors.white, - size: 26, + ), + Text( + 'kg', + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.textMuted, ), ), - const SizedBox(width: AppSpacing.md), + ], + ), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BodyHeatmapWidget(muscleVolumes: normalizedVols), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Start Workout', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - Text( - provider.routines.isEmpty - ? 'Quick start or build a routine' - : '${provider.routines.length} routines ready', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.75), - fontSize: 13, - ), - ), - ], + children: topMuscles.isEmpty + ? [ + Text( + 'No data yet', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ] + : topMuscles.map((e) { + final color = AppColors.muscle(e.key); + final pct = (e.value / maxVol).clamp(0.0, 1.0); + final volStr = e.value >= 1000 + ? '${(e.value / 1000).toStringAsFixed(1)}k' + : e.value.toStringAsFixed(0); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _capitalize(e.key.replaceAll('_', ' ')), + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.textSoft, + ), + ), + Text( + volStr, + style: GoogleFonts.geistMono( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + const SizedBox(height: 3), + Container( + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: AppColors.glass2, + ), + child: FractionallySizedBox( + widthFactor: pct, + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: color, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 4, + ), + ], + ), + ), + ), + ), + ], + ), + ); + }).toList(), ), ), ], ), - const SizedBox(height: AppSpacing.lg), - Row( + ], + ), + ); + } + + Widget _buildRecentWorkouts( + BuildContext context, + WorkoutProvider provider, + _HomeScreenState? homeState, + ) { + final recentSessions = provider.sessions.take(3).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10, left: 4, right: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: GestureDetector( - onTap: () => homeState?._startQuickWorkout(context), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.flash_on_rounded, - color: AppColors.primary, size: 18), - SizedBox(width: 6), - Text( - 'Quick Start', - style: TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.w700, - fontSize: 14, - ), - ), - ], - ), + Text( + 'Recent workouts', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + GestureDetector( + onTap: () => homeState?.switchTab(2), + child: Text( + 'See all', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, ), ), ), - if (provider.routines.isNotEmpty) ...[ - const SizedBox(width: AppSpacing.sm), - Expanded( - child: GestureDetector( - onTap: () => homeState?._showRoutineSelector(context), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), + ], + ), + ), + if (recentSessions.isEmpty) + GlassCard( + padding: const EdgeInsets.all(16), + child: Center( + child: Text( + 'No workouts yet — start one!', + style: GoogleFonts.geist( + fontSize: 13, + color: AppColors.textMuted, + ), + ), + ), + ) + else + ...recentSessions.map((s) { + final dateStr = _formatSessionDate(s.date); + final volStr = s.totalVolume >= 1000 + ? '${(s.totalVolume / 1000).toStringAsFixed(1)}k' + : s.totalVolume.toStringAsFixed(0); + final exCount = s.exercises.length; + final setCount = s.exercises.fold(0, (a, e) => a + e.sets.length); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GlassCard( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 4, + height: 40, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - ), + borderRadius: BorderRadius.circular(2), + color: AppColors.primary, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.4), + blurRadius: 6, + ), + ], ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.list_alt_rounded, - color: Colors.white, size: 18), - SizedBox(width: 6), Text( - 'From Routine', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, + s.routineId != null + ? (provider.routines.cast().firstWhere((r) => r?.id == s.routineId, orElse: () => null)?.name ?? 'Workout') + : 'Quick Workout', + style: GoogleFonts.geist( fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '$dateStr · $exCount exercises · $setCount sets', + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.textMuted, ), ), ], ), ), - ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + volStr, + style: GoogleFonts.geistMono( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.secondary, + ), + ), + Text( + 'kg vol', + style: GoogleFonts.geist( + fontSize: 10, + color: AppColors.textFaint, + ), + ), + ], + ), + ], ), - ], - ], - ), - ], - ), - ); - } - - Widget _buildStatsSection(BuildContext context, WorkoutProvider provider) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const RFSectionHeader('This Week'), - FutureBuilder>( - future: provider.getQuickStats(), - builder: (context, snap) { - final stats = snap.data ?? { - 'totalWorkouts': 0, - 'weeklyWorkouts': 0, - 'weeklyVolume': 0.0, - 'exercisesThisWeek': 0, - }; - return StatGrid(stats: stats); - }, - ), + ), + ); + }), ], ); } - Widget _buildWeekStrip(WorkoutProvider provider) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const RFSectionHeader('This Week'), - const SizedBox(height: AppSpacing.sm), - WeekActivityStrip(sessions: provider.sessions), - ], - ); + String _formatSessionDate(DateTime d) { + final now = DateTime.now(); + final diff = now.difference(d).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + if (diff < 7) return '${diff}d ago'; + return DateFormat('MMM d').format(d); } - Widget _buildQuickActions( - BuildContext context, - _HomeScreenState? homeState, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const RFSectionHeader('Quick Actions'), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - Expanded( - child: QuickActionTile( - icon: Icons.add_circle_outline_rounded, - label: 'New Routine', - color: AppColors.primary, - onTap: () => homeState?.switchTab(2), + String _capitalize(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); +} + +// ── Stat helpers ────────────────────────────────────────────────────────────── + +class _StatItem { + const _StatItem({ + required this.label, + required this.value, + required this.unit, + required this.color, + required this.spark, + }); + final String label; + final String value; + final String unit; + final Color color; + final List spark; +} + +class _StatCard extends StatelessWidget { + const _StatCard({required this.item}); + final _StatItem item; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.label, + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: QuickActionTile( - icon: Icons.library_books_rounded, - label: 'Exercises', - color: AppColors.secondary, - onTap: () => Navigator.push( - context, - _slide(const ExerciseLibraryScreen()), + Sparkline( + data: item.spark, + color: item.color, + width: 42, + height: 16, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.value, + style: GoogleFonts.geistMono( + fontSize: 26, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.52, ), ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: QuickActionTile( - icon: Icons.analytics_outlined, - label: 'Analytics', - color: AppColors.success, - onTap: () => homeState?.switchTab(3), + Text( + item.unit, + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w400, + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ); } } -// ── Routine Selector Sheet ───────────────────────────────────────────────────── +// ── Routine Selector Sheet ──────────────────────────────────────────────────── class _RoutineSelectorSheet extends StatelessWidget { const _RoutineSelectorSheet({ required this.routines, required this.onSelect, + required this.onQuickStart, }); final List routines; final void Function(Routine) onSelect; + final VoidCallback onQuickStart; @override Widget build(BuildContext context) { @@ -707,32 +1006,52 @@ class _RoutineSelectorSheet extends StatelessWidget { Container( width: 40, height: 4, - margin: const EdgeInsets.only(top: AppSpacing.md, bottom: AppSpacing.sm), + margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration( color: AppColors.textMuted, borderRadius: BorderRadius.circular(AppRadius.full), ), ), const Padding( - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.sm, - ), + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: RFSectionHeader('Select Routine'), ), Flexible( - child: ListView.builder( + child: ListView( shrinkWrap: true, - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.lg, - ), - itemCount: routines.length, - itemBuilder: (_, i) { - final r = routines[i]; - return ListTile( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.flash_on_rounded, + color: AppColors.primary, + size: 20, + ), + ), + title: Text( + 'Quick Start', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'Empty workout, no routine', + style: GoogleFonts.geist(color: AppColors.textMuted), + ), + trailing: const Icon(Icons.play_arrow_rounded, color: AppColors.primary), + onTap: onQuickStart, + ), + ...routines.map((r) => ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.md), ), @@ -750,22 +1069,22 @@ class _RoutineSelectorSheet extends StatelessWidget { ), title: Text( r.name, - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontWeight: FontWeight.w600, ), ), subtitle: Text( '${r.exerciseIds.length} exercises', - style: const TextStyle(color: AppColors.textMuted), + style: GoogleFonts.geist(color: AppColors.textMuted), ), trailing: const Icon( Icons.play_arrow_rounded, color: AppColors.primary, ), onTap: () => onSelect(r), - ); - }, + )), + ], ), ), ], diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 1ae972c..8a060b6 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -1,6 +1,8 @@ -// routines_screen.dart — Routines + Programs tabs +// routines_screen.dart — Routines + Programs (soft-futurist redesign) import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../models/models.dart'; @@ -8,7 +10,6 @@ import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import 'programs/programs_screen.dart'; import 'widgets/rf_widgets.dart'; -import 'widgets/rf_cards.dart'; import 'widgets/routine_creator.dart'; class RoutinesScreen extends StatelessWidget { @@ -16,123 +17,411 @@ class RoutinesScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - child: Column( - children: [ - _RoutinesHeader(), - Expanded( - child: TabBarView( - children: [ - _RoutinesTab(), - const ProgramsScreen(), - ], - ), - ), - ], + final provider = context.watch(); + final routines = provider.routines; + + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(context, routines)), + if (routines.isNotEmpty) ...[ + SliverToBoxAdapter(child: _buildQuickStartCard(context, routines.first, provider)), + SliverToBoxAdapter(child: _buildAllRoutinesHeader(routines)), + SliverList( + delegate: SliverChildBuilderDelegate( + (ctx, i) => _RoutineCard( + routine: routines[i], + provider: provider, + ), + childCount: routines.length, + ), + ), + ] else ...[ + SliverToBoxAdapter(child: _buildEmptyState(context)), + ], + SliverToBoxAdapter(child: _buildProgramsSection(context)), + SliverToBoxAdapter(child: _buildNewRoutineButton(context)), + const SliverPadding(padding: EdgeInsets.only(bottom: 100)), + ], + ), ), - ), + ], ), ); } -} -// ── Header with title + tab bar ─────────────────────────────────────────────── -class _RoutinesHeader extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.surface, - border: Border(bottom: BorderSide(color: AppColors.glassBorder)), - ), - child: Column( + Widget _buildHeader(BuildContext context, List routines) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Padding( - padding: EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.lg, - AppSpacing.md, - AppSpacing.sm, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Routines', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 28, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PROGRAMS', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, + ), ), + const SizedBox(height: 2), + Text( + 'Routines', + style: GoogleFonts.geist( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.6, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => _openCreate(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add_rounded, size: 15, color: AppColors.primary), + const SizedBox(width: 4), + Text( + 'New', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], ), ), ), - TabBar( - indicatorColor: AppColors.primary, - indicatorWeight: 2, - labelColor: AppColors.primary, - unselectedLabelColor: AppColors.textMuted, - labelStyle: const TextStyle( + ], + ), + ); + } + + Widget _buildQuickStartCard(BuildContext context, Routine routine, WorkoutProvider provider) { + final exCount = routine.exerciseIds.length; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: GlassCard( + padding: const EdgeInsets.all(18), + child: Stack( + children: [ + // Ambient blob + Positioned( + top: -20, + right: -20, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.18), + AppColors.primary.withValues(alpha: 0), + ], + ), + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.bolt_rounded, size: 14, color: AppColors.primary), + ), + const SizedBox(width: 8), + Text( + 'UP NEXT · TODAY', + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + letterSpacing: 1.0, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + routine.name, + style: GoogleFonts.geist( + fontSize: 22, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + '$exCount exercises', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + HapticFeedback.mediumImpact(); + startRoutineWorkoutFlow(context, routine); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.play_arrow_rounded, size: 16, color: Colors.white), + const SizedBox(width: 6), + Text( + 'Start workout', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => CreateRoutineScreen(routine: routine)), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon(Icons.edit_rounded, size: 16, color: AppColors.textMuted), + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildAllRoutinesHeader(List routines) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Text( + 'All Routines', + style: GoogleFonts.geist( fontSize: 13, fontWeight: FontWeight.w600, + color: AppColors.textSoft, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${routines.length}', + style: GoogleFonts.geistMono( + fontSize: 11, + color: AppColors.textMuted, + ), ), - tabs: const [ - Tab(text: 'My Routines'), - Tab(text: 'Programs'), - ], ), ], ), ); } -} -// ── Routines Tab ────────────────────────────────────────────────────────────── -class _RoutinesTab extends StatelessWidget { - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final routines = provider.routines; - - return Scaffold( - backgroundColor: AppColors.background, - body: routines.isEmpty - ? RFEmptyState( - icon: Icons.list_alt_rounded, - title: 'No Routines Yet', - subtitle: 'Create a routine to organize your workouts', - action: GlowButton( - label: 'Create Routine', - icon: Icons.add_rounded, - onPressed: () => _openCreate(context), + Widget _buildEmptyState(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: GlassCard( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), ), - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.md, - AppSpacing.md, - 100, + child: const Icon(Icons.fitness_center_rounded, size: 32, color: AppColors.primary), + ), + const SizedBox(height: 16), + Text( + 'No Routines Yet', + style: GoogleFonts.geist( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), - physics: const BouncingScrollPhysics(), - itemCount: routines.length, - itemBuilder: (_, i) => RoutineCard( - routine: routines[i], - getExerciseName: provider.getExerciseName, - onStart: () => startRoutineWorkoutFlow(context, routines[i]), - onEdit: () => _openEdit(context, routines[i]), - onDelete: () => _confirmDelete(context, routines[i], provider), + ), + const SizedBox(height: 6), + Text( + 'Create a routine to organize your workouts', + style: GoogleFonts.geist(fontSize: 13, color: AppColors.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildProgramsSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 12), + child: Row( + children: [ + Text( + 'Programs', + style: GoogleFonts.geist( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProgramsScreen()), + ), + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.auto_awesome_rounded, size: 20, color: AppColors.secondary), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Browse Programs', + style: GoogleFonts.geist( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + Text( + 'Structured multi-week training plans', + style: GoogleFonts.geist(fontSize: 12, color: AppColors.textMuted), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textFaint), + ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: () => _openCreate(context), - backgroundColor: AppColors.primary, - elevation: 0, - child: const Icon(Icons.add_rounded, color: Colors.white), + ), + ), + ], + ); + } + + Widget _buildNewRoutineButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: GestureDetector( + onTap: () => _openCreate(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: AppColors.glassBorderStrong, + style: BorderStyle.solid, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add_rounded, size: 16, color: AppColors.textMuted), + const SizedBox(width: 6), + Text( + 'New Routine', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textMuted, + ), + ), + ], + ), + ), ), ); } @@ -143,51 +432,109 @@ class _RoutinesTab extends StatelessWidget { MaterialPageRoute(builder: (_) => const CreateRoutineScreen()), ); } +} - void _openEdit(BuildContext context, Routine routine) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => CreateRoutineScreen(routine: routine)), - ); - } +// ── Routine Card ────────────────────────────────────────────────────────────── - void _confirmDelete( - BuildContext context, - Routine routine, - WorkoutProvider provider, - ) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - title: const Text( - 'Delete Routine?', - style: TextStyle(color: AppColors.textPrimary), - ), - content: Text( - 'Delete "${routine.name}"? This cannot be undone.', - style: const TextStyle(color: AppColors.textSoft), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text( - 'Cancel', - style: TextStyle(color: AppColors.textSoft), +class _RoutineCard extends StatelessWidget { + const _RoutineCard({required this.routine, required this.provider}); + + final Routine routine; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final exCount = routine.exerciseIds.length; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: GlassCard( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + // Accent left bar + Container( + width: 4, + height: 48, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(2), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.4), + blurRadius: 8, + ), + ], + ), ), - ), - TextButton( - onPressed: () { - provider.deleteRoutine(routine.id); - Navigator.of(ctx).pop(); - }, - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Delete'), - ), - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + routine.name, + style: GoogleFonts.geist( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + '$exCount exercise${exCount == 1 ? '' : 's'}', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + // Edit button + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CreateRoutineScreen(routine: routine), + ), + ), + child: Container( + width: 34, + height: 34, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon(Icons.edit_rounded, size: 15, color: AppColors.textMuted), + ), + ), + // Play button + GestureDetector( + onTap: () { + HapticFeedback.mediumImpact(); + startRoutineWorkoutFlow(context, routine); + }, + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 10, + ), + ], + ), + child: const Icon(Icons.play_arrow_rounded, size: 20, color: Colors.white), + ), + ), + ], + ), ), ); } diff --git a/workout-logger/lib/screens/widgets/activity_heatmap.dart b/workout-logger/lib/screens/widgets/activity_heatmap.dart new file mode 100644 index 0000000..35e49b2 --- /dev/null +++ b/workout-logger/lib/screens/widgets/activity_heatmap.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +/// 14-week × 7-day activity heatmap grid. +/// [data] is a list of 98 integers (0–4) ordered column-by-column +/// (col 0 = oldest week, row 0 = Mon). +class ActivityHeatmap extends StatelessWidget { + const ActivityHeatmap({super.key, required this.data}); + + final List data; // length 98 (14 cols × 7 rows) + + static const _cols = 14; + static const _rows = 7; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _cols, + crossAxisSpacing: 3, + mainAxisSpacing: 3, + childAspectRatio: 1, + ), + itemCount: _cols * _rows, + itemBuilder: (context, index) { + // Transpose: GridView fills row-by-row, we want col-by-col + final col = index % _cols; + final row = index ~/ _cols; + final dataIndex = col * _rows + row; + final intensity = dataIndex < data.length ? data[dataIndex] : 0; + return _HeatCell(intensity: intensity); + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Less', + style: TextStyle( + fontSize: 10, color: AppColors.textFaint), + ), + Row( + children: List.generate(5, (i) { + final opacity = i == 0 ? 0.05 : 0.2 + i * 0.15; + return Container( + width: 10, + height: 10, + margin: const EdgeInsets.symmetric(horizontal: 1.5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: i == 0 + ? const Color(0x0DFFFFFF) + : AppColors.primary.withValues(alpha: opacity), + ), + ); + }), + ), + Text( + 'More', + style: TextStyle( + fontSize: 10, color: AppColors.textFaint), + ), + ], + ), + ], + ); + } +} + +class _HeatCell extends StatelessWidget { + const _HeatCell({required this.intensity}); + final int intensity; + + @override + Widget build(BuildContext context) { + final opacity = intensity == 0 ? 0.0 : 0.2 + intensity * 0.18; + final hasGlow = intensity >= 3; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: intensity == 0 + ? const Color(0x0AFFFFFF) + : AppColors.primary.withValues(alpha: opacity.clamp(0, 1)), + boxShadow: hasGlow + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 4, + ), + ] + : null, + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart new file mode 100644 index 0000000..7fcc034 --- /dev/null +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +/// Stylised human body silhouette with muscle heat overlays. +/// [muscleVolumes] maps muscle group id → relative volume 0–1. +class BodyHeatmapWidget extends StatelessWidget { + const BodyHeatmapWidget({ + super.key, + this.muscleVolumes = const {}, + this.width = 74, + this.height = 148, + }); + + final Map muscleVolumes; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: CustomPaint( + painter: _BodyPainter(muscleVolumes: muscleVolumes), + ), + ); + } +} + +class _BodyPainter extends CustomPainter { + const _BodyPainter({required this.muscleVolumes}); + final Map muscleVolumes; + + @override + void paint(Canvas canvas, Size size) { + final sx = size.width / 74; + final sy = size.height / 148; + + final baseFill = Paint() + ..color = const Color(0x0FFFFFFF) + ..style = PaintingStyle.fill; + final baseStroke = Paint() + ..color = const Color(0x1AFFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 0.7; + + // ── Body outline shapes ────────────────────────────────────── + // Head + canvas.drawCircle(Offset(37 * sx, 12 * sy), 9 * sx, baseFill); + canvas.drawCircle(Offset(37 * sx, 12 * sy), 9 * sx, baseStroke); + + // Torso + final torso = Path() + ..moveTo(22 * sx, 24 * sy) + ..lineTo(52 * sx, 24 * sy) + ..lineTo(54 * sx, 50 * sy) + ..lineTo(52 * sx, 72 * sy) + ..lineTo(22 * sx, 72 * sy) + ..lineTo(20 * sx, 50 * sy) + ..close(); + canvas.drawPath(torso, baseFill); + canvas.drawPath(torso, baseStroke); + + // Left arm + final leftArm = Path() + ..moveTo(20 * sx, 28 * sy) + ..lineTo(12 * sx, 32 * sy) + ..lineTo(8 * sx, 60 * sy) + ..lineTo(12 * sx, 70 * sy) + ..lineTo(18 * sx, 50 * sy) + ..close(); + canvas.drawPath(leftArm, baseFill); + canvas.drawPath(leftArm, baseStroke); + + // Right arm + final rightArm = Path() + ..moveTo(54 * sx, 28 * sy) + ..lineTo(62 * sx, 32 * sy) + ..lineTo(66 * sx, 60 * sy) + ..lineTo(62 * sx, 70 * sy) + ..lineTo(56 * sx, 50 * sy) + ..close(); + canvas.drawPath(rightArm, baseFill); + canvas.drawPath(rightArm, baseStroke); + + // Left leg + final leftLeg = Path() + ..moveTo(24 * sx, 73 * sy) + ..lineTo(34 * sx, 73 * sy) + ..lineTo(33 * sx, 110 * sy) + ..lineTo(30 * sx, 140 * sy) + ..lineTo(23 * sx, 140 * sy) + ..lineTo(22 * sx, 105 * sy) + ..close(); + canvas.drawPath(leftLeg, baseFill); + canvas.drawPath(leftLeg, baseStroke); + + // Right leg + final rightLeg = Path() + ..moveTo(40 * sx, 73 * sy) + ..lineTo(50 * sx, 73 * sy) + ..lineTo(52 * sx, 105 * sy) + ..lineTo(51 * sx, 140 * sy) + ..lineTo(44 * sx, 140 * sy) + ..lineTo(41 * sx, 110 * sy) + ..close(); + canvas.drawPath(rightLeg, baseFill); + canvas.drawPath(rightLeg, baseStroke); + + // ── Heat overlays ──────────────────────────────────────────── + _drawHeat(canvas, sx, sy, 'chest', + _ellipse(37, 38, 13, 9, sx, sy), AppColors.primary, 0.55); + _drawHeat(canvas, sx, sy, 'shoulders', + _circle(22, 28, 5, sx, sy), AppColors.primary, 0.42); + _drawHeat(canvas, sx, sy, 'shoulders', + _circle(52, 28, 5, sx, sy), AppColors.primary, 0.42); + _drawHeat(canvas, sx, sy, 'biceps', + _ellipse(14, 46, 3.5, 8, sx, sy), AppColors.secondary, 0.45); + _drawHeat(canvas, sx, sy, 'biceps', + _ellipse(60, 46, 3.5, 8, sx, sy), AppColors.secondary, 0.45); + _drawHeat(canvas, sx, sy, 'quads', + _ellipse(28, 92, 5, 11, sx, sy), AppColors.warning, 0.18); + _drawHeat(canvas, sx, sy, 'quads', + _ellipse(46, 92, 5, 11, sx, sy), AppColors.warning, 0.18); + } + + void _drawHeat(Canvas canvas, double sx, double sy, String muscle, + Path path, Color color, double baseOpacity) { + final vol = muscleVolumes[muscle] ?? 0.5; + final opacity = (baseOpacity * (0.5 + vol * 0.5)).clamp(0.0, 1.0); + canvas.drawPath(path, Paint()..color = color.withValues(alpha: opacity)); + } + + Path _ellipse(double cx, double cy, double rx, double ry, double sx, + double sy) { + return Path() + ..addOval(Rect.fromCenter( + center: Offset(cx * sx, cy * sy), + width: rx * 2 * sx, + height: ry * 2 * sy, + )); + } + + Path _circle(double cx, double cy, double r, double sx, double sy) => + _ellipse(cx, cy, r, r, sx, sy); + + @override + bool shouldRepaint(_BodyPainter old) => + old.muscleVolumes != muscleVolumes; +} diff --git a/workout-logger/lib/screens/widgets/calendar_grid.dart b/workout-logger/lib/screens/widgets/calendar_grid.dart new file mode 100644 index 0000000..32aac67 --- /dev/null +++ b/workout-logger/lib/screens/widgets/calendar_grid.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../theme/app_theme.dart'; + +class CalendarDayData { + const CalendarDayData({required this.intensity, this.hasPr = false}); + final int intensity; // 1–3 + final bool hasPr; +} + +/// Calendar month grid with intensity shading and PR dot indicators. +class CalendarMonthGrid extends StatelessWidget { + const CalendarMonthGrid({ + super.key, + required this.year, + required this.month, + required this.workoutDays, + this.selectedDay, + this.onDayTap, + }); + + final int year; + final int month; + final Map workoutDays; + final int? selectedDay; + final ValueChanged? onDayTap; + + @override + Widget build(BuildContext context) { + final firstDay = DateTime(year, month, 1); + final startOffset = (firstDay.weekday - 1) % 7; + final daysInMonth = DateTime(year, month + 1, 0).day; + final today = DateTime.now(); + final isCurrentMonth = today.year == year && today.month == month; + final todayDay = isCurrentMonth ? today.day : -1; + final totalCells = ((startOffset + daysInMonth) / 7).ceil() * 7; + + return Column( + children: [ + Row( + children: ['M', 'T', 'W', 'T', 'F', 'S', 'S'] + .map((d) => Expanded( + child: Center( + child: Text( + d, + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 0.4, + ), + ), + ), + )) + .toList(), + ), + const SizedBox(height: 8), + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + childAspectRatio: 1, + ), + itemCount: totalCells, + itemBuilder: (context, i) { + final day = i - startOffset + 1; + if (day < 1 || day > daysInMonth) { + return const SizedBox.shrink(); + } + final data = workoutDays[day]; + final isToday = day == todayDay; + final isSelected = day == selectedDay; + final isFuture = isCurrentMonth && day > today.day; + + return _DayCell( + day: day, + data: data, + isToday: isToday, + isSelected: isSelected, + isFuture: isFuture, + onTap: data != null ? () => onDayTap?.call(day) : null, + ); + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 5, + height: 5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.success, + ), + ), + const SizedBox(width: 4), + Text('PR', + style: TextStyle(fontSize: 10, color: AppColors.textFaint)), + ], + ), + Row( + children: [ + Text('Less', + style: TextStyle(fontSize: 10, color: AppColors.textFaint)), + const SizedBox(width: 6), + ...List.generate(4, (i) { + final Color c; + if (i == 0) { + c = Colors.transparent; + } else if (i == 1) { + c = AppColors.primary.withValues(alpha: 0.15); + } else if (i == 2) { + c = AppColors.primary.withValues(alpha: 0.55); + } else { + c = AppColors.primary; + } + return Container( + width: 9, + height: 9, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: c, + border: i == 0 + ? Border.all(color: AppColors.glassBorder) + : null, + ), + ); + }), + const SizedBox(width: 6), + Text('More', + style: TextStyle(fontSize: 10, color: AppColors.textFaint)), + ], + ), + ], + ), + ], + ); + } +} + +class _DayCell extends StatelessWidget { + const _DayCell({ + required this.day, + required this.data, + required this.isToday, + required this.isSelected, + required this.isFuture, + this.onTap, + }); + + final int day; + final CalendarDayData? data; + final bool isToday; + final bool isSelected; + final bool isFuture; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final intensity = data?.intensity ?? 0; + final Color bg; + if (intensity == 0) { + bg = Colors.transparent; + } else if (intensity == 1) { + bg = AppColors.primary.withValues(alpha: 0.15); + } else if (intensity == 2) { + bg = AppColors.primary.withValues(alpha: 0.55); + } else { + bg = AppColors.primary; + } + + final Border? border; + if (isSelected) { + border = Border.all(color: AppColors.primary, width: 1.5); + } else if (isToday) { + border = Border.all(color: AppColors.textMuted, width: 1); + } else { + border = null; + } + + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: bg, + border: border, + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 8, + ), + ] + : null, + ), + child: Stack( + children: [ + Center( + child: Text( + '$day', + style: GoogleFonts.geistMono( + fontSize: 12, + fontWeight: + intensity > 0 ? FontWeight.w600 : FontWeight.w400, + color: intensity > 1 + ? Colors.white + : isFuture + ? AppColors.textFaint + : AppColors.textPrimary, + ), + ), + ), + if (data?.hasPr == true) + Positioned( + top: 2, + right: 2, + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.success, + boxShadow: [ + BoxShadow( + color: AppColors.success.withValues(alpha: 0.5), + blurRadius: 4, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index 1493e4e..93457b9 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -2,12 +2,14 @@ // All widgets consume AppColors/AppSpacing/AppRadius tokens only. import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; // ── GlassCard ─────────────────────────────────────────────────────────────── -// Frosted-glass container. Use for primary content cards. +// Soft-futurist glass card — gradient top-to-bottom + subtle inner highlight. class GlassCard extends StatelessWidget { const GlassCard({ super.key, @@ -17,6 +19,7 @@ class GlassCard extends StatelessWidget { this.borderRadius, this.glowColor, this.borderColor, + this.accentBorder = false, this.onTap, }); @@ -26,19 +29,25 @@ class GlassCard extends StatelessWidget { final BorderRadius? borderRadius; final Color? glowColor; final Color? borderColor; + /// When true, uses accent colour border (e.g. Analytics exercise selector). + final bool accentBorder; final VoidCallback? onTap; @override Widget build(BuildContext context) { - final radius = borderRadius ?? BorderRadius.circular(AppRadius.lg); - final border = Border.all( - color: borderColor ?? AppColors.glassBorder, - width: 1, - ); + final radius = borderRadius ?? BorderRadius.circular(AppRadius.xl); + final effectiveBorderColor = accentBorder + ? AppColors.primary + : (borderColor ?? AppColors.glassBorder); + final decoration = BoxDecoration( - color: AppColors.glass, + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x09FFFFFF), Color(0x04FFFFFF)], + ), borderRadius: radius, - border: border, + border: Border.all(color: effectiveBorderColor, width: 1), boxShadow: glowColor != null ? [ BoxShadow( @@ -60,10 +69,198 @@ class GlassCard extends StatelessWidget { if (onTap == null) return content; return GestureDetector( onTap: onTap, - child: AnimatedScale( - scale: 1.0, - duration: const Duration(milliseconds: 120), - child: content, + child: content, + ); + } +} + +// ── AmbientGlow ────────────────────────────────────────────────────────────── +// Decorative ambient gradient wash — place inside a Stack as first child. +// Matches the design's rf-ambient pseudo-elements. +class AmbientGlow extends StatelessWidget { + const AmbientGlow({super.key, this.showBottom = true}); + final bool showBottom; + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: Stack( + children: [ + // Top violet wash + Positioned( + top: -120, + left: 0, + right: 0, + child: Center( + child: Container( + width: 480, + height: 480, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + const Color(0xFF5B21B6).withValues(alpha: 0.35), + Colors.transparent, + ], + stops: const [0, 0.6], + ), + ), + ), + ), + ), + // Bottom cyan wash + if (showBottom) + Positioned( + bottom: -200, + right: -100, + child: Container( + width: 400, + height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.secondary.withValues(alpha: 0.20), + Colors.transparent, + ], + stops: const [0, 0.6], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// ── RFNavBar ───────────────────────────────────────────────────────────────── +// Custom glassmorphic bottom navigation bar — 4 tabs, accent indicator above +// the active icon, no FAB. +class RFNavBar extends StatelessWidget { + const RFNavBar({ + super.key, + required this.currentIndex, + required this.onTap, + required this.items, + }); + + final int currentIndex; + final ValueChanged onTap; + final List items; + + @override + Widget build(BuildContext context) { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + AppColors.background.withValues(alpha: 0.85), + ], + ), + ), + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.of(context).padding.bottom + 8, + ), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all(color: AppColors.glassBorder), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(items.length, (i) { + final active = i == currentIndex; + return _NavItem( + item: items[i], + active: active, + onTap: () => onTap(i), + ); + }), + ), + ), + ), + ), + ); + } +} + +class RFNavItem { + const RFNavItem({required this.icon, required this.label}); + final IconData icon; + final String label; +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.item, + required this.active, + required this.onTap, + }); + + final RFNavItem item; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 60, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Accent indicator above icon + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: active ? 18 : 0, + height: 2, + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: AppColors.primary, + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.6), + blurRadius: 6, + ), + ] + : null, + ), + ), + Icon( + item.icon, + size: 19, + color: active ? AppColors.textPrimary : AppColors.textMuted, + ), + const SizedBox(height: 4), + Text( + item.label, + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? AppColors.textPrimary : AppColors.textMuted, + letterSpacing: 0.2, + ), + ), + ], + ), ), ); } @@ -155,22 +352,21 @@ class _GlowButtonState extends State vertical: vPad, ), decoration: BoxDecoration( - gradient: disabled - ? null - : LinearGradient( - colors: [color, Color.lerp(color, Colors.white, 0.15)!], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - color: disabled ? AppColors.card : null, + color: disabled ? AppColors.glass2 : color, borderRadius: BorderRadius.circular(AppRadius.lg), + border: disabled + ? Border.all(color: AppColors.glassBorder) + : Border.all( + color: Colors.white.withValues(alpha: 0.18), + width: 1, + ), boxShadow: disabled ? null : [ BoxShadow( - color: color.withValues(alpha: 0.4), - blurRadius: 20, - offset: const Offset(0, 6), + color: color.withValues(alpha: 0.35), + blurRadius: 32, + offset: const Offset(0, 4), ), ], ), diff --git a/workout-logger/lib/screens/widgets/sparkline_painter.dart b/workout-logger/lib/screens/widgets/sparkline_painter.dart new file mode 100644 index 0000000..99200b3 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sparkline_painter.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class SparklinePainter extends CustomPainter { + const SparklinePainter({ + required this.data, + required this.color, + this.strokeWidth = 1.5, + this.fillOpacity = 0.15, + }); + + final List data; + final Color color; + final double strokeWidth; + final double fillOpacity; + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + final minVal = data.reduce((a, b) => a < b ? a : b); + final maxVal = data.reduce((a, b) => a > b ? a : b); + final range = maxVal - minVal == 0 ? 1.0 : maxVal - minVal; + + final points = List.generate(data.length, (i) { + final x = i / (data.length - 1) * size.width; + final y = size.height - ((data[i] - minVal) / range) * (size.height - 4) - 2; + return Offset(x, y); + }); + + final linePath = Path()..moveTo(points[0].dx, points[0].dy); + for (int i = 1; i < points.length; i++) { + linePath.lineTo(points[i].dx, points[i].dy); + } + + final fillPath = Path()..addPath(linePath, Offset.zero); + fillPath.lineTo(size.width, size.height); + fillPath.lineTo(0, size.height); + fillPath.close(); + + canvas.drawPath( + fillPath, + Paint() + ..color = color.withValues(alpha: fillOpacity) + ..style = PaintingStyle.fill, + ); + canvas.drawPath( + linePath, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(SparklinePainter old) => + old.data != data || old.color != color; +} + +class Sparkline extends StatelessWidget { + const Sparkline({ + super.key, + required this.data, + required this.color, + this.width = 42, + this.height = 16, + this.strokeWidth = 1.5, + }); + + final List data; + final Color color; + final double width; + final double height; + final double strokeWidth; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: CustomPaint( + painter: SparklinePainter( + data: data, + color: color, + strokeWidth: strokeWidth, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/volume_chart.dart b/workout-logger/lib/screens/widgets/volume_chart.dart new file mode 100644 index 0000000..ff779b7 --- /dev/null +++ b/workout-logger/lib/screens/widgets/volume_chart.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../theme/app_theme.dart'; + +class VolumeChart extends StatelessWidget { + const VolumeChart({ + super.key, + required this.data, + this.color, + this.height = 130, + this.labels = const [], + }); + + final List data; + final Color? color; + final double height; + final List labels; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return Column( + children: [ + SizedBox( + height: height, + child: CustomPaint( + size: Size.infinite, + painter: _VolumeCurvePainter(data: data, color: c), + ), + ), + if (labels.isNotEmpty) ...[ + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: labels + .map((l) => Text( + l, + style: GoogleFonts.geistMono( + fontSize: 9, + color: AppColors.textFaint, + ), + )) + .toList(), + ), + ], + ], + ); + } +} + +class _VolumeCurvePainter extends CustomPainter { + const _VolumeCurvePainter({required this.data, required this.color}); + + final List data; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + + final minVal = data.reduce((a, b) => a < b ? a : b); + final maxVal = data.reduce((a, b) => a > b ? a : b); + final range = maxVal - minVal == 0 ? 1.0 : maxVal - minVal; + final w = size.width; + final h = size.height; + + final pts = List.generate(data.length, (i) { + final x = i / (data.length - 1) * w; + final y = h - ((data[i] - minVal) / range) * (h - 16) - 8; + return Offset(x, y); + }); + + // Build smooth bezier path + final smooth = Path()..moveTo(pts[0].dx, pts[0].dy); + for (int i = 1; i < pts.length; i++) { + final p0 = pts[i - 1]; + final p1 = pts[i]; + final cx = (p0.dx + p1.dx) / 2; + smooth.cubicTo(cx, p0.dy, cx, p1.dy, p1.dx, p1.dy); + } + + // Filled area + final area = Path()..addPath(smooth, Offset.zero); + area.lineTo(w, h); + area.lineTo(0, h); + area.close(); + + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.4), + color.withValues(alpha: 0), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h)), + ); + + // Gridlines + for (int i = 0; i < 4; i++) { + final y = h * i / 3; + canvas.drawLine( + Offset(0, y), + Offset(w, y), + Paint() + ..color = const Color(0x0AFFFFFF) + ..strokeWidth = 1, + ); + } + + // Line stroke + canvas.drawPath( + smooth, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + + // Data point dots + for (int i = 0; i < pts.length; i++) { + final isLast = i == pts.length - 1; + canvas.drawCircle( + pts[i], + isLast ? 4 : 2.5, + Paint()..color = isLast ? Colors.white : color, + ); + if (isLast) { + canvas.drawCircle( + pts[i], + 4, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + } + } + + @override + bool shouldRepaint(_VolumeCurvePainter old) => + old.data != data || old.color != color; +} diff --git a/workout-logger/lib/screens/widgets/wheel_picker.dart b/workout-logger/lib/screens/widgets/wheel_picker.dart new file mode 100644 index 0000000..56d83a0 --- /dev/null +++ b/workout-logger/lib/screens/widgets/wheel_picker.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../theme/app_theme.dart'; + +/// Two-column wheel picker for weight + reps input. +class WheelPickerField extends StatelessWidget { + const WheelPickerField({ + super.key, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + this.weightStep = 2.5, + this.weightMin = 0, + this.weightMax = 200, + this.repsMin = 1, + this.repsMax = 50, + }); + + final double weight; + final int reps; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final double weightStep; + final double weightMin; + final double weightMax; + final int repsMin; + final int repsMax; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + flex: 14, + child: _SingleWheel( + label: 'WEIGHT', + unit: 'kg', + color: AppColors.primary, + value: weight, + values: _buildDoubleRange(weightMin, weightMax, weightStep), + formatter: (v) => v == v.truncateToDouble() + ? v.toInt().toString() + : v.toStringAsFixed(1), + onChanged: (v) { + HapticFeedback.selectionClick(); + onWeightChanged(v); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 10, + child: _SingleWheel( + label: 'REPS', + unit: '', + color: AppColors.secondary, + value: reps, + values: List.generate(repsMax - repsMin + 1, (i) => repsMin + i), + formatter: (v) => v.toString(), + onChanged: (v) { + HapticFeedback.selectionClick(); + onRepsChanged(v); + }, + ), + ), + ], + ); + } + + static List _buildDoubleRange( + double min, double max, double step) { + final result = []; + double v = min; + while (v <= max + 0.001) { + result.add(double.parse(v.toStringAsFixed(2))); + v += step; + } + return result; + } +} + +class _SingleWheel extends StatefulWidget { + const _SingleWheel({ + required this.label, + required this.unit, + required this.color, + required this.value, + required this.values, + required this.formatter, + required this.onChanged, + }); + + final String label; + final String unit; + final Color color; + final T value; + final List values; + final String Function(T) formatter; + final ValueChanged onChanged; + + @override + State<_SingleWheel> createState() => _SingleWheelState(); +} + +class _SingleWheelState extends State<_SingleWheel> { + late FixedExtentScrollController _ctrl; + int _selectedIndex = 0; + + static const double _itemH = 38; + + @override + void initState() { + super.initState(); + _selectedIndex = widget.values.indexOf(widget.value); + if (_selectedIndex < 0) _selectedIndex = 0; + _ctrl = FixedExtentScrollController(initialItem: _selectedIndex); + } + + @override + void didUpdateWidget(_SingleWheel old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + final idx = widget.values.indexOf(widget.value); + if (idx >= 0 && idx != _selectedIndex) { + _selectedIndex = idx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_ctrl.hasClients) _ctrl.jumpToItem(idx); + }); + } + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x09FFFFFF), Color(0x04FFFFFF)], + ), + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.label, + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.5, + ), + ), + if (widget.unit.isNotEmpty) + Text( + widget.unit, + style: GoogleFonts.geist( + fontSize: 10, + color: AppColors.textFaint, + ), + ), + ], + ), + const SizedBox(height: 6), + SizedBox( + height: _itemH * 3, + child: Stack( + children: [ + // Selection band + Positioned( + top: _itemH, + left: -12, + right: -12, + child: Container( + height: _itemH, + decoration: BoxDecoration( + color: const Color(0x06FFFFFF), + border: Border( + top: BorderSide( + color: AppColors.glassBorderStrong, width: 1), + bottom: BorderSide( + color: AppColors.glassBorderStrong, width: 1), + ), + ), + ), + ), + // Wheel + ListWheelScrollView.useDelegate( + controller: _ctrl, + itemExtent: _itemH, + physics: const FixedExtentScrollPhysics(), + diameterRatio: 3, + overAndUnderCenterOpacity: 0.3, + onSelectedItemChanged: (i) { + setState(() => _selectedIndex = i); + widget.onChanged(widget.values[i]); + }, + childDelegate: ListWheelChildBuilderDelegate( + childCount: widget.values.length, + builder: (context, i) { + final isCurrent = i == _selectedIndex; + return Center( + child: Text( + widget.formatter(widget.values[i]), + style: GoogleFonts.geistMono( + fontSize: isCurrent ? 28 : 16, + fontWeight: FontWeight.w600, + color: isCurrent + ? widget.color + : AppColors.textPrimary, + letterSpacing: + -0.02 * (isCurrent ? 28 : 16), + ), + ), + ); + }, + ), + ), + // Top fade + Positioned( + top: 0, + left: 0, + right: 0, + height: _itemH, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.surface, + AppColors.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + // Bottom fade + Positioned( + bottom: 0, + left: 0, + right: 0, + height: _itemH, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + AppColors.surface, + AppColors.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart index 8d8d6d8..e36aa62 100644 --- a/workout-logger/lib/screens/widgets/workout_header.dart +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; @@ -80,15 +81,12 @@ class _WorkoutHeaderState extends State { Widget build(BuildContext context) { return Container( decoration: BoxDecoration( - color: AppColors.surface, + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF0C0C12), Color(0x000C0C12)], + ), border: Border(bottom: BorderSide(color: AppColors.glassBorder)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], ), child: SafeArea( bottom: false, @@ -111,10 +109,11 @@ class _WorkoutHeaderState extends State { children: [ Text( widget.exerciseName, - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 17, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, ), textAlign: TextAlign.center, maxLines: 1, @@ -125,28 +124,10 @@ class _WorkoutHeaderState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - '${widget.currentExerciseIndex + 1}/${widget.totalExercises}', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 12, - ), - ), - const SizedBox(width: 8), - Container( - width: 3, - height: 3, - decoration: const BoxDecoration( + 'Exercise ${widget.currentExerciseIndex + 1} of ${widget.totalExercises} · Set ${widget.setNumber}', + style: GoogleFonts.geist( color: AppColors.textMuted, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - 'Set ${widget.setNumber}', - style: const TextStyle( - color: AppColors.primary, - fontSize: 12, - fontWeight: FontWeight.w600, + fontSize: 11, ), ), ], @@ -170,18 +151,13 @@ class _WorkoutHeaderState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.timer_outlined, - size: 12, - color: AppColors.textMuted, - ), + const Icon(Icons.timer_outlined, size: 12, color: AppColors.textMuted), const SizedBox(width: 4), Text( _elapsedLabel, - style: const TextStyle( + style: GoogleFonts.geistMono( color: AppColors.textSoft, fontSize: 12, - fontFeatures: [FontFeature.tabularFigures()], ), ), ], diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index 9b0ff4d..73daa3f 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; // ── AppColors ────────────────────────────────────────────────────────────── // Single source of truth for all colour tokens. Never use hex literals in @@ -6,31 +7,35 @@ import 'package:flutter/material.dart'; class AppColors { const AppColors._(); - // Backgrounds - static const background = Color(0xFF080B10); - static const surface = Color(0xFF0F1318); - static const card = Color(0xFF161B22); - static const cardHigh = Color(0xFF1C2333); + // Backgrounds — soft-futurist dark + static const background = Color(0xFF07070A); // --bg + static const surface = Color(0xFF0C0C12); // --bg-1 + static const card = Color(0xFF11111A); // --bg-2 + static const cardHigh = Color(0xFF1A1A24); // slightly elevated - // Glassmorphism - static const glass = Color(0x0AFFFFFF); // 4 % white - static const glassBorder = Color(0x14FFFFFF); // 8 % white - static const divider = Color(0x0FFFFFFF); // 6 % white + // Glassmorphism surfaces + static const glass = Color(0x0AFFFFFF); // --surface 4% + static const glass2 = Color(0x0FFFFFFF); // --surface-2 6% + static const glass3 = Color(0x17FFFFFF); // --surface-3 9% + static const glassBorder = Color(0x12FFFFFF); // --border 7% + static const glassBorderStrong = Color(0x21FFFFFF); // --border-strong 13% + static const divider = Color(0x0FFFFFFF); // 6% white - // Brand - static const primary = Color(0xFF6C5CE7); - static const secondary = Color(0xFF00D9FF); - static const accent = Color(0xFFFF6B6B); + // Brand — electric violet primary, cyan data + static const primary = Color(0xFF7C3AED); // --accent oklch(0.68 0.18 285) + static const secondary = Color(0xFF00C2D4); // --data oklch(0.78 0.14 200) + static const accent = Color(0xFF7C3AED); // alias for primary // Semantic - static const success = Color(0xFF00D26A); - static const warning = Color(0xFFFFB800); - static const error = Color(0xFFFF4757); + static const success = Color(0xFF00C89B); // --success oklch(0.78 0.16 155) + static const warning = Color(0xFFDBA520); // --warn oklch(0.78 0.14 60) + static const error = Color(0xFFE05040); // --danger oklch(0.68 0.20 25) - // Text - static const textPrimary = Color(0xFFE6EDF3); - static const textSoft = Color(0xFF8B949E); - static const textMuted = Color(0xFF484F58); + // Text — opacity levels over the near-white base #F4F4F8 + static const textPrimary = Color(0xFFF4F4F8); // --fg + static const textSoft = Color(0xB8F4F4F8); // --fg-2 72% + static const textMuted = Color(0x7AF4F4F8); // --fg-3 48% + static const textFaint = Color(0x52F4F4F8); // --fg-4 32% // Glow helpers (use in BoxShadow) static Color primaryGlow([double opacity = 0.35]) => @@ -39,6 +44,10 @@ class AppColors { secondary.withValues(alpha: opacity); static Color accentGlow([double opacity = 0.35]) => accent.withValues(alpha: opacity); + static Color successGlow([double opacity = 0.35]) => + success.withValues(alpha: opacity); + static Color warningGlow([double opacity = 0.35]) => + warning.withValues(alpha: opacity); // Muscle group palette static Color muscle(String id) => _muscleColors[id] ?? primary; @@ -49,7 +58,7 @@ class AppColors { 'back': Color(0xFF4ECDC4), 'lats': Color(0xFF45B7AA), 'lower_back': Color(0xFF3D9D94), - 'shoulders': Color(0xFF6C5CE7), + 'shoulders': Color(0xFF7C3AED), 'front_delts': Color(0xFF8B7FE8), 'side_delts': Color(0xFF9D93EA), 'rear_delts': Color(0xFFAFA6EC), @@ -66,11 +75,10 @@ class AppColors { } // ── AppTheme ─────────────────────────────────────────────────────────────── -// Backward-compat aliases + ThemeData builder. class AppTheme { const AppTheme._(); - // Aliases (keep existing callsites compiling during migration) + // Backward-compat aliases static const Color primaryColor = AppColors.primary; static const Color secondaryColor = AppColors.secondary; static const Color accentColor = AppColors.accent; @@ -88,8 +96,8 @@ class AppTheme { static Color getMuscleColor(String id) => AppColors.muscle(id); static ThemeData get darkTheme { - return ThemeData( - useMaterial3: true, + final base = ThemeData.dark(useMaterial3: true); + return base.copyWith( brightness: Brightness.dark, pageTransitionsTheme: const PageTransitionsTheme( builders: { @@ -103,27 +111,28 @@ class AppTheme { surface: AppColors.surface, error: AppColors.error, onPrimary: Colors.white, - onSecondary: Colors.black, + onSecondary: Colors.white, onSurface: AppColors.textPrimary, onError: Colors.white, ), - appBarTheme: const AppBarTheme( + textTheme: _buildTextTheme(), + appBarTheme: AppBarTheme( backgroundColor: AppColors.background, foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle( + titleTextStyle: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w700, - letterSpacing: -0.3, + letterSpacing: -0.44, ), ), cardTheme: CardThemeData( color: AppColors.card, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(18), ), ), elevatedButtonTheme: ElevatedButtonThemeData( @@ -131,12 +140,12 @@ class AppTheme { backgroundColor: AppColors.primary, foregroundColor: Colors.white, elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), - textStyle: const TextStyle( - fontSize: 16, + textStyle: GoogleFonts.geist( + fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.2, ), @@ -146,7 +155,7 @@ class AppTheme { style: OutlinedButton.styleFrom( foregroundColor: AppColors.primary, side: const BorderSide(color: AppColors.primary), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), @@ -157,7 +166,7 @@ class AppTheme { ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: AppColors.surface, + fillColor: AppColors.glass, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -170,34 +179,20 @@ class AppTheme { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.primary, width: 2), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - hintStyle: const TextStyle(color: AppColors.textMuted), - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: AppColors.surface, - selectedItemColor: AppColors.primary, - unselectedItemColor: AppColors.textSoft, - type: BottomNavigationBarType.fixed, - elevation: 0, - ), - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - elevation: 0, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + hintStyle: const TextStyle(color: AppColors.textFaint), ), dividerTheme: const DividerThemeData( color: AppColors.divider, thickness: 1, ), chipTheme: ChipThemeData( - backgroundColor: AppColors.card, - selectedColor: Color(0x4D6C5CE7), - labelStyle: const TextStyle(color: AppColors.textPrimary), - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + backgroundColor: AppColors.glass2, + selectedColor: Color(0x267C3AED), + labelStyle: const TextStyle(color: AppColors.textPrimary, fontSize: 11), + side: const BorderSide(color: AppColors.glassBorder), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), ), snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.cardHigh, @@ -205,48 +200,78 @@ class AppTheme { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), behavior: SnackBarBehavior.floating, ), - textTheme: const TextTheme( - headlineLarge: TextStyle( - color: AppColors.textPrimary, - fontSize: 32, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - ), - headlineMedium: TextStyle( - color: AppColors.textPrimary, - fontSize: 24, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - headlineSmall: TextStyle( - color: AppColors.textPrimary, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - titleLarge: TextStyle( - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - titleMedium: TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - titleSmall: TextStyle( - color: AppColors.textSoft, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - bodyLarge: TextStyle(color: AppColors.textPrimary, fontSize: 16), - bodyMedium: TextStyle(color: AppColors.textSoft, fontSize: 14), - bodySmall: TextStyle(color: AppColors.textMuted, fontSize: 12), - labelLarge: TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + ), + ); + } + + static TextTheme _buildTextTheme() { + return TextTheme( + headlineLarge: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -1.28, + ), + headlineMedium: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -1.12, + ), + headlineSmall: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.88, + ), + titleLarge: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + titleMedium: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + titleSmall: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + bodyLarge: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + ), + bodyMedium: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 14, + ), + bodySmall: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + labelLarge: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + ), + labelMedium: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + labelSmall: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, ), ); } @@ -268,6 +293,7 @@ class AppRadius { static const double sm = 8; static const double md = 12; static const double lg = 16; - static const double xl = 24; + static const double xl = 18; // glass card radius + static const double xxl = 22; // nav pill radius static const double full = 999; } diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 00ac1fe..284cbe3 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -52,6 +52,9 @@ dependencies: http: ^1.2.1 package_info_plus: ^8.3.1 + # Fonts — Geist Sans + Geist Mono (requires ^8.0.0 for Geist support) + google_fonts: ^8.0.0 + # Health Connect integration health_connector: ^3.8.1 From 9b86aad97f84c58a14e944297579ee9b1db5a0c4 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 12 May 2026 23:04:48 +0530 Subject: [PATCH 05/44] feat: add personal records feature - Introduced PersonalRecord model to track best weight, reps, and volume for exercises. - Implemented PRManager to manage personal records, including checking and updating records after workouts. - Added methods in storage service for saving and retrieving personal records. - Updated UI to display personal records in the Analytics screen and Workout Summary screen. - Enhanced onboarding process to prompt for user name and handle version updates. - Refactored various screens to improve layout and user experience. --- workout-logger/lib/main.dart | 63 +++- workout-logger/lib/models/models.dart | 47 +++ .../lib/screens/analytics_screen.dart | 174 ++++++++- .../lib/screens/history_screen.dart | 14 +- workout-logger/lib/screens/home_screen.dart | 7 +- .../lib/screens/onboarding_screen.dart | 342 ++++++++++++++++++ .../lib/screens/routines_screen.dart | 14 +- .../widgets/editable_exercise_card.dart | 2 +- .../widgets/exercise_input_section.dart | 34 +- .../lib/screens/widgets/routine_creator.dart | 2 + .../lib/screens/workout_flow_screen.dart | 122 ++++--- .../lib/screens/workout_summary_screen.dart | 75 +++- .../interfaces/storage_service_interface.dart | 6 + .../lib/services/managers/managers.dart | 1 + .../lib/services/managers/pr_manager.dart | 105 ++++++ .../lib/services/settings_provider.dart | 32 +- .../lib/services/storage_service.dart | 31 ++ .../test/test_utils/mock_storage_service.dart | 14 + 18 files changed, 969 insertions(+), 116 deletions(-) create mode 100644 workout-logger/lib/screens/onboarding_screen.dart create mode 100644 workout-logger/lib/services/managers/pr_manager.dart diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 0a7f206..529aaaa 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -19,8 +19,10 @@ import 'services/api_service.dart'; import 'services/managers/program_manager.dart'; import 'services/managers/history_manager.dart'; import 'services/managers/health_sync_manager.dart'; +import 'services/managers/pr_manager.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; +import 'screens/onboarding_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -58,6 +60,7 @@ class WorkoutLoggerApp extends StatelessWidget { // HistoryManager is the single owner of session history + HC sync trigger. static final HistoryManager _historyManager = HistoryManager(_storageService, healthSyncManager: _healthSyncManager); + static final PRManager _prManager = PRManager(_storageService); const WorkoutLoggerApp({super.key}); @@ -83,6 +86,7 @@ class WorkoutLoggerApp extends StatelessWidget { // HistoryManager is the single source of truth for session history. // Provided as ChangeNotifier so HistoryScreen rebuilds on sync badge changes. ChangeNotifierProvider.value(value: _historyManager), + ChangeNotifierProvider.value(value: _prManager), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( create: (_) => WorkoutProvider( @@ -112,6 +116,7 @@ class AppInitializer extends StatefulWidget { class _AppInitializerState extends State { bool _initialized = false; + bool _needsNamePrompt = false; String? _error; @override @@ -121,32 +126,48 @@ class _AppInitializerState extends State { } Future _initializeApp() async { + // Capture all providers synchronously before any awaits. + final provider = context.read(); + final settings = context.read(); + final historyManager = context.read(); + final prManager = context.read(); + final api = context.read(); + try { - final provider = context.read(); await provider.init(); - - final settings = context.read(); await settings.init(); - - // Load HistoryManager session list (independent of WorkoutProvider). - final historyManager = context.read(); await historyManager.loadSessions(); + await prManager.load(); + await prManager.backfillFromSessions(historyManager.sessions); - // Fire-and-forget analytics in background - final api = context.read(); + final version = await settings.getCurrentVersion(); + final needsName = settings.userName == null || settings.userName!.isEmpty; + final versionChanged = !needsName && + settings.lastSeenVersion != null && + settings.lastSeenVersion != version; + + // Fire-and-forget analytics in background. api.sendHeartbeat(); api.trackEvent('app_open'); - provider - .getQuickStats() - .then((stats) { - api.reportUsage(stats); - }) - .catchError((e) { - debugPrint('Failed to report usage: $e'); - }); - - setState(() => _initialized = true); + provider.getQuickStats().then((stats) => api.reportUsage(stats)).catchError( + (Object e) => debugPrint('Failed to report usage: $e'), + ); + + if (!mounted) return; + setState(() { + _initialized = true; + _needsNamePrompt = needsName; + }); + + if (!needsName && versionChanged) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await showVersionUpdateSheet(context, version); + if (mounted) await settings.markVersionSeen(version); + }); + } } catch (e) { + if (!mounted) return; setState(() => _error = e.toString()); } } @@ -215,6 +236,12 @@ class _AppInitializerState extends State { ); } + if (_needsNamePrompt) { + return WelcomePage( + onComplete: () => setState(() => _needsNamePrompt = false), + ); + } + return const HomeScreen(); } } diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index eea2168..28938d6 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -405,6 +405,53 @@ class GrowthModel { } } +// ==================== Personal Record ==================== + +class PersonalRecord { + final String exerciseId; + final double bestWeight; // heaviest weight in any single set + final int bestReps; // most reps in any single set + final double bestVolume; // highest single-set volume (weight × reps) + final DateTime achievedAt; + + PersonalRecord({ + required this.exerciseId, + required this.bestWeight, + required this.bestReps, + required this.bestVolume, + required this.achievedAt, + }); + + Map toJson() => { + 'exerciseId': exerciseId, + 'bestWeight': bestWeight, + 'bestReps': bestReps, + 'bestVolume': bestVolume, + 'achievedAt': achievedAt.toIso8601String(), + }; + + factory PersonalRecord.fromJson(Map json) => PersonalRecord( + exerciseId: json['exerciseId'] as String, + bestWeight: (json['bestWeight'] as num).toDouble(), + bestReps: json['bestReps'] as int, + bestVolume: (json['bestVolume'] as num).toDouble(), + achievedAt: DateTime.parse(json['achievedAt'] as String), + ); + + PersonalRecord copyWith({ + double? bestWeight, + int? bestReps, + double? bestVolume, + DateTime? achievedAt, + }) => PersonalRecord( + exerciseId: exerciseId, + bestWeight: bestWeight ?? this.bestWeight, + bestReps: bestReps ?? this.bestReps, + bestVolume: bestVolume ?? this.bestVolume, + achievedAt: achievedAt ?? this.achievedAt, + ); +} + // ==================== Training Program ==================== /// One exercise slot inside a program day. diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 5bc5e6e..cb89d66 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -6,7 +6,9 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../models/models.dart'; import '../services/workout_provider.dart'; +import '../services/managers/pr_manager.dart'; import '../data/exercise_database.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; @@ -23,26 +25,24 @@ class AnalyticsScreen extends StatefulWidget { class _AnalyticsScreenState extends State { int _tab = 0; - static const _tabs = ['Overview', 'Exercises', 'Targets']; + static const _tabs = ['Overview', 'Exercises', 'Targets', 'Records']; @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - body: Stack( - children: [ - const AmbientGlow(), - SafeArea( - child: Column( - children: [ - _buildHeader(), - _buildPillTabBar(), - Expanded(child: _buildTabView()), - ], - ), + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, + child: Column( + children: [ + _buildHeader(), + _buildPillTabBar(), + Expanded(child: _buildTabView()), + ], ), - ], - ), + ), + ], ); } @@ -141,6 +141,8 @@ class _AnalyticsScreenState extends State { return const ExerciseProgressView(); case 2: return const TargetsTab(); + case 3: + return const _RecordsTab(); default: return const SizedBox.shrink(); } @@ -397,6 +399,146 @@ class _FrequencyGrid extends StatelessWidget { } } +// ── Records Tab ─────────────────────────────────────────────────────────────── + +class _RecordsTab extends StatelessWidget { + const _RecordsTab(); + + @override + Widget build(BuildContext context) { + final prManager = context.watch(); + final provider = context.read(); + final records = prManager.allRecords + ..sort((a, b) => b.achievedAt.compareTo(a.achievedAt)); + + if (records.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.emoji_events_rounded, size: 48, color: AppColors.textFaint), + const SizedBox(height: 12), + Text( + 'No records yet', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + 'Finish a workout to set your first PRs', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + ), + ], + ), + ); + } + + return ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 100), + itemCount: records.length, + itemBuilder: (context, i) => _PRCard( + record: records[i], + exerciseName: provider.getExerciseName(records[i].exerciseId), + ), + ); + } +} + +class _PRCard extends StatelessWidget { + const _PRCard({required this.record, required this.exerciseName}); + + final PersonalRecord record; + final String exerciseName; + + @override + Widget build(BuildContext context) { + final dateStr = DateFormat('MMM d, yyyy').format(record.achievedAt); + + return GlassCard( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.emoji_events_rounded, color: AppColors.warning, size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exerciseName, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + dateStr, + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + _PRStat(label: 'Best Weight', value: '${record.bestWeight.toStringAsFixed(record.bestWeight % 1 == 0 ? 0 : 1)} kg', color: AppColors.warning), + const SizedBox(width: AppSpacing.sm), + _PRStat(label: 'Best Reps', value: '${record.bestReps}', color: AppColors.secondary), + const SizedBox(width: AppSpacing.sm), + _PRStat(label: 'Best Vol.', value: '${record.bestVolume.toStringAsFixed(0)} kg', color: AppColors.success), + ], + ), + ], + ), + ); + } +} + +class _PRStat extends StatelessWidget { + const _PRStat({required this.label, required this.value, required this.color}); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + const SizedBox(height: 2), + Text(value, style: GoogleFonts.geistMono(color: color, fontSize: 13, fontWeight: FontWeight.w700)), + ], + ), + ), + ); + } +} + // ── Reusable chart card ──────────────────────────────────────────────────────── class _ChartCard extends StatelessWidget { diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 8e56e2e..0f8cce3 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -86,12 +86,11 @@ class _HistoryScreenState extends State { final totalVolume = all.fold(0, (s, e) => s + e.totalVolume); final hasUnsynced = settings.healthConnectEnabled && all.any((s) => s.hcSyncedAt == null); - return Scaffold( - backgroundColor: AppColors.background, - body: Stack( - children: [ - const AmbientGlow(), - SafeArea( + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ @@ -169,8 +168,7 @@ class _HistoryScreenState extends State { ), ), ], - ), - ); + ); } Widget _buildHeader(BuildContext context, bool hasUnsynced, HistoryManager historyManager) { diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index b44d0d8..9aacedc 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -8,6 +8,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; +import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'workout_flow_screen.dart'; import 'history_screen.dart'; @@ -44,6 +45,7 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( + extendBody: true, backgroundColor: AppColors.background, body: IndexedStack( index: _currentIndex, @@ -176,6 +178,7 @@ class _DashboardTab extends StatelessWidget { children: [ const AmbientGlow(), SafeArea( + bottom: false, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ @@ -240,8 +243,8 @@ class _DashboardTab extends StatelessWidget { children: [ TextSpan(text: '${_greeting()}\n'), TextSpan( - text: 'You.', - style: TextStyle(color: AppColors.textMuted), + text: '${context.watch().userName ?? 'You'}.', + style: const TextStyle(color: AppColors.textMuted), ), ], ), diff --git a/workout-logger/lib/screens/onboarding_screen.dart b/workout-logger/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..48ced1c --- /dev/null +++ b/workout-logger/lib/screens/onboarding_screen.dart @@ -0,0 +1,342 @@ +// onboarding_screen.dart — First-launch name prompt + version update modal. +// +// Shown by AppInitializer when: +// • userName == null → full welcome page asking for name +// • userName != null && version changed → version-update bottom sheet + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../services/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; + +// ── Welcome page (first install) ────────────────────────────────────────────── + +class WelcomePage extends StatefulWidget { + const WelcomePage({super.key, required this.onComplete}); + + final VoidCallback onComplete; + + @override + State createState() => _WelcomePageState(); +} + +class _WelcomePageState extends State { + final _controller = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + final name = _controller.text.trim(); + if (name.isEmpty) return; + + setState(() => _saving = true); + final settings = context.read(); + await settings.setUserName(name); + final version = await settings.getCurrentVersion(); + await settings.markVersionSeen(version); + + if (!mounted) return; + widget.onComplete(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(flex: 2), + // Logo mark + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.lg), + gradient: LinearGradient( + colors: [ + AppColors.primary, + AppColors.secondary.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.5), + blurRadius: 32, + spreadRadius: 4, + ), + ], + ), + child: const Icon( + Icons.fitness_center_rounded, + color: Colors.white, + size: 36, + ), + ), + const SizedBox(height: AppSpacing.xl), + Text( + 'Welcome to\nRepForge', + style: GoogleFonts.geist( + fontSize: 36, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 1.1, + letterSpacing: -1, + ), + ), + const SizedBox(height: 12), + Text( + 'Track every rep. Beat every record.\nForge your best self.', + style: GoogleFonts.geist( + fontSize: 15, + color: AppColors.textMuted, + height: 1.5, + ), + ), + const Spacer(flex: 2), + Text( + 'WHAT SHOULD WE CALL YOU?', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.words, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + hintText: 'Your name', + hintStyle: GoogleFonts.geist(color: AppColors.textFaint), + filled: true, + fillColor: AppColors.glass2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: AppColors.glassBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: GlowButton( + label: _saving ? 'Setting up…' : "Let's Go!", + icon: Icons.arrow_forward_rounded, + onPressed: _saving ? () {} : _submit, + ), + ), + const Spacer(flex: 1), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Version-update bottom sheet ─────────────────────────────────────────────── + +Future showVersionUpdateSheet( + BuildContext context, + String version, +) async { + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => _VersionUpdateSheet(version: version), + ); +} + +class _VersionUpdateSheet extends StatelessWidget { + const _VersionUpdateSheet({required this.version}); + + final String version; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border(top: BorderSide(color: AppColors.glassBorder)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 40), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.new_releases_rounded, + color: AppColors.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Updated to v$version', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'RepForge is better than ever', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + _WhatsNewItem( + icon: Icons.emoji_events_rounded, + color: AppColors.warning, + title: 'Personal Records', + description: 'Automatically tracks your best weight, reps, and volume for every exercise.', + ), + const SizedBox(height: 12), + _WhatsNewItem( + icon: Icons.bar_chart_rounded, + color: AppColors.secondary, + title: 'Records Tab', + description: 'View all your PRs at a glance in Analytics → Records.', + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: GlowButton( + label: "Let's Crush It", + icon: Icons.check_rounded, + onPressed: () => Navigator.pop(context), + ), + ), + ], + ), + ); + } +} + +class _WhatsNewItem extends StatelessWidget { + const _WhatsNewItem({ + required this.icon, + required this.color, + required this.title, + required this.description, + }); + + final IconData icon; + final Color color; + final String title; + final String description; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 16), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + height: 1.4, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 8a060b6..d9c535c 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -20,12 +20,11 @@ class RoutinesScreen extends StatelessWidget { final provider = context.watch(); final routines = provider.routines; - return Scaffold( - backgroundColor: AppColors.background, - body: Stack( - children: [ - const AmbientGlow(), - SafeArea( + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ @@ -52,8 +51,7 @@ class RoutinesScreen extends StatelessWidget { ), ), ], - ), - ); + ); } Widget _buildHeader(BuildContext context, List routines) { diff --git a/workout-logger/lib/screens/widgets/editable_exercise_card.dart b/workout-logger/lib/screens/widgets/editable_exercise_card.dart index 5a61090..c60aa36 100644 --- a/workout-logger/lib/screens/widgets/editable_exercise_card.dart +++ b/workout-logger/lib/screens/widgets/editable_exercise_card.dart @@ -134,7 +134,7 @@ class EditableExerciseCard extends StatelessWidget { onRepsChanged: (r) => onSetChanged(i, set.weight, r, set.isDropset, set.drops), onIsDropsetChanged: (d) => - onSetChanged(i, set.weight, set.reps, d, set.drops), + onSetChanged(i, set.weight, set.reps, d, d ? (set.drops ?? []) : set.drops), onDropsChanged: (drops) => onSetChanged(i, set.weight, set.reps, set.isDropset, drops), onDelete: () => onDeleteSet(i), diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 5810e1b..769c016 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; @@ -336,7 +337,7 @@ class _NumberInputCard extends StatelessWidget { children: [ Text( label, - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textMuted, fontSize: 11, fontWeight: FontWeight.w600, @@ -354,11 +355,10 @@ class _NumberInputCard extends StatelessWidget { Expanded( child: Text( _format(), - style: const TextStyle( + style: GoogleFonts.geistMono( color: AppColors.textPrimary, fontSize: 36, - fontWeight: FontWeight.w800, - fontFeatures: [FontFeature.tabularFigures()], + fontWeight: FontWeight.w700, ), textAlign: TextAlign.center, ), @@ -388,12 +388,12 @@ class _StepBtn extends StatelessWidget { HapticFeedback.selectionClick(); }, child: Container( - width: 36, - height: 36, + width: 40, + height: 40, decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), + color: AppColors.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.25)), ), child: Icon(icon, size: 18, color: AppColors.primary), ), @@ -622,12 +622,12 @@ class _PreviousSetsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'THIS SESSION', - style: TextStyle( - color: AppColors.textMuted, + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 10, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: 1.2, ), ), @@ -723,12 +723,12 @@ class _LastSessionSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'LAST SESSION', - style: TextStyle( - color: AppColors.textMuted, + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 10, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: 1.2, ), ), diff --git a/workout-logger/lib/screens/widgets/routine_creator.dart b/workout-logger/lib/screens/widgets/routine_creator.dart index 99f4e06..7e30e8c 100644 --- a/workout-logger/lib/screens/widgets/routine_creator.dart +++ b/workout-logger/lib/screens/widgets/routine_creator.dart @@ -316,6 +316,8 @@ class _CreateRoutineScreenState extends State { GlowButton( label: 'Add ${temp.length}', icon: Icons.check_rounded, + fullWidth: false, + small: true, onPressed: () { setState(() => _selectedIds.addAll(temp)); Navigator.of(ctx).pop(); diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 2986a50..0464cfd 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -5,9 +5,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; + import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; +import '../services/managers/pr_manager.dart'; import '../theme/app_theme.dart'; import 'exercise_library_screen.dart'; import 'workout_summary_screen.dart'; @@ -352,61 +355,87 @@ class _WorkoutFlowScreenState extends State { } Widget _buildBottomNav(WorkoutProvider provider, bool isFirst, bool isLast) { + final bottomPad = MediaQuery.of(context).padding.bottom; return Container( - padding: EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm + MediaQuery.of(context).padding.bottom, - ), + padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomPad), decoration: BoxDecoration( - color: AppColors.surface, + color: AppColors.surface.withValues(alpha: 0.95), border: Border(top: BorderSide(color: AppColors.glassBorder)), ), child: Row( children: [ if (!isFirst) - Expanded( - child: OutlinedButton.icon( - onPressed: () { - provider.previousExercise(); - _loadLastSessionData(); - }, - icon: const Icon(Icons.arrow_back_rounded, size: 18), - label: const Text('Previous'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.textSoft, - side: BorderSide(color: AppColors.glassBorder), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - ), + GestureDetector( + onTap: () { + provider.previousExercise(); + _loadLastSessionData(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.arrow_back_rounded, size: 16, color: AppColors.textMuted), + const SizedBox(width: 6), + Text( + 'Prev', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + ), + ), + ], ), ), ) else - const Spacer(), - const SizedBox(width: AppSpacing.sm), - Expanded( - flex: 2, - child: ElevatedButton.icon( - onPressed: isLast - ? _finishWorkout - : () { - provider.nextExercise(); - _loadLastSessionData(); - }, - icon: Icon( - isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, - size: 18, + const SizedBox.shrink(), + const Spacer(), + GestureDetector( + onTap: isLast + ? _finishWorkout + : () { + provider.nextExercise(); + _loadLastSessionData(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + decoration: BoxDecoration( + color: isLast ? AppColors.success : AppColors.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: (isLast ? AppColors.success : AppColors.primary) + .withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], ), - label: Text(isLast ? 'Finish' : 'Next'), - style: ElevatedButton.styleFrom( - backgroundColor: isLast ? AppColors.success : AppColors.primary, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isLast ? 'Finish' : 'Next exercise', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(width: 6), + Icon( + isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, + size: 16, + color: Colors.white, + ), + ], ), ), ), @@ -609,13 +638,18 @@ class _WorkoutFlowScreenState extends State { ElevatedButton( onPressed: () async { final nav = Navigator.of(context); + final prManager = context.read(); Navigator.of(ctx).pop(); final session = await context.read().finishWorkout(); + final newPRs = await prManager.checkAndUpdatePRs(session); if (!mounted) return; nav.pop(); nav.push(MaterialPageRoute( - builder: (_) => WorkoutSummaryScreen(session: session), + builder: (_) => WorkoutSummaryScreen( + session: session, + newPRs: newPRs, + ), )); }, style: ElevatedButton.styleFrom( diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart index 8d736f1..c5c3b6a 100644 --- a/workout-logger/lib/screens/workout_summary_screen.dart +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -6,14 +6,20 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; +import '../services/managers/pr_manager.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/rf_cards.dart'; class WorkoutSummaryScreen extends StatelessWidget { - const WorkoutSummaryScreen({super.key, required this.session}); + const WorkoutSummaryScreen({ + super.key, + required this.session, + this.newPRs = const [], + }); final WorkoutSession session; + final List newPRs; @override Widget build(BuildContext context) { @@ -59,6 +65,10 @@ class WorkoutSummaryScreen extends StatelessWidget { totalSets, session.exercises.length, ), + if (newPRs.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _buildPRSection(newPRs, provider), + ], if (muscles.isNotEmpty) ...[ const SizedBox(height: AppSpacing.lg), _buildMusclesSection(muscles, provider), @@ -190,6 +200,69 @@ class WorkoutSummaryScreen extends StatelessWidget { ); } + Widget _buildPRSection(List prs, WorkoutProvider provider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('New Personal Records'), + const SizedBox(height: AppSpacing.sm), + ...prs.map((pr) { + final name = provider.getExerciseName(pr.exerciseId); + final badges = pr.types.map((t) { + final (label, color) = switch (t) { + 'weight' => ('Best Weight', AppColors.warning), + 'reps' => ('Best Reps', AppColors.secondary), + _ => ('Best Volume', AppColors.success), + }; + return RFChip(label: label, color: color); + }).toList(); + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.warning.withValues(alpha: 0.08), + AppColors.warning.withValues(alpha: 0.03), + ], + ), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.emoji_events_rounded, + color: AppColors.warning, + size: 16, + ), + const SizedBox(width: 6), + Text( + name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Wrap(spacing: 6, runSpacing: 6, children: badges), + ], + ), + ); + }), + ], + ); + } + Widget _buildMusclesSection(Set muscles, WorkoutProvider provider) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index 7ad6e1e..9274ea1 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -67,6 +67,12 @@ abstract class IStorageService { Future getTrainingProgram(String id); Future deleteTrainingProgram(String id); + // ==================== PERSONAL RECORDS ==================== + + Future savePersonalRecord(PersonalRecord record); + Future getPersonalRecord(String exerciseId); + Future> getAllPersonalRecords(); + // ==================== EXPORT / IMPORT ==================== Future exportAllData(); diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index 8a6aed8..fb59b87 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -17,3 +17,4 @@ export 'target_manager.dart'; export 'analytics_manager.dart'; export 'program_manager.dart'; export 'health_sync_manager.dart'; +export 'pr_manager.dart'; diff --git a/workout-logger/lib/services/managers/pr_manager.dart b/workout-logger/lib/services/managers/pr_manager.dart new file mode 100644 index 0000000..073c38b --- /dev/null +++ b/workout-logger/lib/services/managers/pr_manager.dart @@ -0,0 +1,105 @@ +// PRManager — detects and persists personal records per exercise. +// +// After each workout session is saved, call checkAndUpdatePRs() to compare +// every logged set against the stored PR for that exercise. Returns a list +// of NewPRResult describing which record types were broken so the UI can +// display badges on the summary screen. + +import 'package:flutter/foundation.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +class NewPRResult { + final String exerciseId; + final Set types; // 'weight' | 'reps' | 'volume' + + const NewPRResult({required this.exerciseId, required this.types}); +} + +class PRManager extends ChangeNotifier { + final IStorageService _storage; + + // exerciseId → best known record + final Map _cache = {}; + + PRManager(this._storage); + + Future load() async { + final records = await _storage.getAllPersonalRecords(); + _cache.clear(); + for (final r in records) { + _cache[r.exerciseId] = r; + } + } + + List get allRecords => List.unmodifiable(_cache.values.toList()); + + /// Seed PRs from historical sessions when no stored records exist yet. + /// + /// Sessions must be sorted oldest → newest so later sessions win on ties. + Future backfillFromSessions(List sessions) async { + final sorted = [...sessions]..sort((a, b) => a.date.compareTo(b.date)); + for (final s in sorted) { + await checkAndUpdatePRs(s); + } + } + + PersonalRecord? getRecord(String exerciseId) => _cache[exerciseId]; + + /// Compare each exercise log in [session] against stored PRs. + /// + /// Updates storage + in-memory cache for any broken records. + /// Returns only entries where at least one record was broken. + Future> checkAndUpdatePRs(WorkoutSession session) async { + final results = []; + + for (final log in session.exercises) { + if (log.sets.isEmpty) continue; + + final broken = await _checkExercise(log, session.date); + if (broken.isNotEmpty) { + results.add(NewPRResult(exerciseId: log.exerciseId, types: broken)); + } + } + + if (results.isNotEmpty) notifyListeners(); + return results; + } + + Future> _checkExercise(ExerciseLog log, DateTime date) async { + final existing = _cache[log.exerciseId]; + + double newBestWeight = existing?.bestWeight ?? 0; + int newBestReps = existing?.bestReps ?? 0; + double newBestVolume = existing?.bestVolume ?? 0; + + for (final set in log.sets) { + if (set.weight > newBestWeight) newBestWeight = set.weight; + if (set.reps > newBestReps) newBestReps = set.reps; + if (set.volume > newBestVolume) newBestVolume = set.volume; + } + + final broken = {}; + if (existing == null) { + broken.addAll(['weight', 'reps', 'volume']); + } else { + if (newBestWeight > existing.bestWeight) broken.add('weight'); + if (newBestReps > existing.bestReps) broken.add('reps'); + if (newBestVolume > existing.bestVolume) broken.add('volume'); + } + + if (broken.isEmpty) return broken; + + final updated = PersonalRecord( + exerciseId: log.exerciseId, + bestWeight: newBestWeight, + bestReps: newBestReps, + bestVolume: newBestVolume, + achievedAt: date, + ); + _cache[log.exerciseId] = updated; + await _storage.savePersonalRecord(updated); + + return broken; + } +} diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 00d5cc1..d6d231b 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -1,6 +1,7 @@ -// Settings Provider - User preferences (weight unit, increments) +// Settings Provider - User preferences (weight unit, increments, user profile) import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'interfaces/storage_service_interface.dart'; enum WeightUnit { kg, lbs } @@ -11,11 +12,15 @@ class SettingsProvider extends ChangeNotifier { WeightUnit _weightUnit = WeightUnit.kg; double _weightIncrement = 2.5; bool _healthConnectEnabled = false; + String? _userName; + String? _lastSeenVersion; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; bool get healthConnectEnabled => _healthConnectEnabled; + String? get userName => _userName; + String? get lastSeenVersion => _lastSeenVersion; SettingsProvider(this._storage); @@ -30,6 +35,31 @@ class SettingsProvider extends ChangeNotifier { final hcEnabled = await _storage.getSetting('healthConnectEnabled'); _healthConnectEnabled = hcEnabled == 'true'; + + _userName = await _storage.getSetting('userName'); + _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); + } + + Future setUserName(String name) async { + _userName = name.trim(); + await _storage.saveSetting('userName', _userName!); + notifyListeners(); + } + + Future markVersionSeen(String version) async { + _lastSeenVersion = version; + await _storage.saveSetting('lastSeenVersion', version); + notifyListeners(); + } + + /// Returns the current app version string (e.g. "1.0.19"). + Future getCurrentVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + return info.version; + } catch (_) { + return 'unknown'; + } } double get _defaultIncrement => _weightUnit == WeightUnit.kg ? 2.5 : 5.0; diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index f06c0e2..40cdc39 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -24,6 +24,7 @@ class StorageService implements IStorageService { static const String _customExercisesBox = 'custom_exercises'; static const String _settingsBox = 'settings'; static const String _trainingProgramsBox = 'training_programs'; + static const String _personalRecordsBox = 'personal_records'; late Box _sessionsBox; late Box _routinesBoxInstance; @@ -32,6 +33,7 @@ class StorageService implements IStorageService { late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; late Box _trainingProgramsBoxInstance; + late Box _personalRecordsBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -68,6 +70,9 @@ class StorageService implements IStorageService { _trainingProgramsBoxInstance = await Hive.openBox( _trainingProgramsBox, ); + _personalRecordsBoxInstance = await Hive.openBox( + _personalRecordsBox, + ); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -484,6 +489,32 @@ class StorageService implements IStorageService { await _trainingProgramsBoxInstance.delete(id); } + // ==================== PERSONAL RECORDS ==================== + + @override + Future savePersonalRecord(PersonalRecord record) async { + await _personalRecordsBoxInstance.put( + record.exerciseId, + jsonEncode(record.toJson()), + ); + } + + @override + Future getPersonalRecord(String exerciseId) async { + final json = _personalRecordsBoxInstance.get(exerciseId); + if (json == null) return null; + return PersonalRecord.fromJson(jsonDecode(json)); + } + + @override + Future> getAllPersonalRecords() async { + final records = []; + for (final json in _personalRecordsBoxInstance.values) { + records.add(PersonalRecord.fromJson(jsonDecode(json))); + } + return records; + } + // ==================== STATS ==================== @override diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index 6444969..9685c3b 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -20,6 +20,7 @@ class MockStorageService implements IStorageService { final List _muscleGroups = []; final Map _settings = {}; final List _trainingPrograms = []; + final Map _personalRecords = {}; bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; @@ -265,6 +266,19 @@ class MockStorageService implements IStorageService { _trainingPrograms.removeWhere((p) => p.id == id); } + @override + Future savePersonalRecord(PersonalRecord record) async { + _personalRecords[record.exerciseId] = record; + } + + @override + Future getPersonalRecord(String exerciseId) async => + _personalRecords[exerciseId]; + + @override + Future> getAllPersonalRecords() async => + List.from(_personalRecords.values); + @override Future exportAllData() async => '{}'; From 8027a7c9641d3ea693f0c3931500e382912e3ecf Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 12 May 2026 23:19:54 +0530 Subject: [PATCH 06/44] feat: update exercise library screen tests with new icon and text changes --- .../test/add_custom_exercise_screen_test.dart | 2 +- .../test/exercise_library_screen_test.dart | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index c4e260e..7399dd5 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -186,7 +186,7 @@ void main() { // Assert - Description should change expect( - find.text('Targets a single muscle group (e.g., bicep curls)'), + find.text('Single muscle group'), findsOneWidget, ); }); diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 3eb9da7..2939889 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -40,8 +40,8 @@ void main() { await tester.pumpAndSettle(); // Assert - expect(find.byIcon(Icons.search), findsOneWidget); - expect(find.text('Search exercises...'), findsOneWidget); + expect(find.byIcon(Icons.search_rounded), findsOneWidget); + expect(find.text('Search exercises…'), findsOneWidget); }); testWidgets('should display FAB to add custom exercise', (tester) async { @@ -56,7 +56,7 @@ void main() { // Assert expect(find.byType(FloatingActionButton), findsOneWidget); - expect(find.text('Add Exercise'), findsOneWidget); + expect(find.byIcon(Icons.add_rounded), findsOneWidget); }); testWidgets('should display custom exercises in the list', (tester) async { @@ -97,8 +97,8 @@ void main() { ); await tester.pumpAndSettle(); - // Assert - Should find the CUSTOM tag - expect(find.text('CUSTOM'), findsOneWidget); + // Assert - Should find the Custom tag + expect(find.text('Custom'), findsOneWidget); }); testWidgets('should display custom exercise count in header when present', ( @@ -184,7 +184,7 @@ void main() { await tester.pumpAndSettle(); // Assert - Should navigate to AddCustomExerciseScreen - expect(find.text('Add Custom Exercise'), findsOneWidget); + expect(find.text('New Exercise'), findsOneWidget); }); }); @@ -220,7 +220,7 @@ void main() { await tester.pumpAndSettle(); // Assert - Should show details sheet with delete option - expect(find.byIcon(Icons.delete_outline), findsOneWidget); + expect(find.byIcon(Icons.delete_outline_rounded), findsOneWidget); }); testWidgets('should show confirmation dialog when delete is tapped', ( @@ -240,11 +240,11 @@ void main() { await tester.pumpAndSettle(); // Tap delete button - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); // Assert - Should show confirmation dialog - expect(find.text('Delete Custom Exercise?'), findsOneWidget); + expect(find.text('Delete Exercise?'), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); expect(find.text('Delete'), findsWidgets); }); @@ -265,7 +265,7 @@ void main() { await tester.tap(find.text('Exercise To Delete')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); // Tap Delete in dialog @@ -292,7 +292,7 @@ void main() { // Act - Open details and tap delete await tester.tap(find.text('Exercise To Delete')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); // Tap Cancel From f7328c9275304a05e465c4ed0a2442f1dafaf2fe Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 15 May 2026 23:21:38 +0530 Subject: [PATCH 07/44] feat: update .gitignore and CLAUDE.md for new graphify output and project guidelines; remove MainActivity.kt --- .gitignore | 9 +++++++++ CLAUDE.md | 10 ++++++++++ .../com/workoutlogger/workout_logger/MainActivity.kt | 5 ----- 3 files changed, 19 insertions(+), 5 deletions(-) delete mode 100644 workout-logger/android/app/src/main/kotlin/com/workoutlogger/workout_logger/MainActivity.kt diff --git a/.gitignore b/.gitignore index 529c1be..b6fd96a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,12 @@ coverage.xml # Streamlit secrets dashboard/.streamlit/secrets.toml + +# graphify knowledge graph output +graphify-out/ + +# Local backup exports +repforge_backup_*.json + +# Claude Code project memory & session files +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index b09d982..eddf7f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,3 +289,13 @@ Feature proposals live in `docs/design/`. Before implementing a major feature, c - `wearables_integration.md` - `workout_scheduling.md` - `workout_sharing.md` + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase. +- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files +- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/workout-logger/android/app/src/main/kotlin/com/workoutlogger/workout_logger/MainActivity.kt b/workout-logger/android/app/src/main/kotlin/com/workoutlogger/workout_logger/MainActivity.kt deleted file mode 100644 index 0fa65d5..0000000 --- a/workout-logger/android/app/src/main/kotlin/com/workoutlogger/workout_logger/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.workoutlogger.workout_logger - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() From b7f01b01510425d216d7bcce06975c2643af0d34 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 16 May 2026 00:20:23 +0530 Subject: [PATCH 08/44] analytics_screen.dart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _FrequencyGrid: added w >= 0 && guard so future-dated sessions can't insert negative map keys _MuscleVolumeChart: early-return isEmpty when maxVol == 0 (prevents NaN); converted volume display via settings.toDisplay + settings.unitLabel dashboard_widgets.dart startOfWeek now truncated to midnight (DateTime(y,m,d)) before subtracting weekdays — fixes the bug where sessions earlier in the day than "now" were excluded Volume StatGridCard now uses settings.toDisplay(weeklyVolume) and 'Volume (${settings.unitLabel})' edit_workout_session_screen.dart drops: s.drops?.toList() — defensive copy prevents shared-mutation with the original WorkoutSession exercise_details_sheet.dart Added else branch on delete failure — shows error SnackBar instead of silently doing nothing Set chips now use settings.toDisplay(s.weight) + settings.unitLabel Growth trend label uses settings.toDisplay(growthModel.slope) + settings.unitLabel exercise_input_section.dart Icon(Icons.auto_awesome_rounded, …) → const Icon(…) exercise_progress_view.dart DropdownButton.value guarded with ids.contains(selected) ? selected : null — prevents assert/crash when the selected exercise id is no longer in the performed set profile_sections.dart const _SectionDivider() and const _ComingSoonBadge() constructors + all call sites updated rf_inputs.dart Added onLongPressEnd to _StepButton (wired to GestureDetector.onLongPressEnd) _NumberPickerSheetState now holds Timer? _holdTimer; onLongPress assigns _holdTimer = Timer.periodic(…), onLongPressEnd cancels it, dispose() also cancels it — no more leaked timers session_details_sheet.dart Volume banner: settings.toDisplay(session.totalVolume) + 'Volume ${settings.unitLabel}' Per-exercise total: settings.toDisplay(log.totalVolume) + settings.unitLabel Per-set row: settings.toDisplay(set.weight) formatted + settings.unitLabel targets_tab.dart Added _isSubmitting bool; _submit guards against re-entry and wraps provider call in try/finally; GlowButton.onPressed is null while submitting workout_flow_screen.dart _toggleDropset: weight controller text now uses settings.toDisplay(_currentWeight) with proper decimal formatting (matching _loadLastSessionData) _addDrop: new drop controller text also uses settings.toDisplay(newWeight) WorkoutHeader now receives restSeconds: _restSeconds workout_header.dart Added optional restSeconds field (defaults to 90 for backwards compatibility); _OptionsMenu receives widget.restSeconds instead of the hardcoded literal workout_summary_screen.dart volStr built from settings.toDisplay(session.totalVolume); label updated to 'Volume (${settings.unitLabel})' --- .../lib/screens/analytics_screen.dart | 31 ++++++++++---- .../screens/edit_workout_session_screen.dart | 2 +- .../lib/screens/history_screen.dart | 6 ++- workout-logger/lib/screens/home_screen.dart | 24 ++++++++--- .../lib/screens/onboarding_screen.dart | 6 +-- .../screens/widgets/dashboard_widgets.dart | 12 ++++-- .../widgets/editable_exercise_card.dart | 27 +++++++------ .../widgets/exercise_details_sheet.dart | 18 ++++++++- .../widgets/exercise_input_section.dart | 2 +- .../widgets/exercise_progress_view.dart | 2 +- .../lib/screens/widgets/profile_sections.dart | 16 +++++--- .../lib/screens/widgets/rf_inputs.dart | 40 +++++++++++-------- .../lib/screens/widgets/routine_creator.dart | 36 ++++++++++------- .../widgets/session_details_sheet.dart | 17 ++++++-- .../lib/screens/widgets/targets_tab.dart | 22 ++++++---- .../lib/screens/widgets/workout_header.dart | 4 +- .../lib/screens/workout_flow_screen.dart | 14 ++++++- .../lib/screens/workout_summary_screen.dart | 8 +++- 18 files changed, 195 insertions(+), 92 deletions(-) diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index cb89d66..6cadf50 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -9,6 +9,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/managers/pr_manager.dart'; +import '../services/settings_provider.dart'; import '../data/exercise_database.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; @@ -291,11 +292,20 @@ class _MuscleVolumeChart extends StatelessWidget { ); } + final settings = context.watch(); final sorted = byMuscle.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); final top = sorted.take(8).toList(); final maxVol = top.first.value; + if (maxVol == 0) { + return _ChartCard( + title: 'Weekly Muscle Volume', + isEmpty: true, + child: const SizedBox.shrink(), + ); + } + return _ChartCard( title: 'Weekly Muscle Volume', child: Column( @@ -303,9 +313,10 @@ class _MuscleVolumeChart extends StatelessWidget { final name = MuscleGroups.names[entry.key] ?? entry.key; final color = AppColors.muscle(entry.key); final pct = entry.value / maxVol; - final volStr = entry.value >= 1000 - ? '${(entry.value / 1000).toStringAsFixed(1)}k' - : entry.value.toStringAsFixed(0); + final displayVal = settings.toDisplay(entry.value); + final volStr = displayVal >= 1000 + ? '${(displayVal / 1000).toStringAsFixed(1)}k' + : displayVal.toStringAsFixed(0); return Padding( padding: const EdgeInsets.only(bottom: 10), @@ -316,7 +327,7 @@ class _MuscleVolumeChart extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(name, style: GoogleFonts.geist(color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w500)), - Text('$volStr kg', style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 11)), + Text('$volStr ${settings.unitLabel}', style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 11)), ], ), const SizedBox(height: 4), @@ -342,7 +353,7 @@ class _FrequencyGrid extends StatelessWidget { final weeks = {0: 0, 1: 0, 2: 0, 3: 0}; for (final s in provider.sessions) { final w = now.difference(s.date).inDays ~/ 7; - if (w < 4) weeks[w] = (weeks[w] ?? 0) + 1; + if (w >= 0 && w < 4) weeks[w] = (weeks[w] ?? 0) + 1; } return _ChartCard( @@ -408,7 +419,7 @@ class _RecordsTab extends StatelessWidget { Widget build(BuildContext context) { final prManager = context.watch(); final provider = context.read(); - final records = prManager.allRecords + final records = [...prManager.allRecords] ..sort((a, b) => b.achievedAt.compareTo(a.achievedAt)); if (records.isEmpty) { @@ -452,7 +463,11 @@ class _PRCard extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); final dateStr = DateFormat('MMM d, yyyy').format(record.achievedAt); + final displayWeight = settings.toDisplay(record.bestWeight); + final displayVol = settings.toDisplay(record.bestVolume); + final unit = settings.unitLabel; return GlassCard( margin: const EdgeInsets.only(bottom: 10), @@ -496,11 +511,11 @@ class _PRCard extends StatelessWidget { const SizedBox(height: AppSpacing.sm), Row( children: [ - _PRStat(label: 'Best Weight', value: '${record.bestWeight.toStringAsFixed(record.bestWeight % 1 == 0 ? 0 : 1)} kg', color: AppColors.warning), + _PRStat(label: 'Best Weight', value: '${displayWeight.toStringAsFixed(displayWeight % 1 == 0 ? 0 : 1)} $unit', color: AppColors.warning), const SizedBox(width: AppSpacing.sm), _PRStat(label: 'Best Reps', value: '${record.bestReps}', color: AppColors.secondary), const SizedBox(width: AppSpacing.sm), - _PRStat(label: 'Best Vol.', value: '${record.bestVolume.toStringAsFixed(0)} kg', color: AppColors.success), + _PRStat(label: 'Best Vol.', value: '${displayVol.toStringAsFixed(0)} $unit', color: AppColors.success), ], ), ], diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index 668fc2e..fe0c4ac 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -47,7 +47,7 @@ class _EditWorkoutSessionScreenState extends State { weight: s.weight, reps: s.reps, isDropset: s.isDropset, - drops: s.drops, + drops: s.drops?.toList(), timeTaken: s.timeTaken, timestamp: s.timestamp, ), diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 0f8cce3..8832c07 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -274,9 +274,9 @@ class _HistoryScreenState extends State { child: Row( children: [ _SummaryCell(label: 'WORKOUTS', value: '${all.length}', unit: 'total'), - _VertDivider(), + const _VertDivider(), _SummaryCell(label: 'VOLUME', value: volStr, unit: 'kg'), - _VertDivider(), + const _VertDivider(), _SummaryCell(label: 'THIS MONTH', value: '${all.where((s) => s.date.month == DateTime.now().month && s.date.year == DateTime.now().year).length}', unit: 'sessions'), ], ), @@ -409,6 +409,8 @@ class _SummaryCell extends StatelessWidget { } class _VertDivider extends StatelessWidget { + const _VertDivider(); + @override Widget build(BuildContext context) { return Container(width: 1, color: AppColors.glassBorder); diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 9aacedc..c6211b4 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -348,7 +348,7 @@ class _DashboardTab extends StatelessWidget { children: [ Row( children: [ - Icon( + const Icon( Icons.local_fire_department_rounded, size: 14, color: AppColors.primary, @@ -482,7 +482,7 @@ class _DashboardTab extends StatelessWidget { : isToday ? AppColors.primary.withValues(alpha: 0.3) : isFuture - ? const Color(0x0FFFFFFF) + ? const Color(0x00000000) : const Color(0x0FFFFFFF), border: isToday ? Border.all( @@ -530,7 +530,7 @@ class _DashboardTab extends StatelessWidget { : sessions.take(7).fold(0, (s, e) => s + e.duration) ~/ sessions.take(7).length; - // Sparkline data (last 7 weeks, workouts per week) + // Sparkline data (last 7 weeks) List weeklyWorkouts = List.generate(7, (i) { final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); final wEnd = wStart.add(const Duration(days: 7)); @@ -543,6 +543,20 @@ class _DashboardTab extends StatelessWidget { .where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)) .fold(0, (s, e) => s + e.totalVolume); }); + List weeklySets = List.generate(7, (i) { + final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + return sessions + .where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)) + .fold(0, (s, e) => s + e.exercises.fold(0, (a, ex) => a + ex.sets.length)); + }); + List weeklyAvgDurations = List.generate(7, (i) { + final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + final ws = sessions.where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)).toList(); + if (ws.isEmpty) return 0; + return ws.fold(0, (s, e) => s + e.duration) / ws.length; + }); final stats = [ _StatItem( @@ -566,14 +580,14 @@ class _DashboardTab extends StatelessWidget { value: '$weekSets', unit: 'this week', color: AppColors.success, - spark: List.generate(7, (i) => (weekSets * (0.5 + i * 0.07)).clamp(0, weekSets + 10).toDouble()), + spark: weeklySets, ), _StatItem( label: 'Avg time', value: '$avgDuration', unit: 'min', color: AppColors.warning, - spark: List.generate(7, (i) => (avgDuration * (0.8 + i * 0.04)).toDouble()), + spark: weeklyAvgDurations, ), ]; diff --git a/workout-logger/lib/screens/onboarding_screen.dart b/workout-logger/lib/screens/onboarding_screen.dart index 48ced1c..da869cb 100644 --- a/workout-logger/lib/screens/onboarding_screen.dart +++ b/workout-logger/lib/screens/onboarding_screen.dart @@ -159,7 +159,7 @@ class _WelcomePageState extends State { child: GlowButton( label: _saving ? 'Setting up…' : "Let's Go!", icon: Icons.arrow_forward_rounded, - onPressed: _saving ? () {} : _submit, + onPressed: _saving ? null : _submit, ), ), const Spacer(flex: 1), @@ -255,14 +255,14 @@ class _VersionUpdateSheet extends StatelessWidget { ], ), const SizedBox(height: 20), - _WhatsNewItem( + const _WhatsNewItem( icon: Icons.emoji_events_rounded, color: AppColors.warning, title: 'Personal Records', description: 'Automatically tracks your best weight, reps, and volume for every exercise.', ), const SizedBox(height: 12), - _WhatsNewItem( + const _WhatsNewItem( icon: Icons.bar_chart_rounded, color: AppColors.secondary, title: 'Records Tab', diff --git a/workout-logger/lib/screens/widgets/dashboard_widgets.dart b/workout-logger/lib/screens/widgets/dashboard_widgets.dart index b2004ed..4535ec2 100644 --- a/workout-logger/lib/screens/widgets/dashboard_widgets.dart +++ b/workout-logger/lib/screens/widgets/dashboard_widgets.dart @@ -1,7 +1,9 @@ // dashboard_widgets.dart — Dashboard-specific helper widgets for home_screen. import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../models/models.dart'; +import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; import 'rf_cards.dart'; @@ -17,9 +19,10 @@ class WeekActivityStrip extends StatelessWidget { Widget build(BuildContext context) { final today = DateTime.now(); // Weekday 1=Mon … 7=Sun; align strip Mon→Sun - final startOfWeek = today.subtract(Duration(days: today.weekday - 1)); + final todayMidnight = DateTime(today.year, today.month, today.day); + final startOfWeek = todayMidnight.subtract(Duration(days: today.weekday - 1)); final trainedDays = sessions - .where((s) => s.date.isAfter(startOfWeek.subtract(const Duration(days: 1)))) + .where((s) => !s.date.isBefore(startOfWeek)) .map((s) => s.date.weekday) .toSet(); @@ -96,6 +99,7 @@ class StatGrid extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); final weeklyWorkouts = stats['weeklyWorkouts'] ?? 0; final weeklyVolume = (stats['weeklyVolume'] ?? 0.0).toDouble(); final exercisesThisWeek = stats['exercisesThisWeek'] ?? 0; @@ -117,8 +121,8 @@ class StatGrid extends StatelessWidget { Expanded( child: StatGridCard( icon: Icons.trending_up_rounded, - value: _formatVolume(weeklyVolume), - label: 'Volume (kg)', + value: _formatVolume(settings.toDisplay(weeklyVolume)), + label: 'Volume (${settings.unitLabel})', color: AppColors.success, ), ), diff --git a/workout-logger/lib/screens/widgets/editable_exercise_card.dart b/workout-logger/lib/screens/widgets/editable_exercise_card.dart index c60aa36..db8c1be 100644 --- a/workout-logger/lib/screens/widgets/editable_exercise_card.dart +++ b/workout-logger/lib/screens/widgets/editable_exercise_card.dart @@ -94,19 +94,22 @@ class EditableExerciseCard extends StatelessWidget { ), ), ), - GestureDetector( - onTap: () => _confirmDelete(context), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: AppColors.error.withValues(alpha: 0.1), + IconButton( + tooltip: 'Remove exercise', + onPressed: () => _confirmDelete(context), + style: IconButton.styleFrom( + backgroundColor: AppColors.error.withValues(alpha: 0.1), + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), - child: const Icon( - Icons.delete_outline_rounded, - size: 16, - color: AppColors.error, - ), + padding: const EdgeInsets.all(6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon( + Icons.delete_outline_rounded, + size: 16, + color: AppColors.error, ), ), ], @@ -123,7 +126,7 @@ class EditableExerciseCard extends StatelessWidget { final i = entry.key; final set = entry.value; return EditableSetRow( - key: ValueKey('set_${exerciseName}_$i'), + key: ValueKey(set.timestamp), setNumber: i + 1, weight: set.weight, reps: set.reps, diff --git a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart index dad6545..5c1d51e 100644 --- a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart @@ -1,9 +1,11 @@ // exercise_details_sheet.dart — Bottom sheet showing exercise detail & stats import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import '../../data/exercise_database.dart'; import 'rf_widgets.dart'; @@ -20,6 +22,7 @@ class ExerciseDetailsSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); final lastSession = provider.getLastSessionForExercise(exercise.id); final growthModel = provider.getGrowthModel(exercise.id); final color = exercise.isCustom ? AppColors.warning : AppColors.primary; @@ -194,7 +197,7 @@ class ExerciseDetailsSheet extends StatelessWidget { border: Border.all(color: AppColors.glassBorder), ), child: Text( - '${s.weight}kg × ${s.reps}', + '${settings.toDisplay(s.weight).toStringAsFixed(settings.toDisplay(s.weight) == settings.toDisplay(s.weight).truncateToDouble() ? 0 : 1)}${settings.unitLabel} × ${s.reps}', style: const TextStyle( color: AppColors.textSoft, fontSize: 12, @@ -228,7 +231,7 @@ class ExerciseDetailsSheet extends StatelessWidget { const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - '+${growthModel.slope.toStringAsFixed(1)} kg volume/session', + '+${settings.toDisplay(growthModel.slope).toStringAsFixed(1)} ${settings.unitLabel} volume/session', style: const TextStyle( color: AppColors.success, fontSize: 13, @@ -301,6 +304,17 @@ class ExerciseDetailsSheet extends StatelessWidget { ), ), ); + } else if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text('"${exercise.name}" could not be deleted'), + backgroundColor: AppColors.error, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); } } } diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 769c016..fff329c 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -189,7 +189,7 @@ class _RecommendationCard extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(AppRadius.sm), ), - child: Icon(Icons.auto_awesome_rounded, + child: const Icon(Icons.auto_awesome_rounded, color: AppColors.primary, size: 18), ), const SizedBox(width: AppSpacing.md), diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index 62d9d3d..9b25d58 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -100,7 +100,7 @@ class _ExerciseDropdown extends StatelessWidget { border: Border.all(color: AppColors.glassBorder), ), child: DropdownButton( - value: selected, + value: ids.contains(selected) ? selected : null, isExpanded: true, underline: const SizedBox.shrink(), dropdownColor: AppColors.cardHigh, diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 0878dbd..73bc02c 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -298,7 +298,7 @@ class DataManagementSection extends StatelessWidget { loading: isExporting, onTap: onExport, ), - _SectionDivider(), + const _SectionDivider(), _ActionTile( icon: Icons.download_rounded, iconColor: AppColors.secondary, @@ -307,7 +307,7 @@ class DataManagementSection extends StatelessWidget { loading: isImporting, onTap: onImport, ), - _SectionDivider(), + const _SectionDivider(), _ActionTile( icon: Icons.cloud_upload_outlined, iconColor: AppColors.primary, @@ -333,7 +333,7 @@ class CloudSyncSection extends StatelessWidget { iconColor: AppColors.warning, title: 'Cloud Sync', subtitle: 'Sync your data across devices', - trailing: _ComingSoonBadge(), + trailing: const _ComingSoonBadge(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -394,11 +394,11 @@ class AboutSection extends StatelessWidget { child: Column( children: [ _InfoTile(label: 'Version', value: appVersion, icon: Icons.tag_rounded), - _SectionDivider(), + const _SectionDivider(), _InfoTile(label: 'Created by', value: _createdBy, icon: Icons.person_rounded), - _SectionDivider(), + const _SectionDivider(), _InfoTile(label: 'Platform', value: 'Android', icon: Icons.phone_android_rounded), - _SectionDivider(), + const _SectionDivider(), _InfoTile( label: 'Package', value: 'com.devasy.repforge', @@ -566,6 +566,8 @@ class _InfoTile extends StatelessWidget { } class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + @override Widget build(BuildContext context) { return const Divider(color: AppColors.glassBorder, height: 1, indent: 40); @@ -573,6 +575,8 @@ class _SectionDivider extends StatelessWidget { } class _ComingSoonBadge extends StatelessWidget { + const _ComingSoonBadge(); + @override Widget build(BuildContext context) { return Container( diff --git a/workout-logger/lib/screens/widgets/rf_inputs.dart b/workout-logger/lib/screens/widgets/rf_inputs.dart index db62216..4794a14 100644 --- a/workout-logger/lib/screens/widgets/rf_inputs.dart +++ b/workout-logger/lib/screens/widgets/rf_inputs.dart @@ -483,6 +483,7 @@ class NumberPickerSheet extends StatefulWidget { class _NumberPickerSheetState extends State { late double _value; + Timer? _holdTimer; @override void initState() { @@ -490,6 +491,12 @@ class _NumberPickerSheetState extends State { _value = widget.initial; } + @override + void dispose() { + _holdTimer?.cancel(); + super.dispose(); + } + void _step(double dir) { setState(() { _value = (_value + dir * widget.step).clamp(widget.min, widget.max); @@ -535,17 +542,16 @@ class _NumberPickerSheetState extends State { icon: Icons.remove_rounded, onTap: () => _step(-1), onLongPress: () { - Timer.periodic( + _holdTimer?.cancel(); + _holdTimer = Timer.periodic( const Duration(milliseconds: 100), - (t) { - if (!mounted) { - t.cancel(); - return; - } - _step(-1); - }, + (_) { if (mounted) _step(-1); }, ); }, + onLongPressEnd: () { + _holdTimer?.cancel(); + _holdTimer = null; + }, ), const SizedBox(width: AppSpacing.xl), Text( @@ -562,17 +568,16 @@ class _NumberPickerSheetState extends State { icon: Icons.add_rounded, onTap: () => _step(1), onLongPress: () { - Timer.periodic( + _holdTimer?.cancel(); + _holdTimer = Timer.periodic( const Duration(milliseconds: 100), - (t) { - if (!mounted) { - t.cancel(); - return; - } - _step(1); - }, + (_) { if (mounted) _step(1); }, ); }, + onLongPressEnd: () { + _holdTimer?.cancel(); + _holdTimer = null; + }, ), ], ), @@ -594,17 +599,20 @@ class _StepButton extends StatelessWidget { required this.icon, required this.onTap, required this.onLongPress, + this.onLongPressEnd, }); final IconData icon; final VoidCallback onTap; final VoidCallback onLongPress; + final VoidCallback? onLongPressEnd; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, onLongPress: onLongPress, + onLongPressEnd: (_) => onLongPressEnd?.call(), child: Container( width: 56, height: 56, diff --git a/workout-logger/lib/screens/widgets/routine_creator.dart b/workout-logger/lib/screens/widgets/routine_creator.dart index 7e30e8c..97fc6b5 100644 --- a/workout-logger/lib/screens/widgets/routine_creator.dart +++ b/workout-logger/lib/screens/widgets/routine_creator.dart @@ -422,21 +422,29 @@ class _CreateRoutineScreenState extends State { } final provider = context.read(); - if (widget.routine != null) { - final updated = Routine( - id: widget.routine!.id, - name: _nameController.text.trim(), - exerciseIds: _selectedIds, - createdAt: widget.routine!.createdAt, - ); - await provider.updateRoutine(updated); - } else { - await provider.createRoutine( - _nameController.text.trim(), - _selectedIds, - ); + try { + if (widget.routine != null) { + final updated = Routine( + id: widget.routine!.id, + name: _nameController.text.trim(), + exerciseIds: _selectedIds, + createdAt: widget.routine!.createdAt, + ); + await provider.updateRoutine(updated); + } else { + await provider.createRoutine( + _nameController.text.trim(), + _selectedIds, + ); + } + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save routine: $e')), + ); + } } - if (mounted) Navigator.of(context).pop(); } } diff --git a/workout-logger/lib/screens/widgets/session_details_sheet.dart b/workout-logger/lib/screens/widgets/session_details_sheet.dart index f51341a..9e20c3e 100644 --- a/workout-logger/lib/screens/widgets/session_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/session_details_sheet.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; @@ -28,10 +30,11 @@ class SessionDetailsSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); final dateStr = DateFormat('EEEE, MMMM d, yyyy').format(session.date); final timeStr = DateFormat('h:mm a').format(session.date); final totalSets = session.exercises.fold(0, (s, e) => s + e.sets.length); - final volume = session.totalVolume; + final volume = settings.toDisplay(session.totalVolume); final volStr = volume >= 1000 ? '${(volume / 1000).toStringAsFixed(1)}k' : volume.toStringAsFixed(0); @@ -145,7 +148,7 @@ class SessionDetailsSheet extends StatelessWidget { Expanded( child: _StatBannerBox( value: volStr, - label: 'Volume kg', + label: 'Volume ${settings.unitLabel}', color: AppColors.success, ), ), @@ -291,6 +294,7 @@ class _ExerciseDetailCard extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); final exercise = provider.getExercise(log.exerciseId); final name = exercise?.name ?? 'Unknown Exercise'; @@ -367,7 +371,7 @@ class _ExerciseDetailCard extends StatelessWidget { style: TextStyle(color: AppColors.textMuted, fontSize: 12), ), Text( - '${log.totalVolume.toStringAsFixed(0)} kg', + '${settings.toDisplay(log.totalVolume).toStringAsFixed(0)} ${settings.unitLabel}', style: const TextStyle( color: AppColors.success, fontSize: 13, @@ -391,6 +395,11 @@ class _SetRow extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); + final dw = settings.toDisplay(set.weight); + final wStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); return Padding( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: AppSpacing.sm), child: Row( @@ -416,7 +425,7 @@ class _SetRow extends StatelessWidget { const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - '${set.weight} kg × ${set.reps} reps', + '$wStr ${settings.unitLabel} × ${set.reps} reps', style: const TextStyle( color: AppColors.textSoft, fontSize: 13, diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart index bd6352e..4f0b4bc 100644 --- a/workout-logger/lib/screens/widgets/targets_tab.dart +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -106,6 +106,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { String? _selectedExerciseId; String _targetType = 'weight'; final _valueController = TextEditingController(); + bool _isSubmitting = false; static const _types = [ ('weight', 'Max Weight (kg)'), @@ -287,7 +288,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { GlowButton( label: 'Create Target', icon: Icons.flag_rounded, - onPressed: _submit, + onPressed: _isSubmitting ? null : _submit, fullWidth: true, ), ], @@ -296,6 +297,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { } Future _submit() async { + if (_isSubmitting) return; if (_selectedExerciseId == null || _valueController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -316,12 +318,16 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { return; } - await context.read().createTarget( - exerciseId: _selectedExerciseId!, - type: _targetType, - targetValue: value, - ); - - if (mounted) Navigator.of(context).pop(); + setState(() => _isSubmitting = true); + try { + await context.read().createTarget( + exerciseId: _selectedExerciseId!, + type: _targetType, + targetValue: value, + ); + if (mounted) Navigator.of(context).pop(); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } } } diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart index e36aa62..50665cb 100644 --- a/workout-logger/lib/screens/widgets/workout_header.dart +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -25,6 +25,7 @@ class WorkoutHeader extends StatefulWidget { required this.onFinish, required this.onRemoveLastSet, required this.onSetRestTime, + this.restSeconds = 90, }); final String exerciseName; @@ -41,6 +42,7 @@ class WorkoutHeader extends StatefulWidget { final VoidCallback onFinish; final VoidCallback onRemoveLastSet; final void Function(int seconds) onSetRestTime; + final int restSeconds; @override State createState() => _WorkoutHeaderState(); @@ -164,7 +166,7 @@ class _WorkoutHeaderState extends State { ), ), _OptionsMenu( - restSeconds: 90, + restSeconds: widget.restSeconds, onRemoveLastSet: widget.onRemoveLastSet, onSetRestTime: widget.onSetRestTime, onFinish: widget.onFinish, diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 0464cfd..6966203 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -273,6 +273,7 @@ class _WorkoutFlowScreenState extends State { onFinish: _finishWorkout, onRemoveLastSet: provider.removeLastSet, onSetRestTime: (s) => setState(() => _restSeconds = s), + restSeconds: _restSeconds, ), Expanded( child: SingleChildScrollView( @@ -460,18 +461,27 @@ class _WorkoutFlowScreenState extends State { _dropRepsCtrls.clear(); _drops.clear(); } else { - _mainWeightCtrl.text = _currentWeight.toString(); + final settings = context.read(); + final dw = settings.toDisplay(_currentWeight); + _mainWeightCtrl.text = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); _mainRepsCtrl.text = _currentReps.toString(); } }); } void _addDrop() { + final settings = context.read(); setState(() { final lastWeight = _drops.isEmpty ? _currentWeight : _drops.last.weight; final newWeight = (lastWeight * 0.8).roundToDouble(); _drops.add(DropsetEntry(weight: newWeight, reps: _currentReps)); - _dropWeightCtrls.add(TextEditingController(text: newWeight.toString())); + final dw = settings.toDisplay(newWeight); + final dwStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _dropWeightCtrls.add(TextEditingController(text: dwStr)); _dropRepsCtrls.add( TextEditingController(text: _currentReps.toString()), ); diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart index c5c3b6a..0bd87e4 100644 --- a/workout-logger/lib/screens/workout_summary_screen.dart +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/managers/pr_manager.dart'; +import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/rf_cards.dart'; @@ -24,11 +25,12 @@ class WorkoutSummaryScreen extends StatelessWidget { @override Widget build(BuildContext context) { final provider = context.read(); + final settings = context.read(); final totalSets = session.exercises.fold( 0, (sum, e) => sum + e.sets.length, ); - final volume = session.totalVolume; + final volume = settings.toDisplay(session.totalVolume); final volStr = volume >= 1000 ? '${(volume / 1000).toStringAsFixed(1)}k' : volume.toStringAsFixed(0); @@ -64,6 +66,7 @@ class WorkoutSummaryScreen extends StatelessWidget { volStr, totalSets, session.exercises.length, + settings.unitLabel, ), if (newPRs.isNotEmpty) ...[ const SizedBox(height: AppSpacing.lg), @@ -150,6 +153,7 @@ class WorkoutSummaryScreen extends StatelessWidget { String volume, int sets, int exercises, + String unitLabel, ) { return Column( children: [ @@ -168,7 +172,7 @@ class WorkoutSummaryScreen extends StatelessWidget { child: StatGridCard( icon: Icons.trending_up_rounded, value: volume, - label: 'Volume (kg)', + label: 'Volume ($unitLabel)', color: AppColors.success, ), ), From e3fe4ae26692a3de4c4610d0b75b47f3000bd78a Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 16 May 2026 00:46:03 +0530 Subject: [PATCH 09/44] feat: enhance UI components and improve data handling across screens --- .../lib/screens/analytics_screen.dart | 7 +-- .../lib/screens/history_screen.dart | 27 ++++++---- workout-logger/lib/screens/home_screen.dart | 26 ++++----- .../lib/screens/programs/programs_screen.dart | 2 +- .../lib/screens/routines_screen.dart | 4 +- .../lib/screens/widgets/activity_heatmap.dart | 4 +- .../lib/screens/widgets/body_heatmap.dart | 53 ++++++++++++------- .../lib/screens/widgets/calendar_grid.dart | 10 ++-- .../screens/widgets/program_week_tile.dart | 12 ++--- .../lib/screens/widgets/rf_widgets.dart | 4 +- 10 files changed, 86 insertions(+), 63 deletions(-) diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 6cadf50..da29a2f 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -184,10 +184,11 @@ class _VolumeChart extends StatelessWidget { @override Widget build(BuildContext context) { final sessions = provider.sessions.take(14).toList().reversed.toList(); + final settings = context.watch(); return _ChartCard( title: 'Volume Progression', - subtitle: 'Last ${sessions.length} workouts (tonnes)', + subtitle: '${settings.unitLabel} · Last ${sessions.length} workouts', isEmpty: sessions.isEmpty, child: SizedBox( height: 180, @@ -228,7 +229,7 @@ class _VolumeChart extends StatelessWidget { showTitles: true, reservedSize: 36, getTitlesWidget: (v, _) => Text( - '${v.toStringAsFixed(0)}t', + settings.toDisplay(v).toStringAsFixed(0), style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9), ), ), @@ -238,7 +239,7 @@ class _VolumeChart extends StatelessWidget { lineBarsData: [ LineChartBarData( spots: sessions.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.totalVolume / 1000); + return FlSpot(e.key.toDouble(), settings.toDisplay(e.value.totalVolume)); }).toList(), isCurved: true, curveSmoothness: 0.3, diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 8832c07..abdd6a6 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -65,10 +65,14 @@ class _HistoryScreenState extends State { final map = {}; final monthSessions = sessions.where((s) => s.date.year == _calendarMonth.year && s.date.month == _calendarMonth.month); + final dayVolumes = {}; for (final s in monthSessions) { - final vol = s.totalVolume; + dayVolumes[s.date.day] = (dayVolumes[s.date.day] ?? 0) + s.totalVolume; + } + for (final entry in dayVolumes.entries) { + final vol = entry.value; final intensity = vol > 15000 ? 3 : vol > 5000 ? 2 : 1; - map[s.date.day] = CalendarDayData(intensity: intensity); + map[entry.key] = CalendarDayData(intensity: intensity); } return map; } @@ -96,7 +100,7 @@ class _HistoryScreenState extends State { slivers: [ // Header SliverToBoxAdapter( - child: _buildHeader(context, hasUnsynced, historyManager), + child: _buildHeader(context, hasUnsynced: hasUnsynced, historyManager: historyManager), ), // Search bar (animated) @@ -171,7 +175,7 @@ class _HistoryScreenState extends State { ); } - Widget _buildHeader(BuildContext context, bool hasUnsynced, HistoryManager historyManager) { + Widget _buildHeader(BuildContext context, {required bool hasUnsynced, required HistoryManager historyManager}) { return Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: Row( @@ -544,7 +548,10 @@ class _HistoryCard extends StatelessWidget { MaterialPageRoute(builder: (_) => EditWorkoutSessionScreen(session: session)), ); }, - onDelete: () => _confirmDelete(ctx), + onDelete: () async { + Navigator.of(ctx).pop(); + await _confirmDelete(context); + }, ), ), ); @@ -576,12 +583,10 @@ class _HistoryCard extends StatelessWidget { ); if (confirmed == true && context.mounted) { - final nav = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); try { await provider.deleteWorkoutSession(session.id); if (context.mounted) { - nav.pop(); messenger.showSnackBar(_snackBar('Workout deleted')); } } catch (e) { @@ -699,10 +704,10 @@ class _HistoryCard extends StatelessWidget { icon: const Icon(Icons.more_vert_rounded, color: AppColors.textMuted, size: 18), onSelected: (v) => _handleMenu(context, v), itemBuilder: (_) => [ - _menuItem('edit', Icons.edit_outlined, 'Edit', AppColors.primary), + _menuItem(value: 'edit', icon: Icons.edit_outlined, label: 'Edit', color: AppColors.primary), if (showSync) - _menuItem('sync', Icons.favorite_outlined, 'Sync to Health Connect', _hcColor), - _menuItem('delete', Icons.delete_outline, 'Delete', AppColors.error), + _menuItem(value: 'sync', icon: Icons.favorite_outlined, label: 'Sync to Health Connect', color: _hcColor), + _menuItem(value: 'delete', icon: Icons.delete_outline, label: 'Delete', color: AppColors.error), ], ), ], @@ -712,7 +717,7 @@ class _HistoryCard extends StatelessWidget { ); } - PopupMenuItem _menuItem(String value, IconData icon, String label, Color color) { + PopupMenuItem _menuItem({required String value, required IconData icon, required String label, required Color color}) { return PopupMenuItem( value: value, child: Row( diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index c6211b4..33b9621 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -154,10 +154,12 @@ class _DashboardTab extends StatelessWidget { // Generate deterministic 14-week heatmap (98 cells, col-major) List _buildHeatmapData(List sessions) { - final now = DateTime.now(); + final nowRaw = DateTime.now(); + final now = DateTime(nowRaw.year, nowRaw.month, nowRaw.day); final data = List.filled(98, 0); for (final s in sessions) { - final diff = now.difference(s.date).inDays; + final sessionDate = DateTime(s.date.year, s.date.month, s.date.day); + final diff = now.difference(sessionDate).inDays; if (diff < 0 || diff >= 98) continue; final col = (97 - diff) ~/ 7; final row = (97 - diff) % 7; @@ -190,7 +192,7 @@ class _DashboardTab extends StatelessWidget { children: [ _buildHeader(context, homeState), const SizedBox(height: 24), - _buildStreakHero(context, provider, homeState), + _buildStreakHero(context: context, provider: provider, homeState: homeState), const SizedBox(height: 16), _buildStatsGrid(context, provider), const SizedBox(height: 16), @@ -198,7 +200,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 16), _buildMuscleVolumeCard(context, provider), const SizedBox(height: 16), - _buildRecentWorkouts(context, provider, homeState), + _buildRecentWorkouts(context: context, provider: provider, homeState: homeState), const SizedBox(height: 100), ], ), @@ -275,11 +277,11 @@ class _DashboardTab extends StatelessWidget { ); } - Widget _buildStreakHero( - BuildContext context, - WorkoutProvider provider, + Widget _buildStreakHero({ + required BuildContext context, + required WorkoutProvider provider, _HomeScreenState? homeState, - ) { + }) { final sessions = provider.sessions; // Calculate current streak int streak = 0; @@ -779,11 +781,11 @@ class _DashboardTab extends StatelessWidget { ); } - Widget _buildRecentWorkouts( - BuildContext context, - WorkoutProvider provider, + Widget _buildRecentWorkouts({ + required BuildContext context, + required WorkoutProvider provider, _HomeScreenState? homeState, - ) { + }) { final recentSessions = provider.sessions.take(3).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index e23ae6d..aed5091 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -66,7 +66,7 @@ class ProgramsScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - RFEmptyState( + const RFEmptyState( icon: Icons.calendar_month_rounded, title: 'No Training Programs', subtitle: 'Create a structured multi-week program\nor import one from JSON', diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index d9c535c..740ae48 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -30,7 +30,7 @@ class RoutinesScreen extends StatelessWidget { slivers: [ SliverToBoxAdapter(child: _buildHeader(context, routines)), if (routines.isNotEmpty) ...[ - SliverToBoxAdapter(child: _buildQuickStartCard(context, routines.first, provider)), + SliverToBoxAdapter(child: _buildQuickStartCard(context: context, routine: routines.first, provider: provider)), SliverToBoxAdapter(child: _buildAllRoutinesHeader(routines)), SliverList( delegate: SliverChildBuilderDelegate( @@ -117,7 +117,7 @@ class RoutinesScreen extends StatelessWidget { ); } - Widget _buildQuickStartCard(BuildContext context, Routine routine, WorkoutProvider provider) { + Widget _buildQuickStartCard({required BuildContext context, required Routine routine, required WorkoutProvider provider}) { final exCount = routine.exerciseIds.length; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), diff --git a/workout-logger/lib/screens/widgets/activity_heatmap.dart b/workout-logger/lib/screens/widgets/activity_heatmap.dart index 35e49b2..22667c6 100644 --- a/workout-logger/lib/screens/widgets/activity_heatmap.dart +++ b/workout-logger/lib/screens/widgets/activity_heatmap.dart @@ -40,7 +40,7 @@ class ActivityHeatmap extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( + const Text( 'Less', style: TextStyle( fontSize: 10, color: AppColors.textFaint), @@ -61,7 +61,7 @@ class ActivityHeatmap extends StatelessWidget { ); }), ), - Text( + const Text( 'More', style: TextStyle( fontSize: 10, color: AppColors.textFaint), diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart index 7fcc034..5dac649 100644 --- a/workout-logger/lib/screens/widgets/body_heatmap.dart +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -108,31 +108,44 @@ class _BodyPainter extends CustomPainter { canvas.drawPath(rightLeg, baseStroke); // ── Heat overlays ──────────────────────────────────────────── - _drawHeat(canvas, sx, sy, 'chest', - _ellipse(37, 38, 13, 9, sx, sy), AppColors.primary, 0.55); - _drawHeat(canvas, sx, sy, 'shoulders', - _circle(22, 28, 5, sx, sy), AppColors.primary, 0.42); - _drawHeat(canvas, sx, sy, 'shoulders', - _circle(52, 28, 5, sx, sy), AppColors.primary, 0.42); - _drawHeat(canvas, sx, sy, 'biceps', - _ellipse(14, 46, 3.5, 8, sx, sy), AppColors.secondary, 0.45); - _drawHeat(canvas, sx, sy, 'biceps', - _ellipse(60, 46, 3.5, 8, sx, sy), AppColors.secondary, 0.45); - _drawHeat(canvas, sx, sy, 'quads', - _ellipse(28, 92, 5, 11, sx, sy), AppColors.warning, 0.18); - _drawHeat(canvas, sx, sy, 'quads', - _ellipse(46, 92, 5, 11, sx, sy), AppColors.warning, 0.18); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'chest', + path: _ellipse(cx: 37, cy: 38, rx: 13, ry: 9, sx: sx, sy: sy), color: AppColors.primary, baseOpacity: 0.55); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'shoulders', + path: _circle(cx: 22, cy: 28, r: 5, sx: sx, sy: sy), color: AppColors.primary, baseOpacity: 0.42); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'shoulders', + path: _circle(cx: 52, cy: 28, r: 5, sx: sx, sy: sy), color: AppColors.primary, baseOpacity: 0.42); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'biceps', + path: _ellipse(cx: 14, cy: 46, rx: 3.5, ry: 8, sx: sx, sy: sy), color: AppColors.secondary, baseOpacity: 0.45); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'biceps', + path: _ellipse(cx: 60, cy: 46, rx: 3.5, ry: 8, sx: sx, sy: sy), color: AppColors.secondary, baseOpacity: 0.45); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'quads', + path: _ellipse(cx: 28, cy: 92, rx: 5, ry: 11, sx: sx, sy: sy), color: AppColors.warning, baseOpacity: 0.18); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'quads', + path: _ellipse(cx: 46, cy: 92, rx: 5, ry: 11, sx: sx, sy: sy), color: AppColors.warning, baseOpacity: 0.18); } - void _drawHeat(Canvas canvas, double sx, double sy, String muscle, - Path path, Color color, double baseOpacity) { + void _drawHeat({ + required Canvas canvas, + required double sx, + required double sy, + required String muscle, + required Path path, + required Color color, + required double baseOpacity, + }) { final vol = muscleVolumes[muscle] ?? 0.5; final opacity = (baseOpacity * (0.5 + vol * 0.5)).clamp(0.0, 1.0); canvas.drawPath(path, Paint()..color = color.withValues(alpha: opacity)); } - Path _ellipse(double cx, double cy, double rx, double ry, double sx, - double sy) { + Path _ellipse({ + required double cx, + required double cy, + required double rx, + required double ry, + required double sx, + required double sy, + }) { return Path() ..addOval(Rect.fromCenter( center: Offset(cx * sx, cy * sy), @@ -141,8 +154,8 @@ class _BodyPainter extends CustomPainter { )); } - Path _circle(double cx, double cy, double r, double sx, double sy) => - _ellipse(cx, cy, r, r, sx, sy); + Path _circle({required double cx, required double cy, required double r, required double sx, required double sy}) => + _ellipse(cx: cx, cy: cy, rx: r, ry: r, sx: sx, sy: sy); @override bool shouldRepaint(_BodyPainter old) => diff --git a/workout-logger/lib/screens/widgets/calendar_grid.dart b/workout-logger/lib/screens/widgets/calendar_grid.dart index 32aac67..2858b43 100644 --- a/workout-logger/lib/screens/widgets/calendar_grid.dart +++ b/workout-logger/lib/screens/widgets/calendar_grid.dart @@ -4,7 +4,7 @@ import '../../theme/app_theme.dart'; class CalendarDayData { const CalendarDayData({required this.intensity, this.hasPr = false}); - final int intensity; // 1–3 + final int intensity; // 0–3: 0 = no workout, 1–3 = intensity levels final bool hasPr; } @@ -94,19 +94,19 @@ class CalendarMonthGrid extends StatelessWidget { Container( width: 5, height: 5, - decoration: BoxDecoration( + decoration: const BoxDecoration( shape: BoxShape.circle, color: AppColors.success, ), ), const SizedBox(width: 4), - Text('PR', + const Text('PR', style: TextStyle(fontSize: 10, color: AppColors.textFaint)), ], ), Row( children: [ - Text('Less', + const Text('Less', style: TextStyle(fontSize: 10, color: AppColors.textFaint)), const SizedBox(width: 6), ...List.generate(4, (i) { @@ -134,7 +134,7 @@ class CalendarMonthGrid extends StatelessWidget { ); }), const SizedBox(width: 6), - Text('More', + const Text('More', style: TextStyle(fontSize: 10, color: AppColors.textFaint)), ], ), diff --git a/workout-logger/lib/screens/widgets/program_week_tile.dart b/workout-logger/lib/screens/widgets/program_week_tile.dart index 462742b..ffdfd83 100644 --- a/workout-logger/lib/screens/widgets/program_week_tile.dart +++ b/workout-logger/lib/screens/widgets/program_week_tile.dart @@ -66,7 +66,7 @@ class _ProgramWeekTileState extends State { ), child: Column( children: [ - _buildHeader(week, phase, phaseColor), + _buildHeader(week: week, phase: phase, phaseColor: phaseColor), if (_expanded) ...[ Divider(color: AppColors.glassBorder, height: 1), ...week.days.map((day) => _buildDaySection(day, week)), @@ -105,11 +105,11 @@ class _ProgramWeekTileState extends State { ); } - Widget _buildHeader( - ProgramWeek week, - TrainingPhase? phase, - Color phaseColor, - ) { + Widget _buildHeader({ + required ProgramWeek week, + required TrainingPhase? phase, + required Color phaseColor, + }) { return InkWell( onTap: () => setState(() => _expanded = !_expanded), borderRadius: BorderRadius.circular(AppRadius.lg), diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index 93457b9..a73a81c 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -321,12 +321,14 @@ class _GlowButtonState extends State Future _onTapUp(TapUpDetails _) async { HapticFeedback.heavyImpact(); - widget.onPressed?.call(); await _ctrl.forward(); + if (!mounted) return; + widget.onPressed?.call(); } Future _onTapCancel() async { await _ctrl.forward(); + if (!mounted) return; } @override From 465e2ef413cdcf6f67a2e262aa47076ddf7902e6 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 16 May 2026 00:54:39 +0530 Subject: [PATCH 10/44] feat: update ExerciseLibraryScreen tests to include SettingsProvider in widget setup --- workout-logger/test/exercise_library_screen_test.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 2939889..16caadd 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -5,15 +5,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:repforge/screens/exercise_library_screen.dart'; import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; Widget createTestWidget({ required Widget child, required WorkoutProvider provider, + SettingsProvider? settingsProvider, }) { - return ChangeNotifierProvider.value( - value: provider, + final settings = settingsProvider ?? SettingsProvider(MockStorageService()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: settings), + ], child: MaterialApp(home: child), ); } From a9743cbc220818f41547b58d9a9ccb7eec0fb93e Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 16 May 2026 01:15:54 +0530 Subject: [PATCH 11/44] feat: enhance workout session data collection by including weight in HealthConnectService --- .../lib/services/health_connect_service.dart | 10 ++++++---- workout-logger/pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 8327d16..2f57418 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -135,15 +135,15 @@ class HealthConnectService implements IHealthConnectService { DateTime sessionStart, DateTime sessionEnd, ) { - // Collect (segmentType, reps, timestamp) for every valid set. - final allSets = <(ExerciseSegmentType, int, DateTime)>[]; + // Collect (segmentType, reps, timestamp, weightKg) for every valid set. + final allSets = <(ExerciseSegmentType, int, DateTime, double)>[]; for (final log in session.exercises) { final type = _segmentTypeMap[log.exerciseId] ?? ExerciseSegmentType.otherWorkout; for (final set in log.sets) { if (set.reps > 0) { - allSets.add((type, set.reps, set.timestamp)); + allSets.add((type, set.reps, set.timestamp, set.weight)); } } } @@ -163,7 +163,7 @@ class HealthConnectService implements IHealthConnectService { var ts = s.$3; if (ts.isBefore(sessionStart)) ts = sessionStart; if (ts.isAfter(sessionEnd)) ts = sessionEnd; - return (s.$1, s.$2, ts); + return (s.$1, s.$2, ts, s.$4); }) .toList(); @@ -192,6 +192,7 @@ class HealthConnectService implements IHealthConnectService { endTime: end, segmentType: clampedSets[i].$1, repetitions: clampedSets[i].$2, + weight: clampedSets[i].$4 > 0 ? Mass.kilograms(clampedSets[i].$4) : null, ); }); } @@ -206,6 +207,7 @@ class HealthConnectService implements IHealthConnectService { endTime: end, segmentType: clampedSets[i].$1, repetitions: clampedSets[i].$2, + weight: clampedSets[i].$4 > 0 ? Mass.kilograms(clampedSets[i].$4) : null, )); } return segments; diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 284cbe3..b511c05 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: google_fonts: ^8.0.0 # Health Connect integration - health_connector: ^3.8.1 + health_connector: ^3.9.1 # Backup export/import file_picker: ^10.3.10 From 6a8715e66ceb854c11d25866b669c2497627b647 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 16 May 2026 13:14:33 +0530 Subject: [PATCH 12/44] feat: update Android build configuration, enhance analytics screen, and improve UI components --- workout-logger/android/app/build.gradle.kts | 5 +- .../android/app/src/main/AndroidManifest.xml | 3 +- .../lib/screens/analytics_screen.dart | 77 +++++- .../lib/screens/history_screen.dart | 22 +- workout-logger/lib/screens/home_screen.dart | 15 +- .../lib/screens/widgets/body_heatmap.dart | 3 +- .../widgets/exercise_input_section.dart | 4 +- .../widgets/exercise_progress_view.dart | 259 +++++++++++++++--- .../lib/screens/widgets/rf_inputs.dart | 7 + .../lib/screens/widgets/rf_widgets.dart | 2 +- workout-logger/pubspec.yaml | 2 +- 11 files changed, 324 insertions(+), 75 deletions(-) diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index d12c514..ce6c5dc 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -7,7 +7,8 @@ plugins { android { namespace = "com.devasy.repforge" - compileSdk = flutter.compileSdkVersion + compileSdk = 36 + compileSdkExtension = 19 ndkVersion = flutter.ndkVersion compileOptions { @@ -29,7 +30,7 @@ android { // supported. If downgrading, remove the health_connector dependency and // all HealthConnectService usages, then restore minSdk to flutter.minSdkVersion. minSdk = 26 - targetSdk = flutter.targetSdkVersion + targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName } diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 61b507a..1fcd43a 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> (); + final spots = sessions.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), settings.toDisplay(e.value.totalVolume)); + }).toList(); + + final bestVol = spots.isEmpty + ? 0.0 + : spots.map((s) => s.y).reduce(max); + return _ChartCard( title: 'Volume Progression', subtitle: '${settings.unitLabel} · Last ${sessions.length} workouts', @@ -194,15 +204,40 @@ class _VolumeChart extends StatelessWidget { height: 180, child: LineChart( LineChartData( + backgroundColor: Colors.transparent, gridData: FlGridData( show: true, drawVerticalLine: false, - horizontalInterval: 1, getDrawingHorizontalLine: (_) => FlLine( color: AppColors.glassBorder, strokeWidth: 1, ), ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItems: (spots) => spots.map((spot) { + final i = spot.x.toInt(); + final v = spot.y; + final volStr = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + final dateStr = (i >= 0 && i < sessions.length) + ? DateFormat('MMM d').format(sessions[i].date) + : ''; + return LineTooltipItem( + '$volStr ${settings.unitLabel}', + GoogleFonts.geistMono(color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: '\n$dateStr', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.normal), + ), + ], + ); + }).toList(), + ), + ), titlesData: FlTitlesData( rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), @@ -227,20 +262,44 @@ class _VolumeChart extends StatelessWidget { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 36, - getTitlesWidget: (v, _) => Text( - settings.toDisplay(v).toStringAsFixed(0), - style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9), - ), + reservedSize: 40, + getTitlesWidget: (v, _) { + final label = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + return Text(label, style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9)); + }, ), ), ), borderData: FlBorderData(show: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (bestVol > 0) + HorizontalLine( + y: bestVol, + color: AppColors.warning.withValues(alpha: 0.55), + strokeWidth: 1, + dashArray: [6, 4], + label: HorizontalLineLabel( + show: true, + direction: LabelDirection.horizontal, + alignment: Alignment.topRight, + padding: const EdgeInsets.only(right: 6, bottom: 2), + style: GoogleFonts.geistMono( + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + labelResolver: (line) => + 'BEST ${bestVol.toStringAsFixed(0)}', + ), + ), + ], + ), lineBarsData: [ LineChartBarData( - spots: sessions.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), settings.toDisplay(e.value.totalVolume)); - }).toList(), + spots: spots, isCurved: true, curveSmoothness: 0.3, color: AppColors.primary, diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index abdd6a6..931f3a7 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -117,7 +117,7 @@ class _HistoryScreenState extends State { // Lifetime summary SliverToBoxAdapter( - child: _buildSummaryCard(all, totalVolume), + child: _buildSummaryCard(all, totalVolume, settings), ), // Calendar card @@ -263,12 +263,13 @@ class _HistoryScreenState extends State { ); } - Widget _buildSummaryCard(List all, double totalVolume) { - final volStr = totalVolume >= 1000000 - ? '${(totalVolume / 1000000).toStringAsFixed(1)}M' - : totalVolume >= 1000 - ? '${(totalVolume / 1000).toStringAsFixed(0)}k' - : totalVolume.toStringAsFixed(0); + Widget _buildSummaryCard(List all, double totalVolume, SettingsProvider settings) { + final displayVol = settings.toDisplay(totalVolume); + final volStr = displayVol >= 1000000 + ? '${(displayVol / 1000000).toStringAsFixed(1)}M' + : displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(0)}k' + : displayVol.toStringAsFixed(0); return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), @@ -279,7 +280,7 @@ class _HistoryScreenState extends State { children: [ _SummaryCell(label: 'WORKOUTS', value: '${all.length}', unit: 'total'), const _VertDivider(), - _SummaryCell(label: 'VOLUME', value: volStr, unit: 'kg'), + _SummaryCell(label: 'VOLUME', value: volStr, unit: settings.unitLabel), const _VertDivider(), _SummaryCell(label: 'THIS MONTH', value: '${all.where((s) => s.date.month == DateTime.now().month && s.date.year == DateTime.now().year).length}', unit: 'sessions'), ], @@ -616,7 +617,8 @@ class _HistoryCard extends StatelessWidget { final dayNum = session.date.day; final exCount = session.exercises.length; final setCount = session.exercises.fold(0, (s, e) => s + e.sets.length); - final vol = session.totalVolume; + final settings = context.read(); + final vol = settings.toDisplay(session.totalVolume); final volStr = vol >= 1000 ? '${(vol / 1000).toStringAsFixed(1)}k' : vol.toStringAsFixed(0); final duration = session.duration; final routineName = session.routineId != null @@ -693,7 +695,7 @@ class _HistoryCard extends StatelessWidget { color: AppColors.secondary, ), ), - Text('kg', style: GoogleFonts.geist(fontSize: 10, color: AppColors.textMuted)), + Text(settings.unitLabel, style: GoogleFonts.geist(fontSize: 10, color: AppColors.textMuted)), ], ), ), diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 33b9621..e4d8bc2 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -642,6 +642,7 @@ class _DashboardTab extends StatelessWidget { } Widget _buildMuscleVolumeCard(BuildContext context, WorkoutProvider provider) { + final settings = Provider.of(context, listen: false); final now = DateTime.now(); final weekStart = now.subtract(Duration(days: now.weekday - 1)); final weekSessions = provider.sessions @@ -653,7 +654,7 @@ class _DashboardTab extends StatelessWidget { for (final el in s.exercises) { final ex = provider.getExercise(el.exerciseId); if (ex == null) continue; - final vol = el.totalVolume; + final vol = settings.toDisplay(el.totalVolume); for (final ma in ex.muscleActivations) { muscleVols[ma.muscleGroupId] = (muscleVols[ma.muscleGroupId] ?? 0) + vol * ma.activationPercentage / 100; @@ -687,7 +688,7 @@ class _DashboardTab extends StatelessWidget { ), ), Text( - 'kg', + settings.unitLabel, style: GoogleFonts.geist( fontSize: 11, color: AppColors.textMuted, @@ -786,6 +787,7 @@ class _DashboardTab extends StatelessWidget { required WorkoutProvider provider, _HomeScreenState? homeState, }) { + final settings = context.read(); final recentSessions = provider.sessions.take(3).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -833,9 +835,10 @@ class _DashboardTab extends StatelessWidget { else ...recentSessions.map((s) { final dateStr = _formatSessionDate(s.date); - final volStr = s.totalVolume >= 1000 - ? '${(s.totalVolume / 1000).toStringAsFixed(1)}k' - : s.totalVolume.toStringAsFixed(0); + final displayVol = settings.toDisplay(s.totalVolume); + final volStr = displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0); final exCount = s.exercises.length; final setCount = s.exercises.fold(0, (a, e) => a + e.sets.length); return Padding( @@ -896,7 +899,7 @@ class _DashboardTab extends StatelessWidget { ), ), Text( - 'kg vol', + '${settings.unitLabel} vol', style: GoogleFonts.geist( fontSize: 10, color: AppColors.textFaint, diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart index 5dac649..38cdef9 100644 --- a/workout-logger/lib/screens/widgets/body_heatmap.dart +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; @@ -159,5 +160,5 @@ class _BodyPainter extends CustomPainter { @override bool shouldRepaint(_BodyPainter old) => - old.muscleVolumes != muscleVolumes; + !mapEquals(old.muscleVolumes, muscleVolumes); } diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index fff329c..32e2ae9 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -350,7 +350,7 @@ class _NumberInputCard extends StatelessWidget { children: [ _StepBtn( icon: Icons.remove_rounded, - onTap: () => onChanged((value - step).clamp(0, 999)), + onTap: () => onChanged((value - step).clamp(0, 999).toDouble()), ), Expanded( child: Text( @@ -365,7 +365,7 @@ class _NumberInputCard extends StatelessWidget { ), _StepBtn( icon: Icons.add_rounded, - onTap: () => onChanged((value + step).clamp(0, 999)), + onTap: () => onChanged((value + step).clamp(0, 999).toDouble()), ), ], ), diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index 9b25d58..6fee2ad 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -1,5 +1,7 @@ // exercise_progress_view.dart — Analytics "Exercises" tab +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; @@ -158,7 +160,7 @@ class _ExerciseStats extends StatelessWidget { _GrowthCard(model: growthModel), const SizedBox(height: AppSpacing.sm), ], - _VolumeChart(progression: progression), + _VolumeChart(progression: progression, growthModel: growthModel), const SizedBox(height: AppSpacing.sm), _SessionHistory(progression: progression, settings: settings), ], @@ -250,6 +252,7 @@ class _GrowthCard extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.read(); final isGrowing = model.slope > 0; final color = isGrowing ? AppColors.success : AppColors.warning; @@ -289,7 +292,7 @@ class _GrowthCard extends StatelessWidget { ), Text( isGrowing - ? '+${model.slope.abs().toStringAsFixed(1)} kg/session' + ? '+${settings.toDisplay(model.slope.abs()).toStringAsFixed(1)} ${settings.unitLabel}/session' : 'Volume trend is flat', style: const TextStyle( color: AppColors.textSoft, @@ -324,11 +327,126 @@ class _GrowthCard extends StatelessWidget { // ── Volume progression line chart ───────────────────────────────────────────── class _VolumeChart extends StatelessWidget { - const _VolumeChart({required this.progression}); + const _VolumeChart({required this.progression, this.growthModel}); final List<({DateTime date, double volume})> progression; + final GrowthModel? growthModel; @override Widget build(BuildContext context) { + final settings = context.read(); + final n = progression.length; + + // Residual standard error for 95% confidence interval width + double rse = 0.0; + if (growthModel != null && n >= 3) { + double ssRes = 0.0; + for (int i = 0; i < n; i++) { + final r = progression[i].volume - growthModel!.predict(i); + ssRes += r * r; + } + rse = sqrt(ssRes / (n - 2)); + } + final ci95 = settings.toDisplay(rse * 1.96); + + final bestVol = n > 0 + ? settings.toDisplay(progression.map((e) => e.volume).reduce(max)) + : 0.0; + + final actualSpots = List.generate( + n, + (i) => FlSpot(i.toDouble(), settings.toDisplay(progression[i].volume)), + ); + + // Trend line extends 2 sessions beyond actual data + final trendSpots = (growthModel != null && n >= 2) + ? List.generate( + n + 2, + (i) => FlSpot( + i.toDouble(), + settings.toDisplay(growthModel!.predict(i).clamp(0.0, double.infinity)), + ), + ) + : []; + + // Upper / lower CI bounds rendered as invisible lines; + // BetweenBarsData fills the band between them. + final upperSpots = (ci95 > 0 && trendSpots.isNotEmpty) + ? trendSpots.map((s) => FlSpot(s.x, s.y + ci95)).toList() + : []; + final lowerSpots = (ci95 > 0 && trendSpots.isNotEmpty) + ? trendSpots.map((s) => FlSpot(s.x, max(0.0, s.y - ci95))).toList() + : []; + + // bar indices: 0 = actual, 1 = trend, 2 = upper CI, 3 = lower CI + final lineBars = [ + LineChartBarData( + spots: actualSpots, + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.secondary, + barWidth: 2.5, + dotData: FlDotData( + show: true, + getDotPainter: (_, __, ___, ____) => FlDotCirclePainter( + radius: 3, + color: AppColors.secondary, + strokeWidth: 1.5, + strokeColor: AppColors.surface, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.secondary.withValues(alpha: 0.22), + AppColors.secondary.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + if (trendSpots.isNotEmpty) + LineChartBarData( + spots: trendSpots, + isCurved: false, + color: AppColors.primary.withValues(alpha: 0.5), + barWidth: 1.5, + dashArray: [8, 5], + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + if (upperSpots.isNotEmpty) + LineChartBarData( + spots: upperSpots, + color: Colors.transparent, + barWidth: 0, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + if (lowerSpots.isNotEmpty) + LineChartBarData( + spots: lowerSpots, + color: Colors.transparent, + barWidth: 0, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ]; + + // BetweenBarsData indices depend on how many bars are present + final hasTrend = trendSpots.isNotEmpty; + final hasCi = upperSpots.isNotEmpty; + final betweenBars = (hasTrend && hasCi) + ? [ + BetweenBarsData( + fromIndex: 2, // upper CI + toIndex: 3, // lower CI + color: AppColors.primary.withValues(alpha: 0.08), + ), + ] + : []; + return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( @@ -339,13 +457,34 @@ class _VolumeChart extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Volume Progression', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + Row( + children: [ + const Expanded( + child: Text( + 'Volume Progression', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + if (trendSpots.isNotEmpty) ...[ + Container( + width: 16, + height: 2, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(1), + ), + ), + const SizedBox(width: 4), + const Text( + 'Trend', + style: TextStyle(color: AppColors.textMuted, fontSize: 10), + ), + ], + ], ), const SizedBox(height: AppSpacing.md), if (progression.isEmpty) @@ -363,54 +502,90 @@ class _VolumeChart extends StatelessWidget { height: 160, child: LineChart( LineChartData( + backgroundColor: Colors.transparent, gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (_) => FlLine(color: AppColors.glassBorder, strokeWidth: 1), ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItems: (spots) => spots.map((spot) { + if (spot.barIndex != 0) return null; + final v = spot.y; + final volStr = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + final i = spot.x.toInt(); + final dateStr = (i >= 0 && i < n) + ? DateFormat('MMM d').format(progression[i].date) + : ''; + return LineTooltipItem( + '$volStr ${settings.unitLabel}', + const TextStyle(color: AppColors.secondary, fontSize: 13, fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: '\n$dateStr', + style: const TextStyle(color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.normal), + ), + ], + ); + }).toList(), + ), + ), titlesData: FlTitlesData( - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + bottomTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 38, - getTitlesWidget: (v, _) => Text( - v.toStringAsFixed(0), - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 9, - ), - ), + getTitlesWidget: (v, _) { + final label = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + return Text( + label, + style: const TextStyle(color: AppColors.textMuted, fontSize: 9), + ); + }, ), ), ), borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: progression.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.volume); - }).toList(), - isCurved: true, - curveSmoothness: 0.3, - color: AppColors.secondary, - barWidth: 2.5, - dotData: const FlDotData(show: true), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - AppColors.secondary.withValues(alpha: 0.25), - AppColors.secondary.withValues(alpha: 0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (bestVol > 0) + HorizontalLine( + y: bestVol, + color: AppColors.warning.withValues(alpha: 0.5), + strokeWidth: 1, + dashArray: [6, 4], + label: HorizontalLineLabel( + show: true, + direction: LabelDirection.horizontal, + alignment: Alignment.topRight, + padding: + const EdgeInsets.only(right: 4, bottom: 2), + style: const TextStyle( + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + labelResolver: (line) => + 'BEST ${bestVol.toStringAsFixed(0)}', + ), ), - ), - ), - ], + ], + ), + betweenBarsData: betweenBars, + lineBarsData: lineBars, ), ), ), diff --git a/workout-logger/lib/screens/widgets/rf_inputs.dart b/workout-logger/lib/screens/widgets/rf_inputs.dart index 4794a14..bfde525 100644 --- a/workout-logger/lib/screens/widgets/rf_inputs.dart +++ b/workout-logger/lib/screens/widgets/rf_inputs.dart @@ -131,12 +131,17 @@ class RFNumberField extends StatefulWidget { class _RFNumberFieldState extends State { bool _editing = false; late final TextEditingController _ctrl; + late final FocusNode _focusNode; Timer? _longPressTimer; @override void initState() { super.initState(); _ctrl = TextEditingController(text: _format(widget.value)); + _focusNode = FocusNode(); + _focusNode.addListener(() { + if (!_focusNode.hasFocus && _editing) _commitEdit(); + }); } @override @@ -150,6 +155,7 @@ class _RFNumberFieldState extends State { @override void dispose() { _ctrl.dispose(); + _focusNode.dispose(); _longPressTimer?.cancel(); super.dispose(); } @@ -241,6 +247,7 @@ class _RFNumberFieldState extends State { child: _editing ? TextField( controller: _ctrl, + focusNode: _focusNode, autofocus: true, textAlign: TextAlign.center, keyboardType: const TextInputType.numberWithOptions( diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index a73a81c..e31b9c9 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -886,7 +886,7 @@ class RestTimerRing extends StatelessWidget { @override Widget build(BuildContext context) { - final progress = total > 0 ? remaining / total : 0.0; + final progress = total > 0 ? (remaining / total).clamp(0.0, 1.0) : 0.0; final mins = remaining ~/ 60; final secs = remaining % 60; final label = diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index b511c05..8bf7766 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: provider: ^6.1.1 # Charts for visualization - fl_chart: ^0.69.0 + fl_chart: ^1.2.0 # Utilities uuid: ^4.5.1 From 9ddd6af49c214657883f6d7590c5df9059f8bb59 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 16 May 2026 15:17:03 +0530 Subject: [PATCH 13/44] feat: add muscle recovery and growth tracking features in WorkoutProvider and MLService --- .../lib/screens/analytics_screen.dart | 149 +++++++ .../lib/services/health_connect_service.dart | 15 + .../interfaces/ml_service_interface.dart | 77 +++- workout-logger/lib/services/ml_service.dart | 421 ++++++++++-------- .../lib/services/workout_provider.dart | 23 + 5 files changed, 484 insertions(+), 201 deletions(-) diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index f49e7c9..68b0baa 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -11,6 +11,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/managers/pr_manager.dart'; +import '../services/ml_service.dart' show MuscleRecoveryStatus; import '../services/settings_provider.dart'; import '../data/exercise_database.dart'; import '../theme/app_theme.dart'; @@ -170,6 +171,8 @@ class _OverviewTab extends StatelessWidget { const SizedBox(height: 12), _MuscleVolumeChart(provider: provider), const SizedBox(height: 12), + _MuscleStatusCard(provider: provider), + const SizedBox(height: 12), _FrequencyGrid(provider: provider), ], ), @@ -401,6 +404,152 @@ class _MuscleVolumeChart extends StatelessWidget { } } +// ── Muscle recovery + growth status ─────────────────────────────────────────── + +class _MuscleStatusCard extends StatelessWidget { + const _MuscleStatusCard({required this.provider}); + final WorkoutProvider provider; + + static const _muscleOrder = [ + 'chest', 'back', 'shoulders', 'quads', 'hamstrings', + 'glutes', 'biceps', 'triceps', 'abs', 'calves', + ]; + + @override + Widget build(BuildContext context) { + final recovery = provider.getMuscleRecoveryScores(); + final growth = provider.getMuscleGrowthModels(); + + if (recovery.isEmpty) { + return _ChartCard( + title: 'Muscle Status', + isEmpty: true, + child: const SizedBox.shrink(), + ); + } + + // Show muscles we have recovery data for, in preferred order. + final muscles = [ + ..._muscleOrder.where(recovery.containsKey), + ...recovery.keys.where((k) => !_muscleOrder.contains(k)), + ]; + + return _ChartCard( + title: 'Muscle Status', + subtitle: 'Recovery · Growth trend', + child: Column( + children: muscles.map((id) { + final status = recovery[id]!; + final model = growth[id]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _MuscleRow( + muscleId: id, + status: status, + growthModel: model, + ), + ); + }).toList(), + ), + ); + } +} + +class _MuscleRow extends StatelessWidget { + const _MuscleRow({ + required this.muscleId, + required this.status, + this.growthModel, + }); + + final String muscleId; + final MuscleRecoveryStatus status; + final GrowthModel? growthModel; + + Color get _recoveryColor { + if (status.recoveryFraction >= 0.90) return AppColors.success; + if (status.recoveryFraction >= 0.70) return AppColors.warning; + return AppColors.accent; + } + + ({String label, Color color, IconData icon}) get _trend { + final model = growthModel; + if (model == null || model.r2 < 0.2) { + return (label: 'No data', color: AppColors.textFaint, icon: Icons.remove); + } + if (model.slope > 2) { + return (label: '+${(model.slope * 7).toStringAsFixed(0)}/wk', color: AppColors.success, icon: Icons.trending_up_rounded); + } + if (model.slope > 0) { + return (label: 'Slight gain', color: AppColors.secondary, icon: Icons.trending_up_rounded); + } + return (label: 'Plateau', color: AppColors.warning, icon: Icons.trending_flat_rounded); + } + + @override + Widget build(BuildContext context) { + final name = MuscleGroups.names[muscleId] ?? muscleId; + final pct = status.recoveryFraction; + final color = AppColors.muscle(muscleId); + final trend = _trend; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + style: GoogleFonts.geist( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSoft, + ), + ), + ), + // Recovery badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _recoveryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: _recoveryColor.withValues(alpha: 0.3)), + ), + child: Text( + '${status.recoveryPercent}%', + style: GoogleFonts.geistMono( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _recoveryColor, + ), + ), + ), + const SizedBox(width: 8), + // Growth trend chip + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(trend.icon, size: 12, color: trend.color), + const SizedBox(width: 2), + Text( + trend.label, + style: GoogleFonts.geistMono( + fontSize: 10, + color: trend.color, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 5), + RFProgressBar(value: pct, color: color, height: 5, showGlow: false), + ], + ); + } +} + // ── Weekly frequency grid ────────────────────────────────────────────────────── class _FrequencyGrid extends StatelessWidget { diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 2f57418..275b1e4 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -74,6 +74,7 @@ class HealthConnectService implements IHealthConnectService { _connector ??= await HealthConnector.create(); final results = await _connector!.requestPermissions([ HealthDataType.exerciseSession.writePermission, + HealthDataType.exerciseSession.readPermission, ]); return results.every((r) => r.status == PermissionStatus.granted); } catch (e) { @@ -116,6 +117,20 @@ class HealthConnectService implements IHealthConnectService { ); await _connector!.writeRecords([record]); + + // DEBUG: read back to verify weight is stored — remove after confirming. + final response = await _connector!.readRecords( + HealthDataType.exerciseSession.readInTimeRange( + startTime: sessionStart, + endTime: sessionEnd, + ), + ); + for (final r in response.records.whereType()) { + for (final e in r.events.whereType()) { + debugPrint('[HC debug] segment=${e.segmentType} reps=${e.repetitions} weight=${e.weight}'); + } + } + return true; } catch (e) { debugPrint('Health Connect sync failed: $e'); diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart index 676850f..dcc9530 100644 --- a/workout-logger/lib/services/interfaces/ml_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -1,16 +1,8 @@ -// Abstract ML Service Interface (Dependency Inversion Principle) -// -// This interface defines the contract for machine learning operations. -// By depending on this abstraction, we can: -// - Swap ML algorithms without modifying consumers -// - Easily mock the ML service in tests -// - Follow the Open/Closed principle for new ML strategies - import '../../models/models.dart'; -/// Data point for ML training +/// Data point for ML training. class DataPoint { - final double x; // Session number or time + final double x; // Days since first session (time-based) final double y; // Volume or performance metric DataPoint({required this.x, required this.y}); @@ -28,30 +20,77 @@ class DataPoint { int get hashCode => Object.hash(x, y); } -/// Abstract interface for ML operations -/// -/// Implements Dependency Inversion Principle by allowing high-level modules -/// to depend on this abstraction rather than concrete ML implementations. +/// Per-muscle recovery state estimated by the exponential decay model. +class MuscleRecoveryStatus { + final String muscleGroupId; + + /// 0.0 = just trained (fully fatigued), 1.0 = fully recovered. + final double recoveryFraction; + + final Duration timeSinceLastTrained; + + /// How long until the muscle reaches ~95 % recovery (null = already there). + final Duration? estimatedTimeToFullRecovery; + + const MuscleRecoveryStatus({ + required this.muscleGroupId, + required this.recoveryFraction, + required this.timeSinceLastTrained, + this.estimatedTimeToFullRecovery, + }); + + /// ≥ 90 % — safe to train hard. + bool get isRecovered => recoveryFraction >= 0.90; + + /// < 70 % — still meaningfully fatigued; back off load. + bool get isUnderRecovered => recoveryFraction < 0.70; + + int get recoveryPercent => (recoveryFraction * 100).round(); +} + +/// Abstract interface for ML operations. abstract class IMLService { - /// Train a growth model using data points + /// Train a growth model using data points. GrowthModel trainGrowthModel(List dataPoints); - /// Extract data points from workout history for a specific exercise + /// Extract per-exercise data points (x = days since first session, y = volume). List extractExerciseDataPoints( String exerciseId, List sessions, ); - /// Get recommended sets based on last session and growth model + /// Extract per-muscle aggregate data points for the growth model. + /// y = sum of exercise volumes weighted by [MuscleActivation.activationPercentage]. + List extractMuscleDataPoints( + String muscleGroupId, + List sessions, + Map exerciseMap, + ); + + /// Compute recovery status for every muscle group that appears in [sessions]. + /// Uses an exponential decay model: recovery = 1 − exp(−t / τ). + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, + }); + + /// Get recommended sets based on last session and growth model. + /// [minReps]/[maxReps] define the double-progression rep range. + /// Pass [recoveryScores] + [primaryMuscleIds] for recovery-aware advice. List recommendSets({ required List lastSession, GrowthModel? growthModel, + int minReps = 6, + int maxReps = 12, + Map? recoveryScores, + List? primaryMuscleIds, }); - /// Get default recommendations when no history exists + /// Get default recommendations when no history exists. List getDefaultRecommendations(int setCount); - /// Predict when a target will be completed based on growth model + /// Predict when a target will be completed based on growth model. DateTime? predictTargetCompletion({ required double currentValue, required double targetValue, diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index 47f8997..1b75327 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -1,45 +1,50 @@ -// ML Service - Linear Regression for Growth Rate Prediction -// and Progressive Overload Recommendations -// -// This is a concrete implementation of IMLService. -// Following Dependency Inversion Principle: high-level modules depend on -// the IMLService abstraction, not this concrete class. -// Following Open/Closed Principle: new ML algorithms can be added by -// creating new implementations of IMLService. - import 'dart:math'; import '../models/models.dart'; import 'interfaces/ml_service_interface.dart'; -// Re-export DataPoint from interface for backward compatibility -export 'interfaces/ml_service_interface.dart' show DataPoint; +export 'interfaces/ml_service_interface.dart' show DataPoint, MuscleRecoveryStatus; -/// Linear regression based implementation of the ML service. -/// -/// This class implements IMLService, allowing it to be swapped -/// for other ML algorithms without modifying the consuming code. +/// Exponentially-weighted linear regression + double-progression recommendations +/// + per-muscle recovery scoring. class MLService implements IMLService { - // ==================== LINEAR REGRESSION ==================== + // Decay constant for recency weights. At λ=0.15, a session 10 sessions ago + // carries exp(−1.5) ≈ 22 % of the weight of the most recent session. + static const _lambda = 0.15; + + // Recovery time constants τ (hours) per muscle group. + // Full recovery (~95 %) occurs at ≈ 3τ. + static const _tauHours = { + 'chest': 48.0, + 'back': 60.0, + 'lats': 60.0, + 'quads': 60.0, + 'hamstrings': 60.0, + 'glutes': 60.0, + 'legs': 60.0, + 'shoulders': 40.0, + 'traps': 40.0, + 'biceps': 36.0, + 'triceps': 36.0, + 'abs': 24.0, + 'core': 24.0, + 'calves': 24.0, + 'forearms': 24.0, + }; + static const _defaultTauHours = 48.0; + + // ==================== GROWTH MODEL ==================== - /// Train a growth model using simple linear regression - /// x = session number (0, 1, 2, ...) - /// y = volume or performance metric @override GrowthModel trainGrowthModel(List dataPoints) { return MLService.trainGrowthModelStatic(dataPoints); } - /// Static version for backward compatibility + /// Exponentially-weighted least squares. + /// Weight for point i (0-indexed, n total): exp(−λ · (n−1−i)). static GrowthModel trainGrowthModelStatic(List dataPoints) { if (dataPoints.isEmpty) { - return GrowthModel( - slope: 0, - intercept: 0, - r2: 0, - lastTrained: DateTime.now(), - ); + return GrowthModel(slope: 0, intercept: 0, r2: 0, lastTrained: DateTime.now()); } - if (dataPoints.length == 1) { return GrowthModel( slope: 0, @@ -50,207 +55,274 @@ class MLService implements IMLService { } final n = dataPoints.length; - double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; - - for (var point in dataPoints) { - sumX += point.x; - sumY += point.y; - sumXY += point.x * point.y; - sumX2 += point.x * point.x; + final weights = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); + final wSum = weights.fold(0.0, (s, w) => s + w); + + double wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0; + for (var i = 0; i < n; i++) { + final w = weights[i]; + final x = dataPoints[i].x; + final y = dataPoints[i].y; + wSumX += w * x; + wSumY += w * y; + wSumXY += w * x * y; + wSumX2 += w * x * x; } - // Calculate slope and intercept using least squares - final denominator = n * sumX2 - sumX * sumX; - if (denominator == 0) { - return GrowthModel( - slope: 0, - intercept: sumY / n, - r2: 0, - lastTrained: DateTime.now(), - ); + final denom = wSum * wSumX2 - wSumX * wSumX; + if (denom == 0) { + return GrowthModel(slope: 0, intercept: wSumY / wSum, r2: 0, lastTrained: DateTime.now()); } - final slope = (n * sumXY - sumX * sumY) / denominator; - final intercept = (sumY - slope * sumX) / n; + final slope = (wSum * wSumXY - wSumX * wSumY) / denom; + final intercept = (wSumY - slope * wSumX) / wSum; - // Calculate R² (coefficient of determination) - final yMean = sumY / n; + final yBar = wSumY / wSum; double ssTotal = 0, ssResidual = 0; - - for (var point in dataPoints) { - final predicted = slope * point.x + intercept; - ssTotal += pow(point.y - yMean, 2); - ssResidual += pow(point.y - predicted, 2); + for (var i = 0; i < n; i++) { + final w = weights[i]; + final predicted = slope * dataPoints[i].x + intercept; + ssTotal += w * pow(dataPoints[i].y - yBar, 2); + ssResidual += w * pow(dataPoints[i].y - predicted, 2); } - final double r2Value = ssTotal > 0 - ? (1 - (ssResidual / ssTotal)).toDouble() - : 0.0; - + final r2 = ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0; return GrowthModel( slope: slope, intercept: intercept, - r2: r2Value.clamp(0.0, 1.0), + r2: r2.clamp(0.0, 1.0), lastTrained: DateTime.now(), ); } - /// Extract data points from workout history for a specific exercise + // ==================== DATA EXTRACTION ==================== + + /// x = days since first session for this exercise, y = total volume. @override List extractExerciseDataPoints( String exerciseId, List sessions, ) { final dataPoints = []; - int sessionIndex = 0; - - // Sort sessions by date final sorted = List.from(sessions) ..sort((a, b) => a.date.compareTo(b.date)); - for (var session in sorted) { - for (var exerciseLog in session.exercises) { - if (exerciseLog.exerciseId == exerciseId) { - dataPoints.add( - DataPoint(x: sessionIndex.toDouble(), y: exerciseLog.totalVolume), - ); - sessionIndex++; + DateTime? firstDate; + for (final session in sorted) { + for (final log in session.exercises) { + if (log.exerciseId == exerciseId) { + firstDate ??= session.date; + final days = session.date.difference(firstDate).inDays.toDouble(); + dataPoints.add(DataPoint(x: days, y: log.totalVolume)); break; } } } + return dataPoints; + } + /// x = days since first session training this muscle, + /// y = effective volume = sum(exerciseVolume × activationPercentage / 100). + @override + List extractMuscleDataPoints( + String muscleGroupId, + List sessions, + Map exerciseMap, + ) { + final sorted = List.from(sessions) + ..sort((a, b) => a.date.compareTo(b.date)); + + final dataPoints = []; + DateTime? firstDate; + + for (final session in sorted) { + final volumes = _muscleVolumes(session, exerciseMap); + final vol = volumes[muscleGroupId]; + if (vol == null || vol == 0) continue; + firstDate ??= session.date; + final days = session.date.difference(firstDate).inDays.toDouble(); + dataPoints.add(DataPoint(x: days, y: vol)); + } return dataPoints; } - // ==================== RECOMMENDATIONS ==================== + // ==================== RECOVERY ==================== - /// Generate set recommendations based on previous performance + /// Compute recovery scores for every muscle group trained in [sessions]. + /// + /// Model: recovery(t) = 1 − exp(−t / τ) + /// t = hours since last session that trained this muscle + /// τ = muscle-specific time constant (see [_tauHours]) + /// + /// Full recovery (≥ 95 %) occurs around t = 3τ. @override - List recommendSets({ - required List lastSession, - GrowthModel? growthModel, - double targetProgressPercent = 5.0, // Default 5% increase + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, }) { - if (lastSession.isEmpty) { - return []; - } - - // Calculate target volume increase - final lastVolume = lastSession.fold( - 0, - (sum, set) => sum + set.volume, - ); + final now = asOf ?? DateTime.now(); + final sorted = List.from(sessions) + ..sort((a, b) => a.date.compareTo(b.date)); - // Use growth model slope if available, otherwise use default percentage - double targetVolumeIncrease; - if (growthModel != null && growthModel.r2 > 0.3) { - // Use learned growth rate - targetVolumeIncrease = growthModel.slope; - } else { - // Default: aim for 5% increase - targetVolumeIncrease = lastVolume * (targetProgressPercent / 100); + // Walk sessions forward — each one updates the "last trained" record. + final lastTrained = {}; + for (final session in sorted) { + for (final muscleId in _muscleVolumes(session, exerciseMap).keys) { + lastTrained[muscleId] = session.date; + } } - final recommendations = []; - final volumeIncreasePerSet = targetVolumeIncrease / lastSession.length; - - for (var set in lastSession) { - final targetVolume = set.volume + volumeIncreasePerSet; - final recommendation = _calculateOptimalSet( - currentWeight: set.weight, - currentReps: set.reps, - targetVolume: targetVolume, + final result = {}; + for (final entry in lastTrained.entries) { + final muscleId = entry.key; + final tau = _tauHours[muscleId] ?? _defaultTauHours; + final hours = now.difference(entry.value).inMinutes / 60.0; + final fraction = (1.0 - exp(-hours / tau)).clamp(0.0, 1.0); + // 95 % recovery ≈ 3τ; remaining = 3τ − elapsed. + final hoursRemaining = tau * 3 - hours; + + result[muscleId] = MuscleRecoveryStatus( + muscleGroupId: muscleId, + recoveryFraction: fraction, + timeSinceLastTrained: Duration(minutes: (hours * 60).round()), + estimatedTimeToFullRecovery: hoursRemaining > 0 + ? Duration(minutes: (hoursRemaining * 60).round()) + : null, ); - recommendations.add(recommendation); } - - return recommendations; + return result; } - /// Calculate optimal weight/reps to achieve target volume - static SetRecommendation _calculateOptimalSet({ - required double currentWeight, - required int currentReps, - required double targetVolume, - }) { - // Strategy 1: Try adding reps first (safer progression) - if (currentReps < 12) { - final newReps = currentReps + 1; - final newVolume = currentWeight * newReps; - - if (newVolume >= targetVolume * 0.95) { - return SetRecommendation( - weight: currentWeight, - reps: newReps, - confidence: 'high', - reasoning: 'Add 1 rep for progressive overload', - ); - } - - // Try adding 2 reps - if (currentReps < 11) { - final twoMoreReps = currentReps + 2; - final volumeWith2Reps = currentWeight * twoMoreReps; - - if (volumeWith2Reps >= targetVolume * 0.95) { - return SetRecommendation( - weight: currentWeight, - reps: twoMoreReps, - confidence: 'high', - reasoning: 'Add 2 reps to match target volume', - ); - } + /// Effective volume per muscle group for one session. + static Map _muscleVolumes( + WorkoutSession session, + Map exerciseMap, + ) { + final volumes = {}; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + final total = log.totalVolume; + for (final activation in exercise.muscleActivations) { + volumes[activation.muscleGroupId] = + (volumes[activation.muscleGroupId] ?? 0.0) + + total * activation.activationPercentage / 100.0; } } + return volumes; + } + + // ==================== RECOMMENDATIONS ==================== - // Strategy 2: Increase weight - final weightIncrement = currentWeight < 40 ? 2.5 : 5.0; - final newWeight = currentWeight + weightIncrement; + /// Double-progression with optional recovery awareness. + /// + /// Priority order: + /// 1. Under-recovered primary muscle → maintenance (hold weight & reps). + /// 2. Plateau (model slope ≤ 0, R² > 0.25) → maintenance. + /// 3. reps ≥ maxReps → bump weight, reset to minReps. + /// 4. Otherwise → add 1 rep, hold weight. + @override + List recommendSets({ + required List lastSession, + GrowthModel? growthModel, + int minReps = 6, + int maxReps = 12, + Map? recoveryScores, + List? primaryMuscleIds, + }) { + if (lastSession.isEmpty) return []; + + final isPlateau = growthModel != null && + growthModel.slope <= 0 && + growthModel.r2 > 0.25; + + final isUnderRecovered = primaryMuscleIds != null && + recoveryScores != null && + primaryMuscleIds.any((m) => recoveryScores[m]?.isUnderRecovered ?? false); + + final worstRecovery = isUnderRecovered + ? primaryMuscleIds + .map((m) => recoveryScores[m]) + .whereType() + .map((s) => s.recoveryPercent) + .fold(100, (a, b) => a < b ? a : b) + : null; + + return lastSession + .map((set) => _doubleProgression( + set: set, + minReps: minReps, + maxReps: maxReps, + isPlateau: isPlateau, + isUnderRecovered: isUnderRecovered, + recoveryPercent: worstRecovery, + )) + .toList(); + } - // When increasing weight, maintain or slightly reduce reps - int newReps = currentReps; - if (currentReps >= 10) { - newReps = currentReps - 2; // Reset rep range when weight goes up + static SetRecommendation _doubleProgression({ + required WorkoutSet set, + required int minReps, + required int maxReps, + required bool isPlateau, + required bool isUnderRecovered, + int? recoveryPercent, + }) { + if (isUnderRecovered) { + return SetRecommendation( + weight: set.weight, + reps: set.reps, + confidence: 'low', + reasoning: + 'Muscle only $recoveryPercent% recovered — maintain load, skip progression', + ); } - newReps = newReps.clamp(6, 15); - final newVolume = newWeight * newReps; + if (isPlateau) { + return SetRecommendation( + weight: set.weight, + reps: set.reps, + confidence: 'medium', + reasoning: 'Plateau detected — maintain load and focus on form quality', + ); + } - String confidence; - if (newVolume >= targetVolume * 0.9 && newVolume <= targetVolume * 1.1) { - confidence = 'high'; - } else if (newVolume >= targetVolume * 0.8) { - confidence = 'medium'; - } else { - confidence = 'low'; + if (set.reps >= maxReps) { + final increment = set.weight < 40 ? 2.5 : 5.0; + return SetRecommendation( + weight: set.weight + increment, + reps: minReps, + confidence: 'high', + reasoning: 'Rep target hit — add ${increment}kg and reset to $minReps reps', + ); } return SetRecommendation( - weight: newWeight, - reps: newReps, - confidence: confidence, - reasoning: 'Increase weight by ${weightIncrement}kg, adjust reps', + weight: set.weight, + reps: set.reps + 1, + confidence: 'high', + reasoning: 'Add 1 rep (${set.reps + 1}/$maxReps) — progressive overload', ); } - /// Fill in default recommendations for a new exercise + /// Fill in default recommendations when no history exists. @override List getDefaultRecommendations(int setCount) { return List.generate( setCount, - (index) => SetRecommendation( + (_) => SetRecommendation( weight: 0, reps: 10, confidence: 'low', - reasoning: 'No previous data - adjust based on feel', + reasoning: 'No previous data — adjust based on feel', ), ); } // ==================== TARGET PREDICTIONS ==================== - /// Predict when a target will be achieved + /// Slope is volume/day (x = days since first session). @override DateTime? predictTargetCompletion({ required double currentValue, @@ -258,45 +330,30 @@ class MLService implements IMLService { required GrowthModel growthModel, double sessionsPerWeek = 3.0, }) { - if (currentValue >= targetValue) { - return DateTime.now(); // Already achieved - } - - if (growthModel.slope <= 0) { - return null; // No growth or declining - can't predict - } - - final gapToTarget = targetValue - currentValue; - final sessionsNeeded = gapToTarget / growthModel.slope; - final weeksNeeded = sessionsNeeded / sessionsPerWeek; - final daysNeeded = (weeksNeeded * 7).ceil(); - - return DateTime.now().add(Duration(days: daysNeeded)); + if (currentValue >= targetValue) return DateTime.now(); + if (growthModel.slope <= 0) return null; + final days = ((targetValue - currentValue) / growthModel.slope).ceil(); + return DateTime.now().add(Duration(days: days)); } - /// Calculate confidence interval for prediction + /// Confidence interval around the predicted completion date. static ({DateTime optimistic, DateTime expected, DateTime pessimistic})? - predictTargetWithConfidence({ + predictTargetWithConfidence({ required double currentValue, required double targetValue, required GrowthModel growthModel, double sessionsPerWeek = 3.0, }) { - // Create instance to call the non-static method - final mlService = MLService(); - final expected = mlService.predictTargetCompletion( + final expected = MLService().predictTargetCompletion( currentValue: currentValue, targetValue: targetValue, growthModel: growthModel, sessionsPerWeek: sessionsPerWeek, ); - if (expected == null) return null; - // Adjust based on model quality (R²) final daysToTarget = expected.difference(DateTime.now()).inDays; final uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); - return ( optimistic: expected.subtract(Duration(days: uncertainty)), expected: expected, diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 79ef6c8..70f3cbf 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -962,6 +962,29 @@ class WorkoutProvider extends ChangeNotifier { return volumeByMuscle; } + /// Per-muscle recovery scores using exponential decay (recovery = 1 − e^(−t/τ)). + Map getMuscleRecoveryScores() { + final exerciseMap = {for (final e in _allExercises) e.id: e}; + return _mlService.computeMuscleRecoveryScores(_sessions, exerciseMap); + } + + /// Per-muscle growth models trained on aggregate weighted volume. + Map getMuscleGrowthModels() { + final exerciseMap = {for (final e in _allExercises) e.id: e}; + final muscleIds = { + for (final e in _allExercises) + for (final a in e.muscleActivations) a.muscleGroupId, + }; + final result = {}; + for (final id in muscleIds) { + final points = _mlService.extractMuscleDataPoints(id, _sessions, exerciseMap); + if (points.length >= 2) { + result[id] = _mlService.trainGrowthModel(points); + } + } + return result; + } + /// Get growth model for an exercise GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; From a612864a0dcd6b9b82872b8688a024ce16c06d37 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 20 May 2026 23:20:46 +0530 Subject: [PATCH 14/44] feat: refactor NumberInputCard to StatefulWidget and enhance RFNavBar design --- .../widgets/exercise_input_section.dart | 82 ++++++++++++++++-- .../lib/screens/widgets/rf_widgets.dart | 85 ++++++++++--------- .../lib/screens/workout_flow_screen.dart | 9 ++ 3 files changed, 129 insertions(+), 47 deletions(-) diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 32e2ae9..a3a4c78 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -305,7 +305,7 @@ class _InputRow extends StatelessWidget { } // ── Number Input Card ───────────────────────────────────────────────────────── -class _NumberInputCard extends StatelessWidget { +class _NumberInputCard extends StatefulWidget { const _NumberInputCard({ required this.label, required this.value, @@ -320,9 +320,40 @@ class _NumberInputCard extends StatelessWidget { final int decimals; final ValueChanged onChanged; - String _format() => decimals > 0 - ? value.toStringAsFixed(decimals) - : value.toInt().toString(); + @override + State<_NumberInputCard> createState() => _NumberInputCardState(); +} + +class _NumberInputCardState extends State<_NumberInputCard> { + late final TextEditingController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _format()); + } + + @override + void didUpdateWidget(_NumberInputCard old) { + super.didUpdateWidget(old); + // Sync controller when value changes externally (e.g. AI apply, stepper) + // but don't interrupt the user while they're typing. + if (old.value != widget.value && !_focusNode.hasFocus) { + _controller.text = _format(); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + String _format() => widget.decimals > 0 + ? widget.value.toStringAsFixed(widget.decimals) + : widget.value.toInt().toString(); @override Widget build(BuildContext context) { @@ -336,7 +367,7 @@ class _NumberInputCard extends StatelessWidget { child: Column( children: [ Text( - label, + widget.label, style: GoogleFonts.geist( color: AppColors.textMuted, fontSize: 11, @@ -350,22 +381,55 @@ class _NumberInputCard extends StatelessWidget { children: [ _StepBtn( icon: Icons.remove_rounded, - onTap: () => onChanged((value - step).clamp(0, 999).toDouble()), + onTap: () => widget.onChanged( + (widget.value - widget.step).clamp(0, 999).toDouble(), + ), ), Expanded( - child: Text( - _format(), + child: TextField( + controller: _controller, + focusNode: _focusNode, style: GoogleFonts.geistMono( color: AppColors.textPrimary, fontSize: 36, fontWeight: FontWeight.w700, ), textAlign: TextAlign.center, + keyboardType: TextInputType.numberWithOptions( + decimal: widget.decimals > 0, + ), + inputFormatters: widget.decimals > 0 + ? [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d*$'), + ), + ] + : [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: (text) { + final parsed = double.tryParse(text); + if (parsed != null) { + widget.onChanged(parsed.clamp(0, 999).toDouble()); + } + }, + onEditingComplete: () { + // Reset to last valid value if field is empty/invalid + if (double.tryParse(_controller.text) == null) { + _controller.text = _format(); + } + _focusNode.unfocus(); + }, ), ), _StepBtn( icon: Icons.add_rounded, - onTap: () => onChanged((value + step).clamp(0, 999).toDouble()), + onTap: () => widget.onChanged( + (widget.value + widget.step).clamp(0, 999).toDouble(), + ), ), ], ), diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index e31b9c9..e16ed09 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -137,8 +137,8 @@ class AmbientGlow extends StatelessWidget { } // ── RFNavBar ───────────────────────────────────────────────────────────────── -// Custom glassmorphic bottom navigation bar — 4 tabs, accent indicator above -// the active icon, no FAB. +// Premium floating glassmorphic bottom navigation bar with perfect rounded blur, +// deep drop shadow, and clean transparent padding so it sits elegantly above the content. class RFNavBar extends StatelessWidget { const RFNavBar({ super.key, @@ -153,43 +153,52 @@ class RFNavBar extends StatelessWidget { @override Widget build(BuildContext context) { - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - AppColors.background.withValues(alpha: 0.85), - ], - ), - ), - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - MediaQuery.of(context).padding.bottom + 8, - ), - child: Container( - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(AppRadius.xxl), - border: Border.all(color: AppColors.glassBorder), + final bottomPadding = MediaQuery.of(context).padding.bottom; + return Container( + color: Colors.transparent, // Completely transparent outer container + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + bottomPadding > 0 ? bottomPadding + 8 : 16, + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.xxl), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 28, + spreadRadius: -4, + offset: const Offset(0, 10), ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(items.length, (i) { - final active = i == currentIndex; - return _NavItem( - item: items[i], - active: active, - onTap: () => onTap(i), - ); - }), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.xxl), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.8), // Sleek transparent surface + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all( + color: AppColors.glassBorderStrong, + width: 1.5, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(items.length, (i) { + final active = i == currentIndex; + return _NavItem( + item: items[i], + active: active, + onTap: () => onTap(i), + ); + }), + ), ), ), ), diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 6966203..29e810a 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -12,6 +12,7 @@ import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/managers/pr_manager.dart'; import '../theme/app_theme.dart'; +import 'add_custom_exercise_screen.dart'; import 'exercise_library_screen.dart'; import 'workout_summary_screen.dart'; import 'widgets/workout_header.dart'; @@ -223,6 +224,14 @@ class _WorkoutFlowScreenState extends State { selectionMode: true, onExercisesSelected: _startWithSelected, ), + floatingActionButton: FloatingActionButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + ), + backgroundColor: AppColors.primary, + elevation: 0, + child: const Icon(Icons.add_rounded, color: Colors.white), + ), ); } From 3e9a8ec880caea0d3501842438ed056525aa00d7 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 20 May 2026 23:29:22 +0530 Subject: [PATCH 15/44] feat: enhance ProfileScreen and ProfileSections with Google Fonts for improved typography --- .../lib/screens/profile_screen.dart | 177 +++++++++---- .../lib/screens/widgets/profile_sections.dart | 249 +++++++++++------- 2 files changed, 284 insertions(+), 142 deletions(-) diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 293800a..65c4b76 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -10,6 +10,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:intl/intl.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; @@ -157,27 +158,33 @@ class _ProfileScreenState extends State shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.lg), ), - title: const Text( + title: Text( 'Import Backup', - style: TextStyle(color: AppColors.textPrimary), + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), ), - content: const Text( + content: Text( 'This will merge the backup with your existing data. ' 'Select a .json RepForge backup file to continue.', - style: TextStyle(color: AppColors.textSoft), + style: GoogleFonts.geist(color: AppColors.textSoft), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text( + child: Text( 'Cancel', - style: TextStyle(color: AppColors.textSoft), + style: GoogleFonts.geist(color: AppColors.textMuted), ), ), TextButton( onPressed: () => Navigator.pop(ctx, true), style: TextButton.styleFrom(foregroundColor: AppColors.primary), - child: const Text('Choose File'), + child: Text( + 'Choose File', + style: GoogleFonts.geist(fontWeight: FontWeight.w600), + ), ), ], ), @@ -242,7 +249,10 @@ class _ProfileScreenState extends State void _showSnack(String message, Color color) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(message, style: const TextStyle(color: AppColors.textPrimary)), + content: Text( + message, + style: GoogleFonts.geist(color: AppColors.textPrimary), + ), backgroundColor: color, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( @@ -261,16 +271,21 @@ class _ProfileScreenState extends State body: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - _buildAppBar(), + _buildHero(), SliverPadding( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), sliver: SliverList( delegate: SliverChildListDelegate([ PreferencesSection( settings: settings, onHaptic: () => HapticFeedback.selectionClick(), ), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.md), HealthConnectSection( settings: settings, isLoading: _isRequestingHcPermission, @@ -282,7 +297,7 @@ class _ProfileScreenState extends State } }, ), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.md), DataManagementSection( isExporting: _isExporting, isImporting: _isImporting, @@ -291,9 +306,9 @@ class _ProfileScreenState extends State onImport: _isImporting ? null : _importFromFile, onCloudBackup: _isBackingUp ? null : _performCloudBackup, ), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.md), const CloudSyncSection(), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.md), AboutSection(appVersion: _appVersion), const SizedBox(height: AppSpacing.xxl), ]), @@ -304,67 +319,123 @@ class _ProfileScreenState extends State ); } - Widget _buildAppBar() { - return SliverAppBar( - expandedHeight: 160, - pinned: true, - backgroundColor: AppColors.surface, - flexibleSpace: FlexibleSpaceBar( - background: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppColors.primary, Color(0xFF8B7FE8)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + Widget _buildHero() { + return SliverToBoxAdapter( + child: Stack( + clipBehavior: Clip.none, + children: [ + // Ambient violet wash centred at top + Positioned( + top: -80, + left: 0, + right: 0, + child: Center( + child: Container( + width: 400, + height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.28), + Colors.transparent, + ], + stops: const [0, 0.65], + ), + ), + ), ), ), - child: SafeArea( + SafeArea( + bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, - AppSpacing.md, + AppSpacing.xl, + AppSpacing.lg, AppSpacing.lg, - AppSpacing.md, ), child: Column( - mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Icon( - Icons.fitness_center_rounded, - color: Colors.white, - size: 28, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Brand avatar + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.lg), + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.50), + blurRadius: 24, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.fitness_center_rounded, + color: Colors.white, + size: 28, + ), + ), + const Spacer(), + // Version pill + if (_appVersion.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.glassBorderStrong, + ), + ), + child: Text( + 'v$_appVersion', + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), - const SizedBox(height: AppSpacing.sm), - const Text( + const SizedBox(height: AppSpacing.md), + Text( 'RepForge', - style: TextStyle( - color: Colors.white, - fontSize: 22, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 30, fontWeight: FontWeight.w800, - letterSpacing: -0.3, + letterSpacing: -0.6, ), ), + const SizedBox(height: 2), Text( - 'v$_appVersion', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, + 'Settings & preferences', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 14, + fontWeight: FontWeight.w400, ), ), ], ), ), ), - ), + ], ), ); } diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 73bc02c..7166baa 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; const String _createdBy = 'Devasy Patel'; @@ -28,25 +30,24 @@ class _ProfileSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return GlassCard( padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.glassBorder), - ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: iconColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: iconColor.withValues(alpha: 0.25), + width: 1, + ), ), - child: Icon(icon, color: iconColor, size: 20), + child: Icon(icon, color: iconColor, size: 18), ), const SizedBox(width: 12), Expanded( @@ -55,17 +56,19 @@ class _ProfileSection extends StatelessWidget { children: [ Text( title, - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontWeight: FontWeight.w700, - fontSize: 15, + fontSize: 14, + letterSpacing: -0.2, ), ), Text( subtitle, - style: const TextStyle( - color: AppColors.textSoft, + style: GoogleFonts.geist( + color: AppColors.textMuted, fontSize: 12, + fontWeight: FontWeight.w400, ), ), ], @@ -75,7 +78,7 @@ class _ProfileSection extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.md), - Divider(color: AppColors.glassBorder, height: 1), + const Divider(color: AppColors.glassBorder, height: 1), const SizedBox(height: AppSpacing.md), child, ], @@ -157,7 +160,7 @@ class PreferencesSection extends StatelessWidget { decoration: BoxDecoration( color: selected ? AppColors.primary.withValues(alpha: 0.15) - : AppColors.surface, + : AppColors.glass, borderRadius: BorderRadius.circular(AppRadius.full), border: Border.all( color: selected @@ -168,10 +171,10 @@ class PreferencesSection extends StatelessWidget { ), child: Text( label, - style: TextStyle( + style: GoogleFonts.geistMono( color: selected ? AppColors.primary : AppColors.textSoft, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - fontSize: 13, + fontSize: 12, ), ), ), @@ -211,22 +214,23 @@ class HealthConnectSection extends StatelessWidget { children: [ Row( children: [ - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Sync workouts after finishing', - style: TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 14, + fontWeight: FontWeight.w500, ), ), - SizedBox(height: 2), + const SizedBox(height: 2), Text( 'Writes session + per-set reps to Health Connect', - style: TextStyle( - color: AppColors.textSoft, + style: GoogleFonts.geist( + color: AppColors.textMuted, fontSize: 12, ), ), @@ -243,15 +247,15 @@ class HealthConnectSection extends StatelessWidget { ), if (enabled) ...[ const SizedBox(height: AppSpacing.sm), - Divider(color: AppColors.glassBorder, height: 1), + const Divider(color: AppColors.glassBorder, height: 1), const SizedBox(height: AppSpacing.sm), - const Row( + Row( children: [ - Icon(Icons.check_circle_outline, color: _hcColor, size: 16), - SizedBox(width: 8), + const Icon(Icons.check_circle_outline, color: _hcColor, size: 15), + const SizedBox(width: 8), Text( 'Connected — syncing after each workout', - style: TextStyle(color: _hcColor, fontSize: 12), + style: GoogleFonts.geist(color: _hcColor, fontSize: 12), ), ], ), @@ -341,23 +345,29 @@ class CloudSyncSection extends StatelessWidget { const SizedBox(height: AppSpacing.sm), Container( decoration: BoxDecoration( - color: AppColors.surface, + color: AppColors.glass, borderRadius: BorderRadius.circular(AppRadius.sm), border: Border.all(color: AppColors.glassBorder), ), child: TextField( enabled: false, - style: const TextStyle(color: AppColors.textMuted, fontSize: 13), - decoration: const InputDecoration( + style: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 12, + ), + decoration: InputDecoration( hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: TextStyle(color: AppColors.textMuted, fontSize: 13), - prefixIcon: Icon( + hintStyle: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 12, + ), + prefixIcon: const Icon( Icons.link_rounded, - color: AppColors.textMuted, - size: 18, + color: AppColors.textFaint, + size: 16, ), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm + 4, ), @@ -365,10 +375,10 @@ class CloudSyncSection extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.sm), - const Text( + Text( 'Cloud sync with custom MongoDB will be available in a future update.', - style: TextStyle( - color: AppColors.textMuted, + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 11, fontStyle: FontStyle.italic, ), @@ -393,11 +403,23 @@ class AboutSection extends StatelessWidget { subtitle: 'RepForge Workout Logger', child: Column( children: [ - _InfoTile(label: 'Version', value: appVersion, icon: Icons.tag_rounded), + _InfoTile( + label: 'Version', + value: appVersion, + icon: Icons.tag_rounded, + ), const _SectionDivider(), - _InfoTile(label: 'Created by', value: _createdBy, icon: Icons.person_rounded), + _InfoTile( + label: 'Created by', + value: _createdBy, + icon: Icons.person_rounded, + ), const _SectionDivider(), - _InfoTile(label: 'Platform', value: 'Android', icon: Icons.phone_android_rounded), + _InfoTile( + label: 'Platform', + value: 'Android', + icon: Icons.phone_android_rounded, + ), const _SectionDivider(), _InfoTile( label: 'Package', @@ -420,11 +442,11 @@ class _SectionLabel extends StatelessWidget { Widget build(BuildContext context) { return Text( text, - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 1, + style: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.4, ), ); } @@ -447,11 +469,11 @@ class _UnitToggleButton extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( color: selected ? AppColors.primary.withValues(alpha: 0.15) - : AppColors.surface, + : AppColors.glass, borderRadius: BorderRadius.circular(AppRadius.sm), border: Border.all( color: selected @@ -459,14 +481,23 @@ class _UnitToggleButton extends StatelessWidget { : AppColors.glassBorder, width: selected ? 1.5 : 1, ), + boxShadow: selected + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.20), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, ), child: Center( child: Text( label, - style: TextStyle( + style: GoogleFonts.geist( color: selected ? AppColors.primary : AppColors.textSoft, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - fontSize: 15, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + fontSize: 14, ), ), ), @@ -494,35 +525,68 @@ class _ActionTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppRadius.sm), + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + splashColor: AppColors.primary.withValues(alpha: 0.06), + highlightColor: AppColors.primary.withValues(alpha: 0.04), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: iconColor.withValues(alpha: 0.22), + width: 1, + ), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Text( + subtitle, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + if (loading) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + else + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 18, + ), + ], ), - child: Icon(icon, color: iconColor, size: 20), - ), - title: Text( - title, - style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), - ), - subtitle: Text( - subtitle, - style: const TextStyle(color: AppColors.textSoft, fontSize: 12), ), - trailing: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(AppColors.primary), - ), - ) - : const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted), - onTap: onTap, ); } } @@ -541,21 +605,24 @@ class _InfoTile extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 9), child: Row( children: [ - Icon(icon, color: AppColors.textMuted, size: 18), + Icon(icon, color: AppColors.textFaint, size: 16), const SizedBox(width: 12), Text( label, - style: const TextStyle(color: AppColors.textSoft, fontSize: 13), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), ), const Spacer(), Text( value, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, + style: GoogleFonts.geistMono( + color: AppColors.textSoft, + fontSize: 12, fontWeight: FontWeight.w500, ), ), @@ -570,7 +637,11 @@ class _SectionDivider extends StatelessWidget { @override Widget build(BuildContext context) { - return const Divider(color: AppColors.glassBorder, height: 1, indent: 40); + return const Divider( + color: AppColors.glassBorder, + height: 1, + indent: 40, + ); } } @@ -582,13 +653,13 @@ class _ComingSoonBadge extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.15), + color: AppColors.warning.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.warning.withValues(alpha: 0.4)), + border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)), ), - child: const Text( - 'Coming Soon', - style: TextStyle( + child: Text( + 'Soon', + style: GoogleFonts.geist( color: AppColors.warning, fontSize: 10, fontWeight: FontWeight.w600, From 674b0ec88a51ab9945423bb3f695f50b8b3534cd Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 20 May 2026 23:54:14 +0530 Subject: [PATCH 16/44] feat: update Create button in ProgramsScreen to allow for non-full width display --- workout-logger/lib/screens/programs/programs_screen.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index aed5091..0758167 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -78,6 +78,7 @@ class ProgramsScreen extends StatelessWidget { GlowButton( label: 'Create', icon: Icons.add_rounded, + fullWidth: false, onPressed: () => _openDesigner(context, null), ), const SizedBox(width: AppSpacing.md), From f0dd98ee88637bfd22f2f1932c21bf5453511264 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 22 May 2026 21:13:26 +0530 Subject: [PATCH 17/44] feat: Integrate Gemini AI features for personalized coaching and insights - Added GeminiService for AI integration, including chat and program generation capabilities. - Implemented GeminiContextBuilder to create context strings for AI prompts. - Introduced _WeeklyInsightsCard to display weekly insights based on user workouts. - Added AiSettingsSection in profile settings for managing Gemini API key and model selection. - Updated home screen to include weekly insights and AI coach button. - Enhanced ProgramsScreen with AI program generator button. - Updated SettingsProvider to manage Gemini API key and insights storage. - Added necessary dependencies for Google Generative AI. --- .../android/app/src/main/AndroidManifest.xml | 1 + workout-logger/lib/main.dart | 5 + .../lib/screens/ai_coach_screen.dart | 647 ++++++++++++++++++ .../screens/ai_program_generator_screen.dart | 507 ++++++++++++++ workout-logger/lib/screens/home_screen.dart | 260 ++++++- .../lib/screens/profile_screen.dart | 2 + .../lib/screens/programs/programs_screen.dart | 34 + .../lib/screens/widgets/profile_sections.dart | 213 ++++++ .../lib/services/gemini_context_builder.dart | 137 ++++ .../lib/services/gemini_service.dart | 183 +++++ .../lib/services/settings_provider.dart | 36 + workout-logger/pubspec.yaml | 3 + .../test/test_utils/mock_ml_service.dart | 22 + 13 files changed, 2033 insertions(+), 17 deletions(-) create mode 100644 workout-logger/lib/screens/ai_coach_screen.dart create mode 100644 workout-logger/lib/screens/ai_program_generator_screen.dart create mode 100644 workout-logger/lib/services/gemini_context_builder.dart create mode 100644 workout-logger/lib/services/gemini_service.dart diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 1fcd43a..afd1469 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 529aaaa..f1b6cd1 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'services/storage_service.dart'; import 'services/ml_service.dart'; +import 'services/gemini_service.dart'; import 'services/health_connect_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; @@ -61,6 +62,7 @@ class WorkoutLoggerApp extends StatelessWidget { static final HistoryManager _historyManager = HistoryManager(_storageService, healthSyncManager: _healthSyncManager); static final PRManager _prManager = PRManager(_storageService); + static final GeminiService _geminiService = GeminiService(); const WorkoutLoggerApp({super.key}); @@ -87,6 +89,7 @@ class WorkoutLoggerApp extends StatelessWidget { // Provided as ChangeNotifier so HistoryScreen rebuilds on sync badge changes. ChangeNotifierProvider.value(value: _historyManager), ChangeNotifierProvider.value(value: _prManager), + ChangeNotifierProvider.value(value: _geminiService), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( create: (_) => WorkoutProvider( @@ -132,10 +135,12 @@ class _AppInitializerState extends State { final historyManager = context.read(); final prManager = context.read(); final api = context.read(); + final gemini = context.read(); try { await provider.init(); await settings.init(); + gemini.init(settings.geminiApiKey, model: settings.geminiModel); await historyManager.loadSessions(); await prManager.load(); await prManager.backfillFromSessions(historyManager.sessions); diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart new file mode 100644 index 0000000..44ccaca --- /dev/null +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -0,0 +1,647 @@ +// ai_coach_screen.dart — Conversational AI workout coach powered by Gemini + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../services/gemini_service.dart'; +import '../services/gemini_context_builder.dart'; +import '../services/workout_provider.dart'; +import '../services/settings_provider.dart'; +import '../services/interfaces/ml_service_interface.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; + +// ── Data ────────────────────────────────────────────────────────────────────── + +class _ChatMessage { + const _ChatMessage({required this.role, required this.text}); + final String role; // 'user' | 'model' + final String text; +} + +// ── Screen ──────────────────────────────────────────────────────────────────── + +class AiCoachScreen extends StatefulWidget { + const AiCoachScreen({super.key}); + + @override + State createState() => _AiCoachScreenState(); +} + +class _AiCoachScreenState extends State { + final _controller = TextEditingController(); + final _scrollCtrl = ScrollController(); + final _messages = <_ChatMessage>[]; + bool _loading = false; + String _streamingText = ''; + + @override + void dispose() { + _controller.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + String _buildSystemPrompt() { + final wp = context.read(); + final settings = context.read(); + final mlService = context.read(); + + final exerciseMap = {for (final e in wp.allExercises) e.id: e}; + final allSessions = wp.sessions; + final recoveryScores = mlService.computeMuscleRecoveryScores( + allSessions, + exerciseMap, + ); + final activeTargets = wp.targets.where((t) => !t.isCompleted).toList(); + + return GeminiContextBuilder.buildCoachSystemPrompt( + recentSessions: allSessions, + exerciseMap: exerciseMap, + recoveryScores: recoveryScores, + activeTargets: activeTargets, + userName: settings.userName, + unitLabel: settings.unitLabel, + ); + } + + List _buildHistory() => _messages + .map((m) => Content(m.role, [TextPart(m.text)])) + .toList(); + + Future _send() async { + final text = _controller.text.trim(); + if (text.isEmpty || _loading) return; + + HapticFeedback.lightImpact(); + _controller.clear(); + + setState(() { + _messages.add(_ChatMessage(role: 'user', text: text)); + _loading = true; + _streamingText = ''; + }); + _scrollToBottom(); + + final gemini = context.read(); + final systemPrompt = _buildSystemPrompt(); + // Build history from all messages except the one we just added. + final history = _messages.length > 1 + ? _buildHistory().sublist(0, _messages.length - 1) + : []; + + final buffer = StringBuffer(); + await for (final chunk in gemini.streamCoachReply( + userMessage: text, + systemPrompt: systemPrompt, + history: history, + )) { + buffer.write(chunk); + if (mounted) { + setState(() => _streamingText = buffer.toString()); + _scrollToBottom(); + } + } + + if (mounted) { + setState(() { + _messages.add(_ChatMessage(role: 'model', text: buffer.toString())); + _streamingText = ''; + _loading = false; + }); + _scrollToBottom(); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Column( + children: [ + _buildHeader(context), + Expanded( + child: gemini.isConfigured + ? _buildChatArea() + : _buildNoKeyState(context), + ), + if (gemini.isConfigured) _buildInputBar(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 0, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.arrow_back_rounded, + color: AppColors.textSoft, + size: 18, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ], + ), + child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 16), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Coach', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + Text( + 'Powered by Gemini', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + if (_messages.isNotEmpty) + GestureDetector( + onTap: () => setState(() => _messages.clear()), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + 'Clear', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildChatArea() { + final hasMessages = _messages.isNotEmpty || _loading; + + if (!hasMessages) return _buildWelcome(); + + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + itemCount: _messages.length + (_loading ? 1 : 0), + itemBuilder: (_, i) { + if (i == _messages.length) { + // Streaming bubble + return _StreamingBubble(text: _streamingText); + } + return _MessageBubble(message: _messages[i]); + }, + ); + } + + Widget _buildWelcome() { + final settings = context.read(); + final name = settings.userName; + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.45), + blurRadius: 28, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.auto_awesome_rounded, + color: Colors.white, + size: 32, + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + name != null && name.isNotEmpty + ? 'Hey $name 👋' + : 'Your AI Coach', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Ask me anything — what to train today, how to break a plateau, reading your progress, anything.', + textAlign: TextAlign.center, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 14, + height: 1.5, + ), + ), + const SizedBox(height: AppSpacing.xl), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + alignment: WrapAlignment.center, + children: const [ + _SuggestionChip('What should I train today?'), + _SuggestionChip('How\'s my recovery?'), + _SuggestionChip('Am I progressing on bench?'), + _SuggestionChip('Suggest a deload week'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNoKeyState(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const RFEmptyState( + icon: Icons.key_rounded, + title: 'API Key Required', + subtitle: 'Add your Gemini API key in\nProfile → AI Features to start chatting', + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: 'Go to Profile', + icon: Icons.person_rounded, + fullWidth: false, + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } + + Widget _buildInputBar() { + return Container( + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.9), + border: const Border(top: BorderSide(color: AppColors.glassBorder)), + ), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: TextField( + controller: _controller, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + ), + maxLines: 4, + minLines: 1, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Ask your coach...', + hintStyle: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 14, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + ), + onSubmitted: (_) => _send(), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + GestureDetector( + onTap: _loading ? null : _send, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: _loading + ? null + : const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + color: _loading ? AppColors.glass3 : null, + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: _loading + ? null + : [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ], + ), + child: _loading + ? const Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ), + ) + : const Icon( + Icons.arrow_upward_rounded, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + ); + } +} + +// ── Suggestion chip ─────────────────────────────────────────────────────────── + +class _SuggestionChip extends StatelessWidget { + const _SuggestionChip(this.label); + final String label; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + final state = context.findAncestorStateOfType<_AiCoachScreenState>(); + if (state == null) return; + state._controller.text = label; + state._send(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.30)), + ), + child: Text( + label, + style: GoogleFonts.geist( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +// ── Message bubble ──────────────────────────────────────────────────────────── + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message}); + final _ChatMessage message; + + @override + Widget build(BuildContext context) { + final isUser = message.role == 'user'; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isUser) ...[ + _AiAvatar(), + const SizedBox(width: AppSpacing.sm), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + gradient: isUser + ? const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + color: isUser ? null : AppColors.glass3, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(AppRadius.lg), + topRight: const Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), + bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), + ), + border: isUser + ? null + : Border.all(color: AppColors.glassBorder), + boxShadow: isUser + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.25), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: Text( + message.text, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _StreamingBubble extends StatelessWidget { + const _StreamingBubble({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _AiAvatar(), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppRadius.lg), + topRight: Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(AppRadius.lg), + ), + border: Border.all(color: AppColors.glassBorder), + ), + child: text.isEmpty + ? const RFLoadingDots() + : Text( + text, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _AiAvatar extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.35), + blurRadius: 8, + spreadRadius: -2, + ), + ], + ), + child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 14), + ); + } +} diff --git a/workout-logger/lib/screens/ai_program_generator_screen.dart b/workout-logger/lib/screens/ai_program_generator_screen.dart new file mode 100644 index 0000000..23adabb --- /dev/null +++ b/workout-logger/lib/screens/ai_program_generator_screen.dart @@ -0,0 +1,507 @@ +// ai_program_generator_screen.dart — Natural-language training program generation via Gemini + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../models/models.dart'; +import '../services/gemini_service.dart'; +import '../services/workout_provider.dart'; +import '../services/managers/program_manager.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; + +class AiProgramGeneratorScreen extends StatefulWidget { + const AiProgramGeneratorScreen({super.key}); + + @override + State createState() => + _AiProgramGeneratorScreenState(); +} + +class _AiProgramGeneratorScreenState extends State { + final _promptCtrl = TextEditingController(); + bool _generating = false; + String _statusText = ''; + TrainingProgram? _preview; + String? _error; + + // Prompt suggestions + static const _suggestions = [ + '12-week hypertrophy, 4 days/week, push-pull-legs-upper', + '8-week strength focus, 3 days/week, full body', + '6-week cut program, 5 days/week, high volume', + '16-week powerlifting peaking, 4 days/week', + ]; + + @override + void dispose() { + _promptCtrl.dispose(); + super.dispose(); + } + + Future _generate() async { + final prompt = _promptCtrl.text.trim(); + if (prompt.isEmpty) return; + HapticFeedback.mediumImpact(); + + setState(() { + _generating = true; + _preview = null; + _error = null; + _statusText = 'Designing your program…'; + }); + + try { + final gemini = context.read(); + final wp = context.read(); + + setState(() => _statusText = 'Building workout structure…'); + final program = await gemini.generateProgram( + userPrompt: prompt, + allExercises: wp.allExercises, + ); + + if (mounted) { + setState(() { + _preview = program; + _generating = false; + _statusText = ''; + }); + HapticFeedback.lightImpact(); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString().replaceFirst('Exception: ', ''); + _generating = false; + _statusText = ''; + }); + } + } + } + + Future _saveProgram() async { + if (_preview == null) return; + HapticFeedback.mediumImpact(); + final manager = context.read(); + await manager.saveProgram(_preview!); + if (mounted) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Column( + children: [ + _buildHeader(context), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPromptCard(), + const SizedBox(height: AppSpacing.lg), + if (_generating) _buildGeneratingState(), + if (_error != null) _buildError(), + if (_preview != null) _buildPreview(), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 0, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.arrow_back_rounded, + color: AppColors.textSoft, + size: 18, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.auto_awesome_rounded, + color: Colors.white, + size: 16, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Program Generator', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + Text( + 'Powered by Gemini', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPromptCard() { + return GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.edit_note_rounded, + color: AppColors.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Describe your program', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Container( + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: TextField( + controller: _promptCtrl, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.5, + ), + maxLines: 4, + minLines: 2, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: + 'e.g. "12-week hypertrophy program, 4 days/week, push-pull split, intermediate level"', + hintStyle: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 13, + height: 1.5, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.all(AppSpacing.md), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + 'QUICK PROMPTS', + style: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: _suggestions.map((s) { + return GestureDetector( + onTap: () => setState(() => _promptCtrl.text = s), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + s, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: _generating ? 'Generating…' : 'Generate Program', + icon: Icons.auto_awesome_rounded, + onPressed: _generating ? null : _generate, + ), + ], + ), + ); + } + + Widget _buildGeneratingState() { + return GlassCard( + glowColor: AppColors.primary, + child: Column( + children: [ + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + _statusText, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'Gemini is designing your training block…', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ); + } + + Widget _buildError() { + return GlassCard( + borderColor: AppColors.error.withValues(alpha: 0.4), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + _error!, + style: GoogleFonts.geist( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ); + } + + Widget _buildPreview() { + final p = _preview!; + final deloads = p.weeks.where((w) => w.isDeload).length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader('Generated Program'), + GlassCard( + glowColor: AppColors.success, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.3), + ), + ), + child: const Icon( + Icons.calendar_month_rounded, + color: AppColors.success, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.name, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + if (p.description != null) + Text( + p.description!, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + RFChip(label: '${p.totalWeeks} weeks', color: AppColors.primary), + RFChip(label: '${p.phases.length} phases', color: AppColors.secondary), + RFChip( + label: '${p.weeks.fold(0, (s, w) => s + w.days.length)} training days', + color: AppColors.textSoft, + ), + if (deloads > 0) + RFChip(label: '$deloads deload', color: AppColors.warning), + ], + ), + if (p.phases.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + Text( + 'PHASES', + style: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + ...p.phases.map( + (phase) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Text( + phase.name, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + Text( + 'Wk ${phase.startWeek}–${phase.endWeek}', + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: 'Add to My Programs', + icon: Icons.add_rounded, + onPressed: _saveProgram, + ), + const SizedBox(height: AppSpacing.sm), + OutlineGlowButton( + label: 'Regenerate', + icon: Icons.refresh_rounded, + onPressed: _generate, + ), + const SizedBox(height: AppSpacing.xxl), + ], + ); + } +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index e4d8bc2..66d2c0c 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -9,6 +9,8 @@ import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; +import '../services/gemini_service.dart'; +import '../services/gemini_context_builder.dart'; import '../theme/app_theme.dart'; import 'workout_flow_screen.dart'; import 'history_screen.dart'; @@ -16,6 +18,7 @@ import 'routines_screen.dart'; import 'analytics_screen.dart'; import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; +import 'ai_coach_screen.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; @@ -200,6 +203,8 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 16), _buildMuscleVolumeCard(context, provider), const SizedBox(height: 16), + const _WeeklyInsightsCard(), + const SizedBox(height: 16), _buildRecentWorkouts(context: context, provider: provider, homeState: homeState), const SizedBox(height: 100), ], @@ -253,25 +258,60 @@ class _DashboardTab extends StatelessWidget { ), ], ), - GestureDetector( - onTap: () => Navigator.push( - context, - _slide(const ProfileScreen()), - ), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: AppColors.glass2, - border: Border.all(color: AppColors.glassBorder), + Row( + children: [ + GestureDetector( + onTap: () => Navigator.push( + context, + _slide(const AiCoachScreen()), + ), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.35), + blurRadius: 12, + spreadRadius: -3, + ), + ], + ), + child: const Icon( + Icons.auto_awesome_rounded, + size: 18, + color: Colors.white, + ), + ), ), - child: const Icon( - Icons.person_outline_rounded, - size: 18, - color: AppColors.textSoft, + const SizedBox(width: 8), + GestureDetector( + onTap: () => Navigator.push( + context, + _slide(const ProfileScreen()), + ), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.person_outline_rounded, + size: 18, + color: AppColors.textSoft, + ), + ), ), - ), + ], ), ], ); @@ -1129,3 +1169,189 @@ PageRouteBuilder _slide(Widget page) { transitionDuration: const Duration(milliseconds: 300), ); } + +// ── Weekly Insights Card ────────────────────────────────────────────────────── + +class _WeeklyInsightsCard extends StatefulWidget { + const _WeeklyInsightsCard(); + + @override + State<_WeeklyInsightsCard> createState() => _WeeklyInsightsCardState(); +} + +class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { + bool _loading = false; + + Future _refresh() async { + final gemini = context.read(); + if (!gemini.isConfigured) return; + + setState(() => _loading = true); + + final wp = context.read(); + final settings = context.read(); + + final exerciseMap = {for (final e in wp.allExercises) e.id: e}; + + final now = DateTime.now(); + final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); + final startOfLastWeek = startOfWeek.subtract(const Duration(days: 7)); + + final thisWeek = wp.sessions + .where((s) => s.date.isAfter(startOfWeek) || _sameDay(s.date, startOfWeek)) + .toList(); + final lastWeek = wp.sessions + .where( + (s) => + (s.date.isAfter(startOfLastWeek) || _sameDay(s.date, startOfLastWeek)) && + s.date.isBefore(startOfWeek), + ) + .toList(); + + final context_ = GeminiContextBuilder.buildWeeklyInsightsContext( + thisWeek: thisWeek, + lastWeek: lastWeek, + exerciseMap: exerciseMap, + unitLabel: settings.unitLabel, + ); + + final insights = await gemini.generateWeeklyInsights(context_); + if (mounted) { + await settings.saveWeeklyInsights(insights); + setState(() => _loading = false); + } + } + + bool _sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + final settings = context.watch(); + + if (!gemini.isConfigured) return const SizedBox.shrink(); + + final insights = settings.weeklyInsights; + final updatedAt = settings.weeklyInsightsDate; + final hasInsights = insights.isNotEmpty; + + return GlassCard( + glowColor: AppColors.primary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 10, + spreadRadius: -3, + ), + ], + ), + child: const Icon( + Icons.auto_awesome_rounded, + color: Colors.white, + size: 14, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'This Week\'s Insights', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + ), + GestureDetector( + onTap: _loading ? null : _refresh, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.30), + ), + ), + child: _loading + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Text( + hasInsights ? 'Refresh' : 'Generate', + style: GoogleFonts.geist( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + if (hasInsights || _loading) ...[ + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + if (_loading && !hasInsights) + const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: RFLoadingDots(), + ) + else + Text( + insights, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 13, + height: 1.6, + ), + ), + if (updatedAt != null && !_loading) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + 'Updated ${DateFormat('MMM d, h:mm a').format(updatedAt)}', + style: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 10, + ), + ), + ], + ] else ...[ + const SizedBox(height: AppSpacing.sm), + Text( + 'Tap Generate to get a personalised coaching summary for this week.', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + height: 1.5, + ), + ), + ], + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 65c4b76..ebb0b9c 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -307,6 +307,8 @@ class _ProfileScreenState extends State onCloudBackup: _isBackingUp ? null : _performCloudBackup, ), const SizedBox(height: AppSpacing.md), + const AiSettingsSection(), + const SizedBox(height: AppSpacing.md), const CloudSyncSection(), const SizedBox(height: AppSpacing.md), AboutSection(appVersion: _appVersion), diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index 0758167..af7f9c6 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -10,6 +10,7 @@ import '../widgets/rf_widgets.dart'; import 'program_detail_screen.dart'; import 'program_designer_screen.dart'; import 'import_program_screen.dart'; +import '../ai_program_generator_screen.dart'; class ProgramsScreen extends StatelessWidget { const ProgramsScreen({super.key}); @@ -42,6 +43,17 @@ class ProgramsScreen extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.sm), + FloatingActionButton.small( + heroTag: 'ai_generate', + onPressed: () => _openAiGenerator(context), + backgroundColor: AppColors.card, + elevation: 0, + child: const Icon( + Icons.auto_awesome_rounded, + color: AppColors.primary, + ), + ), + const SizedBox(height: AppSpacing.sm), FloatingActionButton.extended( heroTag: 'new_program', onPressed: () => _openDesigner(context, null), @@ -117,6 +129,28 @@ class ProgramsScreen extends StatelessWidget { ); } + Future _openAiGenerator(BuildContext context) async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiProgramGeneratorScreen()), + ); + if (result == true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'AI program added!', + style: TextStyle(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } + } + Future _openImport(BuildContext context) async { final result = await Navigator.push( context, diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 7166baa..2e8a1ac 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -3,8 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; import '../../services/settings_provider.dart'; +import '../../services/gemini_service.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; @@ -645,6 +647,217 @@ class _SectionDivider extends StatelessWidget { } } +// ── AI Features section ─────────────────────────────────────────────────────── +class AiSettingsSection extends StatefulWidget { + const AiSettingsSection({super.key}); + + @override + State createState() => _AiSettingsSectionState(); +} + +class _AiSettingsSectionState extends State { + late TextEditingController _ctrl; + bool _obscure = true; + bool _saving = false; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController( + text: context.read().geminiApiKey, + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + final key = _ctrl.text.trim(); + final settings = context.read(); + final gemini = context.read(); + await settings.setGeminiApiKey(key); + gemini.updateApiKey(key); + if (mounted) setState(() => _saving = false); + } + + Future _selectModel(String modelId) async { + final settings = context.read(); + final gemini = context.read(); + await settings.setGeminiModel(modelId); + gemini.updateModel(modelId); + } + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + final settings = context.watch(); + return _ProfileSection( + icon: Icons.auto_awesome_rounded, + iconColor: AppColors.primary, + title: 'AI Features', + subtitle: 'Gemini-powered coach, program builder & insights', + trailing: gemini.isConfigured + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.success.withValues(alpha: 0.35)), + ), + child: Text( + 'Active', + style: GoogleFonts.geist( + color: AppColors.success, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('GEMINI API KEY'), + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _ctrl, + obscureText: _obscure, + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 12, + ), + decoration: InputDecoration( + hintText: 'AIza…', + hintStyle: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 12, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + ), + ), + ), + GestureDetector( + onTap: () => setState(() => _obscure = !_obscure), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Icon( + _obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: AppColors.textFaint, + size: 18, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Get a free key at aistudio.google.com. Stored locally on-device.', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: AppSpacing.md), + const _SectionLabel('GEMINI MODEL'), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 8, + runSpacing: 8, + children: kGeminiModels.map(((String, String) entry) { + final (id, label) = entry; + final selected = settings.geminiModel == id; + return GestureDetector( + onTap: () => _selectModel(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Text( + label, + style: GoogleFonts.geistMono( + color: selected ? AppColors.primary : AppColors.textSoft, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.35)), + ), + child: TextButton( + onPressed: _saving ? null : _save, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Text( + 'Save API Key', + style: GoogleFonts.geist( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + class _ComingSoonBadge extends StatelessWidget { const _ComingSoonBadge(); diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart new file mode 100644 index 0000000..362af1e --- /dev/null +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -0,0 +1,137 @@ +// gemini_context_builder.dart — Builds rich context strings from app data for Gemini prompts. + +import '../models/models.dart'; +import 'interfaces/ml_service_interface.dart'; + +class GeminiContextBuilder { + const GeminiContextBuilder._(); + + // ── Coach system prompt ──────────────────────────────────────────────────── + static String buildCoachSystemPrompt({ + required List recentSessions, + required Map exerciseMap, + required Map recoveryScores, + required List activeTargets, + String? userName, + String unitLabel = 'kg', + }) { + final buf = StringBuffer() + ..writeln( + 'You are an expert personal trainer embedded in RepForge, a workout tracking app.', + ) + ..writeln( + 'Answer concisely (under 180 words unless a plan is requested). ' + 'Be encouraging and specific — always reference the user\'s actual data.', + ); + + if (userName != null && userName.isNotEmpty) { + buf.writeln('\nUser: $userName'); + } + + // Recent sessions + buf.writeln('\n--- RECENT SESSIONS (last 14 days) ---'); + final cutoff = DateTime.now().subtract(const Duration(days: 14)); + final recent = recentSessions + .where((s) => s.date.isAfter(cutoff)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + if (recent.isEmpty) { + buf.writeln('No sessions in the last 14 days.'); + } else { + for (final s in recent.take(8)) { + final date = '${_weekday(s.date.weekday)} ${s.date.day}/${s.date.month}'; + final exParts = s.exercises.map((e) { + final name = exerciseMap[e.exerciseId]?.name ?? e.exerciseId; + final sets = e.sets + .map((ws) => '${ws.weight}$unitLabel×${ws.reps}') + .join(', '); + return '$name [$sets]'; + }); + buf.writeln('$date: ${exParts.join(' | ')}'); + } + } + + // Muscle recovery + buf.writeln('\n--- MUSCLE RECOVERY ---'); + if (recoveryScores.isEmpty) { + buf.writeln('No recovery data yet.'); + } else { + final sorted = recoveryScores.entries.toList() + ..sort((a, b) => a.value.recoveryPercent.compareTo(b.value.recoveryPercent)); + for (final e in sorted) { + final name = e.key.replaceAll('_', ' '); + final pct = e.value.recoveryPercent; + final tag = e.value.isRecovered + ? 'ready' + : e.value.isUnderRecovered + ? 'fatigued' + : 'recovering'; + buf.writeln('$name: $pct% ($tag)'); + } + } + + // Active goals + buf.writeln('\n--- ACTIVE GOALS ---'); + if (activeTargets.isEmpty) { + buf.writeln('No active goals set.'); + } else { + for (final t in activeTargets) { + final name = exerciseMap[t.exerciseId]?.name ?? t.exerciseId; + final progress = t.progressPercentage.toStringAsFixed(0); + buf.writeln( + '$name: ${t.currentValue}$unitLabel → ${t.targetValue}$unitLabel ($progress%)', + ); + } + } + + return buf.toString(); + } + + // ── Weekly insights context ──────────────────────────────────────────────── + static String buildWeeklyInsightsContext({ + required List thisWeek, + required List lastWeek, + required Map exerciseMap, + String unitLabel = 'kg', + }) { + final buf = StringBuffer(); + + buf.writeln( + 'THIS WEEK — ${thisWeek.length} sessions, ' + '${_totalVol(thisWeek)}$unitLabel total volume:', + ); + for (final s in thisWeek) { + final day = _weekday(s.date.weekday); + final parts = s.exercises.map((e) { + final name = exerciseMap[e.exerciseId]?.name ?? e.exerciseId; + final sets = e.sets.length; + final vol = e.totalVolume.toStringAsFixed(0); + return '$name $sets×sets ($vol$unitLabel vol)'; + }); + buf.writeln(' $day: ${parts.join(', ')}'); + } + + buf.writeln( + '\nLAST WEEK — ${lastWeek.length} sessions, ' + '${_totalVol(lastWeek)}$unitLabel total volume:', + ); + for (final s in lastWeek) { + final day = _weekday(s.date.weekday); + final names = + s.exercises.map((e) => exerciseMap[e.exerciseId]?.name ?? e.exerciseId); + buf.writeln(' $day: ${names.join(", ")}'); + } + + return buf.toString(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + static String _totalVol(List sessions) => + sessions.fold(0, (sum, s) => sum + s.totalVolume).toStringAsFixed(0); + + static String _weekday(int wd) { + const d = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return d[wd.clamp(1, 7)]; + } +} diff --git a/workout-logger/lib/services/gemini_service.dart b/workout-logger/lib/services/gemini_service.dart new file mode 100644 index 0000000..46ca610 --- /dev/null +++ b/workout-logger/lib/services/gemini_service.dart @@ -0,0 +1,183 @@ +// gemini_service.dart — Gemini AI integration (coach chat, program gen, insights) + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/models.dart'; + +// Ordered list of available Gemini models shown in the picker. +const kGeminiModels = [ + ('gemini-2.5-flash', 'Gemini 2.5 Flash'), + ('gemini-3.0-flash', 'Gemini 3.0 Flash'), + ('gemini-3.1-flash-lite', 'Gemini 3.1 Flash Lite'), + ('gemini-3.5-flash', 'Gemini 3.5 Flash'), +]; + +const kDefaultGeminiModel = 'gemini-2.5-flash'; + +class GeminiService extends ChangeNotifier { + String _apiKey = ''; + String _model = kDefaultGeminiModel; + + bool get isConfigured => _apiKey.isNotEmpty; + String get currentModel => _model; + + void init(String apiKey, {String model = kDefaultGeminiModel}) { + _apiKey = apiKey.trim(); + _model = model; + } + + void updateApiKey(String key) { + _apiKey = key.trim(); + notifyListeners(); + } + + void updateModel(String model) { + _model = model; + notifyListeners(); + } + + GenerativeModel _makeModel({bool jsonMode = false, String? system}) { + return GenerativeModel( + model: _model, + apiKey: _apiKey, + systemInstruction: system != null ? Content.system(system) : null, + generationConfig: jsonMode + ? GenerationConfig(responseMimeType: 'application/json') + : null, + ); + } + + // ── Coach chat (streaming) ───────────────────────────────────────────────── + // [history] is the prior conversation as alternating user/model Content objects. + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + }) async* { + if (!isConfigured) { + yield 'Please add your Gemini API key in Profile → AI Features to get started.'; + return; + } + try { + final session = _makeModel(system: systemPrompt).startChat(history: history); + await for (final chunk + in session.sendMessageStream(Content.text(userMessage))) { + final t = chunk.text; + if (t != null && t.isNotEmpty) yield t; + } + } on GenerativeAIException catch (e) { + yield 'AI error: ${e.message}'; + } catch (e) { + yield 'Error: $e'; + } + } + + // ── Program generator (structured JSON output) ──────────────────────────── + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) async { + if (!isConfigured) { + throw StateError('Gemini API key not configured.'); + } + + final exerciseList = allExercises + .map((e) => ' "${e.id}": "${e.name} [${e.primaryMuscle}]"') + .join('\n'); + + const systemPrompt = '''You are a certified strength and conditioning coach creating structured training programs for RepForge. +Return ONLY raw JSON — no markdown fences, no comments, no explanation text. +Use ONLY exercise IDs from the provided list as exerciseId values. + +Required JSON schema (follow exactly): +{ + "id": "unique-string", + "name": "Program Name", + "description": "Brief description", + "totalWeeks": , + "author": "AI Coach", + "isImported": true, + "createdAt": "", + "phases": [ + {"id":"phase-1","name":"Phase Name","startWeek":1,"endWeek":,"notes":"...","colorHex":null} + ], + "weeks": [ + { + "weekNumber": 1, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-1", + "notes": null, + "days": [ + { + "id": "w1-d1", + "name": "Day Name", + "dayOfWeek": 1, + "notes": null, + "exercises": [ + { + "exerciseId": "", + "sets": 3, + "minReps": 8, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-1-1", + "weightPercentage": null, + "notes": null, + "supersetGroupId": null + } + ] + } + ] + } + ] +}'''; + + final prompt = + 'Available exercises (ID: name [primary muscle]):\n$exerciseList\n\nUser request: $userPrompt'; + + try { + final response = await _makeModel(jsonMode: true, system: systemPrompt) + .generateContent([Content.text(prompt)]); + final raw = response.text ?? ''; + if (raw.isEmpty) throw FormatException('Empty response from Gemini.'); + + final data = jsonDecode(raw) as Map; + // Ensure a fresh UUID so it never collides with an existing program. + data['id'] = const Uuid().v4(); + data['isImported'] = true; + data['author'] = 'AI Coach'; + return TrainingProgram.fromJson(data); + } on GenerativeAIException catch (e) { + throw Exception('Gemini API error: ${e.message}'); + } on FormatException catch (e) { + throw Exception('Could not parse program JSON: $e'); + } + } + + // ── Weekly insights (single-shot text) ──────────────────────────────────── + Future generateWeeklyInsights(String contextText) async { + if (!isConfigured) { + return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; + } + const systemPrompt = + 'You are a performance coach giving weekly training feedback for RepForge users. ' + 'Write 3–4 sentences in a conversational, encouraging tone. ' + 'Be specific — reference actual exercise names and numbers from the data. ' + 'Cover: biggest win, one thing to watch, one tip for next week. ' + 'No bullet points, no headers — natural flowing prose only.'; + try { + final response = await _makeModel(system: systemPrompt) + .generateContent([Content.text(contextText)]); + return response.text?.trim() ?? 'No insights generated.'; + } on GenerativeAIException catch (e) { + return 'AI error: ${e.message}'; + } catch (e) { + return 'Could not generate insights: $e'; + } + } +} diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index d6d231b..19955f1 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -14,6 +14,10 @@ class SettingsProvider extends ChangeNotifier { bool _healthConnectEnabled = false; String? _userName; String? _lastSeenVersion; + String _geminiApiKey = ''; + String _geminiModel = 'gemini-2.5-flash'; + String _weeklyInsights = ''; + DateTime? _weeklyInsightsDate; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; @@ -21,6 +25,10 @@ class SettingsProvider extends ChangeNotifier { bool get healthConnectEnabled => _healthConnectEnabled; String? get userName => _userName; String? get lastSeenVersion => _lastSeenVersion; + String get geminiApiKey => _geminiApiKey; + String get geminiModel => _geminiModel; + String get weeklyInsights => _weeklyInsights; + DateTime? get weeklyInsightsDate => _weeklyInsightsDate; SettingsProvider(this._storage); @@ -38,6 +46,11 @@ class SettingsProvider extends ChangeNotifier { _userName = await _storage.getSetting('userName'); _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); + _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; + _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-2.5-flash'; + _weeklyInsights = await _storage.getSetting('weeklyInsights') ?? ''; + final dateStr = await _storage.getSetting('weeklyInsightsDate'); + _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; } Future setUserName(String name) async { @@ -84,6 +97,29 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setGeminiModel(String model) async { + _geminiModel = model; + await _storage.saveSetting('geminiModel', model); + notifyListeners(); + } + + Future setGeminiApiKey(String key) async { + _geminiApiKey = key.trim(); + await _storage.saveSetting('geminiApiKey', _geminiApiKey); + notifyListeners(); + } + + Future saveWeeklyInsights(String insights) async { + _weeklyInsights = insights; + _weeklyInsightsDate = DateTime.now(); + await _storage.saveSetting('weeklyInsights', insights); + await _storage.saveSetting( + 'weeklyInsightsDate', + _weeklyInsightsDate!.toIso8601String(), + ); + notifyListeners(); + } + /// Convert from internal kg storage to display unit. double toDisplay(double kg) { if (_weightUnit == WeightUnit.lbs) return kg * 2.20462; diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 8bf7766..61b340b 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -58,6 +58,9 @@ dependencies: # Health Connect integration health_connector: ^3.9.1 + # AI — Gemini + google_generative_ai: ^0.4.3 + # Backup export/import file_picker: ^10.3.10 path_provider: ^2.1.5 diff --git a/workout-logger/test/test_utils/mock_ml_service.dart b/workout-logger/test/test_utils/mock_ml_service.dart index 998bbfd..100d089 100644 --- a/workout-logger/test/test_utils/mock_ml_service.dart +++ b/workout-logger/test/test_utils/mock_ml_service.dart @@ -66,10 +66,32 @@ class MockMLService implements IMLService { return dataPoints; } + @override + List extractMuscleDataPoints( + String muscleGroupId, + List sessions, + Map exerciseMap, + ) { + return []; + } + + @override + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, + }) { + return {}; + } + @override List recommendSets({ required List lastSession, GrowthModel? growthModel, + int minReps = 6, + int maxReps = 12, + Map? recoveryScores, + List? primaryMuscleIds, }) { recommendSetsCallCount++; lastRecommendedLastSession = lastSession; From 609180dfb364c59b213af69fce9c4699d63ad678 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 23 May 2026 01:55:30 +0530 Subject: [PATCH 18/44] feat: enhance various screens and models with improved error handling, UI adjustments, and new features --- workout-logger/lib/models/models.dart | 11 +- .../lib/screens/ai_coach_screen.dart | 41 ++-- .../screens/ai_program_generator_screen.dart | 8 +- .../screens/edit_workout_session_screen.dart | 29 ++- .../lib/screens/history_screen.dart | 12 +- workout-logger/lib/screens/home_screen.dart | 60 +++--- .../lib/screens/onboarding_screen.dart | 17 +- .../lib/screens/programs/programs_screen.dart | 3 +- .../lib/screens/widgets/body_heatmap.dart | 2 +- .../screens/widgets/dashboard_widgets.dart | 3 +- .../widgets/editable_exercise_card.dart | 39 ++-- .../widgets/exercise_input_section.dart | 19 +- .../widgets/exercise_progress_view.dart | 17 +- .../lib/screens/widgets/profile_sections.dart | 12 +- .../lib/screens/widgets/rf_widgets.dart | 198 ++++++++++-------- 15 files changed, 259 insertions(+), 212 deletions(-) diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 28938d6..2a5b643 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -1,8 +1,12 @@ // Data Models for Workout Logger App +import 'package:uuid/uuid.dart'; + // Sentinel value for copyWith methods to distinguish "not provided" from "null" const Object _sentinel = Object(); +const _uuid = Uuid(); + // ==================== Muscle Groups ==================== class MuscleGroup { @@ -167,14 +171,17 @@ class WorkoutSet { } class DropsetEntry { + final String id; final double weight; final int reps; - DropsetEntry({required this.weight, required this.reps}); + DropsetEntry({String? id, required this.weight, required this.reps}) + : id = id ?? _uuid.v4(); - Map toJson() => {'weight': weight, 'reps': reps}; + Map toJson() => {'id': id, 'weight': weight, 'reps': reps}; factory DropsetEntry.fromJson(Map json) => DropsetEntry( + id: json['id'] as String?, weight: (json['weight'] as num).toDouble(), reps: json['reps'], ); diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 44ccaca..77a4168 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -13,6 +13,7 @@ import '../services/settings_provider.dart'; import '../services/interfaces/ml_service_interface.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; +import 'profile_screen.dart'; // ── Data ────────────────────────────────────────────────────────────────────── @@ -94,25 +95,28 @@ class _AiCoachScreenState extends State { : []; final buffer = StringBuffer(); - await for (final chunk in gemini.streamCoachReply( - userMessage: text, - systemPrompt: systemPrompt, - history: history, - )) { - buffer.write(chunk); + try { + await for (final chunk in gemini.streamCoachReply( + userMessage: text, + systemPrompt: systemPrompt, + history: history, + )) { + buffer.write(chunk); + if (mounted) { + setState(() => _streamingText = buffer.toString()); + _scrollToBottom(); + } + } if (mounted) { - setState(() => _streamingText = buffer.toString()); + setState(() { + _messages.add(_ChatMessage(role: 'model', text: buffer.toString())); + _streamingText = ''; + _loading = false; + }); _scrollToBottom(); } - } - - if (mounted) { - setState(() { - _messages.add(_ChatMessage(role: 'model', text: buffer.toString())); - _streamingText = ''; - _loading = false; - }); - _scrollToBottom(); + } catch (_) { + if (mounted) setState(() { _streamingText = ''; _loading = false; }); } } @@ -363,7 +367,10 @@ class _AiCoachScreenState extends State { label: 'Go to Profile', icon: Icons.person_rounded, fullWidth: false, - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), ), ], ), diff --git a/workout-logger/lib/screens/ai_program_generator_screen.dart b/workout-logger/lib/screens/ai_program_generator_screen.dart index 23adabb..7297ae7 100644 --- a/workout-logger/lib/screens/ai_program_generator_screen.dart +++ b/workout-logger/lib/screens/ai_program_generator_screen.dart @@ -44,6 +44,13 @@ class _AiProgramGeneratorScreenState extends State { Future _generate() async { final prompt = _promptCtrl.text.trim(); if (prompt.isEmpty) return; + + final gemini = context.read(); + if (!gemini.isConfigured) { + setState(() { _error = 'Add your Gemini API key in Profile → AI Features first.'; }); + return; + } + HapticFeedback.mediumImpact(); setState(() { @@ -54,7 +61,6 @@ class _AiProgramGeneratorScreenState extends State { }); try { - final gemini = context.read(); final wp = context.read(); setState(() => _statusText = 'Building workout structure…'); diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index fe0c4ac..94da3db 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -305,8 +305,8 @@ class _EditWorkoutSessionScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionLabel(context, Icons.calendar_today_rounded, - AppColors.primary, 'Date & Time'), + _sectionLabel(icon: Icons.calendar_today_rounded, + color: AppColors.primary, label: 'Date & Time'), const SizedBox(height: AppSpacing.md), Row( children: [ @@ -337,8 +337,8 @@ class _EditWorkoutSessionScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionLabel(context, Icons.timer_outlined, - AppColors.secondary, 'Duration (minutes)'), + _sectionLabel(icon: Icons.timer_outlined, + color: AppColors.secondary, label: 'Duration (minutes)'), const SizedBox(height: AppSpacing.sm), _StyledField( controller: _durationCtrl, @@ -348,8 +348,8 @@ class _EditWorkoutSessionScreenState extends State { onChanged: (_) => _markChanged(), ), const SizedBox(height: AppSpacing.md), - _sectionLabel(context, Icons.notes_rounded, - AppColors.warning, 'Notes'), + _sectionLabel(icon: Icons.notes_rounded, + color: AppColors.warning, label: 'Notes'), const SizedBox(height: AppSpacing.sm), _StyledField( controller: _notesCtrl, @@ -387,12 +387,12 @@ class _EditWorkoutSessionScreenState extends State { key: ValueKey('exercise_$i'), exerciseName: ex?.name ?? 'Unknown Exercise', editableLog: log, - onSetChanged: (si, w, r, d, drops) { + onSetChanged: ({required int setIndex, required double weight, required int reps, required bool isDropset, List? drops}) { setState(() { - log.sets[si].weight = w; - log.sets[si].reps = r; - log.sets[si].isDropset = d; - if (drops != null) log.sets[si].drops = drops; + log.sets[setIndex].weight = weight; + log.sets[setIndex].reps = reps; + log.sets[setIndex].isDropset = isDropset; + if (drops != null) log.sets[setIndex].drops = drops; }); _markChanged(); }, @@ -416,12 +416,7 @@ class _EditWorkoutSessionScreenState extends State { ); } - Widget _sectionLabel( - BuildContext context, - IconData icon, - Color color, - String label, - ) { + Widget _sectionLabel({required IconData icon, required Color color, required String label}) { return Row( children: [ Icon(icon, size: 16, color: color), diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 931f3a7..a5aa3ab 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -117,7 +117,7 @@ class _HistoryScreenState extends State { // Lifetime summary SliverToBoxAdapter( - child: _buildSummaryCard(all, totalVolume, settings), + child: _buildSummaryCard(all: all, totalVolume: totalVolume, settings: settings), ), // Calendar card @@ -263,7 +263,7 @@ class _HistoryScreenState extends State { ); } - Widget _buildSummaryCard(List all, double totalVolume, SettingsProvider settings) { + Widget _buildSummaryCard({required List all, required double totalVolume, required SettingsProvider settings}) { final displayVol = settings.toDisplay(totalVolume); final volStr = displayVol >= 1000000 ? '${(displayVol / 1000000).toStringAsFixed(1)}M' @@ -550,15 +550,15 @@ class _HistoryCard extends StatelessWidget { ); }, onDelete: () async { - Navigator.of(ctx).pop(); - await _confirmDelete(context); + final confirmed = await _confirmDelete(context); + if (confirmed && ctx.mounted) Navigator.of(ctx).pop(); }, ), ), ); } - Future _confirmDelete(BuildContext context) async { + Future _confirmDelete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -590,12 +590,14 @@ class _HistoryCard extends StatelessWidget { if (context.mounted) { messenger.showSnackBar(_snackBar('Workout deleted')); } + return true; } catch (e) { if (context.mounted) { messenger.showSnackBar(_snackBar('Failed to delete workout', isError: true)); } } } + return false; } void _handleMenu(BuildContext context, String value) { diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 66d2c0c..b39b112 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -559,12 +559,16 @@ class _DashboardTab extends StatelessWidget { Widget _buildStatsGrid(BuildContext context, WorkoutProvider provider) { final sessions = provider.sessions; final now = DateTime.now(); - final weekStart = now.subtract(Duration(days: now.weekday - 1)); + final today = DateTime(now.year, now.month, now.day); + final weekStart = today.subtract(Duration(days: today.weekday - 1)); + final weekEnd = weekStart.add(const Duration(days: 7)); final weekSessions = sessions - .where((s) => s.date.isAfter(weekStart.subtract(const Duration(days: 1)))) + .where((s) => !s.date.isBefore(weekStart) && s.date.isBefore(weekEnd)) .toList(); + final settings = context.read(); final weekVol = weekSessions.fold(0, (s, e) => s + e.totalVolume); + final displayVol = settings.toDisplay(weekVol); final weekSets = weekSessions.fold(0, (s, e) => s + e.exercises.fold(0, (a, ex) => a + ex.sets.length)); final avgDuration = sessions.isEmpty @@ -572,30 +576,31 @@ class _DashboardTab extends StatelessWidget { : sessions.take(7).fold(0, (s, e) => s + e.duration) ~/ sessions.take(7).length; - // Sparkline data (last 7 weeks) + // Sparkline data (last 7 weeks), anchored to midnight boundaries List weeklyWorkouts = List.generate(7, (i) { - final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); final wEnd = wStart.add(const Duration(days: 7)); - return sessions.where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)).length.toDouble(); + return sessions.where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)).length.toDouble(); }); List weeklyVolumes = List.generate(7, (i) { - final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); final wEnd = wStart.add(const Duration(days: 7)); - return sessions - .where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)) + final rawVol = sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) .fold(0, (s, e) => s + e.totalVolume); + return settings.toDisplay(rawVol); }); List weeklySets = List.generate(7, (i) { - final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); final wEnd = wStart.add(const Duration(days: 7)); return sessions - .where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)) + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) .fold(0, (s, e) => s + e.exercises.fold(0, (a, ex) => a + ex.sets.length)); }); List weeklyAvgDurations = List.generate(7, (i) { - final wStart = now.subtract(Duration(days: (6 - i) * 7 + now.weekday - 1)); + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); final wEnd = wStart.add(const Duration(days: 7)); - final ws = sessions.where((s) => s.date.isAfter(wStart) && s.date.isBefore(wEnd)).toList(); + final ws = sessions.where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)).toList(); if (ws.isEmpty) return 0; return ws.fold(0, (s, e) => s + e.duration) / ws.length; }); @@ -610,10 +615,10 @@ class _DashboardTab extends StatelessWidget { ), _StatItem( label: 'Volume', - value: weekVol >= 1000 - ? '${(weekVol / 1000).toStringAsFixed(1)}k' - : weekVol.toStringAsFixed(0), - unit: 'kg', + value: displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0), + unit: settings.unitLabel, color: AppColors.secondary, spark: weeklyVolumes, ), @@ -1194,17 +1199,16 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { final exerciseMap = {for (final e in wp.allExercises) e.id: e}; final now = DateTime.now(); - final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); + final today = DateTime(now.year, now.month, now.day); + final startOfWeek = today.subtract(Duration(days: today.weekday - 1)); final startOfLastWeek = startOfWeek.subtract(const Duration(days: 7)); final thisWeek = wp.sessions - .where((s) => s.date.isAfter(startOfWeek) || _sameDay(s.date, startOfWeek)) + .where((s) => !s.date.isBefore(startOfWeek) && s.date.isBefore(startOfWeek.add(const Duration(days: 7)))) .toList(); final lastWeek = wp.sessions .where( - (s) => - (s.date.isAfter(startOfLastWeek) || _sameDay(s.date, startOfLastWeek)) && - s.date.isBefore(startOfWeek), + (s) => !s.date.isBefore(startOfLastWeek) && s.date.isBefore(startOfWeek), ) .toList(); @@ -1215,16 +1219,16 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { unitLabel: settings.unitLabel, ); - final insights = await gemini.generateWeeklyInsights(context_); - if (mounted) { - await settings.saveWeeklyInsights(insights); - setState(() => _loading = false); + try { + final insights = await gemini.generateWeeklyInsights(context_); + if (mounted) await settings.saveWeeklyInsights(insights); + } catch (_) { + // silently ignore network/API errors + } finally { + if (mounted) setState(() => _loading = false); } } - bool _sameDay(DateTime a, DateTime b) => - a.year == b.year && a.month == b.month && a.day == b.day; - @override Widget build(BuildContext context) { final gemini = context.watch(); diff --git a/workout-logger/lib/screens/onboarding_screen.dart b/workout-logger/lib/screens/onboarding_screen.dart index da869cb..9bcbb9e 100644 --- a/workout-logger/lib/screens/onboarding_screen.dart +++ b/workout-logger/lib/screens/onboarding_screen.dart @@ -34,17 +34,20 @@ class _WelcomePageState extends State { } Future _submit() async { + if (_saving) return; final name = _controller.text.trim(); if (name.isEmpty) return; setState(() => _saving = true); - final settings = context.read(); - await settings.setUserName(name); - final version = await settings.getCurrentVersion(); - await settings.markVersionSeen(version); - - if (!mounted) return; - widget.onComplete(); + try { + final settings = context.read(); + await settings.setUserName(name); + final version = await settings.getCurrentVersion(); + await settings.markVersionSeen(version); + if (mounted) widget.onComplete(); + } finally { + if (mounted) setState(() => _saving = false); + } } @override diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index af7f9c6..1053054 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -297,8 +297,9 @@ class _ProgramCard extends StatelessWidget { children: program.phases.asMap().entries.map((entry) { final phase = entry.value; final color = _phaseColors[entry.key % _phaseColors.length]; + final safeDenominator = program.totalWeeks <= 0 ? 1 : program.totalWeeks; final fraction = - (phase.endWeek - phase.startWeek + 1) / program.totalWeeks; + (phase.endWeek - phase.startWeek + 1) / safeDenominator; return Expanded( flex: ((fraction * 100).round()).clamp(1, 100), child: Container( diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart index 38cdef9..da24ec7 100644 --- a/workout-logger/lib/screens/widgets/body_heatmap.dart +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -134,7 +134,7 @@ class _BodyPainter extends CustomPainter { required Color color, required double baseOpacity, }) { - final vol = muscleVolumes[muscle] ?? 0.5; + final vol = muscleVolumes[muscle] ?? 0.0; final opacity = (baseOpacity * (0.5 + vol * 0.5)).clamp(0.0, 1.0); canvas.drawPath(path, Paint()..color = color.withValues(alpha: opacity)); } diff --git a/workout-logger/lib/screens/widgets/dashboard_widgets.dart b/workout-logger/lib/screens/widgets/dashboard_widgets.dart index 4535ec2..b20bd4c 100644 --- a/workout-logger/lib/screens/widgets/dashboard_widgets.dart +++ b/workout-logger/lib/screens/widgets/dashboard_widgets.dart @@ -21,8 +21,9 @@ class WeekActivityStrip extends StatelessWidget { // Weekday 1=Mon … 7=Sun; align strip Mon→Sun final todayMidnight = DateTime(today.year, today.month, today.day); final startOfWeek = todayMidnight.subtract(Duration(days: today.weekday - 1)); + final startOfNextWeek = startOfWeek.add(const Duration(days: 7)); final trainedDays = sessions - .where((s) => !s.date.isBefore(startOfWeek)) + .where((s) => !s.date.isBefore(startOfWeek) && s.date.isBefore(startOfNextWeek)) .map((s) => s.date.weekday) .toSet(); diff --git a/workout-logger/lib/screens/widgets/editable_exercise_card.dart b/workout-logger/lib/screens/widgets/editable_exercise_card.dart index db8c1be..7911a82 100644 --- a/workout-logger/lib/screens/widgets/editable_exercise_card.dart +++ b/workout-logger/lib/screens/widgets/editable_exercise_card.dart @@ -6,6 +6,14 @@ import 'package:flutter/services.dart'; import '../../models/models.dart'; import '../../theme/app_theme.dart'; +typedef OnSetChanged = void Function({ + required int setIndex, + required double weight, + required int reps, + required bool isDropset, + List? drops, +}); + // ── Shared mutable data classes ─────────────────────────────────────────────── class EditableExerciseLog { final String exerciseId; @@ -51,13 +59,7 @@ class EditableExerciseCard extends StatelessWidget { final String exerciseName; final EditableExerciseLog editableLog; - final void Function( - int setIndex, - double weight, - int reps, - bool isDropset, - List? drops, - ) onSetChanged; + final OnSetChanged onSetChanged; final VoidCallback onAddSet; final void Function(int setIndex) onDeleteSet; final VoidCallback onDeleteExercise; @@ -132,14 +134,15 @@ class EditableExerciseCard extends StatelessWidget { reps: set.reps, isDropset: set.isDropset, drops: set.drops, - onWeightChanged: (w) => - onSetChanged(i, w, set.reps, set.isDropset, set.drops), - onRepsChanged: (r) => - onSetChanged(i, set.weight, r, set.isDropset, set.drops), - onIsDropsetChanged: (d) => - onSetChanged(i, set.weight, set.reps, d, d ? (set.drops ?? []) : set.drops), - onDropsChanged: (drops) => - onSetChanged(i, set.weight, set.reps, set.isDropset, drops), + onWeightChanged: (w) => onSetChanged( + setIndex: i, weight: w, reps: set.reps, isDropset: set.isDropset, drops: set.drops), + onRepsChanged: (r) => onSetChanged( + setIndex: i, weight: set.weight, reps: r, isDropset: set.isDropset, drops: set.drops), + onIsDropsetChanged: (d) => onSetChanged( + setIndex: i, weight: set.weight, reps: set.reps, isDropset: d, + drops: d ? (set.drops ?? []) : set.drops), + onDropsChanged: (drops) => onSetChanged( + setIndex: i, weight: set.weight, reps: set.reps, isDropset: set.isDropset, drops: drops), onDelete: () => onDeleteSet(i), ); }).toList(), @@ -385,18 +388,18 @@ class _EditableSetRowState extends State { final i = e.key; final drop = e.value; return EditableDropRow( - key: ValueKey('drop_${widget.setNumber}_$i'), + key: ValueKey(drop.id), dropNumber: i + 1, weight: drop.weight, reps: drop.reps, onWeightChanged: (w) { final updated = List.from(widget.drops!); - updated[i] = DropsetEntry(weight: w, reps: drop.reps); + updated[i] = DropsetEntry(id: drop.id, weight: w, reps: drop.reps); widget.onDropsChanged(updated); }, onRepsChanged: (r) { final updated = List.from(widget.drops!); - updated[i] = DropsetEntry(weight: drop.weight, reps: r); + updated[i] = DropsetEntry(id: drop.id, weight: drop.weight, reps: r); widget.onDropsChanged(updated); }, onDelete: () { diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index a3a4c78..429ae87 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -417,10 +417,8 @@ class _NumberInputCardState extends State<_NumberInputCard> { } }, onEditingComplete: () { - // Reset to last valid value if field is empty/invalid - if (double.tryParse(_controller.text) == null) { - _controller.text = _format(); - } + final formatted = _format(); + if (_controller.text != formatted) _controller.text = formatted; _focusNode.unfocus(); }, ), @@ -876,15 +874,14 @@ class _ProgramMetaBanner extends StatelessWidget { runSpacing: 4, children: [ _metaChip( - Icons.timer_outlined, - '${slot.restSeconds}s rest', - AppColors.textSoft, + icon: Icons.timer_outlined, + label: '${slot.restSeconds}s rest', + color: AppColors.textSoft, ), if (slot.tempo != null) - _metaChip(Icons.speed_rounded, 'Tempo ${slot.tempo}', - AppColors.secondary), + _metaChip(icon: Icons.speed_rounded, label: 'Tempo ${slot.tempo}', color: AppColors.secondary), if (slot.supersetGroupId != null) - _metaChip(Icons.link_rounded, 'Superset', AppColors.secondary), + _metaChip(icon: Icons.link_rounded, label: 'Superset', color: AppColors.secondary), ], ), if (slot.notes != null) ...[ @@ -902,7 +899,7 @@ class _ProgramMetaBanner extends StatelessWidget { ); } - Widget _metaChip(IconData icon, String label, Color color) { + Widget _metaChip({required IconData icon, required String label, required Color color}) { return Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index 6fee2ad..cbab21f 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -25,11 +25,10 @@ class _ExerciseProgressViewState extends State { @override Widget build(BuildContext context) { - final provider = context.watch(); - final performed = { - for (final s in provider.sessions) - for (final e in s.exercises) e.exerciseId, - }; + final performed = context.select>( + (p) => {for (final s in p.sessions) for (final e in s.exercises) e.exerciseId}, + ); + final provider = context.read(); if (performed.isEmpty) { return RFEmptyState( @@ -39,18 +38,20 @@ class _ExerciseProgressViewState extends State { ); } + final effectiveSelectedId = performed.contains(_selectedId) ? _selectedId : null; + return Column( children: [ _ExerciseDropdown( ids: performed, - selected: _selectedId, + selected: effectiveSelectedId, getExerciseName: provider.getExerciseName, onChanged: (id) => setState(() => _selectedId = id), ), - if (_selectedId != null) + if (effectiveSelectedId != null) Expanded( child: _ExerciseStats( - exerciseId: _selectedId!, + exerciseId: effectiveSelectedId, provider: provider, ), ) diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 2e8a1ac..ef61980 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -679,9 +679,12 @@ class _AiSettingsSectionState extends State { final key = _ctrl.text.trim(); final settings = context.read(); final gemini = context.read(); - await settings.setGeminiApiKey(key); - gemini.updateApiKey(key); - if (mounted) setState(() => _saving = false); + try { + await settings.setGeminiApiKey(key); + gemini.updateApiKey(key); + } finally { + if (mounted) setState(() => _saving = false); + } } Future _selectModel(String modelId) async { @@ -735,6 +738,9 @@ class _AiSettingsSectionState extends State { child: TextField( controller: _ctrl, obscureText: _obscure, + enableSuggestions: false, + autocorrect: false, + keyboardType: TextInputType.visiblePassword, style: GoogleFonts.geistMono( color: AppColors.textPrimary, fontSize: 12, diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index e16ed09..c470f99 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -21,6 +21,7 @@ class GlassCard extends StatelessWidget { this.borderColor, this.accentBorder = false, this.onTap, + this.semanticsLabel, }); final Widget child; @@ -32,6 +33,7 @@ class GlassCard extends StatelessWidget { /// When true, uses accent colour border (e.g. Analytics exercise selector). final bool accentBorder; final VoidCallback? onTap; + final String? semanticsLabel; @override Widget build(BuildContext context) { @@ -67,9 +69,13 @@ class GlassCard extends StatelessWidget { ); if (onTap == null) return content; - return GestureDetector( - onTap: onTap, - child: content, + return Semantics( + button: true, + label: semanticsLabel, + child: GestureDetector( + onTap: onTap, + child: content, + ), ); } } @@ -226,49 +232,53 @@ class _NavItem extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 60, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Accent indicator above icon - AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: active ? 18 : 0, - height: 2, - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(2), - color: AppColors.primary, - boxShadow: active - ? [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.6), - blurRadius: 6, - ), - ] - : null, + return Semantics( + button: true, + label: item.label, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 60, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Accent indicator above icon + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: active ? 18 : 0, + height: 2, + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: AppColors.primary, + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.6), + blurRadius: 6, + ), + ] + : null, + ), ), - ), - Icon( - item.icon, - size: 19, - color: active ? AppColors.textPrimary : AppColors.textMuted, - ), - const SizedBox(height: 4), - Text( - item.label, - style: GoogleFonts.geist( - fontSize: 10, - fontWeight: active ? FontWeight.w600 : FontWeight.w500, + Icon( + item.icon, + size: 19, color: active ? AppColors.textPrimary : AppColors.textMuted, - letterSpacing: 0.2, ), - ), - ], + const SizedBox(height: 4), + Text( + item.label, + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? AppColors.textPrimary : AppColors.textMuted, + letterSpacing: 0.2, + ), + ), + ], + ), ), ), ); @@ -352,58 +362,62 @@ class _GlowButtonState extends State scale: _scale.value, child: child, ), - child: GestureDetector( - onTapDown: disabled ? null : _onTapDown, - onTapUp: disabled ? null : _onTapUp, - onTapCancel: disabled ? null : _onTapCancel, - child: Container( - width: widget.fullWidth ? double.infinity : null, - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: vPad, - ), - decoration: BoxDecoration( - color: disabled ? AppColors.glass2 : color, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: disabled - ? Border.all(color: AppColors.glassBorder) - : Border.all( - color: Colors.white.withValues(alpha: 0.18), - width: 1, - ), - boxShadow: disabled - ? null - : [ - BoxShadow( - color: color.withValues(alpha: 0.35), - blurRadius: 32, - offset: const Offset(0, 4), + child: Semantics( + button: true, + label: widget.label, + child: GestureDetector( + onTapDown: disabled ? null : _onTapDown, + onTapUp: disabled ? null : _onTapUp, + onTapCancel: disabled ? null : _onTapCancel, + child: Container( + width: widget.fullWidth ? double.infinity : null, + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: vPad, + ), + decoration: BoxDecoration( + color: disabled ? AppColors.glass2 : color, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: disabled + ? Border.all(color: AppColors.glassBorder) + : Border.all( + color: Colors.white.withValues(alpha: 0.18), + width: 1, ), - ], - ), - child: Row( - mainAxisSize: - widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.icon != null) ...[ - Icon( - widget.icon, - color: disabled ? AppColors.textMuted : Colors.white, - size: widget.small ? 18 : 20, + boxShadow: disabled + ? null + : [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 32, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: + widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: disabled ? AppColors.textMuted : Colors.white, + size: widget.small ? 18 : 20, + ), + const SizedBox(width: AppSpacing.sm), + ], + Text( + widget.label, + style: TextStyle( + color: disabled ? AppColors.textMuted : Colors.white, + fontSize: widget.small ? 14 : 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), ), - const SizedBox(width: AppSpacing.sm), ], - Text( - widget.label, - style: TextStyle( - color: disabled ? AppColors.textMuted : Colors.white, - fontSize: widget.small ? 14 : 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ], + ), ), ), ), From 76d5b559603934f1b05e2552e03587c14c1c5632 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 23 May 2026 10:57:51 +0530 Subject: [PATCH 19/44] feat: improve error handling in program saving and adjust opacity calculation in body heatmap --- .../lib/screens/ai_program_generator_screen.dart | 12 ++++++++++-- workout-logger/lib/screens/widgets/body_heatmap.dart | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/workout-logger/lib/screens/ai_program_generator_screen.dart b/workout-logger/lib/screens/ai_program_generator_screen.dart index 7297ae7..da7cc5e 100644 --- a/workout-logger/lib/screens/ai_program_generator_screen.dart +++ b/workout-logger/lib/screens/ai_program_generator_screen.dart @@ -92,8 +92,16 @@ class _AiProgramGeneratorScreenState extends State { if (_preview == null) return; HapticFeedback.mediumImpact(); final manager = context.read(); - await manager.saveProgram(_preview!); - if (mounted) Navigator.pop(context, true); + try { + await manager.saveProgram(_preview!); + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save program: $e')), + ); + } + } } @override diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart index da24ec7..9d1f9c2 100644 --- a/workout-logger/lib/screens/widgets/body_heatmap.dart +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -135,7 +135,7 @@ class _BodyPainter extends CustomPainter { required double baseOpacity, }) { final vol = muscleVolumes[muscle] ?? 0.0; - final opacity = (baseOpacity * (0.5 + vol * 0.5)).clamp(0.0, 1.0); + final opacity = (baseOpacity * vol).clamp(0.0, 1.0); canvas.drawPath(path, Paint()..color = color.withValues(alpha: opacity)); } From 201bf88584db14ad17483d9c3b0c82b8284d236d Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 23 May 2026 11:30:12 +0530 Subject: [PATCH 20/44] Add comprehensive tests for MLService, model serialization, PRManager, and WorkoutProvider - Implement tests for MLService covering growth model training, set recommendations, default recommendations, target completion prediction, and muscle recovery score computation. - Create model serialization tests for WorkoutSet, ExerciseLog, WorkoutSession, Exercise, MuscleGroup, Target, GrowthModel, Routine, PersonalRecord, TrainingProgram, and ProgramExerciseSlot to ensure data integrity during JSON serialization/deserialization. - Introduce tests for PRManager to validate loading records, backfilling from sessions, checking and updating personal records, and retrieving records. - Enhance WorkoutProvider tests to verify initialization, session and routine loading, active workout management, and exercise name retrieval. --- .../test/analytics_manager_test.dart | 32 ++ workout-logger/test/history_manager_test.dart | 77 +++ workout-logger/test/ml_service_test.dart | 321 +++++++++++ .../test/model_serialization_test.dart | 525 ++++++++++++++++++ workout-logger/test/pr_manager_test.dart | 239 ++++++++ .../test/workout_provider_test.dart | 160 ++++++ 6 files changed, 1354 insertions(+) create mode 100644 workout-logger/test/ml_service_test.dart create mode 100644 workout-logger/test/model_serialization_test.dart create mode 100644 workout-logger/test/pr_manager_test.dart diff --git a/workout-logger/test/analytics_manager_test.dart b/workout-logger/test/analytics_manager_test.dart index a07368f..e954c94 100644 --- a/workout-logger/test/analytics_manager_test.dart +++ b/workout-logger/test/analytics_manager_test.dart @@ -392,4 +392,36 @@ void main() { expect(stats['totalWorkouts'], 1); }); }); + + group('AnalyticsManager - updateGrowthModelsForExercises', () { + test('trains model only for the specified exercise subset', () async { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 100, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 110, date: DateTime(2024, 1, 8)), + _session(id: 's3', exerciseId: 'ex2', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's4', exerciseId: 'ex2', weight: 90, date: DateTime(2024, 1, 8)), + ]; + + // Only request model update for ex1 + await manager.updateGrowthModelsForExercises({'ex1'}, sessions); + + expect(manager.growthModels.containsKey('ex1'), isTrue); + expect(manager.growthModels.containsKey('ex2'), isFalse); + }); + + test('evicts stale model when data drops below two points', () async { + final twoSessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 100, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 110, date: DateTime(2024, 1, 8)), + ]; + // First: train model with two sessions + await manager.updateGrowthModelsForExercises({'ex1'}, twoSessions); + expect(manager.growthModels.containsKey('ex1'), isTrue); + + // Then: drop to one session — model must be removed + final oneSession = [twoSessions.first]; + await manager.updateGrowthModelsForExercises({'ex1'}, oneSession); + expect(manager.growthModels.containsKey('ex1'), isFalse); + }); + }); } diff --git a/workout-logger/test/history_manager_test.dart b/workout-logger/test/history_manager_test.dart index d8ed53b..98f3db4 100644 --- a/workout-logger/test/history_manager_test.dart +++ b/workout-logger/test/history_manager_test.dart @@ -290,4 +290,81 @@ void main() { expect(storage.sessions.first.hcSyncedAt, isNotNull); }); }); + + group('getSessionsInDateRange (inverted range tolerance)', () { + test('returns sessions even when start is after end', () async { + final s = _session(date: DateTime(2026, 3, 15)); + await manager.addSession(s); + + // Passing end before start — implementation normalises the range + final results = manager.getSessionsInDateRange( + DateTime(2026, 4, 1), + DateTime(2026, 3, 1), + ); + + expect(results, hasLength(1)); + expect(results.first.id, s.id); + }); + + test('returns empty when session falls outside the normalised range', + () async { + final s = _session(date: DateTime(2026, 1, 1)); + await manager.addSession(s); + + final results = manager.getSessionsInDateRange( + DateTime(2026, 5, 1), + DateTime(2026, 3, 1), // normalised: March→May; Jan is outside + ); + + expect(results, isEmpty); + }); + }); + + group('getRecentSessions', () { + test('returns only sessions within the last N days', () async { + final recent = _session( + id: 'recent', + date: DateTime.now().subtract(const Duration(days: 3)), + ); + final old = _session( + id: 'old', + date: DateTime.now().subtract(const Duration(days: 10)), + ); + await manager.addSession(recent); + await manager.addSession(old); + + final results = manager.getRecentSessions(7); + + expect(results.map((s) => s.id), contains('recent')); + expect(results.map((s) => s.id), isNot(contains('old'))); + }); + + test('returns empty when all sessions are older than N days', () async { + final s = _session( + date: DateTime.now().subtract(const Duration(days: 30)), + ); + await manager.addSession(s); + expect(manager.getRecentSessions(7), isEmpty); + }); + }); + + group('addSession - storage failure propagation', () { + test('propagates storage exception to the caller', () async { + final throwingStorage = _ThrowingStorageService(); + final m = HistoryManager(throwingStorage); + expect( + () => m.addSession(_session()), + throwsA(isA()), + ); + }); + }); +} + +// ── Throwing stub ───────────────────────────────────────────────────────────── + +class _ThrowingStorageService extends MockStorageService { + @override + Future saveWorkoutSession(WorkoutSession session) async { + throw StateError('Simulated storage failure'); + } } diff --git a/workout-logger/test/ml_service_test.dart b/workout-logger/test/ml_service_test.dart new file mode 100644 index 0000000..c33f4cd --- /dev/null +++ b/workout-logger/test/ml_service_test.dart @@ -0,0 +1,321 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/ml_service.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +DataPoint dp(double x, double y) => DataPoint(x: x, y: y); + +WorkoutSet wset({double weight = 60.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +Exercise makeExercise(String id, String muscleId) => Exercise( + id: id, + name: id, + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: muscleId, activationPercentage: 100), + ], + ); + +WorkoutSession makeSession({ + required String id, + required DateTime date, + required String exerciseId, + required String muscleId, + double weight = 60.0, + int reps = 10, +}) { + return WorkoutSession( + id: id, + date: date, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: [wset(weight: weight, reps: reps)], + ), + ], + duration: 45, + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + final ml = MLService(); + + group('MLService - trainGrowthModel', () { + test('empty data returns zero-slope model', () { + final model = ml.trainGrowthModel([]); + expect(model.slope, 0.0); + expect(model.intercept, 0.0); + expect(model.r2, 0.0); + }); + + test('single data point returns zero-slope model with intercept = y', () { + final model = ml.trainGrowthModel([dp(0, 500)]); + expect(model.slope, closeTo(0.0, 0.001)); + expect(model.intercept, closeTo(500.0, 0.001)); + }); + + test('perfect linear data yields r2 close to 1.0', () { + // y = 10x + 100 → perfect linear + final points = List.generate(8, (i) => dp(i.toDouble(), 100 + 10.0 * i)); + final model = ml.trainGrowthModel(points); + expect(model.r2, closeTo(1.0, 0.01)); + }); + + test('constant data yields slope close to 0', () { + final points = List.generate(5, (i) => dp(i.toDouble(), 200.0)); + final model = ml.trainGrowthModel(points); + expect(model.slope.abs(), lessThan(0.001)); + }); + + test('positive-trending data produces positive slope', () { + final points = [dp(0, 100), dp(1, 110), dp(2, 120), dp(3, 130)]; + final model = ml.trainGrowthModel(points); + expect(model.slope, greaterThan(0)); + }); + + test('r2 is clamped between 0 and 1', () { + final points = [dp(0, 100), dp(1, 90), dp(2, 110), dp(3, 80)]; + final model = ml.trainGrowthModel(points); + expect(model.r2, greaterThanOrEqualTo(0.0)); + expect(model.r2, lessThanOrEqualTo(1.0)); + }); + + test('predict(n) = slope * n + intercept', () { + final points = List.generate(6, (i) => dp(i.toDouble(), 100 + 5.0 * i)); + final model = ml.trainGrowthModel(points); + // With near-perfect linear data predict should be close to the formula + final expected = model.slope * 3 + model.intercept; + expect(model.predict(3), closeTo(expected, 0.001)); + }); + }); + + group('MLService - recommendSets', () { + test('empty lastSession returns empty list', () { + final recs = ml.recommendSets(lastSession: []); + expect(recs, isEmpty); + }); + + test('reps below maxReps → add one rep, keep weight', () { + final set = wset(weight: 60.0, reps: 10); + final recs = ml.recommendSets(lastSession: [set], maxReps: 12); + expect(recs.first.reps, 11); + expect(recs.first.weight, closeTo(60.0, 0.001)); + expect(recs.first.confidence, 'high'); + }); + + test('reps at maxReps → increase weight by 2.5 kg when weight < 40', () { + final set = wset(weight: 30.0, reps: 12); + final recs = ml.recommendSets(lastSession: [set], minReps: 6, maxReps: 12); + expect(recs.first.weight, closeTo(32.5, 0.001)); + expect(recs.first.reps, 6); + }); + + test('reps at maxReps → increase weight by 5 kg when weight >= 40', () { + final set = wset(weight: 80.0, reps: 12); + final recs = ml.recommendSets(lastSession: [set], minReps: 6, maxReps: 12); + expect(recs.first.weight, closeTo(85.0, 0.001)); + expect(recs.first.reps, 6); + }); + + test('plateau detected → holds weight and reps (medium confidence)', () { + final set = wset(weight: 60.0, reps: 10); + final plateauModel = GrowthModel( + slope: -0.5, + intercept: 600.0, + r2: 0.8, + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: plateauModel, + maxReps: 12, + ); + expect(recs.first.weight, closeTo(60.0, 0.001)); + expect(recs.first.reps, 10); + expect(recs.first.confidence, 'medium'); + }); + + test('under-recovered muscle → maintenance recommendation (low confidence)', + () { + final set = wset(weight: 80.0, reps: 8); + final recovery = MuscleRecoveryStatus( + muscleGroupId: 'chest', + recoveryFraction: 0.4, // 40% recovered + timeSinceLastTrained: const Duration(hours: 24), + estimatedTimeToFullRecovery: const Duration(hours: 72), + ); + final recs = ml.recommendSets( + lastSession: [set], + recoveryScores: {'chest': recovery}, + primaryMuscleIds: ['chest'], + maxReps: 12, + ); + expect(recs.first.weight, closeTo(80.0, 0.001)); + expect(recs.first.reps, 8); + expect(recs.first.confidence, 'low'); + }); + + test('produces one recommendation per set in lastSession', () { + final sets = [wset(weight: 60.0, reps: 10), wset(weight: 60.0, reps: 9)]; + final recs = ml.recommendSets(lastSession: sets, maxReps: 12); + expect(recs.length, 2); + }); + }); + + group('MLService - getDefaultRecommendations', () { + test('returns the requested number of default recommendations', () { + final recs = ml.getDefaultRecommendations(3); + expect(recs.length, 3); + }); + + test('default recommendations have low confidence and zero weight', () { + final recs = ml.getDefaultRecommendations(2); + expect(recs.every((r) => r.confidence == 'low'), isTrue); + expect(recs.every((r) => r.weight == 0), isTrue); + }); + }); + + group('MLService - predictTargetCompletion', () { + test('returns null when slope is zero', () { + final model = GrowthModel( + slope: 0.0, + intercept: 100.0, + r2: 0.0, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNull); + }); + + test('returns null when slope is negative', () { + final model = GrowthModel( + slope: -1.0, + intercept: 200.0, + r2: 0.5, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNull); + }); + + test('returns a future date when slope > 0 and target > current', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNotNull); + expect(result!.isAfter(DateTime.now()), isTrue); + }); + + test('returns now (not null) when current already meets target', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 200.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNotNull); + }); + }); + + group('MLService - computeMuscleRecoveryScores', () { + final chestExercise = makeExercise('bench_press', 'chest'); + final exerciseMap = {'bench_press': chestExercise}; + + test('returns empty map when sessions is empty', () { + final scores = ml.computeMuscleRecoveryScores([], exerciseMap); + expect(scores, isEmpty); + }); + + test('muscle trained just now has low recoveryFraction', () { + final now = DateTime.now(); + final session = makeSession( + id: 's1', + date: now, + exerciseId: 'bench_press', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + exerciseMap, + asOf: now, + ); + expect(scores['chest'], isNotNull); + // At t=0, recovery = 1 - exp(0) = 0 + expect(scores['chest']!.recoveryFraction, closeTo(0.0, 0.05)); + }); + + test('muscle trained 7 days ago is near fully recovered', () { + final asOf = DateTime.now(); + final sevenDaysAgo = asOf.subtract(const Duration(days: 7)); + final session = makeSession( + id: 's1', + date: sevenDaysAgo, + exerciseId: 'bench_press', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + exerciseMap, + asOf: asOf, + ); + // chest τ=48h; 168h elapsed → 1 - exp(-168/48) ≈ 0.97 + expect(scores['chest']!.recoveryFraction, greaterThan(0.9)); + }); + + test('exercises absent from exerciseMap produce no recovery entry', () { + final session = makeSession( + id: 's1', + date: DateTime.now().subtract(const Duration(hours: 12)), + exerciseId: 'unknown_exercise', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + {}, // empty map — exercise not found + ); + expect(scores, isEmpty); + }); + + test('isRecovered is false for a muscle trained very recently', () { + final now = DateTime.now(); + final session = makeSession( + id: 's1', + date: now, + exerciseId: 'bench_press', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + exerciseMap, + asOf: now, + ); + // recoveryFraction ≈ 0 → well below the 95% isRecovered threshold + expect(scores['chest']!.isRecovered, isFalse); + }); + }); +} diff --git a/workout-logger/test/model_serialization_test.dart b/workout-logger/test/model_serialization_test.dart new file mode 100644 index 0000000..d96c132 --- /dev/null +++ b/workout-logger/test/model_serialization_test.dart @@ -0,0 +1,525 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; + +void main() { + // ── WorkoutSet ──────────────────────────────────────────────────────────── + + group('WorkoutSet', () { + final ts = DateTime(2026, 5, 1, 10, 30); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = WorkoutSet( + weight: 80.0, + reps: 8, + isDropset: false, + timeTaken: 45, + timestamp: ts, + ); + final restored = WorkoutSet.fromJson(original.toJson()); + expect(restored.weight, original.weight); + expect(restored.reps, original.reps); + expect(restored.isDropset, original.isDropset); + expect(restored.timeTaken, original.timeTaken); + expect(restored.timestamp, original.timestamp); + }); + + test('volume = weight × reps for a plain set', () { + final s = WorkoutSet(weight: 100.0, reps: 5, timestamp: ts); + expect(s.volume, closeTo(500.0, 0.001)); + }); + + test('volume includes drop entries for a dropset', () { + final s = WorkoutSet( + weight: 60.0, + reps: 10, + isDropset: true, + drops: [DropsetEntry(weight: 40.0, reps: 8)], + timestamp: ts, + ); + // 60*10 + 40*8 = 600 + 320 = 920 + expect(s.volume, closeTo(920.0, 0.001)); + }); + + test('copyWith changes only the specified field', () { + final original = WorkoutSet(weight: 60.0, reps: 10, timestamp: ts); + final copy = original.copyWith(weight: 80.0); + expect(copy.weight, 80.0); + expect(copy.reps, original.reps); + expect(copy.timestamp, original.timestamp); + }); + }); + + // ── ExerciseLog ─────────────────────────────────────────────────────────── + + group('ExerciseLog', () { + final ts = DateTime(2026, 5, 1, 10, 30); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 80.0, reps: 8, timestamp: ts), + WorkoutSet(weight: 80.0, reps: 7, timestamp: ts), + ], + notes: 'felt strong', + ); + final restored = ExerciseLog.fromJson(original.toJson()); + expect(restored.exerciseId, original.exerciseId); + expect(restored.sets.length, original.sets.length); + expect(restored.notes, original.notes); + }); + + test('totalVolume sums all sets', () { + final log = ExerciseLog( + exerciseId: 'squat', + sets: [ + WorkoutSet(weight: 100.0, reps: 5, timestamp: ts), + WorkoutSet(weight: 100.0, reps: 5, timestamp: ts), + WorkoutSet(weight: 100.0, reps: 5, timestamp: ts), + ], + ); + expect(log.totalVolume, closeTo(1500.0, 0.001)); + }); + + test('totalVolume is 0 when sets is empty', () { + final log = ExerciseLog(exerciseId: 'squat', sets: []); + expect(log.totalVolume, 0.0); + }); + + test('copyWith changes exerciseId and preserves sets', () { + final ts2 = DateTime(2026, 5, 1, 10, 30); + final original = ExerciseLog( + exerciseId: 'squat', + sets: [WorkoutSet(weight: 60.0, reps: 10, timestamp: ts2)], + ); + final copy = original.copyWith(exerciseId: 'deadlift'); + expect(copy.exerciseId, 'deadlift'); + expect(copy.sets.length, 1); + }); + }); + + // ── WorkoutSession ──────────────────────────────────────────────────────── + + group('WorkoutSession', () { + final date = DateTime(2026, 5, 10, 8, 0); + final ts = DateTime(2026, 5, 10, 8, 5); + + test('toJson / fromJson round-trip preserves nested structure', () { + final original = WorkoutSession( + id: 'session-1', + date: date, + routineId: 'routine-a', + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 80.0, reps: 8, timestamp: ts)], + ), + ], + duration: 45, + notes: 'good session', + ); + final restored = WorkoutSession.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.date, original.date); + expect(restored.routineId, original.routineId); + expect(restored.exercises.length, 1); + expect(restored.exercises.first.exerciseId, 'bench_press'); + expect(restored.duration, original.duration); + expect(restored.notes, original.notes); + }); + + test('totalVolume aggregates across all exercise logs', () { + final session = WorkoutSession( + id: 'session-2', + date: date, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 80.0, reps: 10, timestamp: ts)], // 800 + ), + ExerciseLog( + exerciseId: 'squat', + sets: [WorkoutSet(weight: 100.0, reps: 10, timestamp: ts)], // 1000 + ), + ], + duration: 60, + ); + expect(session.totalVolume, closeTo(1800.0, 0.001)); + }); + + test('copyWith changes date and preserves other fields', () { + final original = WorkoutSession( + id: 'session-3', + date: date, + exercises: [], + duration: 30, + ); + final newDate = DateTime(2026, 6, 1); + final copy = original.copyWith(date: newDate); + expect(copy.date, newDate); + expect(copy.id, original.id); + expect(copy.duration, original.duration); + }); + + test('hcSyncedAt round-trips correctly when set', () { + final syncTime = DateTime(2026, 5, 10, 9, 0); + final original = WorkoutSession( + id: 'session-4', + date: date, + exercises: [], + duration: 30, + hcSyncedAt: syncTime, + ); + final restored = WorkoutSession.fromJson(original.toJson()); + expect(restored.hcSyncedAt, syncTime); + }); + }); + + // ── Exercise ────────────────────────────────────────────────────────────── + + group('Exercise', () { + test('toJson / fromJson round-trip preserves all fields', () { + final original = Exercise( + id: 'cable_fly', + name: 'Cable Fly', + category: 'isolation', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 80), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 20), + ], + ); + final restored = Exercise.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.category, original.category); + expect(restored.isCustom, original.isCustom); + expect(restored.muscleActivations.length, 2); + }); + + test('primaryMuscle returns muscle with highest activationPercentage', () { + final exercise = Exercise( + id: 'ex1', + name: 'Compound Push', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 60), + MuscleActivation(muscleGroupId: 'shoulders', activationPercentage: 25), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 15), + ], + ); + expect(exercise.primaryMuscle, 'chest'); + }); + + test('primaryMuscle returns "Unknown" when activations is empty', () { + final exercise = Exercise( + id: 'ex2', + name: 'Mystery', + category: 'compound', + muscleActivations: [], + ); + expect(exercise.primaryMuscle, 'Unknown'); + }); + }); + + // ── MuscleGroup ─────────────────────────────────────────────────────────── + + group('MuscleGroup', () { + test('toJson / fromJson round-trip preserves all fields', () { + final updated = DateTime(2026, 4, 1, 12, 0); + final original = MuscleGroup( + id: 'chest', + name: 'Chest', + growthRate: 0.15, + lastUpdated: updated, + ); + final restored = MuscleGroup.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.growthRate, original.growthRate); + expect(restored.lastUpdated, original.lastUpdated); + }); + }); + + // ── Target ──────────────────────────────────────────────────────────────── + + group('Target', () { + final created = DateTime(2026, 3, 1); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = Target( + id: 't1', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 150.0, + currentValue: 100.0, + createdAt: created, + isCompleted: false, + ); + final restored = Target.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.exerciseId, original.exerciseId); + expect(restored.targetType, original.targetType); + expect(restored.targetValue, original.targetValue); + expect(restored.currentValue, original.currentValue); + expect(restored.createdAt, original.createdAt); + expect(restored.isCompleted, original.isCompleted); + }); + + test('progressPercentage = (current / target) × 100', () { + final t = Target( + id: 't2', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 200.0, + currentValue: 50.0, + ); + expect(t.progressPercentage, closeTo(25.0, 0.001)); + }); + + test('progressPercentage clamps to 100 when current exceeds target', () { + final t = Target( + id: 't3', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 100.0, + currentValue: 150.0, + ); + expect(t.progressPercentage, closeTo(100.0, 0.001)); + }); + }); + + // ── GrowthModel ─────────────────────────────────────────────────────────── + + group('GrowthModel', () { + test('predict(n) = slope * n + intercept', () { + final model = GrowthModel( + slope: 2.5, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime(2026, 1, 1), + ); + expect(model.predict(0), closeTo(100.0, 0.001)); + expect(model.predict(4), closeTo(110.0, 0.001)); + expect(model.predict(10), closeTo(125.0, 0.001)); + }); + + test('predict returns intercept when slope is zero', () { + final model = GrowthModel( + slope: 0.0, + intercept: 80.0, + r2: 0.0, + lastTrained: DateTime(2026, 1, 1), + ); + expect(model.predict(100), closeTo(80.0, 0.001)); + }); + }); + + // ── Routine ─────────────────────────────────────────────────────────────── + + group('Routine', () { + test('toJson / fromJson round-trip preserves all fields', () { + final created = DateTime(2026, 2, 15, 9, 0); + final original = Routine( + id: 'r1', + name: 'Push Day', + exerciseIds: ['bench_press', 'overhead_press', 'tricep_pushdown'], + createdAt: created, + ); + final restored = Routine.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.exerciseIds, original.exerciseIds); + expect(restored.createdAt, original.createdAt); + }); + }); + + // ── PersonalRecord ──────────────────────────────────────────────────────── + + group('PersonalRecord', () { + final achieved = DateTime(2026, 4, 20); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = PersonalRecord( + exerciseId: 'deadlift', + bestWeight: 180.0, + bestReps: 5, + bestVolume: 900.0, + achievedAt: achieved, + ); + final restored = PersonalRecord.fromJson(original.toJson()); + expect(restored.exerciseId, original.exerciseId); + expect(restored.bestWeight, original.bestWeight); + expect(restored.bestReps, original.bestReps); + expect(restored.bestVolume, original.bestVolume); + expect(restored.achievedAt, original.achievedAt); + }); + + test('copyWith changes bestWeight and preserves other fields', () { + final original = PersonalRecord( + exerciseId: 'deadlift', + bestWeight: 160.0, + bestReps: 5, + bestVolume: 800.0, + achievedAt: achieved, + ); + final updated = original.copyWith(bestWeight: 180.0); + expect(updated.bestWeight, 180.0); + expect(updated.bestReps, original.bestReps); + expect(updated.exerciseId, original.exerciseId); + expect(updated.achievedAt, original.achievedAt); + }); + }); + + // ── TrainingProgram ─────────────────────────────────────────────────────── + + group('TrainingProgram', () { + ProgramDay makeDay(String id) => ProgramDay( + id: id, + name: 'Day $id', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 4, + minReps: 6, + maxReps: 10, + restSeconds: 120, + ), + ], + ); + + ProgramWeek makeWeek(int n, String? phaseId) => ProgramWeek( + weekNumber: n, + phaseId: phaseId, + days: [makeDay('d$n')], + ); + + TrainingPhase makePhase(String id, int start, int end) => TrainingPhase( + id: id, + name: 'Phase $id', + startWeek: start, + endWeek: end, + ); + + test('toJson / fromJson round-trip preserves nested structure', () { + final created = DateTime(2026, 1, 1, 0, 0); + final original = TrainingProgram( + id: 'prog-1', + name: '12-Week Block', + description: 'Hypertrophy focus', + totalWeeks: 4, + phases: [makePhase('foundation', 1, 2), makePhase('intensify', 3, 4)], + weeks: [makeWeek(1, 'foundation'), makeWeek(2, 'foundation'), makeWeek(3, 'intensify'), makeWeek(4, 'intensify')], + author: 'Coach', + isImported: false, + createdAt: created, + ); + final restored = TrainingProgram.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.description, original.description); + expect(restored.totalWeeks, original.totalWeeks); + expect(restored.phases.length, 2); + expect(restored.weeks.length, 4); + expect(restored.author, original.author); + expect(restored.isImported, original.isImported); + expect(restored.createdAt, original.createdAt); + }); + + test('phaseForWeek returns the matching phase', () { + final program = TrainingProgram( + id: 'p', + name: 'Test', + totalWeeks: 4, + phases: [makePhase('foundation', 1, 2), makePhase('intensify', 3, 4)], + weeks: [], + ); + expect(program.phaseForWeek(1)!.id, 'foundation'); + expect(program.phaseForWeek(2)!.id, 'foundation'); + expect(program.phaseForWeek(3)!.id, 'intensify'); + expect(program.phaseForWeek(4)!.id, 'intensify'); + }); + + test('phaseForWeek returns null for out-of-range week', () { + final program = TrainingProgram( + id: 'p', + name: 'Test', + totalWeeks: 4, + phases: [makePhase('foundation', 1, 4)], + weeks: [], + ); + expect(program.phaseForWeek(5), isNull); + }); + + test('totalDays sums days across all weeks', () { + final program = TrainingProgram( + id: 'p', + name: 'Test', + totalWeeks: 3, + phases: [], + weeks: [ + ProgramWeek(weekNumber: 1, days: [makeDay('d1'), makeDay('d2'), makeDay('d3')]), + ProgramWeek(weekNumber: 2, days: [makeDay('d4'), makeDay('d5')]), + ProgramWeek(weekNumber: 3, days: [makeDay('d6'), makeDay('d7'), makeDay('d8'), makeDay('d9')]), + ], + ); + expect(program.totalDays, 9); + }); + + test('copyWith changes name and preserves other fields', () { + final original = TrainingProgram( + id: 'p', + name: 'Old Name', + totalWeeks: 4, + phases: [], + weeks: [], + ); + final copy = original.copyWith(name: 'New Name'); + expect(copy.name, 'New Name'); + expect(copy.id, original.id); + expect(copy.totalWeeks, original.totalWeeks); + }); + }); + + // ── ProgramExerciseSlot ─────────────────────────────────────────────────── + + group('ProgramExerciseSlot', () { + test('toJson / fromJson round-trip preserves all fields', () { + final original = ProgramExerciseSlot( + exerciseId: 'squat', + sets: 5, + minReps: 3, + maxReps: 5, + restSeconds: 180, + tempo: '3-1-1', + weightPercentage: 85.0, + notes: 'Stay braced', + supersetGroupId: 'ss-1', + ); + final restored = ProgramExerciseSlot.fromJson(original.toJson()); + expect(restored.exerciseId, original.exerciseId); + expect(restored.sets, original.sets); + expect(restored.minReps, original.minReps); + expect(restored.maxReps, original.maxReps); + expect(restored.restSeconds, original.restSeconds); + expect(restored.tempo, original.tempo); + expect(restored.weightPercentage, original.weightPercentage); + expect(restored.notes, original.notes); + expect(restored.supersetGroupId, original.supersetGroupId); + }); + + test('copyWith changes sets and preserves other fields', () { + final original = ProgramExerciseSlot( + exerciseId: 'squat', + sets: 4, + minReps: 6, + maxReps: 10, + restSeconds: 120, + ); + final copy = original.copyWith(sets: 5); + expect(copy.sets, 5); + expect(copy.exerciseId, original.exerciseId); + expect(copy.minReps, original.minReps); + }); + }); +} diff --git a/workout-logger/test/pr_manager_test.dart b/workout-logger/test/pr_manager_test.dart new file mode 100644 index 0000000..5db0270 --- /dev/null +++ b/workout-logger/test/pr_manager_test.dart @@ -0,0 +1,239 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +WorkoutSession _session({ + String id = 's1', + DateTime? date, + List exercises = const [], +}) => WorkoutSession( + id: id, + date: date ?? DateTime(2026, 1, 1), + exercises: exercises, + duration: 30, + ); + +ExerciseLog _log(String exerciseId, {List sets = const []}) => + ExerciseLog(exerciseId: exerciseId, sets: sets); + +WorkoutSet _set({double weight = 60.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late PRManager manager; + + setUp(() { + storage = MockStorageService(); + manager = PRManager(storage); + }); + + group('PRManager - load', () { + test('starts empty when storage has no records', () async { + await manager.load(); + expect(manager.allRecords, isEmpty); + }); + + test('populates allRecords from storage on load', () async { + await storage.savePersonalRecord(PersonalRecord( + exerciseId: 'bench', + bestWeight: 100.0, + bestReps: 5, + bestVolume: 500.0, + achievedAt: DateTime(2026, 1, 1), + )); + await manager.load(); + expect(manager.allRecords, hasLength(1)); + expect(manager.allRecords.first.exerciseId, 'bench'); + expect(manager.allRecords.first.bestWeight, 100.0); + }); + }); + + group('PRManager - backfillFromSessions', () { + test('creates record for exercise with no prior PR', () async { + await manager.backfillFromSessions([ + _session(exercises: [_log('bench', sets: [_set(weight: 80, reps: 5)])]), + ]); + final rec = manager.getRecord('bench'); + expect(rec, isNotNull); + expect(rec!.bestWeight, 80.0); + expect(rec.bestReps, 5); + }); + + test('updates record when later session contains new best weight', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 80, reps: 5)])], + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 100, reps: 5)])], + ), + ]); + expect(manager.getRecord('bench')!.bestWeight, 100.0); + }); + + test('updates record when later session contains new best reps', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 8)])], + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 12)])], + ), + ]); + expect(manager.getRecord('bench')!.bestReps, 12); + }); + + test('updates record when later session contains new best volume', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 10)])], // 600 + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 70, reps: 10)])], // 700 + ), + ]); + expect(manager.getRecord('bench')!.bestVolume, 700.0); + }); + + test('does not lower a PR when a weaker session is processed later', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 100, reps: 10)])], + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 5)])], + ), + ]); + expect(manager.getRecord('bench')!.bestWeight, 100.0); + }); + + test('handles multiple exercises in one session', () async { + await manager.backfillFromSessions([ + _session(exercises: [ + _log('bench', sets: [_set(weight: 80, reps: 8)]), + _log('squat', sets: [_set(weight: 120, reps: 5)]), + ]), + ]); + expect(manager.getRecord('bench'), isNotNull); + expect(manager.getRecord('squat'), isNotNull); + }); + }); + + group('PRManager - checkAndUpdatePRs', () { + test('first session for exercise always creates a PR with all three types', + () async { + final results = await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 60, reps: 10)])]), + ); + expect(results, hasLength(1)); + expect(results.first.exerciseId, 'bench'); + expect(results.first.types, containsAll(['weight', 'reps', 'volume'])); + }); + + test('returns empty list when no PR is broken', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 100, reps: 10)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 60, reps: 5)])], + ), + ); + expect(results, isEmpty); + }); + + test('returns NewPRResult when weight PR is broken', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 80, reps: 5)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 100, reps: 5)])], + ), + ); + expect(results, hasLength(1)); + expect(results.first.types, contains('weight')); + }); + + test('returns NewPRResult when reps PR is broken', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 60, reps: 8)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 60, reps: 12)])], + ), + ); + expect(results.first.types, contains('reps')); + }); + + test('returns NewPRResult when both weight and reps are broken simultaneously', + () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 60, reps: 8)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 80, reps: 10)])], + ), + ); + expect(results.first.types, containsAll(['weight', 'reps'])); + }); + + test('persists updated record to storage', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 100, reps: 5)])]), + ); + final stored = await storage.getPersonalRecord('bench'); + expect(stored, isNotNull); + expect(stored!.bestWeight, 100.0); + }); + + test('skips exercise log with no sets', () async { + final results = await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [])]), + ); + expect(results, isEmpty); + expect(manager.getRecord('bench'), isNull); + }); + }); + + group('PRManager - getRecord', () { + test('returns record for known exercise', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 80, reps: 8)])]), + ); + expect(manager.getRecord('bench'), isNotNull); + }); + + test('returns null for unknown exercise', () { + expect(manager.getRecord('unknown_exercise'), isNull); + }); + }); +} diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index db01f66..e6088c2 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -572,5 +572,165 @@ void main() { expect(provider.hasActiveWorkout, isTrue); }); }); + + group('init / loadAllData', () { + test('allExercises contains built-in exercises after init', () { + expect(provider.allExercises, isNotEmpty); + }); + + test('sessions loaded from storage after init', () async { + mockStorage.addMockSession(WorkoutSession( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [], + duration: 30, + )); + final p2 = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await p2.init(); + expect(p2.sessions, hasLength(1)); + }); + + test('routines loaded from storage after init', () async { + mockStorage.addMockRoutine( + Routine(id: 'r1', name: 'Push Day', exerciseIds: []), + ); + final p2 = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await p2.init(); + expect(p2.routines, hasLength(1)); + }); + }); + + group('active workout flow', () { + test('hasActiveWorkout is false before startWorkout', () { + expect(provider.hasActiveWorkout, isFalse); + }); + + test('hasActiveWorkout is true after startWorkout', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + expect(provider.hasActiveWorkout, isTrue); + }); + + test('addSet increases currentExerciseLog sets count', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + expect(provider.currentExerciseLog!.sets, hasLength(1)); + }); + + test('removeLastSet decreases sets count', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + provider.removeLastSet(); + expect(provider.currentExerciseLog!.sets, hasLength(1)); + }); + + test('nextExercise advances currentExerciseIndex', () { + provider.startWorkout(exerciseIds: const ['bench_press', 'squat']); + final moved = provider.nextExercise(); + expect(moved, isTrue); + expect(provider.currentExerciseIndex, 1); + }); + + test('finishWorkout saves session and clears active state', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + await provider.finishWorkout(); + expect(provider.hasActiveWorkout, isFalse); + expect(provider.sessions, hasLength(1)); + }); + + test('cancelWorkout clears state without saving a session', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + await provider.cancelWorkout(); + expect(provider.hasActiveWorkout, isFalse); + expect(provider.sessions, isEmpty); + }); + }); + + group('startWorkoutSafely', () { + test('starts workout and returns true when no conflict', () async { + final started = await provider.startWorkoutSafely( + exerciseIds: const ['bench_press'], + onConflict: () async => StartWorkoutConflictAction.cancel, + ); + expect(started, isTrue); + expect(provider.hasActiveWorkout, isTrue); + }); + + test('calls onConflict callback when a workout is already active', + () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + var conflictCalled = false; + await provider.startWorkoutSafely( + exerciseIds: const ['squat'], + onConflict: () async { + conflictCalled = true; + return StartWorkoutConflictAction.cancel; + }, + ); + expect(conflictCalled, isTrue); + }); + + test('cancels existing and starts new when discardAndStart chosen', + () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + final started = await provider.startWorkoutSafely( + exerciseIds: const ['squat'], + onConflict: () async => StartWorkoutConflictAction.discardAndStart, + ); + expect(started, isTrue); + expect( + provider.currentExerciseLogs.first.exerciseId, + 'squat', + ); + }); + + test('returns false when conflict resolved with cancel', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + final started = await provider.startWorkoutSafely( + exerciseIds: const ['squat'], + onConflict: () async => StartWorkoutConflictAction.cancel, + ); + expect(started, isFalse); + }); + }); + + group('getExerciseName', () { + test('returns name for a known built-in exercise id', () { + final name = provider.getExerciseName('bench_press'); + expect(name, isNot('Unknown Exercise')); + expect(name, isNotEmpty); + }); + + test('returns fallback for unknown exercise id', () { + expect(provider.getExerciseName('no_such_exercise'), 'Unknown Exercise'); + }); + }); + + group('deleteCustomExercise - routine guard', () { + test('returns false when exercise is referenced in a routine', () async { + await provider.addCustomExercise( + name: 'Cable Fly', + category: 'isolation', + primaryMuscleGroupId: 'chest', + ); + final exerciseId = + provider.allExercises.firstWhere((e) => e.isCustom).id; + await provider.createRoutine('Test Routine', [exerciseId]); + final deleted = await provider.deleteCustomExercise(exerciseId); + expect(deleted, isFalse); + expect( + provider.allExercises.any((e) => e.id == exerciseId), + isTrue, + ); + }); + }); }); } From 761fb2f8277cba75e6a9db30c6705d4837560642 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 23 May 2026 11:34:02 +0530 Subject: [PATCH 21/44] fix: ensure opacity calculation in heatmap drawing is explicitly a double --- workout-logger/lib/screens/widgets/body_heatmap.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart index 9d1f9c2..79ac8f7 100644 --- a/workout-logger/lib/screens/widgets/body_heatmap.dart +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -135,7 +135,7 @@ class _BodyPainter extends CustomPainter { required double baseOpacity, }) { final vol = muscleVolumes[muscle] ?? 0.0; - final opacity = (baseOpacity * vol).clamp(0.0, 1.0); + final opacity = (baseOpacity * vol).clamp(0.0, 1.0).toDouble(); canvas.drawPath(path, Paint()..color = color.withValues(alpha: opacity)); } From f069ec8d3447bae624b98a497c5e3a31c5ea4e88 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 23 May 2026 11:40:56 +0530 Subject: [PATCH 22/44] chore: update version to 2.0.0+21 in pubspec.yaml --- workout-logger/RELEASE_NOTES.md | 30 ++++++++++++++---------------- workout-logger/pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/workout-logger/RELEASE_NOTES.md b/workout-logger/RELEASE_NOTES.md index 88e289e..cd8f2f5 100644 --- a/workout-logger/RELEASE_NOTES.md +++ b/workout-logger/RELEASE_NOTES.md @@ -8,7 +8,7 @@ Your workout logging app has been successfully built and configured with the fol - **Name:** RepForge - **Tagline:** Your personal workout companion to forge strength and track progress - **Package ID:** com.devasy.repforge -- **Version:** 1.0.0 (Build 1) +- **Version:** 2.0.0 (Build 21) ### 👨‍💻 Developer Information - **Name:** Devasy Patel @@ -22,8 +22,7 @@ Your workout logging app has been successfully built and configured with the fol ### 📦 Build Artifacts #### Main Release APK -- **Location:** `RepForge-v1.0.0-release.apk` (root directory) -- **Size:** 47.4 MB +- **Location:** `RepForge-v2.0.0-release.apk` (root directory) - **Type:** Universal APK (all architectures) - **Also available at:** `build/app/outputs/flutter-apk/app-release.apk` @@ -31,7 +30,7 @@ Your workout logging app has been successfully built and configured with the fol 1. **Transfer the APK** to your Android device: - Use USB cable, email, cloud storage, or any file transfer method - - File to transfer: `RepForge-v1.0.0-release.apk` + - File to transfer: `RepForge-v2.0.0-release.apk` 2. **Enable Installation from Unknown Sources** (if needed): - Go to Settings → Security @@ -46,19 +45,18 @@ Your workout logging app has been successfully built and configured with the fol ### 🛠️ What Was Changed -1. **App Name:** Changed from "workout_logger" to "RepForge" -2. **Package Name:** Updated to "com.devasy.repforge" -3. **App Icon:** Created and applied custom minimal dumbbell icon -4. **Android Configuration:** Updated namespace, application ID, and MainActivity -5. **Release Build:** Successfully built production-ready APK +1. **Major UI Refresh:** Cleaner layouts and improved spacing across key screens +2. **Workout Summary Screen:** Added a recap view for completed sessions +3. **Theme Enhancements:** Refined color palette for stronger contrast +4. **Visual Consistency:** Standardized component styling across the app +5. **Release Metadata:** Version bumped to 2.0.0 (Build 21) -### 📋 Configuration Files Updated +### 📋 Areas Updated -- ✅ `pubspec.yaml` - App name and dependencies -- ✅ `android/app/build.gradle.kts` - Package ID and namespace -- ✅ `android/app/src/main/AndroidManifest.xml` - App label -- ✅ `android/app/src/main/kotlin/com/devasy/repforge/MainActivity.kt` - New package structure -- ✅ App icons generated for all densities +- ✅ UI layouts and component styling +- ✅ Workout summary flow and post-workout recap +- ✅ Theme palette and visual hierarchy +- ✅ Release metadata and versioning ### 🎯 Key Features @@ -98,7 +96,7 @@ You're all set! Your app is ready for: --- -**Built on:** January 22, 2026 +**Built on:** May 23, 2026 **Built with:** Flutter 💙 **Made by:** Devasy Patel diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 61b340b..65ed2c6 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.19+20 +version: 2.0.0+21 environment: sdk: ^3.11.4 From b64ed679108da2e3a7a1b7f832a95a921c4bd5db Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 27 May 2026 21:52:02 +0530 Subject: [PATCH 23/44] feat: enhance UI responsiveness with layout adjustments and breakpoints across multiple screens --- .../lib/screens/analytics_screen.dart | 61 +++++---- workout-logger/lib/screens/home_screen.dart | 90 ++++++++------ .../programs/program_designer_screen.dart | 8 +- .../lib/screens/programs/programs_screen.dart | 4 +- .../lib/screens/widgets/activity_heatmap.dart | 5 +- .../widgets/exercise_input_section.dart | 17 +-- .../lib/screens/widgets/rest_timer_view.dart | 75 +++++------ .../lib/screens/widgets/rf_cards.dart | 8 +- .../lib/screens/workout_flow_screen.dart | 116 +++++++++--------- workout-logger/lib/theme/app_theme.dart | 20 +++ 10 files changed, 240 insertions(+), 164 deletions(-) diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 68b0baa..7ed66b5 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -161,21 +161,33 @@ class _OverviewTab extends StatelessWidget { @override Widget build(BuildContext context) { final provider = context.watch(); - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _VolumeChart(provider: provider), - const SizedBox(height: 12), - _MuscleVolumeChart(provider: provider), - const SizedBox(height: 12), - _MuscleStatusCard(provider: provider), - const SizedBox(height: 12), - _FrequencyGrid(provider: provider), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + final hp = AppBreakpoints.hPadding(constraints.maxWidth); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxWidth, + ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(hp, 0, hp, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VolumeChart(provider: provider), + const SizedBox(height: 12), + _MuscleVolumeChart(provider: provider), + const SizedBox(height: 12), + _MuscleStatusCard(provider: provider), + const SizedBox(height: 12), + _FrequencyGrid(provider: provider), + ], + ), + ), + ), + ); + }, ); } } @@ -203,9 +215,10 @@ class _VolumeChart extends StatelessWidget { title: 'Volume Progression', subtitle: '${settings.unitLabel} · Last ${sessions.length} workouts', isEmpty: sessions.isEmpty, - child: SizedBox( - height: 180, - child: LineChart( + child: LayoutBuilder( + builder: (context, constraints) => SizedBox( + height: AppBreakpoints.chartHeight(constraints.maxWidth), + child: LineChart( LineChartData( backgroundColor: Colors.transparent, gridData: FlGridData( @@ -332,6 +345,7 @@ class _VolumeChart extends StatelessWidget { ], ), ), + ), ), ); } @@ -568,7 +582,10 @@ class _FrequencyGrid extends StatelessWidget { return _ChartCard( title: 'Workout Frequency', subtitle: 'Sessions per week', - child: Row( + child: LayoutBuilder( + builder: (context, constraints) { + final boxSize = ((constraints.maxWidth - 48) / 4).clamp(40.0, 64.0); + return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: weeks.entries.map((e) { final count = e.value; @@ -577,8 +594,8 @@ class _FrequencyGrid extends StatelessWidget { return Column( children: [ Container( - width: 54, - height: 54, + width: boxSize, + height: boxSize, decoration: BoxDecoration( color: active ? AppColors.primary.withValues(alpha: 0.12 + count * 0.06) @@ -614,6 +631,8 @@ class _FrequencyGrid extends StatelessWidget { ], ); }).toList(), + ); + }, ), ); } diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index b39b112..730ef6a 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -184,34 +184,46 @@ class _DashboardTab extends StatelessWidget { const AmbientGlow(), SafeArea( bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context, homeState), - const SizedBox(height: 24), - _buildStreakHero(context: context, provider: provider, homeState: homeState), - const SizedBox(height: 16), - _buildStatsGrid(context, provider), - const SizedBox(height: 16), - _buildHeatmapCard(context, provider), - const SizedBox(height: 16), - _buildMuscleVolumeCard(context, provider), - const SizedBox(height: 16), - const _WeeklyInsightsCard(), - const SizedBox(height: 16), - _buildRecentWorkouts(context: context, provider: provider, homeState: homeState), - const SizedBox(height: 100), + child: LayoutBuilder( + builder: (context, constraints) { + final hp = AppBreakpoints.hPadding(constraints.maxWidth); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxWidth, + ), + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(hp, 14, hp, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, homeState), + const SizedBox(height: 24), + _buildStreakHero(context: context, provider: provider, homeState: homeState), + const SizedBox(height: 16), + _buildStatsGrid(context, provider), + const SizedBox(height: 16), + _buildHeatmapCard(context, provider), + const SizedBox(height: 16), + _buildMuscleVolumeCard(context, provider), + const SizedBox(height: 16), + const _WeeklyInsightsCard(), + const SizedBox(height: 16), + _buildRecentWorkouts(context: context, provider: provider, homeState: homeState), + const SizedBox(height: 100), + ], + ), + ), + ), ], ), ), - ), - ], + ); + }, ), ), ], @@ -638,17 +650,23 @@ class _DashboardTab extends StatelessWidget { ), ]; - return GridView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1.4, - ), - itemCount: stats.length, - itemBuilder: (_, i) => _StatCard(item: stats[i]), + return LayoutBuilder( + builder: (context, constraints) { + final cols = AppBreakpoints.gridColumns(constraints.maxWidth); + final ratio = cols == 4 ? 1.8 : 1.4; + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: ratio, + ), + itemCount: stats.length, + itemBuilder: (_, i) => _StatCard(item: stats[i]), + ); + }, ); } diff --git a/workout-logger/lib/screens/programs/program_designer_screen.dart b/workout-logger/lib/screens/programs/program_designer_screen.dart index 1a6ad5f..b3856dd 100644 --- a/workout-logger/lib/screens/programs/program_designer_screen.dart +++ b/workout-logger/lib/screens/programs/program_designer_screen.dart @@ -657,9 +657,13 @@ class _ProgramDesignerScreenState extends State { onChanged: (v) => setDlg(() => exerciseSearch = v), ), const SizedBox(height: AppSpacing.sm), - SizedBox( - height: 140, + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 80, + maxHeight: 160, + ), child: ListView.builder( + shrinkWrap: true, itemCount: filtered.length, itemBuilder: (_, i) => ListTile( dense: true, diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index 1053054..09e0bf2 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -109,11 +109,11 @@ class ProgramsScreen extends StatelessWidget { Widget _buildList(BuildContext context, List programs) { return ListView.builder( physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.md, AppSpacing.md, - 100, + MediaQuery.of(context).padding.bottom + 100, ), itemCount: programs.length, itemBuilder: (context, index) => _ProgramCard(program: programs[index]), diff --git a/workout-logger/lib/screens/widgets/activity_heatmap.dart b/workout-logger/lib/screens/widgets/activity_heatmap.dart index 22667c6..953767c 100644 --- a/workout-logger/lib/screens/widgets/activity_heatmap.dart +++ b/workout-logger/lib/screens/widgets/activity_heatmap.dart @@ -14,7 +14,9 @@ class ActivityHeatmap extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GridView.builder( @@ -69,6 +71,7 @@ class ActivityHeatmap extends StatelessWidget { ], ), ], + ), ); } } diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 429ae87..792ec0e 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -444,14 +444,15 @@ class _StepBtn extends StatelessWidget { @override Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context).width < AppBreakpoints.narrow ? 36.0 : 40.0; return GestureDetector( onTap: () { onTap(); HapticFeedback.selectionClick(); }, child: Container( - width: 40, - height: 40, + width: size, + height: size, decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(12), @@ -603,8 +604,8 @@ class _DropRow extends StatelessWidget { padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Row( children: [ - SizedBox( - width: 52, + Expanded( + flex: 3, child: Text( label, style: const TextStyle( @@ -613,8 +614,8 @@ class _DropRow extends StatelessWidget { ), ), ), - SizedBox( - width: 64, + Expanded( + flex: 4, child: TextField( controller: weightController, decoration: InputDecoration( @@ -638,8 +639,8 @@ class _DropRow extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 6), child: Text('×', style: TextStyle(color: AppColors.textMuted)), ), - SizedBox( - width: 52, + Expanded( + flex: 3, child: TextField( controller: repsController, decoration: const InputDecoration( diff --git a/workout-logger/lib/screens/widgets/rest_timer_view.dart b/workout-logger/lib/screens/widgets/rest_timer_view.dart index 993c788..b614e6a 100644 --- a/workout-logger/lib/screens/widgets/rest_timer_view.dart +++ b/workout-logger/lib/screens/widgets/rest_timer_view.dart @@ -43,45 +43,50 @@ class RestTimerView extends StatelessWidget { ), // Ring + time fills most of the screen Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - RestTimerRing( - remaining: remainingSeconds, - total: totalSeconds, - size: 220, - ), - const SizedBox(height: AppSpacing.xl), - // Adjust buttons - Row( + child: LayoutBuilder( + builder: (context, constraints) { + final ringSize = AppBreakpoints.timerRingSize(constraints.maxWidth); + return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - _AdjustButton(label: '−30s', onTap: () => onAdjust(-30)), - const SizedBox(width: AppSpacing.xl), - _AdjustButton(label: '+30s', onTap: () => onAdjust(30)), - ], - ), - if (nextExerciseName != null) ...[ - const SizedBox(height: AppSpacing.lg), - Text( - 'Next up', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - letterSpacing: 0.5, + RestTimerRing( + remaining: remainingSeconds, + total: totalSeconds, + size: ringSize, ), - ), - const SizedBox(height: 4), - Text( - nextExerciseName!, - style: const TextStyle( - color: AppColors.textSoft, - fontSize: 14, - fontWeight: FontWeight.w600, + const SizedBox(height: AppSpacing.xl), + // Adjust buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _AdjustButton(label: '−30s', onTap: () => onAdjust(-30)), + const SizedBox(width: AppSpacing.xl), + _AdjustButton(label: '+30s', onTap: () => onAdjust(30)), + ], ), - ), - ], - ], + if (nextExerciseName != null) ...[ + const SizedBox(height: AppSpacing.lg), + Text( + 'Next up', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 4), + Text( + nextExerciseName!, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + }, ), ), // Skip button diff --git a/workout-logger/lib/screens/widgets/rf_cards.dart b/workout-logger/lib/screens/widgets/rf_cards.dart index d9dee18..4e5d35e 100644 --- a/workout-logger/lib/screens/widgets/rf_cards.dart +++ b/workout-logger/lib/screens/widgets/rf_cards.dart @@ -52,9 +52,10 @@ class SessionCard extends StatelessWidget { child: Row( children: [ // Date column - Container( - width: 56, - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 48, maxWidth: 64), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: AppSpacing.md), decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.08), borderRadius: const BorderRadius.only( @@ -93,6 +94,7 @@ class SessionCard extends StatelessWidget { ), ], ), + ), ), // Main info Expanded( diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 29e810a..b95ce0b 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -375,77 +375,81 @@ class _WorkoutFlowScreenState extends State { child: Row( children: [ if (!isFirst) - GestureDetector( - onTap: () { - provider.previousExercise(); - _loadLastSessionData(); - }, + Flexible( + child: GestureDetector( + onTap: () { + provider.previousExercise(); + _loadLastSessionData(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.arrow_back_rounded, size: 16, color: AppColors.textMuted), + const SizedBox(width: 6), + Text( + 'Prev', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ), + ) + else + const SizedBox.shrink(), + const Spacer(), + Flexible( + child: GestureDetector( + onTap: isLast + ? _finishWorkout + : () { + provider.nextExercise(); + _loadLastSessionData(); + }, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), decoration: BoxDecoration( - color: AppColors.glass2, + color: isLast ? AppColors.success : AppColors.primary, borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.glassBorderStrong), + boxShadow: [ + BoxShadow( + color: (isLast ? AppColors.success : AppColors.primary) + .withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.arrow_back_rounded, size: 16, color: AppColors.textMuted), - const SizedBox(width: 6), Text( - 'Prev', + isLast ? 'Finish' : 'Next exercise', style: GoogleFonts.geist( fontSize: 13, fontWeight: FontWeight.w600, - color: AppColors.textMuted, + color: Colors.white, ), ), - ], - ), - ), - ) - else - const SizedBox.shrink(), - const Spacer(), - GestureDetector( - onTap: isLast - ? _finishWorkout - : () { - provider.nextExercise(); - _loadLastSessionData(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - decoration: BoxDecoration( - color: isLast ? AppColors.success : AppColors.primary, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: (isLast ? AppColors.success : AppColors.primary) - .withValues(alpha: 0.35), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - isLast ? 'Finish' : 'Next exercise', - style: GoogleFonts.geist( - fontSize: 13, - fontWeight: FontWeight.w600, + const SizedBox(width: 6), + Icon( + isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, + size: 16, color: Colors.white, ), - ), - const SizedBox(width: 6), - Icon( - isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, - size: 16, - color: Colors.white, - ), - ], + ], + ), ), ), ), diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index 73daa3f..e708295 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -297,3 +297,23 @@ class AppRadius { static const double xxl = 22; // nav pill radius static const double full = 999; } + +class AppBreakpoints { + const AppBreakpoints._(); + + static const double narrow = 360; + static const double compact = 600; + static const double contentMaxWidth = 600; + + static double hPadding(double width) { + if (width < narrow) return 12; + if (width < compact) return 20; + return 32; + } + + static int gridColumns(double width) => width >= compact ? 4 : 2; + + static double chartHeight(double width) => width < narrow ? 140 : 180; + + static double timerRingSize(double width) => (width * 0.6).clamp(160, 220); +} From c7bfea37bae5adeb4d30edef860e1b74ae0be788 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 28 May 2026 23:40:51 +0530 Subject: [PATCH 24/44] feat: enhance UI layout and responsiveness with padding adjustments for floating action buttons and improved data representation --- .../lib/screens/analytics_screen.dart | 15 ++++++++--- .../lib/screens/exercise_library_screen.dart | 15 ++++++----- workout-logger/lib/screens/home_screen.dart | 1 + .../lib/screens/programs/programs_screen.dart | 14 +++++++--- .../lib/screens/widgets/activity_heatmap.dart | 1 + .../lib/screens/widgets/targets_tab.dart | 23 +++++++++------- .../lib/screens/workout_flow_screen.dart | 27 ++++++++++++++----- workout-logger/lib/theme/app_theme.dart | 4 +++ 8 files changed, 70 insertions(+), 30 deletions(-) diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 7ed66b5..e52ad96 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -488,16 +488,23 @@ class _MuscleRow extends StatelessWidget { ({String label, Color color, IconData icon}) get _trend { final model = growthModel; - if (model == null || model.r2 < 0.2) { + if (model == null) { return (label: 'No data', color: AppColors.textFaint, icon: Icons.remove); } + final confident = model.r2 >= 0.2; if (model.slope > 2) { - return (label: '+${(model.slope * 7).toStringAsFixed(0)}/wk', color: AppColors.success, icon: Icons.trending_up_rounded); + final label = confident + ? '+${(model.slope * 7).toStringAsFixed(0)}/wk' + : '~gaining'; + return (label: label, color: AppColors.success, icon: Icons.trending_up_rounded); } if (model.slope > 0) { - return (label: 'Slight gain', color: AppColors.secondary, icon: Icons.trending_up_rounded); + return (label: confident ? 'Slight gain' : '~slight gain', color: AppColors.secondary, icon: Icons.trending_up_rounded); } - return (label: 'Plateau', color: AppColors.warning, icon: Icons.trending_flat_rounded); + if (model.slope < -2) { + return (label: confident ? 'Declining' : '~declining', color: AppColors.accent, icon: Icons.trending_down_rounded); + } + return (label: confident ? 'Plateau' : '~plateau', color: AppColors.warning, icon: Icons.trending_flat_rounded); } @override diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index bf85f66..40ae9dd 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -91,13 +91,16 @@ class _ExerciseLibraryScreenState extends State { ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: FloatingActionButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + ), + backgroundColor: AppColors.primary, + elevation: 0, + child: const Icon(Icons.add_rounded, color: Colors.white), ), - backgroundColor: AppColors.primary, - elevation: 0, - child: const Icon(Icons.add_rounded, color: Colors.white), ), ); } diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 730ef6a..08fa72b 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -675,6 +675,7 @@ class _DashboardTab extends StatelessWidget { return GlassCard( padding: const EdgeInsets.all(16), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index 09e0bf2..584afd5 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -25,10 +25,15 @@ class ProgramsScreen extends StatelessWidget { return Scaffold( backgroundColor: AppColors.background, - body: programs.isEmpty - ? _buildEmptyState(context) - : _buildList(context, programs), - floatingActionButton: Column( + body: SafeArea( + bottom: false, + child: programs.isEmpty + ? _buildEmptyState(context) + : _buildList(context, programs), + ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -66,6 +71,7 @@ class ProgramsScreen extends StatelessWidget { ), ), ], + ), ), ); }, diff --git a/workout-logger/lib/screens/widgets/activity_heatmap.dart b/workout-logger/lib/screens/widgets/activity_heatmap.dart index 953767c..ea500d4 100644 --- a/workout-logger/lib/screens/widgets/activity_heatmap.dart +++ b/workout-logger/lib/screens/widgets/activity_heatmap.dart @@ -17,6 +17,7 @@ class ActivityHeatmap extends StatelessWidget { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ GridView.builder( diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart index 4f0b4bc..4730e7a 100644 --- a/workout-logger/lib/screens/widgets/targets_tab.dart +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -65,16 +65,19 @@ class TargetsTab extends StatelessWidget { ], ), ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreateSheet(context), - backgroundColor: AppColors.primary, - elevation: 0, - icon: const Icon(Icons.add_rounded, color: Colors.white), - label: const Text( - 'New Target', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: FloatingActionButton.extended( + onPressed: () => _showCreateSheet(context), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.add_rounded, color: Colors.white), + label: const Text( + 'New Target', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), ), ), ), diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index b95ce0b..1989531 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -224,13 +224,28 @@ class _WorkoutFlowScreenState extends State { selectionMode: true, onExercisesSelected: _startWithSelected, ), - floatingActionButton: FloatingActionButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: FloatingActionButton.extended( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + ), + backgroundColor: AppColors.card, + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + side: const BorderSide(color: AppColors.glassBorderStrong), + ), + icon: const Icon(Icons.add_rounded, color: AppColors.primary), + label: const Text( + 'New exercise', + style: TextStyle( + color: AppColors.textSoft, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), ), - backgroundColor: AppColors.primary, - elevation: 0, - child: const Icon(Icons.add_rounded, color: Colors.white), ), ); } diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index e708295..7f02c15 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -316,4 +316,8 @@ class AppBreakpoints { static double chartHeight(double width) => width < narrow ? 140 : 180; static double timerRingSize(double width) => (width * 0.6).clamp(160, 220); + + /// Vertical clearance needed to lift a Scaffold FAB above the custom RFNavBar. + /// Wrap the FAB in `Padding(padding: EdgeInsets.only(bottom: navBarClearance))`. + static const double navBarClearance = 80.0; } From 2546d7b1d59ddc11af40ba5dca0fcd83203fbffa Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 29 May 2026 13:15:31 +0530 Subject: [PATCH 25/44] feat: add advanced metrics toggle and display estimated 1RM badge in workout input section --- .../widgets/exercise_input_section.dart | 36 +++++++++++++++- .../lib/screens/widgets/profile_sections.dart | 41 +++++++++++++++++++ .../lib/services/settings_provider.dart | 10 +++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 792ec0e..483674a 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -698,14 +698,14 @@ class _PreviousSetsSection extends StatelessWidget { Wrap( spacing: 6, runSpacing: 6, - children: sets.asMap().entries.map((e) { + children: sets.asMap().entries.expand((e) { final i = e.key; final s = e.value; final dw = settings.toDisplay(s.weight); final wStr = dw == dw.truncateToDouble() ? dw.toStringAsFixed(0) : dw.toStringAsFixed(1); - return Container( + final setChip = Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: AppColors.success.withValues(alpha: 0.12), @@ -745,6 +745,38 @@ class _PreviousSetsSection extends StatelessWidget { ], ), ); + + if (settings.showAdvancedMetrics && s.reps > 0 && s.weight > 0) { + final orm = s.reps == 1 + ? s.weight + : s.weight * (1 + s.reps / 30.0); + final ormDisplay = settings.toDisplay(orm); + final ormStr = ormDisplay == ormDisplay.truncateToDouble() + ? ormDisplay.toStringAsFixed(0) + : ormDisplay.toStringAsFixed(1); + final ormChip = Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.35), + ), + ), + child: Text( + '~$ormStr${settings.unitLabel} 1RM', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + return [setChip, ormChip]; + } + + return [setChip]; }).toList(), ), ], diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index ef61980..cbeb430 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -183,6 +183,47 @@ class PreferencesSection extends StatelessWidget { ); }).toList(), ), + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + const _SectionLabel('ADVANCED METRICS'), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Show estimated 1RM', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Display 1-rep max badge on completed sets', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: settings.showAdvancedMetrics, + onChanged: (v) { + onHaptic(); + settings.setShowAdvancedMetrics(v); + }, + activeThumbColor: AppColors.primary, + activeTrackColor: AppColors.primary.withValues(alpha: 0.35), + ), + ], + ), ], ), ); diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 19955f1..d295d65 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -18,6 +18,7 @@ class SettingsProvider extends ChangeNotifier { String _geminiModel = 'gemini-2.5-flash'; String _weeklyInsights = ''; DateTime? _weeklyInsightsDate; + bool _showAdvancedMetrics = false; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; @@ -29,6 +30,7 @@ class SettingsProvider extends ChangeNotifier { String get geminiModel => _geminiModel; String get weeklyInsights => _weeklyInsights; DateTime? get weeklyInsightsDate => _weeklyInsightsDate; + bool get showAdvancedMetrics => _showAdvancedMetrics; SettingsProvider(this._storage); @@ -51,6 +53,8 @@ class SettingsProvider extends ChangeNotifier { _weeklyInsights = await _storage.getSetting('weeklyInsights') ?? ''; final dateStr = await _storage.getSetting('weeklyInsightsDate'); _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; + final advMetrics = await _storage.getSetting('showAdvancedMetrics'); + _showAdvancedMetrics = advMetrics == 'true'; } Future setUserName(String name) async { @@ -109,6 +113,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setShowAdvancedMetrics(bool value) async { + _showAdvancedMetrics = value; + await _storage.saveSetting('showAdvancedMetrics', value.toString()); + notifyListeners(); + } + Future saveWeeklyInsights(String insights) async { _weeklyInsights = insights; _weeklyInsightsDate = DateTime.now(); From cce1a11ac3b634f31e3e59ec3076d74e37c4d7a0 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 29 May 2026 15:05:25 +0530 Subject: [PATCH 26/44] Add widget tests for AnalyticsScreen and ExerciseProgressView - Implement comprehensive widget tests for the AnalyticsScreen, covering the Overview, Targets, and Records tabs. - Validate functionality such as tab switching, data display, and interaction with UI elements. - Add widget tests for ExerciseProgressView, including exercise picker, chart mode toggle, and set progression chart. - Ensure tests cover empty states, search functionality, and interaction with various UI components. --- .../lib/screens/ai_coach_screen.dart | 21 +- .../lib/screens/analytics_screen.dart | 938 +++++------- .../screens/widgets/analytics_overview.dart | 760 ++++++++++ .../widgets/exercise_progress_view.dart | 1290 ++++++++++++++--- .../screens/widgets/muscle_detail_sheet.dart | 388 +++++ .../lib/screens/widgets/targets_tab.dart | 561 ++++++- .../lib/screens/workout_flow_screen.dart | 15 +- .../lib/services/gemini_service.dart | 19 + .../lib/services/workout_provider.dart | 69 + .../test/analytics_queries_test.dart | 373 +++++ .../test/analytics_screen_test.dart | 506 +++++++ .../test/exercise_progress_view_test.dart | 510 +++++++ 12 files changed, 4634 insertions(+), 816 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/analytics_overview.dart create mode 100644 workout-logger/lib/screens/widgets/muscle_detail_sheet.dart create mode 100644 workout-logger/test/analytics_queries_test.dart create mode 100644 workout-logger/test/analytics_screen_test.dart create mode 100644 workout-logger/test/exercise_progress_view_test.dart diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 77a4168..9a57341 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -26,7 +26,12 @@ class _ChatMessage { // ── Screen ──────────────────────────────────────────────────────────────────── class AiCoachScreen extends StatefulWidget { - const AiCoachScreen({super.key}); + const AiCoachScreen({super.key, this.seedPrompt}); + + /// Optional question to auto-send on open (e.g. deep-linked from analytics). + /// The coach system prompt already carries the user's data, so a seed needs + /// no extra context. + final String? seedPrompt; @override State createState() => _AiCoachScreenState(); @@ -39,6 +44,20 @@ class _AiCoachScreenState extends State { bool _loading = false; String _streamingText = ''; + @override + void initState() { + super.initState(); + final seed = widget.seedPrompt?.trim(); + if (seed != null && seed.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!context.read().isConfigured) return; + _controller.text = seed; + _send(); + }); + } + } + @override void dispose() { _controller.dispose(); diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index e52ad96..4a5016c 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -1,21 +1,17 @@ -// analytics_screen.dart — Analytics: Overview / Exercises / Targets - -import 'dart:math' show max; +// analytics_screen.dart — Analytics: Overview / Exercises / Targets / Records import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/managers/pr_manager.dart'; -import '../services/ml_service.dart' show MuscleRecoveryStatus; import '../services/settings_provider.dart'; -import '../data/exercise_database.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; +import 'widgets/analytics_overview.dart'; import 'widgets/exercise_progress_view.dart'; import 'widgets/targets_tab.dart'; @@ -140,7 +136,7 @@ class _AnalyticsScreenState extends State { Widget _buildTabView() { switch (_tab) { case 0: - return const _OverviewTab(); + return const AnalyticsOverviewTab(); case 1: return const ExerciseProgressView(); case 2: @@ -153,538 +149,427 @@ class _AnalyticsScreenState extends State { } } -// ── Overview Tab ────────────────────────────────────────────────────────────── +// ── Records Tab ──────────────────────────────────────────────────────────────── + +enum _RecordsFilter { all, thisMonth, byExercise } +enum _RecordsSort { recent, heaviest } -class _OverviewTab extends StatelessWidget { - const _OverviewTab(); +class _RecordsTab extends StatefulWidget { + const _RecordsTab(); @override - Widget build(BuildContext context) { - final provider = context.watch(); - return LayoutBuilder( - builder: (context, constraints) { - final hp = AppBreakpoints.hPadding(constraints.maxWidth); - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppBreakpoints.contentMaxWidth, - ), - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: EdgeInsets.fromLTRB(hp, 0, hp, 100), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _VolumeChart(provider: provider), - const SizedBox(height: 12), - _MuscleVolumeChart(provider: provider), - const SizedBox(height: 12), - _MuscleStatusCard(provider: provider), - const SizedBox(height: 12), - _FrequencyGrid(provider: provider), - ], - ), - ), - ), - ); - }, - ); - } + State<_RecordsTab> createState() => _RecordsTabState(); } -// ── Volume progression chart ─────────────────────────────────────────────────── - -class _VolumeChart extends StatelessWidget { - const _VolumeChart({required this.provider}); - final WorkoutProvider provider; +class _RecordsTabState extends State<_RecordsTab> { + _RecordsFilter _filter = _RecordsFilter.all; + _RecordsSort _sort = _RecordsSort.recent; @override Widget build(BuildContext context) { - final sessions = provider.sessions.take(14).toList().reversed.toList(); - final settings = context.watch(); + final prManager = context.watch(); + final provider = context.read(); + final allRecords = prManager.allRecords; - final spots = sessions.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), settings.toDisplay(e.value.totalVolume)); - }).toList(); - - final bestVol = spots.isEmpty - ? 0.0 - : spots.map((s) => s.y).reduce(max); - - return _ChartCard( - title: 'Volume Progression', - subtitle: '${settings.unitLabel} · Last ${sessions.length} workouts', - isEmpty: sessions.isEmpty, - child: LayoutBuilder( - builder: (context, constraints) => SizedBox( - height: AppBreakpoints.chartHeight(constraints.maxWidth), - child: LineChart( - LineChartData( - backgroundColor: Colors.transparent, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (_) => FlLine( - color: AppColors.glassBorder, - strokeWidth: 1, - ), - ), - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (_) => AppColors.cardHigh, - getTooltipItems: (spots) => spots.map((spot) { - final i = spot.x.toInt(); - final v = spot.y; - final volStr = v >= 1000 - ? '${(v / 1000).toStringAsFixed(1)}k' - : v.toStringAsFixed(0); - final dateStr = (i >= 0 && i < sessions.length) - ? DateFormat('MMM d').format(sessions[i].date) - : ''; - return LineTooltipItem( - '$volStr ${settings.unitLabel}', - GoogleFonts.geistMono(color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w700), - children: [ - TextSpan( - text: '\n$dateStr', - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.normal), - ), - ], - ); - }).toList(), - ), - ), - titlesData: FlTitlesData( - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - interval: 1, - getTitlesWidget: (v, _) { - final i = v.toInt(); - if (i < 0 || i >= sessions.length) return const Text(''); - return Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - DateFormat('d/M').format(sessions[i].date), - style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9), - ), - ); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (v, _) { - final label = v >= 1000 - ? '${(v / 1000).toStringAsFixed(1)}k' - : v.toStringAsFixed(0); - return Text(label, style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 9)); - }, - ), + if (allRecords.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.emoji_events_rounded, + size: 48, color: AppColors.textFaint), + const SizedBox(height: 12), + Text( + 'No records yet', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 15, + fontWeight: FontWeight.w600, ), ), - borderData: FlBorderData(show: false), - extraLinesData: ExtraLinesData( - horizontalLines: [ - if (bestVol > 0) - HorizontalLine( - y: bestVol, - color: AppColors.warning.withValues(alpha: 0.55), - strokeWidth: 1, - dashArray: [6, 4], - label: HorizontalLineLabel( - show: true, - direction: LabelDirection.horizontal, - alignment: Alignment.topRight, - padding: const EdgeInsets.only(right: 6, bottom: 2), - style: GoogleFonts.geistMono( - color: AppColors.warning, - fontSize: 9, - fontWeight: FontWeight.w600, - ), - labelResolver: (line) => - 'BEST ${bestVol.toStringAsFixed(0)}', - ), - ), - ], + const SizedBox(height: 4), + Text( + 'Finish a workout to set your first PRs', + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 12), ), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: AppColors.primary, - barWidth: 2.5, - isStrokeCapRound: true, - dotData: FlDotData( - show: true, - getDotPainter: (_, __, ___, ____) => FlDotCirclePainter( - radius: 3.5, - color: AppColors.primary, - strokeWidth: 1.5, - strokeColor: AppColors.surface, - ), - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - AppColors.primary.withValues(alpha: 0.25), - AppColors.primary.withValues(alpha: 0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ], - ), - ), + ], ), - ), - ); - } -} - -// ── Muscle volume bars ──────────────────────────────────────────────────────── - -class _MuscleVolumeChart extends StatelessWidget { - const _MuscleVolumeChart({required this.provider}); - final WorkoutProvider provider; - - @override - Widget build(BuildContext context) { - final byMuscle = provider.getWeeklyVolumeByMuscle(); - - if (byMuscle.isEmpty) { - return _ChartCard( - title: 'Weekly Muscle Volume', - isEmpty: true, - child: const SizedBox.shrink(), ); } - final settings = context.watch(); - final sorted = byMuscle.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = sorted.take(8).toList(); - final maxVol = top.first.value; - - if (maxVol == 0) { - return _ChartCard( - title: 'Weekly Muscle Volume', - isEmpty: true, - child: const SizedBox.shrink(), - ); + // Filter + final now = DateTime.now(); + final startOfMonth = DateTime(now.year, now.month, 1); + List filtered = switch (_filter) { + _RecordsFilter.all => [...allRecords], + _RecordsFilter.thisMonth => allRecords + .where((r) => !r.achievedAt.isBefore(startOfMonth)) + .toList(), + _RecordsFilter.byExercise => [...allRecords], + }; + + // Sort + switch (_sort) { + case _RecordsSort.recent: + filtered.sort((a, b) => b.achievedAt.compareTo(a.achievedAt)); + case _RecordsSort.heaviest: + filtered.sort((a, b) => b.bestWeight.compareTo(a.bestWeight)); } - return _ChartCard( - title: 'Weekly Muscle Volume', - child: Column( - children: top.map((entry) { - final name = MuscleGroups.names[entry.key] ?? entry.key; - final color = AppColors.muscle(entry.key); - final pct = entry.value / maxVol; - final displayVal = settings.toDisplay(entry.value); - final volStr = displayVal >= 1000 - ? '${(displayVal / 1000).toStringAsFixed(1)}k' - : displayVal.toStringAsFixed(0); - - return Padding( - padding: const EdgeInsets.only(bottom: 10), + // Stats for summary + final thisMonthCount = allRecords + .where((r) => !r.achievedAt.isBefore(startOfMonth)) + .length; + final newest = allRecords.isEmpty + ? null + : ([...allRecords] + ..sort((a, b) => b.achievedAt.compareTo(a.achievedAt))) + .first; + + // Group by exercise if needed + Map>? grouped; + if (_filter == _RecordsFilter.byExercise) { + grouped = {}; + for (final r in filtered) { + grouped.putIfAbsent(r.exerciseId, () => []).add(r); + } + } + + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Summary header Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(name, style: GoogleFonts.geist(color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w500)), - Text('$volStr ${settings.unitLabel}', style: GoogleFonts.geistMono(color: AppColors.textMuted, fontSize: 11)), + Text( + '${allRecords.length} PRs', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + if (thisMonthCount > 0) ...[ + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: + BorderRadius.circular(AppRadius.full), + border: Border.all( + color: + AppColors.warning.withValues(alpha: 0.4)), + ), + child: Text( + '$thisMonthCount this month', + style: GoogleFonts.geistMono( + color: AppColors.warning, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ], ), - const SizedBox(height: 4), - RFProgressBar(value: pct, color: color, height: 6, showGlow: true), - ], - ), - ); - }).toList(), - ), - ); - } -} -// ── Muscle recovery + growth status ─────────────────────────────────────────── - -class _MuscleStatusCard extends StatelessWidget { - const _MuscleStatusCard({required this.provider}); - final WorkoutProvider provider; - - static const _muscleOrder = [ - 'chest', 'back', 'shoulders', 'quads', 'hamstrings', - 'glutes', 'biceps', 'triceps', 'abs', 'calves', - ]; - - @override - Widget build(BuildContext context) { - final recovery = provider.getMuscleRecoveryScores(); - final growth = provider.getMuscleGrowthModels(); - - if (recovery.isEmpty) { - return _ChartCard( - title: 'Muscle Status', - isEmpty: true, - child: const SizedBox.shrink(), - ); - } + // Newest PR hero + if (newest != null) ...[ + const SizedBox(height: 12), + _NewestPRHero( + record: newest, + exerciseName: + provider.getExerciseName(newest.exerciseId), + ), + ], - // Show muscles we have recovery data for, in preferred order. - final muscles = [ - ..._muscleOrder.where(recovery.containsKey), - ...recovery.keys.where((k) => !_muscleOrder.contains(k)), - ]; + // Filter + sort bar + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All', + selected: + _filter == _RecordsFilter.all, + onTap: () => setState( + () => _filter = _RecordsFilter.all), + ), + const SizedBox(width: 6), + _FilterChip( + label: 'This month', + selected: _filter == + _RecordsFilter.thisMonth, + onTap: () => setState(() => + _filter = _RecordsFilter.thisMonth), + ), + const SizedBox(width: 6), + _FilterChip( + label: 'By exercise', + selected: _filter == + _RecordsFilter.byExercise, + onTap: () => setState(() => + _filter = _RecordsFilter.byExercise), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => setState(() => _sort = _sort == + _RecordsSort.recent + ? _RecordsSort.heaviest + : _RecordsSort.recent), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: + BorderRadius.circular(AppRadius.sm), + border: + Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Icon( + _sort == _RecordsSort.recent + ? Icons.access_time_rounded + : Icons.fitness_center_rounded, + size: 13, + color: AppColors.textMuted, + ), + const SizedBox(width: 4), + Text( + _sort == _RecordsSort.recent + ? 'Recent' + : 'Heaviest', + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ), + ), - return _ChartCard( - title: 'Muscle Status', - subtitle: 'Recovery · Growth trend', - child: Column( - children: muscles.map((id) { - final status = recovery[id]!; - final model = growth[id]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _MuscleRow( - muscleId: id, - status: status, - growthModel: model, + if (grouped != null) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final entry = grouped!.entries.elementAt(i); + return _ExercisePRGroup( + exerciseName: provider.getExerciseName(entry.key), + records: entry.value, + ); + }, + childCount: grouped.length, + ), ), - ); - }).toList(), - ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => _PRCard( + record: filtered[i], + exerciseName: + provider.getExerciseName(filtered[i].exerciseId), + ), + childCount: filtered.length, + ), + ), + ), + ], ); } } -class _MuscleRow extends StatelessWidget { - const _MuscleRow({ - required this.muscleId, - required this.status, - this.growthModel, - }); - - final String muscleId; - final MuscleRecoveryStatus status; - final GrowthModel? growthModel; - - Color get _recoveryColor { - if (status.recoveryFraction >= 0.90) return AppColors.success; - if (status.recoveryFraction >= 0.70) return AppColors.warning; - return AppColors.accent; - } - - ({String label, Color color, IconData icon}) get _trend { - final model = growthModel; - if (model == null) { - return (label: 'No data', color: AppColors.textFaint, icon: Icons.remove); - } - final confident = model.r2 >= 0.2; - if (model.slope > 2) { - final label = confident - ? '+${(model.slope * 7).toStringAsFixed(0)}/wk' - : '~gaining'; - return (label: label, color: AppColors.success, icon: Icons.trending_up_rounded); - } - if (model.slope > 0) { - return (label: confident ? 'Slight gain' : '~slight gain', color: AppColors.secondary, icon: Icons.trending_up_rounded); - } - if (model.slope < -2) { - return (label: confident ? 'Declining' : '~declining', color: AppColors.accent, icon: Icons.trending_down_rounded); - } - return (label: confident ? 'Plateau' : '~plateau', color: AppColors.warning, icon: Icons.trending_flat_rounded); - } +class _NewestPRHero extends StatelessWidget { + const _NewestPRHero({required this.record, required this.exerciseName}); + final PersonalRecord record; + final String exerciseName; @override Widget build(BuildContext context) { - final name = MuscleGroups.names[muscleId] ?? muscleId; - final pct = status.recoveryFraction; - final color = AppColors.muscle(muscleId); - final trend = _trend; + final settings = context.watch(); + final dateStr = DateFormat('MMM d, yyyy').format(record.achievedAt); + final w = settings.toDisplay(record.bestWeight); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - name, - style: GoogleFonts.geist( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.textSoft, - ), - ), - ), - // Recovery badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _recoveryColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: _recoveryColor.withValues(alpha: 0.3)), - ), - child: Text( - '${status.recoveryPercent}%', - style: GoogleFonts.geistMono( - fontSize: 10, - fontWeight: FontWeight.w600, - color: _recoveryColor, - ), + return GlassCard( + glowColor: AppColors.warning, + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.warning, Color(0xFFFF9500)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), + borderRadius: BorderRadius.circular(AppRadius.md), ), - const SizedBox(width: 8), - // Growth trend chip - Row( - mainAxisSize: MainAxisSize.min, + child: const Icon(Icons.emoji_events_rounded, + color: Colors.white, size: 22), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(trend.icon, size: 12, color: trend.color), - const SizedBox(width: 2), Text( - trend.label, - style: GoogleFonts.geistMono( + 'Latest PR', + style: GoogleFonts.geist( + color: AppColors.warning, fontSize: 10, - color: trend.color, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + Text( + exerciseName, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + Text( + dateStr, + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, ), ), ], ), - ], - ), - const SizedBox(height: 5), - RFProgressBar(value: pct, color: color, height: 5, showGlow: false), - ], - ); - } -} - -// ── Weekly frequency grid ────────────────────────────────────────────────────── - -class _FrequencyGrid extends StatelessWidget { - const _FrequencyGrid({required this.provider}); - final WorkoutProvider provider; - - @override - Widget build(BuildContext context) { - final now = DateTime.now(); - final weeks = {0: 0, 1: 0, 2: 0, 3: 0}; - for (final s in provider.sessions) { - final w = now.difference(s.date).inDays ~/ 7; - if (w >= 0 && w < 4) weeks[w] = (weeks[w] ?? 0) + 1; - } - - return _ChartCard( - title: 'Workout Frequency', - subtitle: 'Sessions per week', - child: LayoutBuilder( - builder: (context, constraints) { - final boxSize = ((constraints.maxWidth - 48) / 4).clamp(40.0, 64.0); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: weeks.entries.map((e) { - final count = e.value; - final label = e.key == 0 ? 'This' : '-${e.key}w'; - final active = count > 0; - return Column( + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - Container( - width: boxSize, - height: boxSize, - decoration: BoxDecoration( - color: active - ? AppColors.primary.withValues(alpha: 0.12 + count * 0.06) - : AppColors.glass2, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: active - ? AppColors.primary.withValues(alpha: 0.4) - : AppColors.glassBorder, - ), - boxShadow: active - ? [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.2), - blurRadius: 12, - ) - ] - : null, + Text( + '${w % 1 == 0 ? w.toStringAsFixed(0) : w.toStringAsFixed(1)} ${settings.unitLabel}', + style: GoogleFonts.geistMono( + color: AppColors.warning, + fontSize: 18, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], ), - child: Center( - child: Text( - '$count', - style: GoogleFonts.geistMono( - color: active ? AppColors.primary : AppColors.textMuted, - fontSize: 22, - fontWeight: FontWeight.w700, - ), - ), + ), + Text( + '${record.bestReps} reps', + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 11, ), ), - const SizedBox(height: 6), - Text(label, style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 10)), ], - ); - }).toList(), - ); - }, + ), + ], ), ); } } -// ── Records Tab ─────────────────────────────────────────────────────────────── - -class _RecordsTab extends StatelessWidget { - const _RecordsTab(); +class _ExercisePRGroup extends StatelessWidget { + const _ExercisePRGroup( + {required this.exerciseName, required this.records}); + final String exerciseName; + final List records; @override Widget build(BuildContext context) { - final prManager = context.watch(); - final provider = context.read(); - final records = [...prManager.allRecords] - ..sort((a, b) => b.achievedAt.compareTo(a.achievedAt)); - - if (records.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.emoji_events_rounded, size: 48, color: AppColors.textFaint), - const SizedBox(height: 12), - Text( - 'No records yet', - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 15, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Text( - 'Finish a workout to set your first PRs', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + final best = records.reduce( + (a, b) => a.bestWeight >= b.bestWeight ? a : b); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 6, top: 4), + child: Text( + exerciseName, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, ), - ], + ), ), - ); - } + _PRCard(record: best, exerciseName: exerciseName), + const SizedBox(height: 4), + ], + ); + } +} - return ListView.builder( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 4, 16, 100), - itemCount: records.length, - itemBuilder: (context, i) => _PRCard( - record: records[i], - exerciseName: provider.getExerciseName(records[i].exerciseId), +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + }); + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.18) + : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Text( + label, + style: GoogleFonts.geistMono( + color: selected ? AppColors.primary : AppColors.textMuted, + fontSize: 11, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w500, + ), + ), ), ); } @@ -719,7 +604,8 @@ class _PRCard extends StatelessWidget { color: AppColors.warning.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.emoji_events_rounded, color: AppColors.warning, size: 18), + child: const Icon(Icons.emoji_events_rounded, + color: AppColors.warning, size: 18), ), const SizedBox(width: 10), Expanded( @@ -736,7 +622,8 @@ class _PRCard extends StatelessWidget { ), Text( dateStr, - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 11), ), ], ), @@ -746,11 +633,24 @@ class _PRCard extends StatelessWidget { const SizedBox(height: AppSpacing.sm), Row( children: [ - _PRStat(label: 'Best Weight', value: '${displayWeight.toStringAsFixed(displayWeight % 1 == 0 ? 0 : 1)} $unit', color: AppColors.warning), + _PRStat( + label: 'Best Weight', + value: + '${displayWeight.toStringAsFixed(displayWeight % 1 == 0 ? 0 : 1)} $unit', + color: AppColors.warning, + ), const SizedBox(width: AppSpacing.sm), - _PRStat(label: 'Best Reps', value: '${record.bestReps}', color: AppColors.secondary), + _PRStat( + label: 'Best Reps', + value: '${record.bestReps}', + color: AppColors.secondary, + ), const SizedBox(width: AppSpacing.sm), - _PRStat(label: 'Best Vol.', value: '${displayVol.toStringAsFixed(0)} $unit', color: AppColors.success), + _PRStat( + label: 'Best Vol.', + value: '${displayVol.toStringAsFixed(0)} $unit', + color: AppColors.success, + ), ], ), ], @@ -760,7 +660,10 @@ class _PRCard extends StatelessWidget { } class _PRStat extends StatelessWidget { - const _PRStat({required this.label, required this.value, required this.color}); + const _PRStat( + {required this.label, + required this.value, + required this.color}); final String label; final String value; @@ -770,7 +673,8 @@ class _PRStat extends StatelessWidget { Widget build(BuildContext context) { return Expanded( child: Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + padding: + const EdgeInsets.symmetric(vertical: 8, horizontal: 10), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(AppRadius.sm), @@ -779,68 +683,18 @@ class _PRStat extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 10)), const SizedBox(height: 2), - Text(value, style: GoogleFonts.geistMono(color: color, fontSize: 13, fontWeight: FontWeight.w700)), + Text(value, + style: GoogleFonts.geistMono( + color: color, + fontSize: 13, + fontWeight: FontWeight.w700)), ], ), ), ); } } - -// ── Reusable chart card ──────────────────────────────────────────────────────── - -class _ChartCard extends StatelessWidget { - const _ChartCard({ - required this.title, - required this.child, - this.subtitle, - this.isEmpty = false, - }); - - final String title; - final String? subtitle; - final Widget child; - final bool isEmpty; - - @override - Widget build(BuildContext context) { - return GlassCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: GoogleFonts.geist( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - Text(subtitle!, style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11)), - ], - if (isEmpty) ...[ - const SizedBox(height: 24), - Center( - child: Column( - children: [ - const Icon(Icons.show_chart_rounded, size: 32, color: AppColors.textFaint), - const SizedBox(height: 8), - Text('No data yet', style: GoogleFonts.geist(fontSize: 13, color: AppColors.textMuted)), - Text('Complete workouts to see progress', style: GoogleFonts.geist(fontSize: 11, color: AppColors.textFaint)), - ], - ), - ), - ] else ...[ - const SizedBox(height: 14), - child, - ], - ], - ), - ); - } -} diff --git a/workout-logger/lib/screens/widgets/analytics_overview.dart b/workout-logger/lib/screens/widgets/analytics_overview.dart new file mode 100644 index 0000000..509b488 --- /dev/null +++ b/workout-logger/lib/screens/widgets/analytics_overview.dart @@ -0,0 +1,760 @@ +// analytics_overview.dart — Analytics "Overview" tab +// +// Top-down hierarchy: weekly Volume Trend (with range toggle) → unified +// Muscle Focus (body map + per-muscle rows, tappable drill-down) → Frequency. + +import 'dart:math' show max; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/ml_service.dart' show MuscleRecoveryStatus; +import '../../data/exercise_database.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import 'body_heatmap.dart'; +import 'muscle_detail_sheet.dart'; + +class AnalyticsOverviewTab extends StatelessWidget { + const AnalyticsOverviewTab({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + return LayoutBuilder( + builder: (context, constraints) { + final hp = AppBreakpoints.hPadding(constraints.maxWidth); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxWidth, + ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(hp, 0, hp, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _VolumeTrendCard(), + const SizedBox(height: 12), + _MuscleFocusCard(provider: provider), + const SizedBox(height: 12), + _FrequencyGrid(provider: provider), + ], + ), + ), + ), + ); + }, + ); + } +} + +// ── Volume trend (weekly aggregate + range toggle) ───────────────────────────── + +enum _Range { w4, w12, all } + +extension on _Range { + String get label => switch (this) { + _Range.w4 => '4W', + _Range.w12 => '12W', + _Range.all => 'All', + }; + int? get weeks => switch (this) { + _Range.w4 => 4, + _Range.w12 => 12, + _Range.all => null, // computed from history (capped) + }; +} + +class _VolumeTrendCard extends StatefulWidget { + const _VolumeTrendCard(); + + @override + State<_VolumeTrendCard> createState() => _VolumeTrendCardState(); +} + +class _VolumeTrendCardState extends State<_VolumeTrendCard> { + _Range _range = _Range.w12; + + static const int _allCap = 26; // keep the chart readable for long histories + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final settings = context.watch(); + final sessions = provider.sessions; + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final currentWeekStart = today.subtract(Duration(days: today.weekday - 1)); + + // Resolve number of weeks to display. + int weeks; + if (_range.weeks != null) { + weeks = _range.weeks!; + } else if (sessions.isEmpty) { + weeks = 0; + } else { + final earliest = sessions + .map((s) => s.date) + .reduce((a, b) => a.isBefore(b) ? a : b); + final earliestWeekStart = DateTime(earliest.year, earliest.month, earliest.day) + .subtract(Duration(days: earliest.weekday - 1)); + final span = currentWeekStart.difference(earliestWeekStart).inDays ~/ 7 + 1; + weeks = span.clamp(1, _allCap); + } + + double weeklyVolume(int weekIndex) { + // weekIndex 0 == oldest week shown, weeks-1 == current week. + final wStart = + currentWeekStart.subtract(Duration(days: (weeks - 1 - weekIndex) * 7)); + final wEnd = wStart.add(const Duration(days: 7)); + final raw = sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) + .fold(0, (sum, s) => sum + s.totalVolume); + return settings.toDisplay(raw); + } + + final spots = [ + for (int i = 0; i < weeks; i++) FlSpot(i.toDouble(), weeklyVolume(i)), + ]; + final hasData = spots.any((s) => s.y > 0); + final bestVol = spots.isEmpty ? 0.0 : spots.map((s) => s.y).reduce(max); + + // Period delta: current shown period vs equal-length previous period. + final curSum = spots.fold(0, (sum, s) => sum + s.y); + double prevSum = 0; + for (int i = 0; i < weeks; i++) { + final wStart = + currentWeekStart.subtract(Duration(days: (weeks + i) * 7)); + final wEnd = wStart.add(const Duration(days: 7)); + prevSum += settings.toDisplay(sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) + .fold(0, (sum, s) => sum + s.totalVolume)); + } + final double? deltaPct = + prevSum > 0 ? ((curSum - prevSum) / prevSum * 100) : null; + + DateTime weekStartFor(int i) => + currentWeekStart.subtract(Duration(days: (weeks - 1 - i) * 7)); + final labelInterval = weeks <= 1 ? 1.0 : (weeks / 6).ceilToDouble(); + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Volume Trend', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${settings.unitLabel} · per week', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + _RangeToggle( + value: _range, + onChanged: (r) => setState(() => _range = r), + ), + ], + ), + if (deltaPct != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + deltaPct >= 0 + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 13, + color: deltaPct >= 0 ? AppColors.success : AppColors.error, + ), + const SizedBox(width: 2), + Text( + '${deltaPct.abs().toStringAsFixed(0)}% vs prev ${weeks}w', + style: GoogleFonts.geistMono( + color: deltaPct >= 0 ? AppColors.success : AppColors.error, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + if (!hasData) ...[ + const SizedBox(height: 24), + const _EmptyChart(), + const SizedBox(height: 8), + ] else ...[ + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) => SizedBox( + height: AppBreakpoints.chartHeight(constraints.maxWidth), + child: LineChart( + LineChartData( + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItems: (touched) => touched.map((spot) { + final i = spot.x.toInt(); + final volStr = _fmtK(spot.y); + final ws = weekStartFor(i); + return LineTooltipItem( + '$volStr ${settings.unitLabel}', + GoogleFonts.geistMono( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: + '\nwk of ${DateFormat('MMM d').format(ws)}', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.normal, + ), + ), + ], + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: labelInterval, + getTitlesWidget: (v, _) { + final i = v.toInt(); + if (i < 0 || i >= weeks) return const Text(''); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + DateFormat('d/M').format(weekStartFor(i)), + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 9, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (v, _) => Text( + _fmtK(v), + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 9, + ), + ), + ), + ), + ), + borderData: FlBorderData(show: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (bestVol > 0) + HorizontalLine( + y: bestVol, + color: AppColors.warning.withValues(alpha: 0.55), + strokeWidth: 1, + dashArray: [6, 4], + label: HorizontalLineLabel( + show: true, + direction: LabelDirection.horizontal, + alignment: Alignment.topRight, + padding: const EdgeInsets.only(right: 6, bottom: 2), + style: GoogleFonts.geistMono( + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + labelResolver: (_) => 'BEST ${_fmtK(bestVol)}', + ), + ), + ], + ), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.primary, + barWidth: 2.5, + isStrokeCapRound: true, + dotData: FlDotData( + show: weeks <= 12, + getDotPainter: (_, __, ___, ____) => + FlDotCirclePainter( + radius: 3.5, + color: AppColors.primary, + strokeWidth: 1.5, + strokeColor: AppColors.surface, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.25), + AppColors.primary.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _RangeToggle extends StatelessWidget { + const _RangeToggle({required this.value, required this.onChanged}); + final _Range value; + final ValueChanged<_Range> onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: _Range.values.map((r) { + final active = r == value; + return GestureDetector( + onTap: () => onChanged(r), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: active ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + r.label, + style: GoogleFonts.geistMono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: active ? Colors.white : AppColors.textMuted, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Muscle Focus (unified volume + recovery + growth, drill-down) ────────────── + +class _MuscleFocusCard extends StatelessWidget { + const _MuscleFocusCard({required this.provider}); + final WorkoutProvider provider; + + static const _muscleOrder = [ + 'chest', 'back', 'shoulders', 'quads', 'hamstrings', + 'glutes', 'biceps', 'triceps', 'abs', 'calves', + ]; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final byMuscle = provider.getWeeklyVolumeByMuscle(); + final recovery = provider.getMuscleRecoveryScores(); + final growth = provider.getMuscleGrowthModels(); + + if (byMuscle.isEmpty && recovery.isEmpty) { + return _ChartCard( + title: 'Muscle Focus', + isEmpty: true, + child: const SizedBox.shrink(), + ); + } + + // Union of muscles with volume or recovery data, ordered by volume desc. + final ids = {...byMuscle.keys, ...recovery.keys}.toList() + ..sort((a, b) { + final cmp = (byMuscle[b] ?? 0).compareTo(byMuscle[a] ?? 0); + if (cmp != 0) return cmp; + return _muscleOrder.indexOf(a).compareTo(_muscleOrder.indexOf(b)); + }); + final top = ids.take(8).toList(); + final maxVol = byMuscle.values.isEmpty + ? 0.0 + : byMuscle.values.reduce(max); + + final normalized = { + if (maxVol > 0) + for (final e in byMuscle.entries) e.key: e.value / maxVol, + }; + + return _ChartCard( + title: 'Muscle Focus', + subtitle: 'Weekly volume · recovery · trend — tap a muscle', + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BodyHeatmapWidget(muscleVolumes: normalized), + const SizedBox(width: 14), + Expanded( + child: Column( + children: [ + for (final id in top) + _MuscleFocusRow( + muscleId: id, + weeklyVolume: byMuscle[id] ?? 0, + fraction: maxVol > 0 ? (byMuscle[id] ?? 0) / maxVol : 0, + recovery: recovery[id], + growth: growth[id], + settings: settings, + onTap: () => _openDrillDown(context, id), + ), + ], + ), + ), + ], + ), + ); + } + + void _openDrillDown(BuildContext context, String muscleId) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => MuscleDetailSheet( + muscleId: muscleId, + provider: provider, + ), + ); + } +} + +class _MuscleFocusRow extends StatelessWidget { + const _MuscleFocusRow({ + required this.muscleId, + required this.weeklyVolume, + required this.fraction, + required this.recovery, + required this.growth, + required this.settings, + required this.onTap, + }); + + final String muscleId; + final double weeklyVolume; + final double fraction; + final MuscleRecoveryStatus? recovery; + final GrowthModel? growth; + final SettingsProvider settings; + final VoidCallback onTap; + + ({Color color, IconData icon}) get _trend { + final model = growth; + if (model == null) { + return (color: AppColors.textFaint, icon: Icons.remove_rounded); + } + if (model.slope > 2) { + return (color: AppColors.success, icon: Icons.trending_up_rounded); + } + if (model.slope > 0) { + return (color: AppColors.secondary, icon: Icons.trending_up_rounded); + } + if (model.slope < -2) { + return (color: AppColors.error, icon: Icons.trending_down_rounded); + } + return (color: AppColors.warning, icon: Icons.trending_flat_rounded); + } + + Color get _recoveryColor { + final r = recovery; + if (r == null) return AppColors.textFaint; + if (r.recoveryFraction >= 0.90) return AppColors.success; + if (r.recoveryFraction >= 0.70) return AppColors.warning; + return AppColors.error; + } + + @override + Widget build(BuildContext context) { + final name = MuscleGroups.names[muscleId] ?? muscleId; + final color = AppColors.muscle(muscleId); + final trend = _trend; + final displayVol = settings.toDisplay(weeklyVolume); + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '${_fmtK(displayVol)} ${settings.unitLabel}', + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, + size: 14, color: AppColors.textFaint), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + Expanded( + child: RFProgressBar( + value: fraction, + color: color, + height: 5, + showGlow: false, + ), + ), + if (recovery != null) ...[ + const SizedBox(width: 8), + Text( + '${recovery!.recoveryPercent}%', + style: GoogleFonts.geistMono( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _recoveryColor, + ), + ), + ], + const SizedBox(width: 6), + Icon(trend.icon, size: 13, color: trend.color), + ], + ), + ], + ), + ), + ); + } +} + +// ── Weekly frequency grid ────────────────────────────────────────────────────── + +class _FrequencyGrid extends StatelessWidget { + const _FrequencyGrid({required this.provider}); + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final weeks = {0: 0, 1: 0, 2: 0, 3: 0}; + for (final s in provider.sessions) { + final w = now.difference(s.date).inDays ~/ 7; + if (w >= 0 && w < 4) weeks[w] = (weeks[w] ?? 0) + 1; + } + + return _ChartCard( + title: 'Workout Frequency', + subtitle: 'Sessions per week', + child: LayoutBuilder( + builder: (context, constraints) { + final boxSize = ((constraints.maxWidth - 48) / 4).clamp(40.0, 64.0); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: weeks.entries.map((e) { + final count = e.value; + final label = e.key == 0 ? 'This wk' : '${e.key} wk'; + final active = count > 0; + return Column( + children: [ + Container( + width: boxSize, + height: boxSize, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.12 + count * 0.06) + : AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active + ? AppColors.primary.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.2), + blurRadius: 12, + ) + ] + : null, + ), + child: Center( + child: Text( + '$count', + style: GoogleFonts.geistMono( + color: active ? AppColors.primary : AppColors.textMuted, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + ), + ), + ], + ); + }).toList(), + ); + }, + ), + ); + } +} + +// ── Reusable chart card ──────────────────────────────────────────────────────── + +class _ChartCard extends StatelessWidget { + const _ChartCard({ + required this.title, + required this.child, + this.subtitle, + this.isEmpty = false, + }); + + final String title; + final String? subtitle; + final Widget child; + final bool isEmpty; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11), + ), + ], + if (isEmpty) ...[ + const SizedBox(height: 24), + const _EmptyChart(), + ] else ...[ + const SizedBox(height: 14), + child, + ], + ], + ), + ); + } +} + +class _EmptyChart extends StatelessWidget { + const _EmptyChart(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: [ + const Icon(Icons.show_chart_rounded, size: 32, color: AppColors.textFaint), + const SizedBox(height: 8), + Text('No data yet', + style: GoogleFonts.geist(fontSize: 13, color: AppColors.textMuted)), + Text('Complete workouts to see progress', + style: GoogleFonts.geist(fontSize: 11, color: AppColors.textFaint)), + ], + ), + ); + } +} + +String _fmtK(double v) => + v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}k' : v.toStringAsFixed(0); diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index cbab21f..d7283c6 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -6,12 +6,17 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; +import '../../services/gemini_service.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import '../ai_coach_screen.dart'; + +enum _ChartMode { volume, sets } class ExerciseProgressView extends StatefulWidget { const ExerciseProgressView({super.key}); @@ -22,6 +27,7 @@ class ExerciseProgressView extends StatefulWidget { class _ExerciseProgressViewState extends State { String? _selectedId; + _ChartMode _chartMode = _ChartMode.volume; @override Widget build(BuildContext context) { @@ -38,21 +44,26 @@ class _ExerciseProgressViewState extends State { ); } - final effectiveSelectedId = performed.contains(_selectedId) ? _selectedId : null; + final effectiveId = performed.contains(_selectedId) ? _selectedId : null; return Column( children: [ _ExerciseDropdown( ids: performed, - selected: effectiveSelectedId, + selected: effectiveId, getExerciseName: provider.getExerciseName, - onChanged: (id) => setState(() => _selectedId = id), + onChanged: (id) => setState(() { + _selectedId = id; + _chartMode = _ChartMode.volume; + }), ), - if (effectiveSelectedId != null) + if (effectiveId != null) Expanded( child: _ExerciseStats( - exerciseId: effectiveSelectedId, + exerciseId: effectiveId, provider: provider, + chartMode: _chartMode, + onChartModeChanged: (m) => setState(() => _chartMode = m), ), ) else @@ -60,7 +71,7 @@ class _ExerciseProgressViewState extends State { child: Center( child: Text( 'Select an exercise above', - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textMuted, fontSize: 14, ), @@ -72,7 +83,8 @@ class _ExerciseProgressViewState extends State { } } -// ── Exercise dropdown selector ───────────────────────────────────────────────── +// ── Exercise picker — modern sheet with search ──────────────────────────────── + class _ExerciseDropdown extends StatelessWidget { const _ExerciseDropdown({ required this.ids, @@ -86,8 +98,33 @@ class _ExerciseDropdown extends StatelessWidget { final String Function(String) getExerciseName; final ValueChanged onChanged; + void _openSheet(BuildContext context) { + final sorted = ids.toList() + ..sort((a, b) => getExerciseName(a).compareTo(getExerciseName(b))); + + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => _ExercisePickerSheet( + ids: sorted, + selected: selected, + getExerciseName: getExerciseName, + onPicked: (id) { + Navigator.pop(context); + onChanged(id); + }, + ), + ); + } + @override Widget build(BuildContext context) { + final hasSelection = selected != null && ids.contains(selected); return Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.md, @@ -95,50 +132,303 @@ class _ExerciseDropdown extends StatelessWidget { AppSpacing.md, AppSpacing.sm, ), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppColors.glassBorder), - ), - child: DropdownButton( - value: ids.contains(selected) ? selected : null, - isExpanded: true, - underline: const SizedBox.shrink(), - dropdownColor: AppColors.cardHigh, - hint: const Text( - 'Select exercise…', - style: TextStyle(color: AppColors.textMuted, fontSize: 14), + child: GestureDetector( + onTap: () => _openSheet(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 14, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: hasSelection + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: hasSelection + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.glass2, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.fitness_center_rounded, + size: 15, + color: hasSelection + ? AppColors.primary + : AppColors.textFaint, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + hasSelection + ? getExerciseName(selected!) + : 'Pick an exercise…', + style: GoogleFonts.geist( + color: hasSelection + ? AppColors.textPrimary + : AppColors.textMuted, + fontSize: 14, + fontWeight: hasSelection + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 20, + color: hasSelection + ? AppColors.primary + : AppColors.textFaint, + ), + ], ), - style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), - items: ids.map((id) { - return DropdownMenuItem( - value: id, - child: Text(getExerciseName(id)), - ); - }).toList(), - onChanged: onChanged, ), ), ); } } -// ── Stats view for a selected exercise ─────────────────────────────────────── +class _ExercisePickerSheet extends StatefulWidget { + const _ExercisePickerSheet({ + required this.ids, + required this.selected, + required this.getExerciseName, + required this.onPicked, + }); + final List ids; + final String? selected; + final String Function(String) getExerciseName; + final ValueChanged onPicked; + + @override + State<_ExercisePickerSheet> createState() => _ExercisePickerSheetState(); +} + +class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { + final _search = TextEditingController(); + List _filtered = []; + + @override + void initState() { + super.initState(); + _filtered = widget.ids; + _search.addListener(_onSearch); + } + + void _onSearch() { + final q = _search.text.toLowerCase(); + setState(() { + _filtered = q.isEmpty + ? widget.ids + : widget.ids + .where((id) => + widget.getExerciseName(id).toLowerCase().contains(q)) + .toList(); + }); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Container( + // 75% of screen height + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + padding: EdgeInsets.only(bottom: bottomInset), + child: Column( + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title + count + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, AppSpacing.md, AppSpacing.lg, 0), + child: Row( + children: [ + Text( + 'Select Exercise', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + '${widget.ids.length} logged', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 12, + ), + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, AppSpacing.md, AppSpacing.lg, AppSpacing.sm), + child: Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: _search, + autofocus: true, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'Search…', + hintStyle: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 14, + ), + prefixIcon: const Icon(Icons.search_rounded, + color: AppColors.textFaint, size: 18), + suffixIcon: _search.text.isNotEmpty + ? GestureDetector( + onTap: () => _search.clear(), + child: const Icon(Icons.close_rounded, + color: AppColors.textFaint, size: 16), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: AppSpacing.sm, + ), + ), + ), + ), + ), + // Divider + Divider( + height: 1, + color: AppColors.glassBorder, + indent: AppSpacing.lg, + endIndent: AppSpacing.lg), + // List + Expanded( + child: _filtered.isEmpty + ? Center( + child: Text( + 'No exercises match', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + itemCount: _filtered.length, + itemBuilder: (context, i) { + final id = _filtered[i]; + final name = widget.getExerciseName(id); + final isSelected = id == widget.selected; + return InkWell( + onTap: () => widget.onPicked(id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: 14, + ), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary.withValues(alpha: 0.10) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: AppColors.glassBorder, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + name, + style: GoogleFonts.geist( + color: isSelected + ? AppColors.primary + : AppColors.textSoft, + fontSize: 14, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + if (isSelected) + const Icon(Icons.check_rounded, + color: AppColors.primary, size: 18), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +// ── Stats view for a selected exercise ──────────────────────────────────────── + class _ExerciseStats extends StatelessWidget { const _ExerciseStats({ required this.exerciseId, required this.provider, + required this.chartMode, + required this.onChartModeChanged, }); final String exerciseId; final WorkoutProvider provider; + final _ChartMode chartMode; + final ValueChanged<_ChartMode> onChartModeChanged; @override Widget build(BuildContext context) { final settings = context.watch(); final progression = provider.getVolumeProgression(exerciseId); + final setProgression = provider.getSetProgression(exerciseId); final growthModel = provider.getGrowthModel(exerciseId); final bestOneRM = provider.getBestOneRM(exerciseId); @@ -161,9 +451,21 @@ class _ExerciseStats extends StatelessWidget { _GrowthCard(model: growthModel), const SizedBox(height: AppSpacing.sm), ], - _VolumeChart(progression: progression, growthModel: growthModel), + _ChartSection( + exerciseId: exerciseId, + progression: progression, + setProgression: setProgression, + growthModel: growthModel, + chartMode: chartMode, + onChartModeChanged: onChartModeChanged, + ), const SizedBox(height: AppSpacing.sm), _SessionHistory(progression: progression, settings: settings), + const SizedBox(height: AppSpacing.md), + _AskCoachButton( + exerciseName: provider.getExerciseName(exerciseId), + growthModel: growthModel, + ), ], ), ); @@ -171,6 +473,7 @@ class _ExerciseStats extends StatelessWidget { } // ── 1RM card ────────────────────────────────────────────────────────────────── + class _OneRMCard extends StatelessWidget { const _OneRMCard({required this.oneRM, required this.settings}); final double oneRM; @@ -178,20 +481,9 @@ class _OneRMCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return GlassCard( padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.primary.withValues(alpha: 0.18), - AppColors.primary.withValues(alpha: 0.06), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.25)), - ), + glowColor: AppColors.primary, child: Row( children: [ Container( @@ -203,7 +495,7 @@ class _OneRMCard extends StatelessWidget { child: const Icon( Icons.emoji_events_rounded, color: AppColors.primary, - size: 28, + size: 26, ), ), const SizedBox(width: AppSpacing.md), @@ -211,32 +503,41 @@ class _OneRMCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Estimated 1RM', - style: TextStyle(color: AppColors.textMuted, fontSize: 11), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), ), Text( settings.formatWeight(oneRM), - style: const TextStyle( + style: GoogleFonts.geistMono( color: AppColors.primary, - fontSize: 30, + fontSize: 28, fontWeight: FontWeight.w800, - fontFeatures: [FontFeature.tabularFigures()], + fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ), - const Column( + Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( 'Epley formula', - style: TextStyle(color: AppColors.textMuted, fontSize: 10), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + ), ), Text( 'Best across sets', - style: TextStyle(color: AppColors.textMuted, fontSize: 10), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + ), ), ], ), @@ -247,6 +548,7 @@ class _OneRMCard extends StatelessWidget { } // ── Growth trend card ───────────────────────────────────────────────────────── + class _GrowthCard extends StatelessWidget { const _GrowthCard({required this.model}); final GrowthModel model; @@ -257,26 +559,17 @@ class _GrowthCard extends StatelessWidget { final isGrowing = model.slope > 0; final color = isGrowing ? AppColors.success : AppColors.warning; - return Container( + return GlassCard( padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - color.withValues(alpha: 0.18), - color.withValues(alpha: 0.06), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: color.withValues(alpha: 0.25)), - ), + glowColor: color, child: Row( children: [ Icon( - isGrowing ? Icons.trending_up_rounded : Icons.trending_flat_rounded, + isGrowing + ? Icons.trending_up_rounded + : Icons.trending_flat_rounded, color: color, - size: 40, + size: 38, ), const SizedBox(width: AppSpacing.md), Expanded( @@ -285,7 +578,7 @@ class _GrowthCard extends StatelessWidget { children: [ Text( isGrowing ? 'Growing!' : 'Plateau', - style: TextStyle( + style: GoogleFonts.geist( color: color, fontSize: 17, fontWeight: FontWeight.w700, @@ -295,7 +588,7 @@ class _GrowthCard extends StatelessWidget { isGrowing ? '+${settings.toDisplay(model.slope.abs()).toStringAsFixed(1)} ${settings.unitLabel}/session' : 'Volume trend is flat', - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textSoft, fontSize: 12, ), @@ -308,15 +601,18 @@ class _GrowthCard extends StatelessWidget { children: [ Text( 'R² ${(model.r2 * 100).toStringAsFixed(0)}%', - style: TextStyle( + style: GoogleFonts.geistMono( color: color, fontSize: 13, fontWeight: FontWeight.w700, ), ), - const Text( + Text( 'model fit', - style: TextStyle(color: AppColors.textMuted, fontSize: 10), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + ), ), ], ), @@ -326,7 +622,108 @@ class _GrowthCard extends StatelessWidget { } } +// ── Chart section (Volume line ↔ Set-progression bars) ─────────────────────── + +class _ChartSection extends StatelessWidget { + const _ChartSection({ + required this.exerciseId, + required this.progression, + required this.setProgression, + required this.growthModel, + required this.chartMode, + required this.onChartModeChanged, + }); + + final String exerciseId; + final List<({DateTime date, double volume})> progression; + final List<({DateTime date, List sets})> setProgression; + final GrowthModel? growthModel; + final _ChartMode chartMode; + final ValueChanged<_ChartMode> onChartModeChanged; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + chartMode == _ChartMode.volume + ? 'Volume Progression' + : 'Set Progression', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + _ChartModeToggle( + value: chartMode, + onChanged: onChartModeChanged, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (chartMode == _ChartMode.volume) + _VolumeChart(progression: progression, growthModel: growthModel) + else + _SetProgressionChart(setProgression: setProgression), + ], + ), + ); + } +} + +class _ChartModeToggle extends StatelessWidget { + const _ChartModeToggle({required this.value, required this.onChanged}); + final _ChartMode value; + final ValueChanged<_ChartMode> onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in _ChartMode.values) + GestureDetector( + onTap: () => onChanged(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: mode == value ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + mode == _ChartMode.volume ? 'Volume' : 'Sets', + style: GoogleFonts.geistMono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: mode == value ? Colors.white : AppColors.textMuted, + ), + ), + ), + ), + ], + ), + ); + } +} + // ── Volume progression line chart ───────────────────────────────────────────── + class _VolumeChart extends StatelessWidget { const _VolumeChart({required this.progression, this.growthModel}); final List<({DateTime date, double volume})> progression; @@ -337,7 +734,6 @@ class _VolumeChart extends StatelessWidget { final settings = context.read(); final n = progression.length; - // Residual standard error for 95% confidence interval width double rse = 0.0; if (growthModel != null && n >= 3) { double ssRes = 0.0; @@ -348,7 +744,6 @@ class _VolumeChart extends StatelessWidget { rse = sqrt(ssRes / (n - 2)); } final ci95 = settings.toDisplay(rse * 1.96); - final bestVol = n > 0 ? settings.toDisplay(progression.map((e) => e.volume).reduce(max)) : 0.0; @@ -357,20 +752,16 @@ class _VolumeChart extends StatelessWidget { n, (i) => FlSpot(i.toDouble(), settings.toDisplay(progression[i].volume)), ); - - // Trend line extends 2 sessions beyond actual data final trendSpots = (growthModel != null && n >= 2) ? List.generate( n + 2, (i) => FlSpot( i.toDouble(), - settings.toDisplay(growthModel!.predict(i).clamp(0.0, double.infinity)), + settings.toDisplay( + growthModel!.predict(i).clamp(0.0, double.infinity)), ), ) : []; - - // Upper / lower CI bounds rendered as invisible lines; - // BetweenBarsData fills the band between them. final upperSpots = (ci95 > 0 && trendSpots.isNotEmpty) ? trendSpots.map((s) => FlSpot(s.x, s.y + ci95)).toList() : []; @@ -378,7 +769,6 @@ class _VolumeChart extends StatelessWidget { ? trendSpots.map((s) => FlSpot(s.x, max(0.0, s.y - ci95))).toList() : []; - // bar indices: 0 = actual, 1 = trend, 2 = upper CI, 3 = lower CI final lineBars = [ LineChartBarData( spots: actualSpots, @@ -435,158 +825,587 @@ class _VolumeChart extends StatelessWidget { ), ]; - // BetweenBarsData indices depend on how many bars are present final hasTrend = trendSpots.isNotEmpty; final hasCi = upperSpots.isNotEmpty; final betweenBars = (hasTrend && hasCi) ? [ BetweenBarsData( - fromIndex: 2, // upper CI - toIndex: 3, // lower CI + fromIndex: 2, + toIndex: 3, color: AppColors.primary.withValues(alpha: 0.08), ), ] : []; - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.glassBorder), + if (progression.isEmpty) { + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center( + child: Text('No data', + style: TextStyle(color: AppColors.textMuted)), + ), + ); + } + + return SizedBox( + height: 160, + child: LineChart( + LineChartData( + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItems: (spots) => spots.map((spot) { + if (spot.barIndex != 0) return null; + final v = spot.y; + final volStr = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + final i = spot.x.toInt(); + final dateStr = (i >= 0 && i < n) + ? DateFormat('MMM d').format(progression[i].date) + : ''; + return LineTooltipItem( + '$volStr ${settings.unitLabel}', + GoogleFonts.geistMono( + color: AppColors.secondary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: '\n$dateStr', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.normal, + ), + ), + ], + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 38, + getTitlesWidget: (v, _) { + final label = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + return Text( + label, + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 9, + ), + ); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (bestVol > 0) + HorizontalLine( + y: bestVol, + color: AppColors.warning.withValues(alpha: 0.5), + strokeWidth: 1, + dashArray: [6, 4], + label: HorizontalLineLabel( + show: true, + direction: LabelDirection.horizontal, + alignment: Alignment.topRight, + padding: const EdgeInsets.only(right: 4, bottom: 2), + style: GoogleFonts.geistMono( + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + labelResolver: (line) => + 'BEST ${bestVol.toStringAsFixed(0)}', + ), + ), + ], + ), + betweenBarsData: betweenBars, + lineBarsData: lineBars, + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ); + } +} + +// ── Set progression — grouped dual-colour bars (weight + reps per set) ─────── +// +// Layout per session group: [w₁ r₁ | w₂ r₂ | w₃ r₃ …] — purple = weight, +// cyan = reps (scaled to same axis via factor = maxWeight / maxReps). +// Left axis labels show weight (kg/lbs), right axis shows reps. + +// ── Set progression — grouped dual-colour bars, interactive legend + auto-fit ─ + +enum _SetViewMode { recent, weekly } + +class _SetProgressionChart extends StatefulWidget { + const _SetProgressionChart({required this.setProgression}); + final List<({DateTime date, List sets})> setProgression; + + static const _maxSetsPerSession = 4; + static const _weightColor = AppColors.primary; + static const _repsColor = AppColors.secondary; + + @override + State<_SetProgressionChart> createState() => _SetProgressionChartState(); +} + +class _SetProgressionChartState extends State<_SetProgressionChart> { + bool _showWeight = true; + bool _showReps = true; + _SetViewMode _mode = _SetViewMode.recent; + + static const _wc = _SetProgressionChart._weightColor; + static const _rc = _SetProgressionChart._repsColor; + static const _maxSets = _SetProgressionChart._maxSetsPerSession; + + // ── Data helpers ───────────────────────────────────────────────────────────── + + static DateTime _weekStart(DateTime d) { + final n = DateTime(d.year, d.month, d.day); + return n.subtract(Duration(days: n.weekday - 1)); + } + + /// How many session groups fit given available chart pixel width. + int _maxFit(double chartWidth) { + final setsPerGroup = _mode == _SetViewMode.weekly ? 1 : _maxSets; + final visTypes = (_showWeight ? 1 : 0) + (_showReps ? 1 : 0); + final rodsPerGroup = setsPerGroup * max(1, visTypes); + const rodW = 6.0, gap = 2.0, groupGap = 12.0; + final groupW = rodsPerGroup * rodW + (rodsPerGroup - 1) * gap + groupGap; + return max(3, (chartWidth / groupW).floor()); + } + + List<({DateTime date, List sets})> _buildSessions( + SettingsProvider settings, int maxFit) { + final raw = widget.setProgression; + + if (_mode == _SetViewMode.recent) { + final slice = raw.length > maxFit + ? raw.sublist(raw.length - maxFit) + : raw; + return slice.map((e) => ( + date: e.date, + sets: e.sets.take(_maxSets).toList(), + )).toList(); + } + + // Weekly aggregation — one synthetic set (avg weight, avg reps) per week. + final byWeek = >{}; + for (final s in raw) { + byWeek.putIfAbsent(_weekStart(s.date), () => []).addAll(s.sets); + } + final sorted = byWeek.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + final visible = sorted.length > maxFit + ? sorted.sublist(sorted.length - maxFit) + : sorted; + return visible.map((e) { + final sets = e.value; + final avgW = sets.fold(0.0, (s, x) => s + x.weight) / sets.length; + final avgR = (sets.fold(0.0, (s, x) => s + x.reps) / sets.length).round(); + return (date: e.key, sets: [WorkoutSet(weight: avgW, reps: avgR)]); + }).toList(); + } + + // ── Bar group builder ───────────────────────────────────────────────────────── + + List _buildGroups( + List<({DateTime date, List sets})> sessions, + SettingsProvider settings, + double scale, + ) { + return [ + for (int si = 0; si < sessions.length; si++) + () { + final rods = []; + for (final set in sessions[si].sets) { + final w = settings.toDisplay(set.weight); + if (_showWeight) { + rods.add(BarChartRodData( + toY: w, + width: 6, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + gradient: LinearGradient( + colors: [_wc, _wc.withValues(alpha: 0.55)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + )); + } + if (_showReps) { + rods.add(BarChartRodData( + toY: set.reps * scale, + width: 6, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + gradient: LinearGradient( + colors: [_rc, _rc.withValues(alpha: 0.50)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + )); + } + } + // Always emit at least an invisible rod so x-axis label stays. + if (rods.isEmpty) { + rods.add(BarChartRodData( + toY: 0, width: 0, color: Colors.transparent)); + } + return BarChartGroupData(x: si, barRods: rods, barsSpace: 2); + }(), + ]; + } + + // ── Tooltip ─────────────────────────────────────────────────────────────────── + + BarTooltipItem? _tooltip( + int groupIndex, + int rodIndex, + List<({DateTime date, List sets})> sessions, + SettingsProvider settings, + ) { + if (groupIndex < 0 || groupIndex >= sessions.length) return null; + final session = sessions[groupIndex]; + + // Map rodIndex back to (setIndex, isWeight) based on visible toggles. + int setIndex; + bool isWeight; + if (_showWeight && _showReps) { + setIndex = rodIndex ~/ 2; + isWeight = rodIndex.isEven; + } else if (_showWeight) { + setIndex = rodIndex; + isWeight = true; + } else { + setIndex = rodIndex; + isWeight = false; + } + if (setIndex >= session.sets.length) return null; + final set = session.sets[setIndex]; + + final dateLabel = _mode == _SetViewMode.weekly + ? 'wk of ${DateFormat('MMM d').format(session.date)}' + : DateFormat('MMM d').format(session.date); + final setLabel = + _mode == _SetViewMode.weekly ? 'Avg' : 'Set ${setIndex + 1}'; + + if (isWeight) { + final w = settings.toDisplay(set.weight); + final wStr = + w % 1 == 0 ? w.toStringAsFixed(0) : w.toStringAsFixed(1); + return BarTooltipItem( + '$setLabel $wStr ${settings.unitLabel}', + GoogleFonts.geistMono( + color: _wc, fontSize: 12, fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: '\n$dateLabel', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 10, + fontWeight: FontWeight.normal), + ) + ], + ); + } else { + return BarTooltipItem( + '$setLabel ${set.reps} reps', + GoogleFonts.geistMono( + color: _rc, fontSize: 12, fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: '\n$dateLabel', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 10, + fontWeight: FontWeight.normal), + ) + ], + ); + } + } + + // ── Build ───────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final settings = context.read(); + final raw = widget.setProgression; + + if (raw.isEmpty) { + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center( + child: + Text('No data', style: TextStyle(color: AppColors.textMuted)), + ), + ); + } + + // Compute global maxes from full history so axes don't jump on toggle. + double maxW = 0, maxR = 0; + for (final s in raw) { + for (final set in s.sets) { + final w = settings.toDisplay(set.weight); + if (w > maxW) maxW = w; + if (set.reps > maxR) maxR = set.reps.toDouble(); + } + } + if (maxW == 0) maxW = 1; + if (maxR == 0) maxR = 1; + final scale = maxW / maxR; + final chartMaxY = maxW * 1.15; + + return LayoutBuilder(builder: (context, constraints) { + // Reserve left(36) + right(28) axis widths from total. + final chartWidth = (constraints.maxWidth - 64).clamp(60.0, double.infinity); + final maxFit = _maxFit(chartWidth); + final sessions = _buildSessions(settings, maxFit); + final barGroups = _buildGroups(sessions, settings, scale); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Controls row: tappable legend + mode toggle Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Expanded( - child: Text( - 'Volume Progression', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, + Row( + children: [ + _ToggleLegend( + color: _wc, + label: 'Weight', + active: _showWeight, + onTap: () => setState(() => _showWeight = !_showWeight), ), - ), - ), - if (trendSpots.isNotEmpty) ...[ - Container( - width: 16, - height: 2, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(1), + const SizedBox(width: 14), + _ToggleLegend( + color: _rc, + label: 'Reps', + active: _showReps, + onTap: () => setState(() => _showReps = !_showReps), ), - ), - const SizedBox(width: 4), - const Text( - 'Trend', - style: TextStyle(color: AppColors.textMuted, fontSize: 10), - ), - ], + ], + ), + _SetModeToggle( + value: _mode, + onChanged: (m) => setState(() => _mode = m), + ), ], ), - const SizedBox(height: AppSpacing.md), - if (progression.isEmpty) - const Center( - child: Padding( - padding: EdgeInsets.all(AppSpacing.lg), - child: Text( - 'No data', - style: TextStyle(color: AppColors.textMuted), + const SizedBox(height: 10), + SizedBox( + height: 180, + child: BarChart( + BarChartData( + maxY: chartMaxY, + groupsSpace: 12, + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), ), - ), - ) - else - SizedBox( - height: 160, - child: LineChart( - LineChartData( - backgroundColor: Colors.transparent, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (_) => - FlLine(color: AppColors.glassBorder, strokeWidth: 1), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItem: (group, gi, rod, ri) => + _tooltip(gi, ri, sessions, settings), ), - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (_) => AppColors.cardHigh, - getTooltipItems: (spots) => spots.map((spot) { - if (spot.barIndex != 0) return null; - final v = spot.y; - final volStr = v >= 1000 + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + axisNameWidget: Text( + settings.unitLabel, + style: GoogleFonts.geistMono( + color: _wc, + fontSize: 9, + fontWeight: FontWeight.w700), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: _showWeight, + reservedSize: 36, + getTitlesWidget: (v, _) => Text( + v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}k' - : v.toStringAsFixed(0); - final i = spot.x.toInt(); - final dateStr = (i >= 0 && i < n) - ? DateFormat('MMM d').format(progression[i].date) - : ''; - return LineTooltipItem( - '$volStr ${settings.unitLabel}', - const TextStyle(color: AppColors.secondary, fontSize: 13, fontWeight: FontWeight.w700), - children: [ - TextSpan( - text: '\n$dateStr', - style: const TextStyle(color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.normal), - ), - ], - ); - }).toList(), + : v.toStringAsFixed(0), + style: GoogleFonts.geistMono( + color: _wc.withValues(alpha: 0.7), + fontSize: 9), + ), ), ), - titlesData: FlTitlesData( - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - bottomTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 38, - getTitlesWidget: (v, _) { - final label = v >= 1000 - ? '${(v / 1000).toStringAsFixed(1)}k' - : v.toStringAsFixed(0); - return Text( - label, - style: const TextStyle(color: AppColors.textMuted, fontSize: 9), - ); - }, - ), + rightTitles: AxisTitles( + axisNameWidget: Text( + 'reps', + style: GoogleFonts.geistMono( + color: _rc, + fontSize: 9, + fontWeight: FontWeight.w700), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: _showReps, + reservedSize: 28, + getTitlesWidget: (v, _) { + final r = (v / scale).round(); + if (r <= 0) return const Text(''); + return Text('$r', + style: GoogleFonts.geistMono( + color: _rc.withValues(alpha: 0.7), + fontSize: 9)); + }, ), ), - borderData: FlBorderData(show: false), - extraLinesData: ExtraLinesData( - horizontalLines: [ - if (bestVol > 0) - HorizontalLine( - y: bestVol, - color: AppColors.warning.withValues(alpha: 0.5), - strokeWidth: 1, - dashArray: [6, 4], - label: HorizontalLineLabel( - show: true, - direction: LabelDirection.horizontal, - alignment: Alignment.topRight, - padding: - const EdgeInsets.only(right: 4, bottom: 2), - style: const TextStyle( - color: AppColors.warning, - fontSize: 9, - fontWeight: FontWeight.w600, - ), - labelResolver: (line) => - 'BEST ${bestVol.toStringAsFixed(0)}', - ), - ), - ], + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + getTitlesWidget: (v, _) { + final i = v.toInt(); + if (i < 0 || i >= sessions.length) { + return const Text(''); + } + final label = _mode == _SetViewMode.weekly + ? DateFormat('d/M') + .format(sessions[i].date) + : DateFormat('d/M').format(sessions[i].date); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(label, + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 9)), + ); + }, + ), + ), + ), + barGroups: barGroups, + ), + ), + ), + ], + ); + }); + } +} + +// ── Interactive legend dot ──────────────────────────────────────────────────── + +class _ToggleLegend extends StatelessWidget { + const _ToggleLegend({ + required this.color, + required this.label, + required this.active, + required this.onTap, + }); + final Color color; + final String label; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: active ? 1.0 : 0.32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 10, + height: 10, + decoration: BoxDecoration( + color: active ? color : AppColors.textFaint, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + label, + style: GoogleFonts.geistMono( + color: active ? AppColors.textSoft : AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } +} + +// ── Recent / Weekly mode toggle ─────────────────────────────────────────────── + +class _SetModeToggle extends StatelessWidget { + const _SetModeToggle({required this.value, required this.onChanged}); + final _SetViewMode value; + final ValueChanged<_SetViewMode> onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in _SetViewMode.values) + GestureDetector( + onTap: () => onChanged(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: mode == value ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + mode == _SetViewMode.recent ? 'Recent' : 'Weekly', + style: GoogleFonts.geistMono( + fontSize: 10, + fontWeight: FontWeight.w700, + color: mode == value ? Colors.white : AppColors.textMuted, ), - betweenBarsData: betweenBars, - lineBarsData: lineBars, ), ), ), @@ -597,6 +1416,7 @@ class _VolumeChart extends StatelessWidget { } // ── Session history list ─────────────────────────────────────────────────────── + class _SessionHistory extends StatelessWidget { const _SessionHistory({ required this.progression, @@ -610,19 +1430,14 @@ class _SessionHistory extends StatelessWidget { Widget build(BuildContext context) { if (progression.isEmpty) return const SizedBox.shrink(); - return Container( + return GlassCard( padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.glassBorder), - ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Session History', - style: TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, @@ -638,14 +1453,14 @@ class _SessionHistory extends StatelessWidget { children: [ Text( DateFormat('MMM d, yyyy').format(entry.date), - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textSoft, fontSize: 13, ), ), Text( '${displayVol.toStringAsFixed(0)} ${settings.unitLabel}', - style: const TextStyle( + style: GoogleFonts.geistMono( color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w600, @@ -660,3 +1475,40 @@ class _SessionHistory extends StatelessWidget { ); } } + +// ── Ask Coach button ────────────────────────────────────────────────────────── + +class _AskCoachButton extends StatelessWidget { + const _AskCoachButton({ + required this.exerciseName, + required this.growthModel, + }); + + final String exerciseName; + final GrowthModel? growthModel; + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + if (!gemini.isConfigured) return const SizedBox.shrink(); + + final isPlateauing = + growthModel != null && growthModel!.slope <= 0; + final seed = isPlateauing + ? 'I\'ve been plateauing on $exerciseName. How can I break through and start progressing again?' + : 'How can I continue to progress on $exerciseName and make the most of my current momentum?'; + + return OutlineGlowButton( + label: 'Ask Coach about $exerciseName', + icon: Icons.auto_awesome_rounded, + color: AppColors.primary, + fullWidth: true, + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AiCoachScreen(seedPrompt: seed), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart new file mode 100644 index 0000000..e66011d --- /dev/null +++ b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart @@ -0,0 +1,388 @@ +// muscle_detail_sheet.dart — Drill-down sheet for a muscle group. +// Shows weekly contributing exercises (volume + growth trend) and an +// on-demand AI insight via GeminiService.generateInsight. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/gemini_service.dart'; +import '../../services/interfaces/ml_service_interface.dart'; +import '../../data/exercise_database.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import '../ai_coach_screen.dart'; + +class MuscleDetailSheet extends StatelessWidget { + const MuscleDetailSheet({ + super.key, + required this.muscleId, + required this.provider, + }); + + final String muscleId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final recovery = provider.getMuscleRecoveryScores()[muscleId]; + final name = MuscleGroups.names[muscleId] ?? muscleId; + final color = AppColors.muscle(muscleId); + final exercises = provider.getMuscleExerciseBreakdown(muscleId); + + Color recoveryColor = AppColors.textFaint; + String recoveryLabel = '—'; + if (recovery != null) { + recoveryLabel = '${recovery.recoveryPercent}%'; + if (recovery.recoveryFraction >= 0.90) { + recoveryColor = AppColors.success; + } else if (recovery.recoveryFraction >= 0.70) { + recoveryColor = AppColors.warning; + } else { + recoveryColor = AppColors.error; + } + } + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + MediaQuery.of(context).viewInsets.bottom + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header row: muscle name + recovery badge + Row( + children: [ + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + name, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: recoveryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: recoveryColor.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: recoveryColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + recoveryLabel, + style: GoogleFonts.geistMono( + color: recoveryColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + + if (recovery != null) ...[ + const SizedBox(height: 4), + Text( + recovery.isRecovered + ? 'Ready to train' + : recovery.isUnderRecovered + ? 'Still fatigued — consider rest' + : 'Recovering', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + + const SizedBox(height: AppSpacing.lg), + RFSectionHeader('Contributing this week', bottomPad: false), + const SizedBox(height: AppSpacing.sm), + + if (exercises.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Text( + 'No sessions logged for this muscle in the last 7 days.', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ...exercises.map((ex) { + final displayVol = settings.toDisplay(ex.volume); + final volStr = displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0); + final growth = ex.growth; + Color trendColor = AppColors.textFaint; + IconData trendIcon = Icons.remove_rounded; + if (growth != null) { + if (growth.slope > 2) { + trendColor = AppColors.success; + trendIcon = Icons.trending_up_rounded; + } else if (growth.slope > 0) { + trendColor = AppColors.secondary; + trendIcon = Icons.trending_up_rounded; + } else if (growth.slope < -2) { + trendColor = AppColors.error; + trendIcon = Icons.trending_down_rounded; + } else { + trendColor = AppColors.warning; + trendIcon = Icons.trending_flat_rounded; + } + } + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Expanded( + child: Text( + ex.name, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '$volStr ${settings.unitLabel}', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon(trendIcon, size: 16, color: trendColor), + ], + ), + ); + }), + + const SizedBox(height: AppSpacing.md), + _AiInsightSection( + muscleId: muscleId, + muscleName: name, + provider: provider, + ), + ], + ), + ); + } +} + +// ── AI insight section ───────────────────────────────────────────────────────── + +class _AiInsightSection extends StatefulWidget { + const _AiInsightSection({ + required this.muscleId, + required this.muscleName, + required this.provider, + }); + + final String muscleId; + final String muscleName; + final WorkoutProvider provider; + + @override + State<_AiInsightSection> createState() => _AiInsightSectionState(); +} + +class _AiInsightSectionState extends State<_AiInsightSection> { + String? _insight; + bool _loading = false; + + Future _fetchInsight() async { + setState(() => _loading = true); + final gemini = context.read(); + final settings = context.read(); + final mlService = context.read(); + final provider = widget.provider; + + final exerciseMap = {for (final e in provider.allExercises) e.id: e}; + final recovery = mlService.computeMuscleRecoveryScores( + provider.sessions, + exerciseMap, + ); + final exercises = provider.getMuscleExerciseBreakdown(widget.muscleId); + final recoveryScore = recovery[widget.muscleId]; + + final contextText = StringBuffer() + ..writeln('Muscle: ${widget.muscleName}') + ..writeln( + 'Recovery: ${recoveryScore != null ? "${recoveryScore.recoveryPercent}% (${recoveryScore.isRecovered ? "ready" : recoveryScore.isUnderRecovered ? "fatigued" : "recovering"})" : "no data"}') + ..writeln('Weekly contributing exercises:'); + for (final ex in exercises) { + final vol = settings.toDisplay(ex.volume); + contextText.writeln( + ' ${ex.name}: ${vol.toStringAsFixed(0)} ${settings.unitLabel}'); + } + + const system = + 'You are an expert personal trainer. Give a specific, actionable 2–3 sentence insight about this muscle group — cover training readiness, volume, and one practical tip. Be concise and direct.'; + + final insight = + await gemini.generateInsight(system, contextText.toString()); + if (mounted) setState(() { _insight = insight; _loading = false; }); + } + + void _openCoach(BuildContext context) { + final seed = + 'Give me advice on training my ${widget.muscleName}. ' + 'What should I focus on in my next session?'; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AiCoachScreen(seedPrompt: seed), + ), + ); + } + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + if (!gemini.isConfigured) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_insight == null && !_loading) ...[ + Row( + children: [ + Expanded( + child: OutlineGlowButton( + label: 'Get AI Insight', + icon: Icons.auto_awesome_rounded, + color: AppColors.primary, + fullWidth: true, + small: true, + onPressed: _fetchInsight, + ), + ), + const SizedBox(width: AppSpacing.sm), + OutlineGlowButton( + label: 'Ask Coach', + icon: Icons.chat_bubble_outline_rounded, + color: AppColors.secondary, + small: true, + onPressed: () => _openCoach(context), + ), + ], + ), + ] else if (_loading) ...[ + const Center(child: RFLoadingDots()), + ] else ...[ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 13, color: AppColors.primary), + const SizedBox(width: 5), + Text( + 'AI Insight', + style: GoogleFonts.geist( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + _insight!, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: AppSpacing.sm), + GestureDetector( + onTap: () => _openCoach(context), + child: Text( + 'Continue in Coach →', + style: GoogleFonts.geist( + color: AppColors.secondary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart index 4730e7a..1865d4b 100644 --- a/workout-logger/lib/screens/widgets/targets_tab.dart +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -1,14 +1,20 @@ -// targets_tab.dart — Analytics "Targets" tab with create/manage targets +// targets_tab.dart — Analytics "Targets" tab import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import '../../models/models.dart'; import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/gemini_service.dart'; import '../../theme/app_theme.dart'; import '../../data/exercise_database.dart'; import 'rf_widgets.dart'; import 'rf_cards.dart'; +import '../ai_coach_screen.dart'; class TargetsTab extends StatelessWidget { const TargetsTab({super.key}); @@ -21,7 +27,7 @@ class TargetsTab extends StatelessWidget { final completed = targets.where((t) => t.isCompleted).toList(); return Scaffold( - backgroundColor: AppColors.background, + backgroundColor: Colors.transparent, body: targets.isEmpty ? RFEmptyState( icon: Icons.flag_rounded, @@ -39,13 +45,15 @@ class TargetsTab extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _SummaryHeader(active: active, completed: completed), + const SizedBox(height: AppSpacing.md), if (active.isNotEmpty) ...[ const RFSectionHeader('Active'), - const SizedBox(height: AppSpacing.sm), ...active.map( - (t) => TargetCard( + (t) => _TargetCardWithAi( target: t, exerciseName: provider.getExerciseName(t.exerciseId), + growthModel: provider.getGrowthModel(t.exerciseId), onDelete: () => provider.deleteTarget(t.id), ), ), @@ -53,7 +61,6 @@ class TargetsTab extends StatelessWidget { if (completed.isNotEmpty) ...[ const SizedBox(height: AppSpacing.md), const RFSectionHeader('Completed'), - const SizedBox(height: AppSpacing.sm), ...completed.map( (t) => TargetCard( target: t, @@ -72,9 +79,9 @@ class TargetsTab extends StatelessWidget { backgroundColor: AppColors.primary, elevation: 0, icon: const Icon(Icons.add_rounded, color: Colors.white), - label: const Text( + label: Text( 'New Target', - style: TextStyle( + style: GoogleFonts.geist( color: Colors.white, fontWeight: FontWeight.w700, ), @@ -97,7 +104,355 @@ class TargetsTab extends StatelessWidget { } } +// ── Summary header ───────────────────────────────────────────────────────────── + +class _SummaryHeader extends StatelessWidget { + const _SummaryHeader({ + required this.active, + required this.completed, + }); + + final List active; + final List completed; + + @override + Widget build(BuildContext context) { + final onTrack = active + .where((t) => + t.estimatedCompletionDate != null && + t.estimatedCompletionDate!.isAfter(DateTime.now())) + .length; + final stalled = active.length - onTrack; + + return Row( + children: [ + _SummaryChip( + label: '${active.length} active', + color: AppColors.primary, + ), + const SizedBox(width: AppSpacing.sm), + if (onTrack > 0) + _SummaryChip( + label: '$onTrack on track', + color: AppColors.success, + ), + if (stalled > 0) ...[ + const SizedBox(width: AppSpacing.sm), + _SummaryChip( + label: '$stalled stalled', + color: AppColors.warning, + ), + ], + if (completed.isNotEmpty) ...[ + const SizedBox(width: AppSpacing.sm), + _SummaryChip( + label: '${completed.length} done', + color: AppColors.textMuted, + ), + ], + ], + ); + } +} + +class _SummaryChip extends StatelessWidget { + const _SummaryChip({required this.label, required this.color}); + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Text( + label, + style: GoogleFonts.geistMono( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +// ── Target card with status word + AI stalled nudge ──────────────────────────── + +class _TargetCardWithAi extends StatefulWidget { + const _TargetCardWithAi({ + required this.target, + required this.exerciseName, + required this.growthModel, + this.onDelete, + }); + + final Target target; + final String exerciseName; + final GrowthModel? growthModel; + final VoidCallback? onDelete; + + @override + State<_TargetCardWithAi> createState() => _TargetCardWithAiState(); +} + +class _TargetCardWithAiState extends State<_TargetCardWithAi> { + String? _nudge; + bool _loadingNudge = false; + + bool get _isStalled { + final t = widget.target; + if (t.estimatedCompletionDate == null) return true; + return t.estimatedCompletionDate!.isBefore(DateTime.now()); + } + + String get _statusWord { + if (widget.target.isCompleted) return 'Done'; + if (!_isStalled) return 'On track'; + return 'Stalled'; + } + + Color get _statusColor { + if (widget.target.isCompleted) return AppColors.success; + if (!_isStalled) return AppColors.success; + return AppColors.warning; + } + + Future _fetchNudge() async { + setState(() => _loadingNudge = true); + final gemini = context.read(); + final settings = context.read(); + final t = widget.target; + + final contextText = + 'Exercise: ${widget.exerciseName}\n' + 'Target: ${t.targetValue} ${settings.unitLabel} (${t.targetType})\n' + 'Current: ${t.currentValue.toStringAsFixed(1)} ${settings.unitLabel} ' + '(${t.progressPercentage.toStringAsFixed(0)}%)\n' + 'Estimated completion: ${t.estimatedCompletionDate != null ? DateFormat('MMM d, y').format(t.estimatedCompletionDate!) : "unknown — no growth trend"}\n' + '${widget.growthModel != null ? "Growth slope: ${widget.growthModel!.slope.toStringAsFixed(2)} per session, R² ${(widget.growthModel!.r2 * 100).toStringAsFixed(0)}%" : "No growth model yet."}'; + + const system = + 'You are a concise personal trainer. Give 1–2 sentences of actionable advice to help the user get this stalled target back on track. Be specific and encouraging.'; + + final nudge = await gemini.generateInsight(system, contextText); + if (mounted) setState(() { _nudge = nudge; _loadingNudge = false; }); + } + + void _openCoach() { + final seed = + 'I\'m stuck on my ${widget.target.targetType} target for ${widget.exerciseName}. ' + 'Currently at ${widget.target.currentValue.toStringAsFixed(1)}, ' + 'aiming for ${widget.target.targetValue}. How do I get unstuck?'; + Navigator.push( + context, + MaterialPageRoute(builder: (_) => AiCoachScreen(seedPrompt: seed)), + ); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final gemini = context.watch(); + final t = widget.target; + final pct = t.progressPercentage.clamp(0.0, 100.0); + final etaStr = t.estimatedCompletionDate != null + ? DateFormat('MMM d, y').format(t.estimatedCompletionDate!) + : null; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.exerciseName, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + RFChip( + label: t.targetType, + small: true, + color: AppColors.secondary, + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: _statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: _statusColor.withValues(alpha: 0.35)), + ), + child: Text( + _statusWord, + style: GoogleFonts.geistMono( + color: _statusColor, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (widget.onDelete != null) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close_rounded, + size: 16, + color: AppColors.textMuted, + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${settings.toDisplay(t.currentValue).toStringAsFixed(1)} / ' + '${settings.toDisplay(t.targetValue).toStringAsFixed(1)} ${settings.unitLabel}', + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + ), + ), + Text( + '${pct.toStringAsFixed(0)}%', + style: GoogleFonts.geistMono( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + RFProgressBar(value: t.progressPercentage / 100), + if (etaStr != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.schedule_rounded, + size: 11, color: AppColors.textMuted), + const SizedBox(width: 4), + Text( + 'Est. $etaStr', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ], + + // AI stalled nudge section + if (gemini.isConfigured && _isStalled) ...[ + const SizedBox(height: AppSpacing.sm), + if (_nudge == null && !_loadingNudge) + Row( + children: [ + Expanded( + child: OutlineGlowButton( + label: 'Why am I stuck?', + icon: Icons.auto_awesome_rounded, + color: AppColors.warning, + fullWidth: true, + small: true, + onPressed: _fetchNudge, + ), + ), + const SizedBox(width: AppSpacing.sm), + OutlineGlowButton( + label: 'Ask Coach', + icon: Icons.chat_bubble_outline_rounded, + color: AppColors.secondary, + small: true, + onPressed: _openCoach, + ), + ], + ) + else if (_loadingNudge) + const Center(child: RFLoadingDots()) + else + Container( + padding: const EdgeInsets.all(AppSpacing.sm + 2), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 12, color: AppColors.warning), + const SizedBox(width: 4), + Text( + 'AI Tip', + style: GoogleFonts.geist( + color: AppColors.warning, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + _nudge!, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + height: 1.5, + ), + ), + const SizedBox(height: 4), + GestureDetector( + onTap: _openCoach, + child: Text( + 'Continue in Coach →', + style: GoogleFonts.geist( + color: AppColors.secondary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + // ── Create target bottom sheet ───────────────────────────────────────────────── + class _CreateTargetSheet extends StatefulWidget { const _CreateTargetSheet(); @@ -110,11 +465,13 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { String _targetType = 'weight'; final _valueController = TextEditingController(); bool _isSubmitting = false; + bool _loadingSuggestion = false; + String? _suggestionText; static const _types = [ - ('weight', 'Max Weight (kg)'), + ('weight', 'Max Weight'), ('reps', 'Max Reps'), - ('volume', 'Total Volume (kg)'), + ('volume', 'Volume'), ]; @override @@ -123,10 +480,47 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { super.dispose(); } + Future _fetchSuggestion() async { + if (_selectedExerciseId == null) return; + setState(() => _loadingSuggestion = true); + + final provider = context.read(); + final settings = context.read(); + final gemini = context.read(); + final exerciseName = provider.getExerciseName(_selectedExerciseId!); + final growth = provider.getGrowthModel(_selectedExerciseId!); + final oneRM = provider.getBestOneRM(_selectedExerciseId!); + + String contextText = + 'Exercise: $exerciseName\nTarget type: $_targetType\n'; + if (growth != null) { + contextText += + 'Growth slope: ${settings.toDisplay(growth.slope).toStringAsFixed(2)} ${settings.unitLabel}/session\n' + 'R²: ${(growth.r2 * 100).toStringAsFixed(0)}%\n'; + } + if (oneRM != null) { + contextText += + 'Estimated 1RM: ${settings.formatWeight(oneRM)}\n'; + } + + const system = + 'You are a strength coach. Suggest ONE realistic target value and an estimated timeframe (e.g. "100 kg in ~8 weeks based on your current progression"). ' + 'Be concise — one sentence max. State only the number and timeframe.'; + + final suggestion = await gemini.generateInsight(system, contextText); + if (mounted) { + setState(() { + _suggestionText = suggestion; + _loadingSuggestion = false; + }); + } + } + @override Widget build(BuildContext context) { final exercises = ExerciseDatabase.getAll(); final bottom = MediaQuery.of(context).viewInsets.bottom; + final gemini = context.watch(); return Padding( padding: EdgeInsets.fromLTRB( @@ -139,7 +533,6 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Handle Center( child: Container( width: 36, @@ -151,9 +544,9 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { ), ), ), - const Text( + Text( 'New Target', - style: TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w800, @@ -161,10 +554,9 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { ), const SizedBox(height: AppSpacing.lg), - // Exercise picker - const Text( + Text( 'EXERCISE', - style: TextStyle( + style: GoogleFonts.geist( color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w700, @@ -179,29 +571,42 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all(color: AppColors.glassBorder), ), - child: DropdownButton( - value: _selectedExerciseId, - isExpanded: true, - underline: const SizedBox.shrink(), - dropdownColor: AppColors.cardHigh, - hint: const Text( - 'Select exercise…', - style: TextStyle(color: AppColors.textMuted, fontSize: 14), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedExerciseId, + isExpanded: true, + dropdownColor: AppColors.cardHigh, + hint: Text( + 'Select exercise…', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 14, + ), + ), + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + ), + icon: const Icon(Icons.expand_more_rounded, + color: AppColors.textMuted, size: 20), + items: exercises + .map((e) => DropdownMenuItem( + value: e.id, + child: Text(e.name), + )) + .toList(), + onChanged: (v) => setState(() { + _selectedExerciseId = v; + _suggestionText = null; + }), ), - style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), - items: exercises - .map((e) => DropdownMenuItem(value: e.id, child: Text(e.name))) - .toList(), - onChanged: (v) => setState(() => _selectedExerciseId = v), ), ), const SizedBox(height: AppSpacing.md), - - // Target type - const Text( + Text( 'TARGET TYPE', - style: TextStyle( + style: GoogleFonts.geist( color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w700, @@ -214,10 +619,14 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { final selected = _targetType == t.$1; return Expanded( child: GestureDetector( - onTap: () => setState(() => _targetType = t.$1), + onTap: () => setState(() { + _targetType = t.$1; + _suggestionText = null; + }), child: Container( margin: const EdgeInsets.only(right: 6), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: + const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: selected ? AppColors.primary.withValues(alpha: 0.15) @@ -232,11 +641,14 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { child: Text( t.$2, textAlign: TextAlign.center, - style: TextStyle( - color: selected ? AppColors.primary : AppColors.textMuted, + style: GoogleFonts.geist( + color: selected + ? AppColors.primary + : AppColors.textMuted, fontSize: 11, - fontWeight: - selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w400, ), ), ), @@ -246,11 +658,9 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { ), const SizedBox(height: AppSpacing.md), - - // Value input - const Text( + Text( 'TARGET VALUE', - style: TextStyle( + style: GoogleFonts.geist( color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w700, @@ -268,17 +678,19 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { controller: _valueController, keyboardType: TextInputType.number, inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d*$')), ], - style: const TextStyle( + style: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 16, ), - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'e.g. 100', - hintStyle: TextStyle(color: AppColors.textMuted), + hintStyle: + GoogleFonts.geist(color: AppColors.textMuted), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.md, ), @@ -286,8 +698,61 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { ), ), - const SizedBox(height: AppSpacing.lg), + // AI suggestion + if (gemini.isConfigured && _selectedExerciseId != null) ...[ + const SizedBox(height: AppSpacing.sm), + if (_suggestionText == null && !_loadingSuggestion) + GestureDetector( + onTap: _fetchSuggestion, + child: Row( + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 13, color: AppColors.primary), + const SizedBox(width: 5), + Text( + 'Suggest a target based on my progress', + style: GoogleFonts.geist( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ) + else if (_loadingSuggestion) + const Center(child: RFLoadingDots()) + else + Container( + padding: const EdgeInsets.all(AppSpacing.sm + 2), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 13, color: AppColors.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + _suggestionText!, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: AppSpacing.lg), GlowButton( label: 'Create Target', icon: Icons.flag_rounded, diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 1989531..b5dd37e 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -449,12 +449,15 @@ class _WorkoutFlowScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - isLast ? 'Finish' : 'Next exercise', - style: GoogleFonts.geist( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.white, + Flexible( + child: Text( + isLast ? 'Finish' : 'Next exercise', + overflow: TextOverflow.ellipsis, + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), ), ), const SizedBox(width: 6), diff --git a/workout-logger/lib/services/gemini_service.dart b/workout-logger/lib/services/gemini_service.dart index 46ca610..e87be84 100644 --- a/workout-logger/lib/services/gemini_service.dart +++ b/workout-logger/lib/services/gemini_service.dart @@ -180,4 +180,23 @@ Required JSON schema (follow exactly): return 'Could not generate insights: $e'; } } + + // ── Generic one-shot insight (contextual) ───────────────────────────────── + // Thin, tool-agnostic helper for on-demand contextual insights (muscle + // drill-down, target suggestions, stalled-target nudges). Kept generic so a + // future function-calling path can be added additively over [_makeModel]. + Future generateInsight(String system, String context) async { + if (!isConfigured) { + return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; + } + try { + final response = await _makeModel(system: system) + .generateContent([Content.text(context)]); + return response.text?.trim() ?? 'No insight generated.'; + } on GenerativeAIException catch (e) { + return 'AI error: ${e.message}'; + } catch (e) { + return 'Could not generate insight: $e'; + } + } } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 70f3cbf..485b13b 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -1011,6 +1011,75 @@ class WorkoutProvider extends ChangeNotifier { return best; } + /// Per-exercise contribution to a muscle group's volume within a time window. + /// + /// [start]/[end] default to the last 7 days. Results are sorted by contributed + /// volume (desc). This is a pure, parameterized query intended to double as the + /// implementation surface for a future Coach agent tool. + List<({String exerciseId, String name, double volume, GrowthModel? growth})> + getMuscleExerciseBreakdown( + String muscleId, { + DateTime? start, + DateTime? end, + }) { + final now = DateTime.now(); + final from = start ?? now.subtract(const Duration(days: 7)); + final to = end ?? now; + final exerciseMap = { + for (final e in _allExercises) e.id: e, + }; + + final byExercise = {}; + for (final session in _sessions) { + if (session.date.isBefore(from) || session.date.isAfter(to)) continue; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + for (final activation in exercise.muscleActivations) { + if (activation.muscleGroupId != muscleId) continue; + byExercise[log.exerciseId] = (byExercise[log.exerciseId] ?? 0) + + log.totalVolume * (activation.activationPercentage / 100); + } + } + } + + final result = byExercise.entries + .map((e) => ( + exerciseId: e.key, + name: getExerciseName(e.key), + volume: e.value, + growth: _growthModels[e.key], + )) + .toList() + ..sort((a, b) => b.volume.compareTo(a.volume)); + return result; + } + + /// Per-session set-by-set progression for an exercise (oldest-first). + /// + /// Optionally bounded by [start]/[end]. Each entry holds the working sets + /// logged for the exercise in that session, so callers can chart weight/reps + /// per set. Pure & parameterized — also the surface for a future agent tool. + List<({DateTime date, List sets})> getSetProgression( + String exerciseId, { + DateTime? start, + DateTime? end, + }) { + final result = <({DateTime date, List sets})>[]; + // _sessions is maintained newest-first; reverse for oldest-first output. + for (final session in _sessions.reversed) { + if (start != null && session.date.isBefore(start)) continue; + if (end != null && session.date.isAfter(end)) continue; + for (final log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + result.add((date: session.date, sets: log.sets)); + break; + } + } + } + return result; + } + // ==================== QUICK STATS ==================== Future> getQuickStats() async { diff --git a/workout-logger/test/analytics_queries_test.dart b/workout-logger/test/analytics_queries_test.dart new file mode 100644 index 0000000..a559528 --- /dev/null +++ b/workout-logger/test/analytics_queries_test.dart @@ -0,0 +1,373 @@ +// Unit tests for the two new parameterised analytics query methods on +// WorkoutProvider: getSetProgression() and getMuscleExerciseBreakdown(). +// +// These methods are designed as the future agent-tool surface, so the tests +// double as a contract: pure, side-effect-free, date-range-aware. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +WorkoutSession _session({ + required String id, + required DateTime date, + required List logs, +}) => + WorkoutSession(id: id, date: date, duration: 30, exercises: logs); + +ExerciseLog _log(String exerciseId, List sets) => + ExerciseLog(exerciseId: exerciseId, sets: sets); + +WorkoutSet _set({double weight = 80.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +Exercise _exercise(String id, String muscleId, {int activation = 100}) => + Exercise( + id: id, + name: 'Ex-$id', + category: 'compound', + muscleActivations: [ + MuscleActivation( + muscleGroupId: muscleId, activationPercentage: activation), + ], + ); + +Future _makeProvider( + MockStorageService storage, { + MockMLService? ml, +}) async { + final p = WorkoutProvider( + storage, + mlService: ml ?? MockMLService(), + programManager: ProgramManager(storage), + ); + await p.init(); + return p; +} + +// ── getSetProgression ───────────────────────────────────────────────────────── + +void main() { + group('WorkoutProvider.getSetProgression', () { + late MockStorageService storage; + + setUp(() => storage = MockStorageService()); + + test('returns empty list when no sessions exist', () async { + final p = await _makeProvider(storage); + expect(p.getSetProgression('bench'), isEmpty); + }); + + test('returns empty list when exercise was never performed', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 1, 10), + logs: [_log('squat', [_set(weight: 100)])], + )); + final p = await _makeProvider(storage); + expect(p.getSetProgression('bench'), isEmpty); + }); + + test('returns sessions oldest-first', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 1, 10), + logs: [_log('bench', [_set(weight: 80)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 1, 5), + logs: [_log('bench', [_set(weight: 75)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression('bench'); + + expect(result.length, 2); + expect(result[0].date, DateTime(2024, 1, 5)); // older first + expect(result[1].date, DateTime(2024, 1, 10)); + }); + + test('each entry carries the correct sets', () async { + final set1 = _set(weight: 80, reps: 8); + final set2 = _set(weight: 85, reps: 6); + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 2, 1), + logs: [_log('bench', [set1, set2])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression('bench'); + expect(result.length, 1); + expect(result[0].sets.length, 2); + expect(result[0].sets[0].weight, 80.0); + expect(result[0].sets[1].weight, 85.0); + }); + + test('excludes sessions outside [start, end] range', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 3, 1), + logs: [_log('bench', [_set(weight: 70)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 3, 10), + logs: [_log('bench', [_set(weight: 80)])], + )); + storage.addMockSession(_session( + id: 's3', + date: DateTime(2024, 3, 20), + logs: [_log('bench', [_set(weight: 90)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression( + 'bench', + start: DateTime(2024, 3, 5), + end: DateTime(2024, 3, 15), + ); + + // Only s2 (Mar 10) falls in [Mar 5, Mar 15]. + expect(result.length, 1); + expect(result[0].sets[0].weight, 80.0); + }); + + test('start-only filter excludes sessions before start', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 1, 1), + logs: [_log('bench', [_set(weight: 60)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 6, 1), + logs: [_log('bench', [_set(weight: 90)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression( + 'bench', + start: DateTime(2024, 3, 1), + ); + + expect(result.length, 1); + expect(result[0].sets[0].weight, 90.0); + }); + + test('only includes the target exercise from mixed-exercise sessions', + () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 4, 1), + logs: [ + _log('bench', [_set(weight: 80)]), + _log('squat', [_set(weight: 120)]), + ], + )); + final p = await _makeProvider(storage); + + final bench = p.getSetProgression('bench'); + final squat = p.getSetProgression('squat'); + + expect(bench.length, 1); + expect(bench[0].sets[0].weight, 80.0); + expect(squat.length, 1); + expect(squat[0].sets[0].weight, 120.0); + }); + + test('sessions with no sets for the exercise are excluded', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 5, 1), + logs: [_log('bench', [])], // empty sets + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 5, 10), + logs: [_log('bench', [_set(weight: 80)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression('bench'); + // Session with empty log is excluded; only s2 appears. + expect(result.length, 1); + expect(result[0].date, DateTime(2024, 5, 10)); + }); + }); + + // ── getMuscleExerciseBreakdown ───────────────────────────────────────────── + + group('WorkoutProvider.getMuscleExerciseBreakdown', () { + late MockStorageService storage; + + setUp(() => storage = MockStorageService()); + + test('returns empty list when no sessions exist', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + final p = await _makeProvider(storage); + expect(p.getMuscleExerciseBreakdown('chest'), isEmpty); + }); + + test('returns empty list when no session is in the default 7-day window', + () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + storage.addMockSession(_session( + id: 's1', + date: DateTime(2020, 1, 1), // long ago + logs: [_log('bench', [_set(weight: 80)])], + )); + final p = await _makeProvider(storage); + expect(p.getMuscleExerciseBreakdown('chest'), isEmpty); + }); + + test('returns exercises sorted by contributed volume descending', () async { + // Two exercises both hitting chest. + storage.addMockCustomExercise( + _exercise('bench', 'chest', activation: 70)); + storage.addMockCustomExercise( + _exercise('cable', 'chest')); + + final now = DateTime.now(); + // bench: 80kg × 10 reps × 70% = 560 volume + // cable: 30kg × 8 reps × 100% = 240 volume + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [ + _log('bench', [_set(weight: 80, reps: 10)]), + _log('cable', [_set(weight: 30, reps: 8)]), + ], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown('chest'); + + expect(result.length, 2); + expect(result[0].exerciseId, 'bench'); // higher volume first + expect(result[1].exerciseId, 'cable'); + }); + + test('volume is weight × reps × (activationPercentage / 100)', () async { + storage.addMockCustomExercise( + _exercise('bench', 'chest', activation: 70)); + + final now = DateTime.now(); + // 1 set: 100kg × 5 reps × 70% = 350 + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [_log('bench', [_set(weight: 100, reps: 5)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown('chest'); + expect(result.length, 1); + expect(result[0].volume, closeTo(350.0, 0.01)); + }); + + test('exercises that do not activate the muscle are excluded', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + storage.addMockCustomExercise(_exercise('curl', 'biceps')); + + final now = DateTime.now(); + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [ + _log('bench', [_set(weight: 80)]), + _log('curl', [_set(weight: 20)]), + ], + )); + final p = await _makeProvider(storage); + + final chestResult = p.getMuscleExerciseBreakdown('chest'); + expect(chestResult.every((e) => e.exerciseId == 'bench'), isTrue); + + final bicepsResult = p.getMuscleExerciseBreakdown('biceps'); + expect(bicepsResult.every((e) => e.exerciseId == 'curl'), isTrue); + }); + + test('date range filter excludes sessions outside [start, end]', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + storage.addMockSession(_session( + id: 's_old', + date: DateTime(2024, 1, 1), + logs: [_log('bench', [_set(weight: 60)])], + )); + storage.addMockSession(_session( + id: 's_new', + date: DateTime(2024, 6, 15), + logs: [_log('bench', [_set(weight: 90)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown( + 'chest', + start: DateTime(2024, 6, 1), + end: DateTime(2024, 6, 30), + ); + + expect(result.length, 1); + // 90kg × 10 reps × 100% + expect(result[0].volume, closeTo(900.0, 0.01)); + }); + + test('sums volume across multiple sessions for the same exercise', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + final start = DateTime(2024, 7, 1); + // Two sessions: 800 + 1000 = 1800 total volume (100% activation). + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 7, 5), + logs: [_log('bench', [_set(weight: 80, reps: 10)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 7, 10), + logs: [_log('bench', [_set(weight: 100, reps: 10)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown( + 'chest', + start: start, + end: DateTime(2024, 7, 31), + ); + + expect(result.length, 1); + expect(result[0].volume, closeTo(1800.0, 0.01)); + }); + + test('returns exercise name via getExerciseName', () async { + storage.addMockCustomExercise( + Exercise( + id: 'bench_custom', + name: 'Bench Press Custom', + category: 'compound', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ), + ); + final now = DateTime.now(); + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [_log('bench_custom', [_set(weight: 80)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown('chest'); + expect(result.length, 1); + expect(result[0].name, 'Bench Press Custom'); + }); + }); +} diff --git a/workout-logger/test/analytics_screen_test.dart b/workout-logger/test/analytics_screen_test.dart new file mode 100644 index 0000000..038c2a9 --- /dev/null +++ b/workout-logger/test/analytics_screen_test.dart @@ -0,0 +1,506 @@ +// Widget tests for the refactored AnalyticsScreen tabs: +// • Overview — volume trend range toggle (4W / 12W / All) +// • Targets — summary header counts, on-track / stalled status words +// • Records — summary header, All / This month / By exercise filter, +// Recent / Heaviest sort toggle + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/analytics_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/gemini_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/interfaces/ml_service_interface.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +Widget _wrap({ + required WorkoutProvider workoutProvider, + required PRManager prManager, + SettingsProvider? settings, +}) { + final sp = settings ?? SettingsProvider(MockStorageService()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider.value(value: prManager), + ChangeNotifierProvider.value(value: GeminiService()), + Provider.value(value: MockMLService()), + ], + child: const MaterialApp(home: AnalyticsScreen()), + ); +} + +Future _makeProvider(MockStorageService storage) async { + final p = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await p.init(); + return p; +} + +Future _makePRManager(MockStorageService storage) async { + final m = PRManager(storage); + await m.load(); + return m; +} + +WorkoutSession _session({ + required String id, + required DateTime date, + String exerciseId = 'bench_press', + double weight = 80.0, + int reps = 10, +}) => + WorkoutSession( + id: id, + date: date, + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: [WorkoutSet(weight: weight, reps: reps)], + ), + ], + ); + +/// Switch to tab [index] (0=Overview, 1=Exercises, 2=Targets, 3=Records). +Future _switchTab(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); +} + +// ── Overview tab ────────────────────────────────────────────────────────────── + +void main() { + group('AnalyticsScreen – Overview tab', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + prManager = await _makePRManager(storage); + }); + + testWidgets('shows empty chart when no sessions', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + // Overview is the first visible tab. + expect(find.text('Volume Trend'), findsOneWidget); + // Both Volume Trend and Muscle Focus cards show "No data yet" when empty. + expect(find.text('No data yet'), findsWidgets); + }); + + testWidgets('range toggle buttons 4W, 12W and All are visible', + (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + expect(find.text('4W'), findsOneWidget); + expect(find.text('12W'), findsOneWidget); + expect(find.text('All'), findsOneWidget); + }); + + testWidgets('tapping 4W does not throw and keeps 4W visible', + (tester) async { + storage.addMockSession(_session(id: 's1', date: DateTime.now())); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('4W')); + await tester.pumpAndSettle(); + expect(find.text('4W'), findsOneWidget); + }); + + testWidgets('tapping All does not throw', (tester) async { + storage.addMockSession(_session(id: 's1', date: DateTime.now())); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + expect(find.text('All'), findsOneWidget); + }); + + testWidgets('Muscle Focus card is shown on the Overview tab', + (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + expect(find.text('Muscle Focus'), findsOneWidget); + }); + + testWidgets('Workout Frequency grid uses "This wk" label', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + expect(find.text('This wk'), findsOneWidget); + }); + }); + + // ── Targets tab ───────────────────────────────────────────────────────────── + + group('AnalyticsScreen – Targets tab', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + prManager = await _makePRManager(storage); + }); + + testWidgets('shows empty state when no targets', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('No Targets Set'), findsOneWidget); + }); + + testWidgets('summary header shows "N active" count', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 80.0, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('1 active'), findsOneWidget); + }); + + testWidgets('shows "stalled" chip when no ETA is set', (tester) async { + // A target with no estimatedCompletionDate is stalled. + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 60.0, + // estimatedCompletionDate left null → stalled + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('Stalled'), findsOneWidget); + }); + + testWidgets('shows "On track" chip when ETA is in the future', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 80.0, + estimatedCompletionDate: DateTime.now().add(const Duration(days: 30)), + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('On track'), findsOneWidget); + }); + + testWidgets('shows "stalled" chip when ETA is in the past', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 60.0, + estimatedCompletionDate: + DateTime.now().subtract(const Duration(days: 1)), + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('Stalled'), findsOneWidget); + }); + + testWidgets('completed targets show in summary', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 100.0, + isCompleted: true, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + // 1 completed → "1 done" chip; active count is 0 which shows "0 active" + expect(find.text('1 done'), findsOneWidget); + }); + }); + + // ── Records tab ────────────────────────────────────────────────────────────── + + group('AnalyticsScreen – Records tab', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + prManager = await _makePRManager(storage); + }); + + testWidgets('shows empty state when no PRs exist', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('No records yet'), findsOneWidget); + expect( + find.text('Finish a workout to set your first PRs'), findsOneWidget); + }); + + testWidgets('summary shows total PR count after seeding records', + (tester) async { + final session = _session(id: 's1', date: DateTime.now(), weight: 100.0); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('1 PRs'), findsOneWidget); + }); + + testWidgets('newest PR hero card is shown', (tester) async { + final session = _session(id: 's1', date: DateTime.now(), weight: 100.0); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('Latest PR'), findsOneWidget); + }); + + testWidgets('filter chips All, This month, By exercise are visible', + (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('All'), findsOneWidget); + expect(find.text('This month'), findsOneWidget); + expect(find.text('By exercise'), findsOneWidget); + }); + + testWidgets('tapping "This month" filter does not throw', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('This month')); + await tester.pumpAndSettle(); + expect(find.text('This month'), findsOneWidget); + }); + + testWidgets('tapping "By exercise" filter does not throw', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('By exercise')); + await tester.pumpAndSettle(); + expect(find.text('By exercise'), findsOneWidget); + }); + + testWidgets('sort toggle shows Recent and Heaviest labels', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + // Default sort label + expect(find.text('Recent'), findsOneWidget); + }); + + testWidgets('tapping sort toggle switches label to Heaviest', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + expect(find.text('Heaviest'), findsOneWidget); + }); + + testWidgets('tapping sort toggle twice returns to Recent', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Heaviest')); + await tester.pumpAndSettle(); + expect(find.text('Recent'), findsOneWidget); + }); + + testWidgets('"This month" filter hides old PRs', (tester) async { + // One PR from last year, one from today. + final old = _session( + id: 's_old', + date: DateTime(2020, 1, 1), + exerciseId: 'bench_press', + weight: 50.0, + ); + final recent = _session( + id: 's_new', + date: DateTime.now(), + exerciseId: 'squat', + weight: 120.0, + ); + + // Add a custom exercise for squat so it resolves properly. + storage.addMockCustomExercise(Exercise( + id: 'squat', + name: 'Squat Custom', + category: 'compound', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quads', activationPercentage: 100), + ], + )); + + await prManager.checkAndUpdatePRs(old); + await prManager.checkAndUpdatePRs(recent); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + // Both PRs shown under "All". + expect(find.text('2 PRs'), findsOneWidget); + + // Filter to This month — only the recent one remains. + await tester.tap(find.text('This month')); + await tester.pumpAndSettle(); + + expect(find.text('1 this month'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/exercise_progress_view_test.dart b/workout-logger/test/exercise_progress_view_test.dart new file mode 100644 index 0000000..2913cbe --- /dev/null +++ b/workout-logger/test/exercise_progress_view_test.dart @@ -0,0 +1,510 @@ +// Widget tests for ExerciseProgressView (Analytics > Exercises tab). +// +// Covers: +// • exercise picker — trigger, bottom-sheet open, search filter, selection +// • chart-mode toggle — Volume ↔ Sets +// • set-progression chart — legend toggle (Weight / Reps hides bars), +// Recent / Weekly mode toggle +// +// fl_chart renders bars on a canvas, so bar-presence can't be verified with +// finders. Legend and axis-title visibility are tested through the text +// widgets that the State exposes, and state-transitions are verified by +// observing those text widgets before and after interactions. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/gemini_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/interfaces/ml_service_interface.dart'; +import 'package:repforge/screens/widgets/exercise_progress_view.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const _kBenchId = 'bench_press'; // built-in exercise ID in ExerciseDatabase + +Widget _wrap({ + required Widget child, + required WorkoutProvider provider, + SettingsProvider? settings, +}) { + final sp = settings ?? SettingsProvider(MockStorageService()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider.value(value: GeminiService()), + Provider.value(value: MockMLService()), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +WorkoutSession _session({ + required String id, + required String exerciseId, + required DateTime date, + List? sets, +}) => + WorkoutSession( + id: id, + date: date, + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: sets ?? + [ + WorkoutSet(weight: 80.0, reps: 8), + WorkoutSet(weight: 85.0, reps: 6), + ], + ), + ], + ); + +Future _makeProvider(MockStorageService storage) async { + final p = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await p.init(); + return p; +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late WorkoutProvider provider; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + }); + + // ── Empty state ──────────────────────────────────────────────────────────── + + group('empty state', () { + testWidgets('shows empty state when no sessions logged', (tester) async { + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('No Exercise Data'), findsOneWidget); + expect(find.text('Complete workouts to track exercises'), findsOneWidget); + }); + }); + + // ── Exercise picker trigger ──────────────────────────────────────────────── + + group('exercise picker trigger', () { + testWidgets('shows "Pick an exercise" when no exercise is selected', + (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('Pick an exercise…'), findsOneWidget); + // Chevron icon for the trigger + expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsOneWidget); + }); + + testWidgets('tapping trigger opens a bottom sheet', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + // Sheet title and search field + expect(find.text('Select Exercise'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('sheet shows "N logged" count', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + expect(find.text('1 logged'), findsOneWidget); + }); + }); + + // ── Search filter ────────────────────────────────────────────────────────── + + group('exercise picker search', () { + testWidgets('typing filters the exercise list', (tester) async { + // Add two exercises to the performed set via sessions. + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + // Also add a custom exercise so we have a second item with a unique name. + storage.addMockCustomExercise(Exercise( + id: 'leg_press_custom', + name: 'Leg Press Custom', + category: 'compound', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quads', activationPercentage: 100), + ], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime.now(), + exerciseId: 'leg_press_custom', + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + // Both exercises visible before filtering. + expect(find.text('Leg Press Custom'), findsOneWidget); + + // Type to filter — only the custom exercise should remain. + await tester.enterText(find.byType(TextField), 'Leg Press'); + await tester.pumpAndSettle(); + + expect(find.text('Leg Press Custom'), findsOneWidget); + }); + + testWidgets('shows "No exercises match" when search has no results', + (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'xyznonexistent'); + await tester.pumpAndSettle(); + + expect(find.text('No exercises match'), findsOneWidget); + }); + }); + + // ── Chart mode toggle ────────────────────────────────────────────────────── + + group('chart mode toggle', () { + testWidgets('Volume and Sets mode buttons appear after selecting exercise', + (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + // Open picker and select the exercise. + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + // The built-in Bench Press appears somewhere in the list; tap it. + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + + // Chart mode toggle should now be visible. + expect(find.text('Volume'), findsOneWidget); + expect(find.text('Sets'), findsOneWidget); + }); + + testWidgets('tapping Sets shows Weight and Reps legend', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + + // Switch to Sets mode. + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + + expect(find.text('Weight'), findsOneWidget); + expect(find.text('Reps'), findsOneWidget); + }); + + testWidgets('tapping Volume restores volume chart header', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + + // Go to Sets then back to Volume. + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Volume')); + await tester.pumpAndSettle(); + + // Volume chart header visible, legend gone. + expect(find.text('Volume Progression'), findsOneWidget); + expect(find.text('Weight'), findsNothing); + }); + }); + + // ── Set progression legend toggle ────────────────────────────────────────── + + group('set progression legend toggle', () { + Future openSetsChart(WidgetTester tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + } + + testWidgets('Weight and Reps legend items render', (tester) async { + await openSetsChart(tester); + expect(find.text('Weight'), findsOneWidget); + expect(find.text('Reps'), findsOneWidget); + }); + + testWidgets('tapping Weight legend does not throw and toggles opacity', + (tester) async { + await openSetsChart(tester); + + // Both legends start fully opaque (opacity = 1.0). + final weightOpacityBefore = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(weightOpacityBefore.every((o) => o == 1.0), isTrue); + + // Tap Weight to toggle it off. + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + + // One AnimatedOpacity should now be at 0.32 (the dimmed state). + final opacitiesAfter = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(opacitiesAfter.any((o) => o < 1.0), isTrue); + }); + + testWidgets('tapping Reps legend dims it', (tester) async { + await openSetsChart(tester); + + await tester.tap(find.text('Reps')); + await tester.pumpAndSettle(); + + final opacities = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(opacities.any((o) => o < 1.0), isTrue); + }); + + testWidgets('tapping legend twice restores full opacity', (tester) async { + await openSetsChart(tester); + + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + + final opacities = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(opacities.every((o) => o == 1.0), isTrue); + }); + + testWidgets('left axis label (unit) is absent when Weight is toggled off', + (tester) async { + final sp = SettingsProvider(MockStorageService()); + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + settings: sp, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + + // Unit label appears as left axis name before toggle. + expect(find.text(sp.unitLabel), findsWidgets); + + // Toggle Weight off — the axis name widget is hidden via showTitles:false. + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + + // After toggle, the axis name widget for weight is suppressed. + // The SideTitles widgets generated by fl_chart are gone; only the legend + // text "Weight" (now dimmed) remains — still findable by text. + // What disappears is the fl_chart axis tick labels, verified indirectly + // by checking that showTitles propagates without throwing. + expect(find.text('Weight'), findsOneWidget); // legend still visible + }); + }); + + // ── Recent / Weekly mode toggle ──────────────────────────────────────────── + + group('set progression mode toggle', () { + Future openSetsMode(WidgetTester tester) async { + // Seed two sessions on different days. + storage.addMockSession(_session( + id: 's1', + date: DateTime.now().subtract(const Duration(days: 3)), + exerciseId: _kBenchId, + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + } + + testWidgets('Recent and Weekly mode buttons are visible', (tester) async { + await openSetsMode(tester); + expect(find.text('Recent'), findsOneWidget); + expect(find.text('Weekly'), findsOneWidget); + }); + + testWidgets('tapping Weekly does not throw', (tester) async { + await openSetsMode(tester); + await tester.tap(find.text('Weekly')); + await tester.pumpAndSettle(); + // No exception → Weekly aggregation rendered without error. + expect(find.text('Weekly'), findsOneWidget); + }); + + testWidgets('tapping Weekly then Recent returns to recent view', + (tester) async { + await openSetsMode(tester); + + await tester.tap(find.text('Weekly')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + + expect(find.text('Recent'), findsOneWidget); + }); + }); +} From 5bcf86dd2fd73b28dfd8c083b89bec478dab74d1 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 29 May 2026 16:07:43 +0530 Subject: [PATCH 27/44] feat: update app label handling and improve navigation in home screen --- workout-logger/android/app/build.gradle.kts | 11 +++++++++++ .../android/app/src/main/AndroidManifest.xml | 2 +- workout-logger/lib/screens/home_screen.dart | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index ce6c5dc..d478d13 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -33,9 +33,20 @@ android { targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName + // App display name; overridden per build type below so debug installs + // alongside the real app instead of replacing it. + manifestPlaceholders["appLabel"] = "RepForge" } buildTypes { + debug { + // Install debug builds as a SEPARATE app (com.devasy.repforge.debug) + // with its own data sandbox, so testing never touches the real app's + // data. Remove this block to go back to a single shared package. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + manifestPlaceholders["appLabel"] = "RepForge (Debug)" + } release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index afd1469..9fe9f1e 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 08fa72b..e57ab32 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -275,7 +275,7 @@ class _DashboardTab extends StatelessWidget { GestureDetector( onTap: () => Navigator.push( context, - _slide(const AiCoachScreen()), + MaterialPageRoute(builder: (_) => const AiCoachScreen()), ), child: Container( width: 40, @@ -306,7 +306,7 @@ class _DashboardTab extends StatelessWidget { GestureDetector( onTap: () => Navigator.push( context, - _slide(const ProfileScreen()), + MaterialPageRoute(builder: (_) => const ProfileScreen()), ), child: Container( width: 40, From 380ae18d2ca32749991e65b93f26ae776b922388 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Sat, 30 May 2026 13:01:09 +0530 Subject: [PATCH 28/44] feat: Implement Gemini AI service for AI coach functionality (#49) * feat: Implement Gemini AI service for AI coach functionality - Added GeminiAiService to handle AI coach chat, program generation, and insights using Google Generative AI. - Created IAiService interface to define the contract for AI services. - Developed GeminiContextBuilder to construct context for AI interactions. - Introduced ConversationManager to manage AI conversations, including persistence and active conversation logic. - Implemented storage service methods for saving and retrieving AI conversations. - Built AiCoachViewModel to orchestrate AI interactions and manage conversation state. - Added unit tests for AiCoachViewModel, CoachToolService, and ConversationManager to ensure functionality and persistence. - Updated analytics and exercise progress views to use the new GeminiAiService. * feat: Enhance Gemini AI service with token usage tracking and UI updates for AI coach * feat: Refactor AiCoachViewModel and related services for improved readability and error handling * feat: Improve error handling and async behavior in Gemini AI service usage tracking --------- Co-authored-by: Devasy Patel <110348311+Devasy23@users.noreply.github.com> --- workout-logger/lib/main.dart | 33 +- workout-logger/lib/models/models.dart | 98 ++++ .../lib/screens/ai_coach_screen.dart | 503 ++++++++++++------ .../screens/ai_program_generator_screen.dart | 4 +- workout-logger/lib/screens/home_screen.dart | 6 +- .../widgets/exercise_progress_view.dart | 4 +- .../screens/widgets/muscle_detail_sheet.dart | 6 +- .../lib/screens/widgets/profile_sections.dart | 95 +++- .../lib/screens/widgets/targets_tab.dart | 10 +- .../lib/services/ai/coach_tool_service.dart | 458 ++++++++++++++++ .../gemini_ai_service.dart} | 176 +++++- .../lib/services/gemini_context_builder.dart | 89 +--- .../interfaces/ai_service_interface.dart | 52 ++ .../interfaces/storage_service_interface.dart | 7 + .../managers/conversation_manager.dart | 118 ++++ .../lib/services/storage_service.dart | 55 ++ .../lib/viewmodels/ai_coach_view_model.dart | 143 +++++ workout-logger/pubspec.yaml | 1 + .../test/ai_coach_view_model_test.dart | 148 ++++++ .../test/analytics_screen_test.dart | 4 +- .../test/coach_tool_service_test.dart | 170 ++++++ .../test/conversation_manager_test.dart | 117 ++++ .../test/exercise_progress_view_test.dart | 4 +- .../test/gemini_ai_service_usage_test.dart | 59 ++ .../test/test_utils/mock_storage_service.dart | 21 + 25 files changed, 2106 insertions(+), 275 deletions(-) create mode 100644 workout-logger/lib/services/ai/coach_tool_service.dart rename workout-logger/lib/services/{gemini_service.dart => ai/gemini_ai_service.dart} (51%) create mode 100644 workout-logger/lib/services/interfaces/ai_service_interface.dart create mode 100644 workout-logger/lib/services/managers/conversation_manager.dart create mode 100644 workout-logger/lib/viewmodels/ai_coach_view_model.dart create mode 100644 workout-logger/test/ai_coach_view_model_test.dart create mode 100644 workout-logger/test/coach_tool_service_test.dart create mode 100644 workout-logger/test/conversation_manager_test.dart create mode 100644 workout-logger/test/gemini_ai_service_usage_test.dart diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index f1b6cd1..91aa03a 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -9,7 +9,8 @@ import 'package:provider/provider.dart'; import 'services/storage_service.dart'; import 'services/ml_service.dart'; -import 'services/gemini_service.dart'; +import 'services/ai/gemini_ai_service.dart'; +import 'services/ai/coach_tool_service.dart'; import 'services/health_connect_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; @@ -21,6 +22,7 @@ import 'services/managers/program_manager.dart'; import 'services/managers/history_manager.dart'; import 'services/managers/health_sync_manager.dart'; import 'services/managers/pr_manager.dart'; +import 'services/managers/conversation_manager.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; import 'screens/onboarding_screen.dart'; @@ -62,7 +64,10 @@ class WorkoutLoggerApp extends StatelessWidget { static final HistoryManager _historyManager = HistoryManager(_storageService, healthSyncManager: _healthSyncManager); static final PRManager _prManager = PRManager(_storageService); - static final GeminiService _geminiService = GeminiService(); + static final GeminiAiService _geminiService = + GeminiAiService(storage: _storageService); + static final ConversationManager _conversationManager = + ConversationManager(_storageService); const WorkoutLoggerApp({super.key}); @@ -89,7 +94,15 @@ class WorkoutLoggerApp extends StatelessWidget { // Provided as ChangeNotifier so HistoryScreen rebuilds on sync badge changes. ChangeNotifierProvider.value(value: _historyManager), ChangeNotifierProvider.value(value: _prManager), - ChangeNotifierProvider.value(value: _geminiService), + // GeminiAiService is the single AI backend instance. It's a ChangeNotifier + // (settings UI watches isConfigured/model), so it's provided as such. + // Consumers that should depend on the abstraction (the coach ViewModel, + // program generator) receive it typed as IAiService at construction — + // the future firebase_ai swap point — without a separate provider. + ChangeNotifierProvider.value(value: _geminiService), + ChangeNotifierProvider.value( + value: _conversationManager, + ), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( create: (_) => WorkoutProvider( @@ -99,6 +112,13 @@ class WorkoutLoggerApp extends StatelessWidget { programManager: _programManager, ), ), + // CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager. + Provider( + create: (ctx) => CoachToolService( + ctx.read(), + ctx.read(), + ), + ), ], child: MaterialApp( title: 'Workout Logger', @@ -135,12 +155,17 @@ class _AppInitializerState extends State { final historyManager = context.read(); final prManager = context.read(); final api = context.read(); - final gemini = context.read(); + final gemini = context.read(); try { await provider.init(); await settings.init(); gemini.init(settings.geminiApiKey, model: settings.geminiModel); + try { + await gemini.loadUsage(); + } catch (e, st) { + debugPrint('gemini.loadUsage failed: $e\n$st'); + } await historyManager.loadSessions(); await prManager.load(); await prManager.backfillFromSessions(historyManager.sessions); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 2a5b643..ecf7551 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -812,3 +812,101 @@ class TrainingProgram { createdAt == _sentinel ? this.createdAt : createdAt as DateTime, ); } + +// ==================== AI Coach Chat ==================== + +/// A single message in an AI coach conversation. +class ChatMessage { + final String id; + final String role; // 'user' | 'model' + final String text; + final DateTime timestamp; + + ChatMessage({ + String? id, + required this.role, + required this.text, + DateTime? timestamp, + }) : id = id ?? _uuid.v4(), + timestamp = timestamp ?? DateTime.now(); + + Map toJson() => { + 'id': id, + 'role': role, + 'text': text, + 'timestamp': timestamp.toIso8601String(), + }; + + factory ChatMessage.fromJson(Map json) => ChatMessage( + id: json['id'] as String?, + role: json['role'] as String, + text: json['text'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + + ChatMessage copyWith({ + Object? role = _sentinel, + Object? text = _sentinel, + Object? timestamp = _sentinel, + }) => ChatMessage( + id: id, + role: role == _sentinel ? this.role : role as String, + text: text == _sentinel ? this.text : text as String, + timestamp: timestamp == _sentinel ? this.timestamp : timestamp as DateTime, + ); +} + +/// A persisted AI coach conversation: an ordered list of [ChatMessage]s. +class Conversation { + final String id; + final String title; + final DateTime createdAt; + final DateTime updatedAt; + final List messages; + + Conversation({ + String? id, + required this.title, + DateTime? createdAt, + DateTime? updatedAt, + List? messages, + }) : id = id ?? _uuid.v4(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? createdAt ?? DateTime.now(), + messages = messages ?? const []; + + Map toJson() => { + 'id': id, + 'title': title, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'messages': messages.map((m) => m.toJson()).toList(), + }; + + factory Conversation.fromJson(Map json) => Conversation( + id: json['id'] as String?, + title: json['title'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : DateTime.parse(json['createdAt'] as String), + messages: (json['messages'] as List) + .map((m) => ChatMessage.fromJson(m as Map)) + .toList(), + ); + + Conversation copyWith({ + Object? title = _sentinel, + Object? createdAt = _sentinel, + Object? updatedAt = _sentinel, + Object? messages = _sentinel, + }) => Conversation( + id: id, + title: title == _sentinel ? this.title : title as String, + createdAt: createdAt == _sentinel ? this.createdAt : createdAt as DateTime, + updatedAt: updatedAt == _sentinel ? this.updatedAt : updatedAt as DateTime, + messages: messages == _sentinel + ? this.messages + : messages as List, + ); +} diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 9a57341..79f8145 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -1,48 +1,58 @@ -// ai_coach_screen.dart — Conversational AI workout coach powered by Gemini +// ai_coach_screen.dart — Conversational AI workout coach (View). +// +// This is a lean View: all orchestration (streaming, tool calls, persistence, +// system-prompt building) lives in AiCoachViewModel. The widget only renders +// state, forwards user intents, and holds UI-local controllers. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_generative_ai/google_generative_ai.dart'; import 'package:provider/provider.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; -import '../services/gemini_service.dart'; -import '../services/gemini_context_builder.dart'; -import '../services/workout_provider.dart'; +import '../models/models.dart'; +import '../viewmodels/ai_coach_view_model.dart'; +import '../services/ai/gemini_ai_service.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; import '../services/settings_provider.dart'; -import '../services/interfaces/ml_service_interface.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; import 'profile_screen.dart'; -// ── Data ────────────────────────────────────────────────────────────────────── - -class _ChatMessage { - const _ChatMessage({required this.role, required this.text}); - final String role; // 'user' | 'model' - final String text; -} - -// ── Screen ──────────────────────────────────────────────────────────────────── - -class AiCoachScreen extends StatefulWidget { +/// Public entry point. Owns the screen-scoped [AiCoachViewModel]. +class AiCoachScreen extends StatelessWidget { const AiCoachScreen({super.key, this.seedPrompt}); /// Optional question to auto-send on open (e.g. deep-linked from analytics). - /// The coach system prompt already carries the user's data, so a seed needs - /// no extra context. final String? seedPrompt; @override - State createState() => _AiCoachScreenState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (ctx) => AiCoachViewModel( + ai: ctx.read(), + coachTools: ctx.read(), + conversations: ctx.read(), + settings: ctx.read(), + )..loadConversations(), + child: _AiCoachView(seedPrompt: seedPrompt), + ); + } } -class _AiCoachScreenState extends State { +class _AiCoachView extends StatefulWidget { + const _AiCoachView({this.seedPrompt}); + final String? seedPrompt; + + @override + State<_AiCoachView> createState() => _AiCoachViewState(); +} + +class _AiCoachViewState extends State<_AiCoachView> { final _controller = TextEditingController(); final _scrollCtrl = ScrollController(); - final _messages = <_ChatMessage>[]; - bool _loading = false; - String _streamingText = ''; + AiCoachViewModel? _vm; @override void initState() { @@ -51,92 +61,41 @@ class _AiCoachScreenState extends State { if (seed != null && seed.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (!context.read().isConfigured) return; - _controller.text = seed; - _send(); + final vm = context.read(); + if (!vm.isConfigured) return; + _controller.clear(); + vm.sendMessage(seed); }); } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Attach a scroll-follow listener once. + final vm = context.read(); + if (!identical(vm, _vm)) { + _vm?.removeListener(_onVmChanged); + _vm = vm..addListener(_onVmChanged); + } + } + + void _onVmChanged() => _scrollToBottom(); + @override void dispose() { + _vm?.removeListener(_onVmChanged); _controller.dispose(); _scrollCtrl.dispose(); super.dispose(); } - String _buildSystemPrompt() { - final wp = context.read(); - final settings = context.read(); - final mlService = context.read(); - - final exerciseMap = {for (final e in wp.allExercises) e.id: e}; - final allSessions = wp.sessions; - final recoveryScores = mlService.computeMuscleRecoveryScores( - allSessions, - exerciseMap, - ); - final activeTargets = wp.targets.where((t) => !t.isCompleted).toList(); - - return GeminiContextBuilder.buildCoachSystemPrompt( - recentSessions: allSessions, - exerciseMap: exerciseMap, - recoveryScores: recoveryScores, - activeTargets: activeTargets, - userName: settings.userName, - unitLabel: settings.unitLabel, - ); - } - - List _buildHistory() => _messages - .map((m) => Content(m.role, [TextPart(m.text)])) - .toList(); - - Future _send() async { + void _send() { final text = _controller.text.trim(); - if (text.isEmpty || _loading) return; - + if (text.isEmpty) return; HapticFeedback.lightImpact(); _controller.clear(); - - setState(() { - _messages.add(_ChatMessage(role: 'user', text: text)); - _loading = true; - _streamingText = ''; - }); - _scrollToBottom(); - - final gemini = context.read(); - final systemPrompt = _buildSystemPrompt(); - // Build history from all messages except the one we just added. - final history = _messages.length > 1 - ? _buildHistory().sublist(0, _messages.length - 1) - : []; - - final buffer = StringBuffer(); - try { - await for (final chunk in gemini.streamCoachReply( - userMessage: text, - systemPrompt: systemPrompt, - history: history, - )) { - buffer.write(chunk); - if (mounted) { - setState(() => _streamingText = buffer.toString()); - _scrollToBottom(); - } - } - if (mounted) { - setState(() { - _messages.add(_ChatMessage(role: 'model', text: buffer.toString())); - _streamingText = ''; - _loading = false; - }); - _scrollToBottom(); - } - } catch (_) { - if (mounted) setState(() { _streamingText = ''; _loading = false; }); - } + context.read().sendMessage(text); } void _scrollToBottom() { @@ -153,7 +112,7 @@ class _AiCoachScreenState extends State { @override Widget build(BuildContext context) { - final gemini = context.watch(); + final vm = context.watch(); return Scaffold( backgroundColor: AppColors.background, @@ -163,13 +122,13 @@ class _AiCoachScreenState extends State { SafeArea( child: Column( children: [ - _buildHeader(context), + _buildHeader(context, vm), Expanded( - child: gemini.isConfigured - ? _buildChatArea() + child: vm.isConfigured + ? _buildChatArea(vm) : _buildNoKeyState(context), ), - if (gemini.isConfigured) _buildInputBar(), + if (vm.isConfigured) _buildInputBar(vm), ], ), ), @@ -178,7 +137,7 @@ class _AiCoachScreenState extends State { ); } - Widget _buildHeader(BuildContext context) { + Widget _buildHeader(BuildContext context, AiCoachViewModel vm) { return Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.md, @@ -248,34 +207,42 @@ class _AiCoachScreenState extends State { ], ), ), - if (_messages.isNotEmpty) - GestureDetector( - onTap: () => setState(() => _messages.clear()), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: AppColors.glass, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.glassBorder), - ), - child: Text( - 'Clear', - style: GoogleFonts.geist( - color: AppColors.textMuted, - fontSize: 11, - ), - ), - ), + if (vm.isConfigured) ...[ + _HeaderIconButton( + icon: Icons.history_rounded, + onTap: () => _openHistory(context, vm), ), + const SizedBox(width: AppSpacing.sm), + _HeaderIconButton( + icon: Icons.add_rounded, + onTap: () { + HapticFeedback.lightImpact(); + vm.newConversation(); + }, + ), + ], ], ), ); } - Widget _buildChatArea() { - final hasMessages = _messages.isNotEmpty || _loading; + Future _openHistory(BuildContext context, AiCoachViewModel vm) async { + HapticFeedback.lightImpact(); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => _ConversationsSheet(vm: vm), + ); + } - if (!hasMessages) return _buildWelcome(); + Widget _buildChatArea(AiCoachViewModel vm) { + final messages = vm.messages; + final hasContent = messages.isNotEmpty || vm.isLoading; + if (!hasContent) return _buildWelcome(); return ListView.builder( controller: _scrollCtrl, @@ -285,20 +252,18 @@ class _AiCoachScreenState extends State { AppSpacing.md, AppSpacing.sm, ), - itemCount: _messages.length + (_loading ? 1 : 0), + itemCount: messages.length + (vm.isLoading ? 1 : 0), itemBuilder: (_, i) { - if (i == _messages.length) { - // Streaming bubble - return _StreamingBubble(text: _streamingText); + if (i == messages.length) { + return _StreamingBubble(text: vm.streamingText); } - return _MessageBubble(message: _messages[i]); + return _MessageBubble(message: messages[i]); }, ); } Widget _buildWelcome() { - final settings = context.read(); - final name = settings.userName; + final name = context.read().userName; return Center( child: Padding( padding: const EdgeInsets.all(AppSpacing.xl), @@ -331,9 +296,7 @@ class _AiCoachScreenState extends State { ), const SizedBox(height: AppSpacing.lg), Text( - name != null && name.isNotEmpty - ? 'Hey $name 👋' - : 'Your AI Coach', + name != null && name.isNotEmpty ? 'Hey $name 👋' : 'Your AI Coach', style: GoogleFonts.geist( color: AppColors.textPrimary, fontSize: 22, @@ -356,11 +319,20 @@ class _AiCoachScreenState extends State { spacing: AppSpacing.sm, runSpacing: AppSpacing.sm, alignment: WrapAlignment.center, - children: const [ - _SuggestionChip('What should I train today?'), - _SuggestionChip('How\'s my recovery?'), - _SuggestionChip('Am I progressing on bench?'), - _SuggestionChip('Suggest a deload week'), + children: [ + for (final s in const [ + 'What should I train today?', + 'How\'s my recovery?', + 'Am I progressing on bench?', + 'Suggest a deload week', + ]) + _SuggestionChip( + label: s, + onTap: () { + _controller.text = s; + _send(); + }, + ), ], ), ], @@ -397,7 +369,8 @@ class _AiCoachScreenState extends State { ); } - Widget _buildInputBar() { + Widget _buildInputBar(AiCoachViewModel vm) { + final loading = vm.isLoading; return Container( padding: EdgeInsets.fromLTRB( AppSpacing.md, @@ -445,22 +418,22 @@ class _AiCoachScreenState extends State { ), const SizedBox(width: AppSpacing.sm), GestureDetector( - onTap: _loading ? null : _send, + onTap: loading ? null : _send, child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 44, height: 44, decoration: BoxDecoration( - gradient: _loading + gradient: loading ? null : const LinearGradient( colors: [AppColors.primary, Color(0xFF5B21B6)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), - color: _loading ? AppColors.glass3 : null, + color: loading ? AppColors.glass3 : null, borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: _loading + boxShadow: loading ? null : [ BoxShadow( @@ -470,7 +443,7 @@ class _AiCoachScreenState extends State { ), ], ), - child: _loading + child: loading ? const Center( child: SizedBox( width: 18, @@ -494,21 +467,202 @@ class _AiCoachScreenState extends State { } } +// ── Header icon button ────────────────────────────────────────────────────── + +class _HeaderIconButton extends StatelessWidget { + const _HeaderIconButton({required this.icon, required this.onTap}); + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, color: AppColors.textSoft, size: 18), + ), + ); + } +} + +// ── Conversations history sheet ─────────────────────────────────────────────── + +class _ConversationsSheet extends StatelessWidget { + const _ConversationsSheet({required this.vm}); + final AiCoachViewModel vm; + + @override + Widget build(BuildContext context) { + // Rebuild when the conversation list changes (delete, new message). + return AnimatedBuilder( + animation: vm, + builder: (context, _) { + final conversations = vm.conversations; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Conversations', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + GestureDetector( + onTap: () { + vm.newConversation(); + Navigator.pop(context); + }, + child: Row( + children: [ + const Icon(Icons.add_rounded, + color: AppColors.primary, size: 18), + const SizedBox(width: 4), + Text( + 'New chat', + style: GoogleFonts.geist( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (conversations.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Text( + 'No saved conversations yet.', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: conversations.length, + separatorBuilder: (_, __) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (_, i) { + final c = conversations[i]; + final isActive = c.id == vm.activeConversationId; + return _ConversationTile( + conversation: c, + isActive: isActive, + onTap: () { + vm.selectConversation(c.id); + Navigator.pop(context); + }, + onDelete: () => vm.deleteConversation(c.id), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ConversationTile extends StatelessWidget { + const _ConversationTile({ + required this.conversation, + required this.isActive, + required this.onTap, + required this.onDelete, + }); + + final Conversation conversation; + final bool isActive; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: isActive ? AppColors.primary.withValues(alpha: 0.12) : AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isActive ? AppColors.primary.withValues(alpha: 0.4) : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + const Icon(Icons.chat_bubble_outline_rounded, + color: AppColors.textMuted, size: 16), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + conversation.title.isEmpty ? 'New chat' : conversation.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + GestureDetector( + onTap: onDelete, + child: const Padding( + padding: EdgeInsets.only(left: AppSpacing.sm), + child: Icon(Icons.delete_outline_rounded, + color: AppColors.textFaint, size: 18), + ), + ), + ], + ), + ), + ); + } +} + // ── Suggestion chip ─────────────────────────────────────────────────────────── class _SuggestionChip extends StatelessWidget { - const _SuggestionChip(this.label); + const _SuggestionChip({required this.label, required this.onTap}); final String label; + final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( - onTap: () { - final state = context.findAncestorStateOfType<_AiCoachScreenState>(); - if (state == null) return; - state._controller.text = label; - state._send(); - }, + onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( @@ -533,7 +687,7 @@ class _SuggestionChip extends StatelessWidget { class _MessageBubble extends StatelessWidget { const _MessageBubble({required this.message}); - final _ChatMessage message; + final ChatMessage message; @override Widget build(BuildContext context) { @@ -583,14 +737,16 @@ class _MessageBubble extends StatelessWidget { ] : null, ), - child: Text( - message.text, - style: GoogleFonts.geist( - color: AppColors.textPrimary, - fontSize: 14, - height: 1.55, - ), - ), + child: isUser + ? Text( + message.text, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ) + : _CoachMarkdown(text: message.text), ), ), ], @@ -630,14 +786,7 @@ class _StreamingBubble extends StatelessWidget { ), child: text.isEmpty ? const RFLoadingDots() - : Text( - text, - style: GoogleFonts.geist( - color: AppColors.textPrimary, - fontSize: 14, - height: 1.55, - ), - ), + : _CoachMarkdown(text: text), ), ), ], @@ -646,6 +795,24 @@ class _StreamingBubble extends StatelessWidget { } } +/// Markdown renderer for coach replies, styled to the app theme. +class _CoachMarkdown extends StatelessWidget { + const _CoachMarkdown({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return GptMarkdown( + text, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ); + } +} + class _AiAvatar extends StatelessWidget { @override Widget build(BuildContext context) { diff --git a/workout-logger/lib/screens/ai_program_generator_screen.dart b/workout-logger/lib/screens/ai_program_generator_screen.dart index da7cc5e..ded3dc5 100644 --- a/workout-logger/lib/screens/ai_program_generator_screen.dart +++ b/workout-logger/lib/screens/ai_program_generator_screen.dart @@ -6,7 +6,7 @@ import 'package:provider/provider.dart'; import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; -import '../services/gemini_service.dart'; +import '../services/ai/gemini_ai_service.dart'; import '../services/workout_provider.dart'; import '../services/managers/program_manager.dart'; import '../theme/app_theme.dart'; @@ -45,7 +45,7 @@ class _AiProgramGeneratorScreenState extends State { final prompt = _promptCtrl.text.trim(); if (prompt.isEmpty) return; - final gemini = context.read(); + final gemini = context.read(); if (!gemini.isConfigured) { setState(() { _error = 'Add your Gemini API key in Profile → AI Features first.'; }); return; diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index e57ab32..b55a3e9 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -9,7 +9,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; -import '../services/gemini_service.dart'; +import '../services/ai/gemini_ai_service.dart'; import '../services/gemini_context_builder.dart'; import '../theme/app_theme.dart'; import 'workout_flow_screen.dart'; @@ -1207,7 +1207,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { bool _loading = false; Future _refresh() async { - final gemini = context.read(); + final gemini = context.read(); if (!gemini.isConfigured) return; setState(() => _loading = true); @@ -1250,7 +1250,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { @override Widget build(BuildContext context) { - final gemini = context.watch(); + final gemini = context.watch(); final settings = context.watch(); if (!gemini.isConfigured) return const SizedBox.shrink(); diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index d7283c6..640a664 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -11,7 +11,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; -import '../../services/gemini_service.dart'; +import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; import '../ai_coach_screen.dart'; @@ -1489,7 +1489,7 @@ class _AskCoachButton extends StatelessWidget { @override Widget build(BuildContext context) { - final gemini = context.watch(); + final gemini = context.watch(); if (!gemini.isConfigured) return const SizedBox.shrink(); final isPlateauing = diff --git a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart index e66011d..5fa8f97 100644 --- a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart +++ b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart @@ -8,7 +8,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; -import '../../services/gemini_service.dart'; +import '../../services/ai/gemini_ai_service.dart'; import '../../services/interfaces/ml_service_interface.dart'; import '../../data/exercise_database.dart'; import '../../theme/app_theme.dart'; @@ -252,7 +252,7 @@ class _AiInsightSectionState extends State<_AiInsightSection> { Future _fetchInsight() async { setState(() => _loading = true); - final gemini = context.read(); + final gemini = context.read(); final settings = context.read(); final mlService = context.read(); final provider = widget.provider; @@ -298,7 +298,7 @@ class _AiInsightSectionState extends State<_AiInsightSection> { @override Widget build(BuildContext context) { - final gemini = context.watch(); + final gemini = context.watch(); if (!gemini.isConfigured) return const SizedBox.shrink(); return Column( diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index cbeb430..d86bfd0 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -6,7 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../../services/settings_provider.dart'; -import '../../services/gemini_service.dart'; +import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; @@ -719,7 +719,7 @@ class _AiSettingsSectionState extends State { setState(() => _saving = true); final key = _ctrl.text.trim(); final settings = context.read(); - final gemini = context.read(); + final gemini = context.read(); try { await settings.setGeminiApiKey(key); gemini.updateApiKey(key); @@ -730,14 +730,14 @@ class _AiSettingsSectionState extends State { Future _selectModel(String modelId) async { final settings = context.read(); - final gemini = context.read(); + final gemini = context.read(); await settings.setGeminiModel(modelId); gemini.updateModel(modelId); } @override Widget build(BuildContext context) { - final gemini = context.watch(); + final gemini = context.watch(); final settings = context.watch(); return _ProfileSection( icon: Icons.auto_awesome_rounded, @@ -899,12 +899,99 @@ class _AiSettingsSectionState extends State { ), ), ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + const _SectionLabel('TOKEN USAGE'), + const Spacer(), + if (gemini.aiRequestCount > 0) + GestureDetector( + onTap: () => context.read().resetUsage(), + child: Text( + 'Reset', + style: GoogleFonts.geist( + color: AppColors.accent, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + children: [ + _UsageRow(label: 'Total tokens', value: _formatInt(gemini.totalTokensUsed)), + const SizedBox(height: 6), + _UsageRow(label: 'Input (prompt)', value: _formatInt(gemini.promptTokensUsed)), + const SizedBox(height: 6), + _UsageRow(label: 'Output (response)', value: _formatInt(gemini.responseTokensUsed)), + const SizedBox(height: 6), + _UsageRow(label: 'Requests', value: _formatInt(gemini.aiRequestCount)), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Cumulative billable tokens across coach, program builder & insights.', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), ], ), ); } } +/// One label/value line in the token-usage card. +class _UsageRow extends StatelessWidget { + const _UsageRow({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + ), + Text( + value, + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Format an int with thousands separators (e.g. 12345 → "12,345"). +String _formatInt(int n) { + final s = n.toString(); + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); + buf.write(s[i]); + } + return buf.toString(); +} + class _ComingSoonBadge extends StatelessWidget { const _ComingSoonBadge(); diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart index 1865d4b..5cc1744 100644 --- a/workout-logger/lib/screens/widgets/targets_tab.dart +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -9,7 +9,7 @@ import 'package:intl/intl.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; -import '../../services/gemini_service.dart'; +import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; import '../../data/exercise_database.dart'; import 'rf_widgets.dart'; @@ -224,7 +224,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { Future _fetchNudge() async { setState(() => _loadingNudge = true); - final gemini = context.read(); + final gemini = context.read(); final settings = context.read(); final t = widget.target; @@ -257,7 +257,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { @override Widget build(BuildContext context) { final settings = context.watch(); - final gemini = context.watch(); + final gemini = context.watch(); final t = widget.target; final pct = t.progressPercentage.clamp(0.0, 100.0); final etaStr = t.estimatedCompletionDate != null @@ -486,7 +486,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { final provider = context.read(); final settings = context.read(); - final gemini = context.read(); + final gemini = context.read(); final exerciseName = provider.getExerciseName(_selectedExerciseId!); final growth = provider.getGrowthModel(_selectedExerciseId!); final oneRM = provider.getBestOneRM(_selectedExerciseId!); @@ -520,7 +520,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { Widget build(BuildContext context) { final exercises = ExerciseDatabase.getAll(); final bottom = MediaQuery.of(context).viewInsets.bottom; - final gemini = context.watch(); + final gemini = context.watch(); return Padding( padding: EdgeInsets.fromLTRB( diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart new file mode 100644 index 0000000..846c3c9 --- /dev/null +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -0,0 +1,458 @@ +// coach_tool_service.dart — DB-backed function-calling tools for the AI coach. +// +// Exposes a set of read-only query functions the model can call to ground its +// answers in the user's real data. Every tool reuses existing parameterized +// query methods on WorkoutProvider / PRManager — no new analytics logic lives +// here, only the schema + arg parsing + JSON shaping. + +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../../models/models.dart'; +import '../workout_provider.dart'; +import '../managers/pr_manager.dart'; + +class AmbiguousMatchException implements Exception { + const AmbiguousMatchException(this.candidates); + final List candidates; +} + +class CoachToolService { + final WorkoutProvider _wp; + final PRManager _pr; + + CoachToolService(this._wp, this._pr); + + /// Tool declarations advertised to the model. + List buildTools() => [ + Tool(functionDeclarations: [ + FunctionDeclaration( + 'get_exercise_performance', + 'Get how a specific exercise has progressed: per-session volume ' + 'trend, growth slope, best estimated 1RM, last logged sets, and ' + 'personal record. Use for questions like "how is my bench press ' + 'progressing".', + Schema.object( + properties: { + 'exercise_name': Schema.string( + description: + 'Name of the exercise, e.g. "Bench Press" or "Squat".', + ), + 'days': Schema.integer( + description: + 'Optional. Only consider sessions from the last N days.', + nullable: true, + ), + }, + requiredProperties: ['exercise_name'], + ), + ), + FunctionDeclaration( + 'get_workouts_in_range', + 'Summarize workouts in a date range: session count, total volume, ' + 'and a per-session breakdown. Use for "what did I do last week" ' + 'or "how many workouts in the last 3 months".', + Schema.object( + properties: { + 'start_date': Schema.string( + description: 'Optional ISO date (YYYY-MM-DD) range start.', + nullable: true, + ), + 'end_date': Schema.string( + description: 'Optional ISO date (YYYY-MM-DD) range end.', + nullable: true, + ), + 'days': Schema.integer( + description: + 'Optional. Last N days; overrides start/end when set. ' + 'Defaults to 30 if no dates are provided.', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'get_routine_performance', + 'Get how a named routine is performing: number of sessions logged ' + 'against it, total volume, volume trend over time, and the ' + 'exercises it contains.', + Schema.object( + properties: { + 'routine_name': Schema.string( + description: 'Name of the routine, e.g. "Push Day".', + ), + 'days': Schema.integer( + description: + 'Optional. Only consider sessions from the last N days.', + nullable: true, + ), + }, + requiredProperties: ['routine_name'], + ), + ), + FunctionDeclaration( + 'get_personal_records', + 'Get personal records (best weight, reps, and single-set volume). ' + 'Pass an exercise name for one exercise, or omit for all PRs.', + Schema.object( + properties: { + 'exercise_name': Schema.string( + description: 'Optional exercise name to filter to.', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'get_goal_progress', + 'Get progress toward training goals/targets: current vs target ' + 'value, percent complete, and estimated completion date.', + Schema.object( + properties: { + 'exercise_name': Schema.string( + description: 'Optional exercise name to filter goals to.', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'get_muscle_recovery', + 'Get current per-muscle-group recovery status (percent recovered ' + 'and whether each is ready, recovering, or fatigued). Use for ' + '"what can I train today".', + Schema.object(properties: {}), + ), + ]), + ]; + + /// Dispatch a model function call to the matching query and return a + /// JSON-serializable result map. + Future> handleCall(FunctionCall call) async { + switch (call.name) { + case 'get_exercise_performance': + return _exercisePerformance(call.args); + case 'get_workouts_in_range': + return _workoutsInRange(call.args); + case 'get_routine_performance': + return _routinePerformance(call.args); + case 'get_personal_records': + return _personalRecords(call.args); + case 'get_goal_progress': + return _goalProgress(call.args); + case 'get_muscle_recovery': + return _muscleRecovery(); + default: + return {'error': 'Unknown tool: ${call.name}'}; + } + } + + // ── Tool implementations ─────────────────────────────────────────────────── + + Map _exercisePerformance(Map args) { + final name = (args['exercise_name'] as String?)?.trim() ?? ''; + final Exercise exercise; + try { + final resolved = _resolveExercise(name); + if (resolved == null) { + return { + 'error': 'No exercise found matching "$name".', + 'available_examples': _exampleExerciseNames(), + }; + } + exercise = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple exercises match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + + final days = (args['days'] as num?)?.toInt(); + final cutoff = + days != null ? DateTime.now().subtract(Duration(days: days)) : null; + + final progression = _wp + .getVolumeProgression(exercise.id) + .where((p) => cutoff == null || !p.date.isBefore(cutoff)) + .toList(); + + final growth = _wp.getGrowthModel(exercise.id); + final lastLog = _wp.getLastSessionForExercise(exercise.id); + final pr = _pr.getRecord(exercise.id); + + return { + 'exercise': exercise.name, + 'session_count': progression.length, + if (days != null) 'window_days': days, + 'volume_trend': [ + for (final p in progression.length > 40 + ? progression.sublist(progression.length - 40) + : progression) + {'date': _d(p.date), 'volume': _round(p.volume)}, + ], + 'growth': growth == null + ? null + : { + 'slope_per_session': _round(growth.slope), + 'r2': _round(growth.r2), + 'trend': growth.slope > 0 + ? 'improving' + : growth.slope < 0 + ? 'declining' + : 'flat', + }, + 'best_estimated_1rm': _roundOrNull(_wp.getBestOneRM(exercise.id)), + 'last_session': lastLog == null + ? null + : [ + for (final s in lastLog.sets) + {'weight': _round(s.weight), 'reps': s.reps}, + ], + 'personal_record': pr == null + ? null + : { + 'best_weight': _round(pr.bestWeight), + 'best_reps': pr.bestReps, + 'best_volume': _round(pr.bestVolume), + 'achieved_at': _d(pr.achievedAt), + }, + }; + } + + Map _workoutsInRange(Map args) { + final now = DateTime.now(); + final days = (args['days'] as num?)?.toInt(); + final startArg = DateTime.tryParse((args['start_date'] as String?) ?? ''); + final endArg = DateTime.tryParse((args['end_date'] as String?) ?? ''); + + final DateTime start; + final DateTime end; + if (days != null) { + start = now.subtract(Duration(days: days)); + end = now; + } else if (startArg != null || endArg != null) { + start = startArg ?? now.subtract(const Duration(days: 30)); + end = endArg ?? now; + } else { + start = now.subtract(const Duration(days: 30)); + end = now; + } + + final sessions = _wp.sessions + .where((s) => !s.date.isBefore(start) && !s.date.isAfter(end)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + final totalVolume = + sessions.fold(0, (sum, s) => sum + s.totalVolume); + + return { + 'start_date': _d(start), + 'end_date': _d(end), + 'session_count': sessions.length, + 'total_volume': _round(totalVolume), + 'sessions': [ + for (final s in sessions.take(40)) + { + 'date': _d(s.date), + 'duration_min': s.duration, + 'exercise_count': s.exercises.length, + 'volume': _round(s.totalVolume), + 'exercises': [ + for (final e in s.exercises) _wp.getExerciseName(e.exerciseId), + ], + }, + ], + }; + } + + Map _routinePerformance(Map args) { + final name = (args['routine_name'] as String?)?.trim() ?? ''; + final Routine routine; + try { + final resolved = _resolveRoutine(name); + if (resolved == null) { + return { + 'error': 'No routine found matching "$name".', + 'available_routines': [for (final r in _wp.routines) r.name], + }; + } + routine = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple routines match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + + final days = (args['days'] as num?)?.toInt(); + final cutoff = + days != null ? DateTime.now().subtract(Duration(days: days)) : null; + + final sessions = _wp.sessions + .where((s) => s.routineId == routine.id) + .where((s) => cutoff == null || !s.date.isBefore(cutoff)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + final totalVolume = + sessions.fold(0, (sum, s) => sum + s.totalVolume); + + return { + 'routine': routine.name, + 'exercises': [for (final id in routine.exerciseIds) _wp.getExerciseName(id)], + 'session_count': sessions.length, + if (days != null) 'window_days': days, + 'total_volume': _round(totalVolume), + 'volume_over_time': [ + for (final s in sessions.length > 40 + ? sessions.sublist(sessions.length - 40) + : sessions) + {'date': _d(s.date), 'volume': _round(s.totalVolume)}, + ], + }; + } + + Map _personalRecords(Map args) { + final name = (args['exercise_name'] as String?)?.trim(); + if (name != null && name.isNotEmpty) { + final Exercise exercise; + try { + final resolved = _resolveExercise(name); + if (resolved == null) { + return {'error': 'No exercise found matching "$name".'}; + } + exercise = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple exercises match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + final pr = _pr.getRecord(exercise.id); + return { + 'exercise': exercise.name, + 'personal_record': pr == null + ? null + : { + 'best_weight': _round(pr.bestWeight), + 'best_reps': pr.bestReps, + 'best_volume': _round(pr.bestVolume), + 'achieved_at': _d(pr.achievedAt), + }, + }; + } + + return { + 'records': [ + for (final pr in _pr.allRecords) + { + 'exercise': _wp.getExerciseName(pr.exerciseId), + 'best_weight': _round(pr.bestWeight), + 'best_reps': pr.bestReps, + 'best_volume': _round(pr.bestVolume), + 'achieved_at': _d(pr.achievedAt), + }, + ], + }; + } + + Map _goalProgress(Map args) { + final name = (args['exercise_name'] as String?)?.trim(); + Iterable targets = _wp.targets; + if (name != null && name.isNotEmpty) { + final Exercise exercise; + try { + final resolved = _resolveExercise(name); + if (resolved == null) { + return {'error': 'No exercise found matching "$name".'}; + } + exercise = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple exercises match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + targets = targets.where((t) => t.exerciseId == exercise.id); + } + + return { + 'goals': [ + for (final t in targets) + { + 'exercise': _wp.getExerciseName(t.exerciseId), + 'type': t.targetType, + 'current_value': _round(t.currentValue), + 'target_value': _round(t.targetValue), + 'progress_percent': _round(t.progressPercentage), + 'completed': t.isCompleted, + 'estimated_completion': t.estimatedCompletionDate == null + ? null + : _d(t.estimatedCompletionDate!), + }, + ], + }; + } + + Map _muscleRecovery() { + final scores = _wp.getMuscleRecoveryScores(); + final entries = scores.entries.toList() + ..sort((a, b) => a.value.recoveryPercent.compareTo(b.value.recoveryPercent)); + return { + 'muscles': [ + for (final e in entries) + { + 'muscle': _wp.getMuscleGroupName(e.key), + 'recovery_percent': e.value.recoveryPercent, + 'status': e.value.isRecovered + ? 'ready' + : e.value.isUnderRecovered + ? 'fatigued' + : 'recovering', + }, + ], + }; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + Exercise? _resolveExercise(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + final all = _wp.allExercises; + for (final e in all) { + if (e.name.toLowerCase() == q) return e; + } + final partials = [for (final e in all) if (e.name.toLowerCase().contains(q)) e]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final e in partials) e.name]); + } + + Routine? _resolveRoutine(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + for (final r in _wp.routines) { + if (r.name.toLowerCase() == q) return r; + } + final partials = [ + for (final r in _wp.routines) if (r.name.toLowerCase().contains(q)) r + ]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final r in partials) r.name]); + } + + List _exampleExerciseNames() => + _wp.allExercises.take(8).map((e) => e.name).toList(); + + String _d(DateTime dt) { + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + return '${dt.year}-$m-$d'; + } + + double _round(double v) => (v * 10).round() / 10; + double? _roundOrNull(double? v) => v == null ? null : _round(v); +} diff --git a/workout-logger/lib/services/gemini_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart similarity index 51% rename from workout-logger/lib/services/gemini_service.dart rename to workout-logger/lib/services/ai/gemini_ai_service.dart index e87be84..08f811c 100644 --- a/workout-logger/lib/services/gemini_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -1,11 +1,18 @@ -// gemini_service.dart — Gemini AI integration (coach chat, program gen, insights) +// gemini_ai_service.dart — google_generative_ai implementation of IAiService. +// +// Backs the AI coach chat (streaming + tool calling), program generation, and +// insights. Uses a user-supplied Google AI Studio API key (free-tier friendly). +// Implements [IAiService] so the backend can be swapped (e.g. firebase_ai) +// without touching consumers. import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:google_generative_ai/google_generative_ai.dart'; import 'package:uuid/uuid.dart'; -import '../models/models.dart'; +import '../../models/models.dart'; +import '../interfaces/ai_service_interface.dart'; +import '../interfaces/storage_service_interface.dart'; // Ordered list of available Gemini models shown in the picker. const kGeminiModels = [ @@ -15,20 +22,116 @@ const kGeminiModels = [ ('gemini-3.5-flash', 'Gemini 3.5 Flash'), ]; -const kDefaultGeminiModel = 'gemini-2.5-flash'; +// Default to a fast, free-tier 3.x model. gemini-3.5-flash is selectable and +// preferable when heavy tool-calling reliability matters. +const kDefaultGeminiModel = 'gemini-3.1-flash-lite'; + +// Upper bound on tool-resolution rounds per user turn, to bound runaway loops. +const int _kMaxToolRounds = 5; + +class GeminiAiService extends ChangeNotifier implements IAiService { + // Optional storage so cumulative token usage survives restarts. + final IStorageService? _storage; + + GeminiAiService({IStorageService? storage}) : _storage = storage; + + static const String _usageKey = 'aiTokenUsage'; -class GeminiService extends ChangeNotifier { String _apiKey = ''; String _model = kDefaultGeminiModel; + // Cumulative token usage across all AI calls (persisted). + int _promptTokens = 0; + int _responseTokens = 0; + int _totalTokens = 0; + int _requestCount = 0; + + @override bool get isConfigured => _apiKey.isNotEmpty; + + @override String get currentModel => _model; + /// Cumulative input (prompt) tokens billed across all AI calls. + int get promptTokensUsed => _promptTokens; + + /// Cumulative output (response) tokens across all AI calls. + int get responseTokensUsed => _responseTokens; + + /// Cumulative total tokens (prompt + response) across all AI calls. + int get totalTokensUsed => _totalTokens; + + /// Number of AI requests recorded. + int get aiRequestCount => _requestCount; + void init(String apiKey, {String model = kDefaultGeminiModel}) { _apiKey = apiKey.trim(); _model = model; } + /// Load persisted cumulative token usage (call once at startup). + Future loadUsage() async { + final raw = await _storage?.getSetting(_usageKey); + if (raw == null || raw.isEmpty) return; + try { + final m = jsonDecode(raw) as Map; + _promptTokens = (m['prompt'] as num?)?.toInt() ?? 0; + _responseTokens = (m['response'] as num?)?.toInt() ?? 0; + _totalTokens = (m['total'] as num?)?.toInt() ?? 0; + _requestCount = (m['requests'] as num?)?.toInt() ?? 0; + notifyListeners(); + } catch (_) { + // Ignore corrupt usage data. + } + } + + /// Reset cumulative token usage to zero. + Future resetUsage() async { + _promptTokens = 0; + _responseTokens = 0; + _totalTokens = 0; + _requestCount = 0; + await _persistUsage(); + notifyListeners(); + } + + /// Accumulate one request's token counts. Exposed for testing; normally + /// fed from a response's [UsageMetadata] via [_recordUsage]. + @visibleForTesting + Future recordUsage({ + required int prompt, + required int response, + required int total, + }) async { + _promptTokens += prompt; + _responseTokens += response; + _totalTokens += total; + _requestCount += 1; + await _persistUsage(); + notifyListeners(); + } + + void _recordUsage(UsageMetadata? m) { + if (m == null) return; + final p = m.promptTokenCount ?? 0; + final r = m.candidatesTokenCount ?? 0; + recordUsage(prompt: p, response: r, total: m.totalTokenCount ?? (p + r)); + } + + Future _persistUsage() async { + final storage = _storage; + if (storage == null) return; + await storage.saveSetting( + _usageKey, + jsonEncode({ + 'prompt': _promptTokens, + 'response': _responseTokens, + 'total': _totalTokens, + 'requests': _requestCount, + }), + ); + } + void updateApiKey(String key) { _apiKey = key.trim(); notifyListeners(); @@ -39,35 +142,73 @@ class GeminiService extends ChangeNotifier { notifyListeners(); } - GenerativeModel _makeModel({bool jsonMode = false, String? system}) { + GenerativeModel _makeModel({ + bool jsonMode = false, + String? system, + List? tools, + }) { return GenerativeModel( model: _model, apiKey: _apiKey, systemInstruction: system != null ? Content.system(system) : null, + tools: tools, generationConfig: jsonMode ? GenerationConfig(responseMimeType: 'application/json') : null, ); } - // ── Coach chat (streaming) ───────────────────────────────────────────────── - // [history] is the prior conversation as alternating user/model Content objects. + // ── Coach chat (streaming + optional tool-call loop) ─────────────────────── + // [history] is the prior conversation as alternating user/model Content. + // When [tools] + [onToolCall] are supplied, function calls the model emits + // are dispatched and their results fed back until a text answer is produced. + @override Stream streamCoachReply({ required String userMessage, required String systemPrompt, required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, }) async* { if (!isConfigured) { yield 'Please add your Gemini API key in Profile → AI Features to get started.'; return; } try { - final session = _makeModel(system: systemPrompt).startChat(history: history); - await for (final chunk - in session.sendMessageStream(Content.text(userMessage))) { - final t = chunk.text; - if (t != null && t.isNotEmpty) yield t; + final chat = _makeModel(system: systemPrompt, tools: tools) + .startChat(history: history); + + Content next = Content.text(userMessage); + + for (var round = 0; round < _kMaxToolRounds; round++) { + final calls = []; + UsageMetadata? roundUsage; + await for (final chunk in chat.sendMessageStream(next)) { + final t = chunk.text; + if (t != null && t.isNotEmpty) yield t; + calls.addAll(chunk.functionCalls); + if (chunk.usageMetadata != null) roundUsage = chunk.usageMetadata; + } + // The final chunk of each round carries that round's cumulative usage. + _recordUsage(roundUsage); + + // No tools requested (or no handler) → the streamed text is the answer. + if (calls.isEmpty || onToolCall == null) return; + + // Resolve every requested call and feed the results back as one turn. + final responses = []; + for (final call in calls) { + try { + final result = await onToolCall(call); + responses.add(FunctionResponse(call.name, result)); + } catch (e) { + responses.add(FunctionResponse(call.name, {'error': '$e'})); + } + } + next = Content.functionResponses(responses); } + // Exhausted the tool-round budget without a final text answer. + yield '\n\n_(Stopped after $_kMaxToolRounds tool steps — try rephrasing.)_'; } on GenerativeAIException catch (e) { yield 'AI error: ${e.message}'; } catch (e) { @@ -76,6 +217,7 @@ class GeminiService extends ChangeNotifier { } // ── Program generator (structured JSON output) ──────────────────────────── + @override Future generateProgram({ required String userPrompt, required List allExercises, @@ -143,8 +285,9 @@ Required JSON schema (follow exactly): try { final response = await _makeModel(jsonMode: true, system: systemPrompt) .generateContent([Content.text(prompt)]); + _recordUsage(response.usageMetadata); final raw = response.text ?? ''; - if (raw.isEmpty) throw FormatException('Empty response from Gemini.'); + if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); final data = jsonDecode(raw) as Map; // Ensure a fresh UUID so it never collides with an existing program. @@ -160,6 +303,7 @@ Required JSON schema (follow exactly): } // ── Weekly insights (single-shot text) ──────────────────────────────────── + @override Future generateWeeklyInsights(String contextText) async { if (!isConfigured) { return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; @@ -173,6 +317,7 @@ Required JSON schema (follow exactly): try { final response = await _makeModel(system: systemPrompt) .generateContent([Content.text(contextText)]); + _recordUsage(response.usageMetadata); return response.text?.trim() ?? 'No insights generated.'; } on GenerativeAIException catch (e) { return 'AI error: ${e.message}'; @@ -182,9 +327,7 @@ Required JSON schema (follow exactly): } // ── Generic one-shot insight (contextual) ───────────────────────────────── - // Thin, tool-agnostic helper for on-demand contextual insights (muscle - // drill-down, target suggestions, stalled-target nudges). Kept generic so a - // future function-calling path can be added additively over [_makeModel]. + @override Future generateInsight(String system, String context) async { if (!isConfigured) { return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; @@ -192,6 +335,7 @@ Required JSON schema (follow exactly): try { final response = await _makeModel(system: system) .generateContent([Content.text(context)]); + _recordUsage(response.usageMetadata); return response.text?.trim() ?? 'No insight generated.'; } on GenerativeAIException catch (e) { return 'AI error: ${e.message}'; diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart index 362af1e..a017bb6 100644 --- a/workout-logger/lib/services/gemini_context_builder.dart +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -1,88 +1,49 @@ // gemini_context_builder.dart — Builds rich context strings from app data for Gemini prompts. import '../models/models.dart'; -import 'interfaces/ml_service_interface.dart'; class GeminiContextBuilder { const GeminiContextBuilder._(); // ── Coach system prompt ──────────────────────────────────────────────────── + // + // Deliberately STATIC (no per-turn workout data) so the prefix stays + // byte-identical across a conversation and Gemini's implicit prompt caching + // can engage. All live data is fetched on demand via the coach tools + // (see CoachToolService), not embedded here. static String buildCoachSystemPrompt({ - required List recentSessions, - required Map exerciseMap, - required Map recoveryScores, - required List activeTargets, String? userName, String unitLabel = 'kg', + DateTime? now, }) { + final n = now ?? DateTime.now(); + final today = '${n.year}-${n.month.toString().padLeft(2, '0')}-' + '${n.day.toString().padLeft(2, '0')}'; + final buf = StringBuffer() ..writeln( 'You are an expert personal trainer embedded in RepForge, a workout tracking app.', ) ..writeln( 'Answer concisely (under 180 words unless a plan is requested). ' - 'Be encouraging and specific — always reference the user\'s actual data.', + 'Be encouraging and specific.', + ) + ..writeln('Today is $today. Use this when interpreting relative dates ' + '("last week", "3 months ago").') + ..writeln( + 'This prompt contains NO workout data. To answer anything about the ' + 'user\'s training — exercise progression, workouts in a date range, ' + 'routine performance, personal records, goal progress, or muscle ' + 'recovery — CALL THE PROVIDED TOOLS rather than guessing or inventing ' + 'numbers. Pass ISO dates (YYYY-MM-DD) or a day count to the tools.', + ) + ..writeln( + 'Weights are in $unitLabel. Format replies with Markdown (lists, bold, ' + 'tables) where it aids clarity.', ); if (userName != null && userName.isNotEmpty) { - buf.writeln('\nUser: $userName'); - } - - // Recent sessions - buf.writeln('\n--- RECENT SESSIONS (last 14 days) ---'); - final cutoff = DateTime.now().subtract(const Duration(days: 14)); - final recent = recentSessions - .where((s) => s.date.isAfter(cutoff)) - .toList() - ..sort((a, b) => b.date.compareTo(a.date)); - - if (recent.isEmpty) { - buf.writeln('No sessions in the last 14 days.'); - } else { - for (final s in recent.take(8)) { - final date = '${_weekday(s.date.weekday)} ${s.date.day}/${s.date.month}'; - final exParts = s.exercises.map((e) { - final name = exerciseMap[e.exerciseId]?.name ?? e.exerciseId; - final sets = e.sets - .map((ws) => '${ws.weight}$unitLabel×${ws.reps}') - .join(', '); - return '$name [$sets]'; - }); - buf.writeln('$date: ${exParts.join(' | ')}'); - } - } - - // Muscle recovery - buf.writeln('\n--- MUSCLE RECOVERY ---'); - if (recoveryScores.isEmpty) { - buf.writeln('No recovery data yet.'); - } else { - final sorted = recoveryScores.entries.toList() - ..sort((a, b) => a.value.recoveryPercent.compareTo(b.value.recoveryPercent)); - for (final e in sorted) { - final name = e.key.replaceAll('_', ' '); - final pct = e.value.recoveryPercent; - final tag = e.value.isRecovered - ? 'ready' - : e.value.isUnderRecovered - ? 'fatigued' - : 'recovering'; - buf.writeln('$name: $pct% ($tag)'); - } - } - - // Active goals - buf.writeln('\n--- ACTIVE GOALS ---'); - if (activeTargets.isEmpty) { - buf.writeln('No active goals set.'); - } else { - for (final t in activeTargets) { - final name = exerciseMap[t.exerciseId]?.name ?? t.exerciseId; - final progress = t.progressPercentage.toStringAsFixed(0); - buf.writeln( - '$name: ${t.currentValue}$unitLabel → ${t.targetValue}$unitLabel ($progress%)', - ); - } + buf.writeln('\nThe user\'s name is $userName.'); } return buf.toString(); diff --git a/workout-logger/lib/services/interfaces/ai_service_interface.dart b/workout-logger/lib/services/interfaces/ai_service_interface.dart new file mode 100644 index 0000000..6f0d2d1 --- /dev/null +++ b/workout-logger/lib/services/interfaces/ai_service_interface.dart @@ -0,0 +1,52 @@ +// Abstract AI Service Interface (Dependency Inversion Principle) +// +// Defines the contract for the conversational AI / generation backend. +// High-level modules (the coach ViewModel, program generator) depend on this +// abstraction rather than a concrete SDK, so the backend can be swapped (e.g. +// google_generative_ai today → firebase_ai later) without touching consumers. +// +// The signatures intentionally use the google_generative_ai content model +// (Content / Tool / FunctionCall). firebase_ai exposes an almost identical +// shape, so a future backend swap is a mechanical adapter rather than a rewrite. + +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../../models/models.dart'; + +/// Contract for the AI backend used across RepForge (coach chat, program +/// generation, insights). Implemented by [GeminiAiService] today. +abstract class IAiService { + /// True once an API key (or equivalent credential) has been supplied. + bool get isConfigured; + + /// The model identifier currently in use (e.g. `gemini-3.1-flash-lite`). + String get currentModel; + + /// Stream a coach reply token-by-token. + /// + /// When [tools] and [onToolCall] are provided, the implementation runs a + /// tool-call loop: any function calls the model emits are dispatched through + /// [onToolCall] and their results fed back, until the model produces a final + /// natural-language answer. Only text is yielded to the caller. + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }); + + /// Generate a structured multi-week training program from a natural-language + /// prompt, constrained to the provided exercise catalogue. + Future generateProgram({ + required String userPrompt, + required List allExercises, + }); + + /// One-shot weekly training summary in conversational prose. + Future generateWeeklyInsights(String contextText); + + /// Generic one-shot contextual insight given a [system] instruction and + /// [context] payload. + Future generateInsight(String system, String context); +} diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index 9274ea1..34132f6 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -73,6 +73,13 @@ abstract class IStorageService { Future getPersonalRecord(String exerciseId); Future> getAllPersonalRecords(); + // ==================== AI CONVERSATIONS ==================== + + Future saveConversation(Conversation conversation); + Future> getAllConversations(); + Future getConversation(String id); + Future deleteConversation(String id); + // ==================== EXPORT / IMPORT ==================== Future exportAllData(); diff --git a/workout-logger/lib/services/managers/conversation_manager.dart b/workout-logger/lib/services/managers/conversation_manager.dart new file mode 100644 index 0000000..ed39a52 --- /dev/null +++ b/workout-logger/lib/services/managers/conversation_manager.dart @@ -0,0 +1,118 @@ +// Conversation Manager (Single Responsibility Principle) +// +// Single source of truth for persisted AI coach conversations. Owns the +// in-memory list + the currently active conversation, and mirrors every +// mutation to storage. Does NOT talk to the AI backend — that's the +// AiCoachViewModel's job. + +import 'package:flutter/foundation.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +/// Manages the lifecycle of AI coach [Conversation]s (load, create, append, +/// rename, delete) backed by [IStorageService]. +class ConversationManager extends ChangeNotifier { + final IStorageService _storage; + + List _conversations = []; + Conversation? _active; + + ConversationManager(this._storage); + + /// All conversations, most-recently-updated first. + List get conversations => List.unmodifiable(_conversations); + + /// The conversation currently shown in the coach screen, or null for a + /// fresh (unsaved) chat. + Conversation? get active => _active; + + /// Messages of the active conversation (empty for a fresh chat). + List get activeMessages => _active?.messages ?? const []; + + /// Load all conversations from storage. Does not change the active one. + Future loadConversations() async { + _conversations = await _storage.getAllConversations(); + notifyListeners(); + } + + /// Begin a fresh conversation. Nothing is persisted until the first message + /// is appended (avoids littering storage with empty chats). + void startNewConversation() { + _active = null; + notifyListeners(); + } + + /// Make [id] the active conversation, if it exists. + void selectConversation(String id) { + final idx = _conversations.indexWhere((c) => c.id == id); + if (idx < 0) return; + _active = _conversations[idx]; + notifyListeners(); + } + + /// Append [message] to the active conversation, creating one if needed, + /// then persist. The conversation title is derived from the first user + /// message. Bumps `updatedAt` and re-sorts the list newest-first. + Future appendMessage(ChatMessage message) async { + final current = _active; + final Conversation updated; + + if (current == null) { + updated = Conversation( + title: _deriveTitle(message), + messages: [message], + ); + } else { + final title = current.title.isEmpty && message.role == 'user' + ? _deriveTitle(message) + : current.title; + updated = current.copyWith( + title: title, + updatedAt: DateTime.now(), + messages: [...current.messages, message], + ); + } + + _active = updated; + _upsert(updated); + await _storage.saveConversation(updated); + notifyListeners(); + } + + /// Rename a conversation. + Future renameConversation(String id, String title) async { + final idx = _conversations.indexWhere((c) => c.id == id); + if (idx < 0) return; + final updated = _conversations[idx].copyWith( + title: title.trim(), + updatedAt: DateTime.now(), + ); + if (_active?.id == id) _active = updated; + _upsert(updated); + await _storage.saveConversation(updated); + notifyListeners(); + } + + /// Delete a conversation. Clears the active one if it was deleted. + Future deleteConversation(String id) async { + _conversations = _conversations.where((c) => c.id != id).toList(); + if (_active?.id == id) _active = null; + await _storage.deleteConversation(id); + notifyListeners(); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + void _upsert(Conversation conversation) { + final next = _conversations.where((c) => c.id != conversation.id).toList() + ..add(conversation) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + _conversations = next; + } + + String _deriveTitle(ChatMessage message) { + final text = message.text.trim().replaceAll(RegExp(r'\s+'), ' '); + if (text.isEmpty) return 'New chat'; + return text.length <= 40 ? text : '${text.substring(0, 40).trim()}…'; + } +} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 40cdc39..873de63 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -25,6 +25,7 @@ class StorageService implements IStorageService { static const String _settingsBox = 'settings'; static const String _trainingProgramsBox = 'training_programs'; static const String _personalRecordsBox = 'personal_records'; + static const String _aiConversationsBox = 'ai_conversations'; late Box _sessionsBox; late Box _routinesBoxInstance; @@ -34,6 +35,7 @@ class StorageService implements IStorageService { late Box _settingsBoxInstance; late Box _trainingProgramsBoxInstance; late Box _personalRecordsBoxInstance; + late Box _aiConversationsBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -73,6 +75,9 @@ class StorageService implements IStorageService { _personalRecordsBoxInstance = await Hive.openBox( _personalRecordsBox, ); + _aiConversationsBoxInstance = await Hive.openBox( + _aiConversationsBox, + ); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -364,6 +369,9 @@ class StorageService implements IStorageService { 'customExercises': _customExercisesBoxInstance.values .map(_normalizeExportValue) .toList(growable: false), + 'conversations': _aiConversationsBoxInstance.values + .map(_normalizeExportValue) + .toList(growable: false), 'settings': settingsMap, 'exportDate': DateTime.now().toIso8601String(), 'appVersion': _appVersion, @@ -455,6 +463,20 @@ class StorageService implements IStorageService { } } } + + // Import AI conversations (merge: skip if id already exists) + final conversations = data['conversations']; + if (conversations is List) { + for (var item in conversations) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final conversation = Conversation.fromJson(map); + final existing = await getConversation(conversation.id); + if (existing == null) { + await saveConversation(conversation); + } + } + } } // ==================== TRAINING PROGRAMS ==================== @@ -515,6 +537,39 @@ class StorageService implements IStorageService { return records; } + // ==================== AI CONVERSATIONS ==================== + + @override + Future saveConversation(Conversation conversation) async { + await _aiConversationsBoxInstance.put( + conversation.id, + jsonEncode(conversation.toJson()), + ); + } + + @override + Future> getAllConversations() async { + final conversations = []; + for (final json in _aiConversationsBoxInstance.values) { + conversations.add(Conversation.fromJson(jsonDecode(json))); + } + // Most recently updated first. + conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return conversations; + } + + @override + Future getConversation(String id) async { + final json = _aiConversationsBoxInstance.get(id); + if (json == null) return null; + return Conversation.fromJson(jsonDecode(json)); + } + + @override + Future deleteConversation(String id) async { + await _aiConversationsBoxInstance.delete(id); + } + // ==================== STATS ==================== @override diff --git a/workout-logger/lib/viewmodels/ai_coach_view_model.dart b/workout-logger/lib/viewmodels/ai_coach_view_model.dart new file mode 100644 index 0000000..036154c --- /dev/null +++ b/workout-logger/lib/viewmodels/ai_coach_view_model.dart @@ -0,0 +1,143 @@ +// ai_coach_view_model.dart — orchestration for the AI coach screen. +// +// Owns all coach logic so the View stays dumb: builds the system prompt, +// drives the streaming tool-call loop via IAiService + CoachToolService, and +// persists each turn through ConversationManager. Exposes immutable state. + +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' show Content, TextPart; + +import '../models/models.dart'; +import '../services/interfaces/ai_service_interface.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/settings_provider.dart'; +import '../services/gemini_context_builder.dart'; + +class AiCoachViewModel extends ChangeNotifier { + final IAiService _ai; + final CoachToolService _coachTools; + final ConversationManager _conversations; + final SettingsProvider _settings; + + bool _loading = false; + String _streamingText = ''; + + AiCoachViewModel({ + required IAiService ai, + required CoachToolService coachTools, + required ConversationManager conversations, + required SettingsProvider settings, + }) : _ai = ai, + _coachTools = coachTools, + _conversations = conversations, + _settings = settings { + // Forward conversation-store changes so the View only watches the VM. + _conversations.addListener(notifyListeners); + } + + @override + void dispose() { + _conversations.removeListener(notifyListeners); + super.dispose(); + } + + // ── Exposed state (immutable snapshots) ──────────────────────────────────── + + bool get isConfigured => _ai.isConfigured; + bool get isLoading => _loading; + String get streamingText => _streamingText; + List get messages => _conversations.activeMessages; + List get conversations => _conversations.conversations; + String? get activeConversationId => _conversations.active?.id; + + // ── Commands ─────────────────────────────────────────────────────────────── + + /// Load the persisted conversation list (call when the screen opens). + Future loadConversations() => _conversations.loadConversations(); + + /// Start a fresh, unsaved conversation. + void newConversation() { + if (_loading) return; + _conversations.startNewConversation(); + } + + /// Switch to an existing conversation. + void selectConversation(String id) { + if (_loading) return; + _conversations.selectConversation(id); + } + + /// Delete a conversation. + Future deleteConversation(String id) => + _conversations.deleteConversation(id); + + /// Send a user message and stream the coach's reply (running the tool-call + /// loop). Both the user message and the final reply are persisted. + Future sendMessage(String text) async { + final trimmed = text.trim(); + if (trimmed.isEmpty || _loading) return; + + _loading = true; + _streamingText = ''; + notifyListeners(); + + // Persist the user message first; history is derived from the store. + await _conversations.appendMessage( + ChatMessage(role: 'user', text: trimmed), + ); + + final systemPrompt = _buildSystemPrompt(); + final history = _buildHistory(); + + final buffer = StringBuffer(); + try { + await for (final chunk in _ai.streamCoachReply( + userMessage: trimmed, + systemPrompt: systemPrompt, + history: history, + tools: _coachTools.buildTools(), + onToolCall: _coachTools.handleCall, + )) { + buffer.write(chunk); + _streamingText = buffer.toString(); + notifyListeners(); + } + final reply = buffer.toString().trim(); + if (reply.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: reply), + ); + } + } catch (e) { + buffer.write('\n\n_Error: ${e}_'); + final errText = buffer.toString().trim(); + if (errText.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: errText), + ); + } + } finally { + _streamingText = ''; + _loading = false; + notifyListeners(); + } + } + + // ── Internals ────────────────────────────────────────────────────────────── + + // Static prompt — live data is fetched by the model via the coach tools, + // keeping the prefix stable for implicit prompt caching. + String _buildSystemPrompt() => GeminiContextBuilder.buildCoachSystemPrompt( + userName: _settings.userName, + unitLabel: _settings.unitLabel, + ); + + /// Prior turns (everything before the user message just appended). + List _buildHistory() { + final msgs = _conversations.activeMessages; + final prior = + msgs.length > 1 ? msgs.sublist(0, msgs.length - 1) : []; + return prior.map((m) => Content(m.role, [TextPart(m.text)])).toList(); + } +} diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 65ed2c6..d4a544d 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: file_picker: ^10.3.10 path_provider: ^2.1.5 share_plus: ^12.0.1 + gpt_markdown: ^1.1.7 dev_dependencies: flutter_test: diff --git a/workout-logger/test/ai_coach_view_model_test.dart b/workout-logger/test/ai_coach_view_model_test.dart new file mode 100644 index 0000000..2832a2a --- /dev/null +++ b/workout-logger/test/ai_coach_view_model_test.dart @@ -0,0 +1,148 @@ +// Unit tests for AiCoachViewModel — verifies orchestration (send → stream → +// persist) using a fake IAiService, so the View has no logic left to test. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, Tool, FunctionCall; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/ai_service_interface.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/viewmodels/ai_coach_view_model.dart'; +import 'test_utils/mock_storage_service.dart'; + +/// Scripted IAiService: yields fixed chunks; optionally invokes a tool first. +class _FakeAiService implements IAiService { + _FakeAiService({this.chunks = const ['Hello ', 'world'], this.invokeTool = false}); + + final List chunks; + final bool invokeTool; + int toolCallsMade = 0; + + @override + bool get isConfigured => true; + + @override + String get currentModel => 'fake-model'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + if (invokeTool && onToolCall != null) { + await onToolCall(FunctionCall('get_muscle_recovery', {})); + toolCallsMade++; + } + for (final c in chunks) { + yield c; + } + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => + throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +void main() { + group('AiCoachViewModel', () { + late MockStorageService storage; + late WorkoutProvider provider; + late ConversationManager conversations; + late SettingsProvider settings; + late PRManager pr; + + Future buildVm(_FakeAiService ai) async { + provider = WorkoutProvider( + storage, + programManager: ProgramManager(storage), + ); + await provider.init(); + pr = PRManager(storage); + settings = SettingsProvider(storage); + conversations = ConversationManager(storage); + return AiCoachViewModel( + ai: ai, + coachTools: CoachToolService(provider, pr), + conversations: conversations, + settings: settings, + ); + } + + setUp(() { + storage = MockStorageService(); + }); + + test('sendMessage appends user + model messages and persists', () async { + final vm = await buildVm(_FakeAiService()); + + await vm.sendMessage('How am I doing?'); + + expect(vm.messages, hasLength(2)); + expect(vm.messages[0].role, 'user'); + expect(vm.messages[0].text, 'How am I doing?'); + expect(vm.messages[1].role, 'model'); + expect(vm.messages[1].text, 'Hello world'); + expect(vm.isLoading, isFalse); + expect(vm.streamingText, isEmpty); + + // Persisted. + final stored = await storage.getAllConversations(); + expect(stored, hasLength(1)); + expect(stored.first.messages, hasLength(2)); + }); + + test('blank or whitespace messages are ignored', () async { + final vm = await buildVm(_FakeAiService()); + await vm.sendMessage(' '); + expect(vm.messages, isEmpty); + }); + + test('runs the tool-call loop via CoachToolService', () async { + final ai = _FakeAiService(invokeTool: true, chunks: const ['done']); + final vm = await buildVm(ai); + + await vm.sendMessage('what can I train?'); + + expect(ai.toolCallsMade, 1); + expect(vm.messages.last.text, 'done'); + }); + + test('newConversation then selectConversation swaps active state', + () async { + final vm = await buildVm(_FakeAiService()); + + await vm.sendMessage('first chat'); + final firstId = vm.activeConversationId; + expect(firstId, isNotNull); + + vm.newConversation(); + expect(vm.messages, isEmpty); + + await vm.sendMessage('second chat'); + final secondId = vm.activeConversationId; + expect(secondId, isNot(firstId)); + expect(vm.conversations, hasLength(2)); + + vm.selectConversation(firstId!); + expect(vm.activeConversationId, firstId); + expect(vm.messages.first.text, 'first chat'); + }); + }); +} diff --git a/workout-logger/test/analytics_screen_test.dart b/workout-logger/test/analytics_screen_test.dart index 038c2a9..fbfb123 100644 --- a/workout-logger/test/analytics_screen_test.dart +++ b/workout-logger/test/analytics_screen_test.dart @@ -11,7 +11,7 @@ import 'package:repforge/models/models.dart'; import 'package:repforge/screens/analytics_screen.dart'; import 'package:repforge/services/workout_provider.dart'; import 'package:repforge/services/settings_provider.dart'; -import 'package:repforge/services/gemini_service.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; import 'package:repforge/services/managers/program_manager.dart'; import 'package:repforge/services/managers/pr_manager.dart'; import 'package:repforge/services/interfaces/ml_service_interface.dart'; @@ -31,7 +31,7 @@ Widget _wrap({ ChangeNotifierProvider.value(value: workoutProvider), ChangeNotifierProvider.value(value: sp), ChangeNotifierProvider.value(value: prManager), - ChangeNotifierProvider.value(value: GeminiService()), + ChangeNotifierProvider.value(value: GeminiAiService()), Provider.value(value: MockMLService()), ], child: const MaterialApp(home: AnalyticsScreen()), diff --git a/workout-logger/test/coach_tool_service_test.dart b/workout-logger/test/coach_tool_service_test.dart new file mode 100644 index 0000000..4cecf55 --- /dev/null +++ b/workout-logger/test/coach_tool_service_test.dart @@ -0,0 +1,170 @@ +// Unit tests for CoachToolService — each tool returns expected JSON shapes, +// backed by a seeded WorkoutProvider + PRManager. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' show FunctionCall; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('CoachToolService', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager pr; + late CoachToolService tools; + + WorkoutSession benchSession(DateTime date, double weight, {String? routineId}) { + return WorkoutSession( + id: 'sess-${date.millisecondsSinceEpoch}', + date: date, + routineId: routineId, + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: weight, reps: 8), + WorkoutSet(weight: weight, reps: 8), + ], + ), + ], + ); + } + + setUp(() async { + storage = MockStorageService(); + + // Two bench sessions on different days → enough for a growth model. + final now = DateTime.now(); + storage.addMockRoutine( + Routine(id: 'r1', name: 'Push Day', exerciseIds: ['bench_press']), + ); + storage.addMockSession( + benchSession(now.subtract(const Duration(days: 10)), 60, routineId: 'r1'), + ); + storage.addMockSession( + benchSession(now.subtract(const Duration(days: 3)), 65, routineId: 'r1'), + ); + + provider = WorkoutProvider( + storage, + programManager: ProgramManager(storage), + ); + await provider.init(); + + pr = PRManager(storage); + await pr.backfillFromSessions(provider.sessions); + + tools = CoachToolService(provider, pr); + }); + + test('exposes the expected tool declarations', () { + final declared = tools + .buildTools() + .expand((t) => t.functionDeclarations ?? []) + .map((f) => f.name) + .toSet(); + expect( + declared, + containsAll([ + 'get_exercise_performance', + 'get_workouts_in_range', + 'get_routine_performance', + 'get_personal_records', + 'get_goal_progress', + 'get_muscle_recovery', + ]), + ); + }); + + test('get_exercise_performance returns trend + PR for a known exercise', + () async { + final result = await tools.handleCall( + FunctionCall('get_exercise_performance', {'exercise_name': 'Bench Press'}), + ); + + expect(result['exercise'], 'Bench Press'); + expect(result['session_count'], 2); + expect(result['volume_trend'], isA>()); + expect((result['volume_trend'] as List), isNotEmpty); + expect(result['personal_record'], isNotNull); + }); + + test('get_exercise_performance returns an error for an unknown exercise', + () async { + final result = await tools.handleCall( + FunctionCall('get_exercise_performance', {'exercise_name': 'Nonexistent'}), + ); + expect(result['error'], isNotNull); + expect(result['available_examples'], isA>()); + }); + + test('get_workouts_in_range summarizes sessions in the window', () async { + final result = await tools.handleCall( + FunctionCall('get_workouts_in_range', {'days': 30}), + ); + expect(result['session_count'], 2); + expect(result['total_volume'], isA()); + expect((result['total_volume'] as num) > 0, isTrue); + }); + + test('get_routine_performance returns sessions logged against the routine', + () async { + final result = await tools.handleCall( + FunctionCall('get_routine_performance', {'routine_name': 'Push Day'}), + ); + expect(result['routine'], 'Push Day'); + expect(result['session_count'], 2); + expect(result['exercises'], contains('Bench Press')); + }); + + test('get_routine_performance errors for an unknown routine', () async { + final result = await tools.handleCall( + FunctionCall('get_routine_performance', {'routine_name': 'Leg Day'}), + ); + expect(result['error'], isNotNull); + expect(result['available_routines'], contains('Push Day')); + }); + + test('get_personal_records returns all records when unfiltered', () async { + final result = await tools.handleCall( + FunctionCall('get_personal_records', {}), + ); + final records = result['records'] as List; + expect(records, isNotEmpty); + expect((records.first as Map)['exercise'], 'Bench Press'); + }); + + test('get_goal_progress reflects active targets', () async { + await provider.createTarget( + exerciseId: 'bench_press', + type: 'weight', + targetValue: 100, + ); + + final result = await tools.handleCall( + FunctionCall('get_goal_progress', {'exercise_name': 'Bench Press'}), + ); + final goals = result['goals'] as List; + expect(goals, hasLength(1)); + expect((goals.first as Map)['type'], 'weight'); + expect((goals.first as Map)['target_value'], 100); + }); + + test('get_muscle_recovery returns per-muscle status', () async { + final result = await tools.handleCall( + FunctionCall('get_muscle_recovery', {}), + ); + final muscles = result['muscles'] as List; + expect(muscles, isNotEmpty); + final first = muscles.first as Map; + expect(first['muscle'], isA()); + expect(first['recovery_percent'], isA()); + expect(first['status'], isA()); + }); + }); +} diff --git a/workout-logger/test/conversation_manager_test.dart b/workout-logger/test/conversation_manager_test.dart new file mode 100644 index 0000000..e533313 --- /dev/null +++ b/workout-logger/test/conversation_manager_test.dart @@ -0,0 +1,117 @@ +// Unit tests for ConversationManager — persistence + active-conversation logic. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('ConversationManager', () { + late MockStorageService storage; + late ConversationManager manager; + + setUp(() { + storage = MockStorageService(); + manager = ConversationManager(storage); + }); + + test('appendMessage creates a conversation and persists it', () async { + await manager.appendMessage( + ChatMessage(role: 'user', text: 'How is my bench press?'), + ); + + expect(manager.active, isNotNull); + expect(manager.activeMessages, hasLength(1)); + + // Persisted to storage. + final stored = await storage.getAllConversations(); + expect(stored, hasLength(1)); + expect(stored.first.messages.first.text, 'How is my bench press?'); + }); + + test('title is derived from the first user message', () async { + await manager.appendMessage( + ChatMessage(role: 'user', text: 'Plan my next push day please'), + ); + expect(manager.active!.title, 'Plan my next push day please'); + }); + + test('long first message title is truncated', () async { + final long = 'a' * 80; + await manager.appendMessage(ChatMessage(role: 'user', text: long)); + expect(manager.active!.title.length, lessThanOrEqualTo(41)); + expect(manager.active!.title.endsWith('…'), isTrue); + }); + + test('multiple messages append to the same active conversation', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + await manager.appendMessage(ChatMessage(role: 'model', text: 'hello!')); + + expect(manager.activeMessages, hasLength(2)); + final stored = await storage.getAllConversations(); + expect(stored, hasLength(1)); + expect(stored.first.messages, hasLength(2)); + }); + + test('reload restores conversations from storage', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'first')); + + final fresh = ConversationManager(storage); + await fresh.loadConversations(); + expect(fresh.conversations, hasLength(1)); + expect(fresh.conversations.first.messages.first.text, 'first'); + }); + + test('conversations are sorted most-recently-updated first', () async { + // Small delays keep updatedAt timestamps distinct (millisecond clock). + await manager.appendMessage(ChatMessage(role: 'user', text: 'older')); + final olderId = manager.active!.id; + + await Future.delayed(const Duration(milliseconds: 5)); + manager.startNewConversation(); + await manager.appendMessage(ChatMessage(role: 'user', text: 'newer')); + final newerId = manager.active!.id; + + expect(manager.conversations.first.id, newerId); + + // Touching the older one bumps it to the front. + await Future.delayed(const Duration(milliseconds: 5)); + manager.selectConversation(olderId); + await manager.appendMessage(ChatMessage(role: 'model', text: 'reply')); + expect(manager.conversations.first.id, olderId); + }); + + test('startNewConversation clears the active conversation', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + expect(manager.active, isNotNull); + + manager.startNewConversation(); + expect(manager.active, isNull); + expect(manager.activeMessages, isEmpty); + // The prior conversation is still saved. + expect(manager.conversations, hasLength(1)); + }); + + test('deleteConversation removes it and clears active when needed', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + final id = manager.active!.id; + + await manager.deleteConversation(id); + + expect(manager.active, isNull); + expect(manager.conversations, isEmpty); + expect(await storage.getAllConversations(), isEmpty); + }); + + test('renameConversation updates the title and persists', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + final id = manager.active!.id; + + await manager.renameConversation(id, 'My chat'); + + expect(manager.active!.title, 'My chat'); + final stored = await storage.getConversation(id); + expect(stored!.title, 'My chat'); + }); + }); +} diff --git a/workout-logger/test/exercise_progress_view_test.dart b/workout-logger/test/exercise_progress_view_test.dart index 2913cbe..f8f1ba4 100644 --- a/workout-logger/test/exercise_progress_view_test.dart +++ b/workout-logger/test/exercise_progress_view_test.dart @@ -17,7 +17,7 @@ import 'package:provider/provider.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/workout_provider.dart'; import 'package:repforge/services/settings_provider.dart'; -import 'package:repforge/services/gemini_service.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; import 'package:repforge/services/managers/program_manager.dart'; import 'package:repforge/services/interfaces/ml_service_interface.dart'; import 'package:repforge/screens/widgets/exercise_progress_view.dart'; @@ -38,7 +38,7 @@ Widget _wrap({ providers: [ ChangeNotifierProvider.value(value: provider), ChangeNotifierProvider.value(value: sp), - ChangeNotifierProvider.value(value: GeminiService()), + ChangeNotifierProvider.value(value: GeminiAiService()), Provider.value(value: MockMLService()), ], child: MaterialApp(home: Scaffold(body: child)), diff --git a/workout-logger/test/gemini_ai_service_usage_test.dart b/workout-logger/test/gemini_ai_service_usage_test.dart new file mode 100644 index 0000000..046bdae --- /dev/null +++ b/workout-logger/test/gemini_ai_service_usage_test.dart @@ -0,0 +1,59 @@ +// Unit tests for GeminiAiService token-usage tracking + persistence. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('GeminiAiService token usage', () { + late MockStorageService storage; + late GeminiAiService service; + + setUp(() { + storage = MockStorageService(); + service = GeminiAiService(storage: storage); + }); + + test('starts at zero', () { + expect(service.totalTokensUsed, 0); + expect(service.promptTokensUsed, 0); + expect(service.responseTokensUsed, 0); + expect(service.aiRequestCount, 0); + }); + + test('recordUsage accumulates across calls', () { + service.recordUsage(prompt: 10, response: 5, total: 15); + service.recordUsage(prompt: 20, response: 10, total: 30); + + expect(service.promptTokensUsed, 30); + expect(service.responseTokensUsed, 15); + expect(service.totalTokensUsed, 45); + expect(service.aiRequestCount, 2); + }); + + test('usage is persisted and reloaded by a fresh instance', () async { + await service.recordUsage(prompt: 100, response: 40, total: 140); + + final reloaded = GeminiAiService(storage: storage); + await reloaded.loadUsage(); + + expect(reloaded.promptTokensUsed, 100); + expect(reloaded.responseTokensUsed, 40); + expect(reloaded.totalTokensUsed, 140); + expect(reloaded.aiRequestCount, 1); + }); + + test('resetUsage zeros counters and persists', () async { + await service.recordUsage(prompt: 100, response: 40, total: 140); + await service.resetUsage(); + + expect(service.totalTokensUsed, 0); + expect(service.aiRequestCount, 0); + + final reloaded = GeminiAiService(storage: storage); + await reloaded.loadUsage(); + expect(reloaded.totalTokensUsed, 0); + expect(reloaded.aiRequestCount, 0); + }); + }); +} diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index 9685c3b..2f0a9c3 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -21,6 +21,7 @@ class MockStorageService implements IStorageService { final Map _settings = {}; final List _trainingPrograms = []; final Map _personalRecords = {}; + final Map _conversations = {}; bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; @@ -279,6 +280,26 @@ class MockStorageService implements IStorageService { Future> getAllPersonalRecords() async => List.from(_personalRecords.values); + @override + Future saveConversation(Conversation conversation) async { + _conversations[conversation.id] = conversation; + } + + @override + Future> getAllConversations() async { + final list = _conversations.values.toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return list; + } + + @override + Future getConversation(String id) async => _conversations[id]; + + @override + Future deleteConversation(String id) async { + _conversations.remove(id); + } + @override Future exportAllData() async => '{}'; From a895686b7b64d6b6a0da615a31c23e533502e7b2 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:10:59 +0530 Subject: [PATCH 29/44] feat: Add muscle volume trend chart and recent sessions section in MuscleDetailSheet --- .../screens/widgets/muscle_detail_sheet.dart | 336 ++++++++++++++++++ .../lib/services/workout_provider.dart | 64 ++++ 2 files changed, 400 insertions(+) diff --git a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart index 5fa8f97..832411b 100644 --- a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart +++ b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart @@ -2,9 +2,14 @@ // Shows weekly contributing exercises (volume + growth trend) and an // on-demand AI insight via GeminiService.generateInsight. +import 'dart:math' show max; + import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; @@ -217,6 +222,18 @@ class MuscleDetailSheet extends StatelessWidget { ); }), + const SizedBox(height: AppSpacing.lg), + _MuscleVolumeTrendChart( + muscleId: muscleId, + provider: provider, + ), + + const SizedBox(height: AppSpacing.lg), + _RecentMuscleSessionsSection( + muscleId: muscleId, + provider: provider, + ), + const SizedBox(height: AppSpacing.md), _AiInsightSection( muscleId: muscleId, @@ -229,6 +246,325 @@ class MuscleDetailSheet extends StatelessWidget { } } +// ── Volume trend chart ──────────────────────────────────────────────────────── + +// Provider-aware shell — fetches data, delegates rendering to _VolumeTrendChartView. +class _MuscleVolumeTrendChart extends StatelessWidget { + const _MuscleVolumeTrendChart({ + required this.muscleId, + required this.provider, + }); + + final String muscleId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return _VolumeTrendChartView( + muscleId: muscleId, + series: provider.getMuscleWeeklyVolumeSeries(muscleId, weeks: 8), + toDisplay: settings.toDisplay, + unitLabel: settings.unitLabel, + ); + } +} + +// Pure presentation widget — no provider dependencies; previewable. +class _VolumeTrendChartView extends StatelessWidget { + const _VolumeTrendChartView({ + required this.muscleId, + required this.series, + required this.toDisplay, + required this.unitLabel, + }); + + final String muscleId; + final List<({DateTime weekStart, double volume})> series; + final double Function(double) toDisplay; + final String unitLabel; + + @override + Widget build(BuildContext context) { + final color = AppColors.muscle(muscleId); + final hasData = series.any((p) => p.volume > 0); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader('Volume trend (8 wk)', bottomPad: false), + const SizedBox(height: AppSpacing.sm), + if (!hasData) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Text( + 'Not enough data yet.', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + SizedBox( + height: 96, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: series.map((p) => toDisplay(p.volume)).fold(0.0, max) * 1.25, + barTouchData: BarTouchData(enabled: false), + titlesData: FlTitlesData( + show: true, + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final i = value.toInt(); + if (i < 0 || i >= series.length) return const SizedBox.shrink(); + if (i != 0 && i != series.length - 1) return const SizedBox.shrink(); + return Text( + DateFormat('MMM d').format(series[i].weekStart), + style: GoogleFonts.geistMono( + color: AppColors.textFaint, + fontSize: 9, + ), + ); + }, + reservedSize: 16, + ), + ), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barGroups: series.asMap().entries.map((entry) { + final vol = toDisplay(entry.value.volume); + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: vol, + color: vol > 0 + ? color.withValues(alpha: 0.85) + : AppColors.glass2, + width: 10, + borderRadius: BorderRadius.circular(3), + ), + ], + ); + }).toList(), + ), + ), + ), + ], + ); + } +} + +// ── Recent sessions for this muscle ─────────────────────────────────────────── + +// Provider-aware shell — fetches data, delegates rendering to _RecentSessionsView. +class _RecentMuscleSessionsSection extends StatelessWidget { + const _RecentMuscleSessionsSection({ + required this.muscleId, + required this.provider, + }); + + final String muscleId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return _RecentSessionsView( + sessions: provider.getRecentMuscleSessionSummaries(muscleId), + toDisplay: settings.toDisplay, + unitLabel: settings.unitLabel, + ); + } +} + +// Pure presentation widget — no provider dependencies; previewable. +class _RecentSessionsView extends StatelessWidget { + const _RecentSessionsView({ + required this.sessions, + required this.toDisplay, + required this.unitLabel, + }); + + final List<({DateTime date, List exerciseNames, double volume})> sessions; + final double Function(double) toDisplay; + final String unitLabel; + + String _relativeDate(DateTime date) { + final diff = DateTime.now().difference(date).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + if (diff < 7) return '$diff days ago'; + if (diff < 14) return '1 week ago'; + return DateFormat('MMM d').format(date); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader('Recent sessions', bottomPad: false), + const SizedBox(height: AppSpacing.sm), + if (sessions.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Text( + 'No sessions recorded for this muscle yet.', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ...sessions.map((s) { + final displayVol = toDisplay(s.volume); + final volStr = displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0); + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _relativeDate(s.date), + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + s.exerciseNames.join(' · '), + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Text( + '$volStr $unitLabel', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }), + ], + ); + } +} + +// ── Widget previews ─────────────────────────────────────────────────────────── + +Widget _previewScaffold(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.darkTheme, + home: Scaffold( + backgroundColor: AppColors.background, + body: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + +List<({DateTime weekStart, double volume})> _stubSeries() { + final now = DateTime.now(); + const vols = [1200.0, 1450.0, 980.0, 1600.0, 1750.0, 1400.0, 1900.0, 2100.0]; + return List.generate( + 8, + (i) => ( + weekStart: now.subtract(Duration(days: (8 - i) * 7)), + volume: vols[i], + ), + ); +} + +List<({DateTime date, List exerciseNames, double volume})> + _stubSessions() { + final now = DateTime.now(); + return [ + (date: now, exerciseNames: ['Bench Press', 'Incline Dumbbell Press'], volume: 4200), + (date: now.subtract(const Duration(days: 3)), exerciseNames: ['Cable Fly'], volume: 1800), + (date: now.subtract(const Duration(days: 7)), exerciseNames: ['Bench Press', 'Push-Up'], volume: 3900), + (date: now.subtract(const Duration(days: 14)), exerciseNames: ['Bench Press'], volume: 3600), + ]; +} + +@Preview(name: 'Volume Trend – growing', group: 'MuscleDetailSheet') +Widget previewVolumeTrend() => _previewScaffold( + _VolumeTrendChartView( + muscleId: 'chest', + series: _stubSeries(), + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +@Preview(name: 'Volume Trend – empty', group: 'MuscleDetailSheet') +Widget previewVolumeTrendEmpty() => _previewScaffold( + _VolumeTrendChartView( + muscleId: 'chest', + series: List.generate( + 8, + (i) => (weekStart: DateTime.now().subtract(Duration(days: (8 - i) * 7)), volume: 0.0), + ), + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +@Preview(name: 'Recent Sessions – with data', group: 'MuscleDetailSheet') +Widget previewRecentSessions() => _previewScaffold( + _RecentSessionsView( + sessions: _stubSessions(), + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +@Preview(name: 'Recent Sessions – empty', group: 'MuscleDetailSheet') +Widget previewRecentSessionsEmpty() => _previewScaffold( + _RecentSessionsView( + sessions: const [], + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + // ── AI insight section ───────────────────────────────────────────────────────── class _AiInsightSection extends StatefulWidget { diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 485b13b..62418a3 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -1080,6 +1080,70 @@ class WorkoutProvider extends ChangeNotifier { return result; } + /// Last [limit] sessions where [muscleId] was trained (newest-first). + List<({DateTime date, List exerciseNames, double volume})> + getRecentMuscleSessionSummaries(String muscleId, {int limit = 6}) { + final exerciseMap = { + for (final e in _allExercises) e.id: e, + }; + final result = <({DateTime date, List exerciseNames, double volume})>[]; + for (final session in _sessions) { + if (result.length >= limit) break; + final names = []; + double vol = 0; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + final activation = exercise.muscleActivations + .where((a) => a.muscleGroupId == muscleId) + .firstOrNull; + if (activation == null) continue; + names.add(exercise.name); + vol += log.totalVolume * (activation.activationPercentage / 100); + } + if (names.isNotEmpty) { + result.add((date: session.date, exerciseNames: names, volume: vol)); + } + } + return result; + } + + /// Weekly volume for [muscleId] over the last [weeks] weeks, oldest-first. + List<({DateTime weekStart, double volume})> getMuscleWeeklyVolumeSeries( + String muscleId, { + int weeks = 8, + }) { + final now = DateTime.now(); + final exerciseMap = { + for (final e in _allExercises) e.id: e, + }; + final buckets = {}; + final cutoff = now.subtract(Duration(days: weeks * 7)); + + for (final session in _sessions) { + if (session.date.isBefore(cutoff)) continue; + final weekIndex = now.difference(session.date).inDays ~/ 7; + if (weekIndex >= weeks) continue; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + for (final activation in exercise.muscleActivations) { + if (activation.muscleGroupId != muscleId) continue; + buckets[weekIndex] = (buckets[weekIndex] ?? 0) + + log.totalVolume * (activation.activationPercentage / 100); + } + } + } + + // weekIndex 0 = this week, weeks-1 = oldest; return oldest-first + return List.generate(weeks, (i) => weeks - 1 - i) + .map((wi) => ( + weekStart: now.subtract(Duration(days: (wi + 1) * 7)), + volume: buckets[wi] ?? 0, + )) + .toList(); + } + // ==================== QUICK STATS ==================== Future> getQuickStats() async { From 21953c1d1680732840a76186cac4ff8d1318e353 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:54:44 +0530 Subject: [PATCH 30/44] docs: add conversational routine optimizer design spec Replaces the one-shot optimizer sheet with a dedicated conversational optimizer screen that reuses the coach streaming tool-loop, adds an interactive ask_user_questions tool, gates on insufficient data, and persists sessions to a separate optimizer inbox. Co-Authored-By: Claude Opus 4.8 --- ...conversational-routine-optimizer-design.md | 258 +++++++++ workout-logger/lib/models/models.dart | 74 +++ .../lib/screens/routines_screen.dart | 21 + .../widgets/routine_optimization_sheet.dart | 535 ++++++++++++++++++ .../lib/services/ai/coach_tool_service.dart | 193 +++++++ .../lib/services/ai/gemini_ai_service.dart | 57 ++ .../interfaces/ai_service_interface.dart | 6 + .../lib/services/workout_provider.dart | 3 +- .../routine_optimizer_view_model.dart | 214 +++++++ .../test/ai_coach_view_model_test.dart | 5 + 10 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md create mode 100644 workout-logger/lib/screens/widgets/routine_optimization_sheet.dart create mode 100644 workout-logger/lib/viewmodels/routine_optimizer_view_model.dart diff --git a/docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md b/docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md new file mode 100644 index 0000000..b1daf88 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md @@ -0,0 +1,258 @@ +# Conversational Routine Optimizer — Design + +**Date:** 2026-06-08 +**Status:** Approved (pending spec review) + +## Context + +RepForge has an AI coach (streaming chat with a function-calling tool loop) and, +from a prior session, a **standalone one-shot routine optimizer**: tapping +"Optimize" on a routine card opened a bottom sheet that made a single +`generateOptimization()` JSON call and rendered accept/reject suggestion cards. + +That one-shot sheet has three gaps we now want to close: + +1. **No clarifying questions.** It guesses user intent (goal, frequency, which + exercises to keep) instead of asking. +2. **No data gate.** It will "optimize" a routine that has never been logged. +3. **No conversation / history.** It is isolated from the coach's + streaming + tool-call + persistence machinery and keeps no record. + +This redesign replaces the one-shot sheet with a **dedicated conversational +optimizer screen** that reuses the coach's existing streaming tool-loop, adds an +interactive `ask_user_questions` tool (Claude-Code style: a question + 3–4 +option chips + custom text input), gates on insufficient data, and persists each +optimization session to its own history "inbox" — separate from the coach chat. + +## Goals + +- Optimize a routine through a multi-turn, streaming conversation. +- Let the AI ask the user clarifying questions mid-stream and wait for answers. +- Block optimization when the routine has too little history (< 3 sessions). +- Persist optimization conversations in a **separate inbox**, never mixed with + coach chats. Launching from a routine always starts a **new** conversation. +- Reuse existing infrastructure (`streamCoachReply`, `CoachToolService`, + `ConversationManager`) — no new AI backend method. + +## Non-Goals + +- No new Hive box (the existing `conversations` box is reused, discriminated by + a `kind` field). +- No changes to the coach screen's behavior or its conversation list. +- No streaming-pause/parsing of text "markers" — the SDK already separates + `functionCalls` from text, so a tool call in flight is detectable directly. + +## Architecture Overview + +``` +RoutineCard "Optimize" tap + │ (gate: sessions for routine.id >= 3 ?) + ▼ +RoutineOptimizerScreen ──watches──► RoutineOptimizerViewModel + │ │ + │ renders transcript + │ orchestrates: + │ inline RFQuestionCard │ - new conversation (kind='optimizer') + │ │ - streamCoachReply(systemPrompt, tools, onToolCall) + │ │ - intercepts ask_user_questions → Completer + │ ▼ + │ IAiService.streamCoachReply (EXISTING, unchanged) + │ │ tool-call loop awaits onToolCall(call) + │ ▼ + │ onToolCall router: + │ ask_user_questions ─► VM (UI prompt, await Completer) + │ everything else ─► CoachToolService.handleCall + ▼ +ConversationManager(kind='optimizer') ──► IStorageService (shared box, filtered) +``` + +## Components + +### 1. Entry point & data gate (`routines_screen.dart`) + +The existing "Optimize" button (`_RoutineCard`) changes its action: + +- Compute `sessionCount = wp.sessions.where((s) => s.routineId == routine.id).length`. +- If `sessionCount < 3`: show a SnackBar / inline message: + *"Not enough data yet — log '{routine.name}' at least 3 times so the coach has + something to analyze."* Do not navigate. +- Else: `Navigator.push` to `RoutineOptimizerScreen(routine: routine)`. + +Rationale for client-side gate: deterministic and cheap (no AI call wasted), and +the threshold (3) is a fixed product decision. + +### 2. `ask_user_questions` tool (Claude-Code style) + +A new `FunctionDeclaration` advertised to the model **only in the optimizer +flow** (added to the optimizer's tool list, not the coach's). Schema: + +```jsonc +{ + "preamble": "string? // optional short message shown above the questions", + "questions": [ + { + "question": "string", + "options": ["string", ...], // 3-4 suggested answers + "multiSelect": true | false, // AI chooses per question + "allowCustom": true // always allow free-text + } + ] +} +``` + +The model returns answers indirectly: the tool's **function response** is the +user's answers, e.g. +`{ "answers": [ { "question": "...", "selected": ["Hypertrophy"], "custom": null } ] }`. + +This tool is **not** handled by `CoachToolService` (which has no UI). Instead the +ViewModel's `onToolCall` router intercepts `ask_user_questions` and routes all +other calls to `CoachToolService.handleCall`. + +### 3. The pause/resume mechanism (the "simple Approach C") + +`IAiService.streamCoachReply` already `await`s `onToolCall(call)` inside its +tool-loop. We exploit this directly: + +- When `ask_user_questions` arrives, the VM: + 1. Parses the questions into a `PendingQuestions` value object. + 2. Sets `_pendingQuestions`, `notifyListeners()` → UI renders `RFQuestionCard`s. + 3. Creates a `Completer>` and **returns its `.future`** + from `onToolCall`. The stream loop is now naturally suspended. +- When the user submits, the screen calls `vm.submitAnswers(...)`, which + completes the Completer with the answers map. The loop resumes, feeds answers + back to Gemini, and streaming continues. + +While `isLoading` is true: +- If `_pendingQuestions != null` → render the question card (awaiting input). +- Else → render a small status row ("Analyzing your performance…") + live + streaming text. (A tool call being in flight is simply: loading, no pending + questions, no new text yet.) + +No text-marker parsing is required. + +### 4. `RFQuestionCard` (reusable widget, `rf_widgets.dart`) + +A generic, app-wide widget so future flows (coach, onboarding) can reuse it: + +```dart +RFQuestionCard({ + required QuestionSpec spec, // question, options, multiSelect, allowCustom + required ValueChanged onSubmit, +}) +``` + +- Renders the question text, option chips (single- or multi-select per `spec`), + a "custom answer" text field when `allowCustom`, and a Submit button. +- Single-select: tapping a chip selects it (radio behavior). +- Multi-select: chips toggle; multiple can be active. +- Custom text, when non-empty, is included alongside (or instead of) chips. +- Pure UI — no provider/AI knowledge. Driven entirely by `spec` + `onSubmit`. + +Data classes `QuestionSpec` / `AnswerSpec` live in `models.dart` (or a small +`ai_question.dart`), with `fromJson`/`toJson` for the tool payload. + +### 5. `RoutineOptimizerViewModel` (rewritten from one-shot to conversational) + +Replaces the old one-shot analyze/apply VM. Responsibilities mirror +`AiCoachViewModel`, scoped to optimization: + +- Constructor injects `IAiService`, `CoachToolService`, and a + `ConversationManager` instance **scoped to `kind='optimizer'`**, plus + `WorkoutProvider` (for the seed prompt / gate context) and `SettingsProvider`. +- `startForRoutine(Routine)`: starts a fresh conversation and auto-sends the + seed user message *"Optimize my '{name}' routine based on my past performance."* +- `sendMessage(text)`: same streaming/persist flow as the coach VM, but with: + - an **optimization-focused system prompt** (instructs the model to ask + clarifying questions via `ask_user_questions` when intent is unclear, to use + the read tools to ground analysis, to propose reorder/replace/add changes, + and to apply them via `update_routine` only after the user agrees); + - `tools = _coachTools.buildTools() + [askUserQuestionsDeclaration]`; + - `onToolCall = _routeToolCall` (intercepts `ask_user_questions`). +- `submitAnswers(AnswerSpec...)`: completes the pending Completer. +- Exposed state: `isLoading`, `streamingText`, `messages`, `conversations` + (optimizer inbox), `activeConversationId`, `pendingQuestions`. + +**History persistence note:** the interactive question card is ephemeral UI. +What gets persisted is plain text: the model's `preamble` (as a model message) +and the user's chosen answers (as a user message, e.g. +*"Goal: Hypertrophy · Frequency: 4×/week"*). This keeps conversations replayable +as ordinary text transcripts. + +### 6. Separate optimizer inbox (`Conversation.kind` + scoped manager) + +- Add `String kind` to `Conversation` (default `'coach'`; optimizer uses + `'optimizer'`). Backward compatible: missing JSON field → `'coach'`. +- `ConversationManager` gains an optional `kind` filter (constructor param): + `loadConversations()` filters `getAllConversations()` to that kind; + `appendMessage` stamps the kind on new conversations. Each instance keeps its + own `_active`, so coach and optimizer never collide. +- DI: register a second `ConversationManager(storage, kind: 'optimizer')` (or + construct it inside the optimizer screen's provider scope). The coach's + existing manager defaults to `kind: 'coach'`. +- The optimizer screen shows its own history list (the "inbox") + a "new + optimization" affordance; launching from a routine always begins a new + conversation. + +### 7. Removed code + +- `lib/screens/widgets/routine_optimization_sheet.dart` — deleted. +- `IAiService.generateOptimization` + its `GeminiAiService` implementation — + deleted. +- Old one-shot body of `RoutineOptimizerViewModel` — rewritten. +- `RoutineOptimizationResult` / `RoutineSuggestion` / `SuggestionType` models — + **retained only if** reused by the new prompt/tooling; otherwise deleted. (The + conversational flow applies changes via `update_routine`, so these are likely + removed.) Decision deferred to the implementation plan after confirming no + other references. + +## Data Flow (happy path) + +1. User taps Optimize on "Push Day" (5 logged sessions → passes gate). +2. `RoutineOptimizerScreen` opens; VM starts a new `kind='optimizer'` + conversation and sends the seed prompt. +3. Model streams: *"Let me check a few things first."* then calls + `ask_user_questions` → stream suspends, card renders: + - "Primary goal?" [Strength / Hypertrophy / Endurance] (single, +custom) + - "Sessions per week for this routine?" [2 / 3 / 4 / 5] (single, +custom) +4. User answers → `submitAnswers` → loop resumes; answers persisted as a user + message. +5. Model calls `get_routine_performance` / `get_exercise_performance` (status + row shows "Analyzing…"), then streams its analysis + proposed changes. +6. Model calls `ask_user_questions` again to confirm which changes to apply + (multiSelect over the proposed reorder/replace/add). +7. On confirmation, model calls `update_routine` (existing write tool) → routine + saved. Model confirms in text. Conversation persisted in the optimizer inbox. + +## Error Handling + +- **Insufficient data:** gated before navigation (SnackBar/inline message). +- **AI not configured:** existing check — SnackBar "Add your Gemini API key…". +- **Stream/tool error:** caught in `sendMessage` (as in the coach VM); error + appended as a model message; loop ends cleanly; `_pendingQuestions` cleared so + the UI never gets stuck awaiting answers. +- **User abandons a pending question** (navigates back): the Completer is + completed with an empty/declined answer on dispose so no Future leaks. +- **`update_routine` failure / unresolved exercise names:** the tool already + returns an `error` map; the model surfaces it conversationally. + +## Testing + +- **Unit (`RoutineOptimizerViewModel`)** with a fake `IAiService`: + - Seed prompt is sent on `startForRoutine`. + - A scripted `ask_user_questions` call sets `pendingQuestions`; `submitAnswers` + completes it and the loop resumes (assert tool response shape). + - Abandoning while pending completes the Completer without leaking. + - Error path appends a model error message and clears loading/pending. +- **Unit (`ConversationManager` kind scoping):** an `optimizer` manager only + loads/saves `kind='optimizer'` conversations; legacy (no-kind) rows read as + `coach` and are excluded. +- **Unit (gate):** `< 3` sessions blocks; `>= 3` proceeds. +- **Widget (`RFQuestionCard`):** single-select radio behavior, multi-select + toggling, custom text inclusion, Submit emits correct `AnswerSpec`. +- Run `flutter analyze` and `flutter test` before completion. + +## Open Items (resolve in plan) + +- Final decision on retaining vs deleting `RoutineSuggestion` / + `RoutineOptimizationResult` / `SuggestionType` (grep for references first). +- Exact DI wiring location for the `kind='optimizer'` `ConversationManager` + (composition root in `main.dart` vs screen-scoped provider). diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index ecf7551..383004f 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -323,6 +323,80 @@ class Routine { exerciseIds: List.from(json['exerciseIds']), createdAt: DateTime.parse(json['createdAt']), ); + + Routine copyWith({String? name, List? exerciseIds}) => Routine( + id: id, + name: name ?? this.name, + exerciseIds: exerciseIds ?? this.exerciseIds, + createdAt: createdAt, + ); +} + +// ==================== Routine Optimization ==================== + +enum SuggestionType { reorder, replace, add } + +class RoutineSuggestion { + final SuggestionType type; + final String reasoning; + + /// reorder only: full new ordered list of the routine's existing exercise IDs. + final List? reorderedExerciseIds; + + /// replace only: ID of the exercise to remove from the routine. + final String? removeExerciseId; + + /// replace only: AI-supplied name of the replacement exercise. + final String? replaceWithName; + + /// replace only: resolved exercise ID (filled by ViewModel after parsing). + String? replaceWithId; + + /// add only: AI-supplied name of the exercise to append. + final String? addExerciseName; + + /// add only: resolved exercise ID (filled by ViewModel after parsing). + String? addExerciseId; + + RoutineSuggestion({ + required this.type, + required this.reasoning, + this.reorderedExerciseIds, + this.removeExerciseId, + this.replaceWithName, + this.replaceWithId, + this.addExerciseName, + this.addExerciseId, + }); + + factory RoutineSuggestion.fromJson(Map j) => + RoutineSuggestion( + type: SuggestionType.values.byName(j['type'] as String), + reasoning: j['reasoning'] as String? ?? '', + reorderedExerciseIds: + (j['reordered_exercise_ids'] as List?)?.cast(), + removeExerciseId: j['remove_exercise_id'] as String?, + replaceWithName: j['replace_with_name'] as String?, + addExerciseName: j['add_exercise_name'] as String?, + ); +} + +class RoutineOptimizationResult { + final String summary; + final List suggestions; + + const RoutineOptimizationResult({ + required this.summary, + required this.suggestions, + }); + + factory RoutineOptimizationResult.fromJson(Map j) => + RoutineOptimizationResult( + summary: j['summary'] as String? ?? '', + suggestions: (j['suggestions'] as List? ?? []) + .map((s) => RoutineSuggestion.fromJson(s as Map)) + .toList(), + ); } // ==================== Target ==================== diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 740ae48..059bbae 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -11,6 +11,7 @@ import '../theme/app_theme.dart'; import 'programs/programs_screen.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/routine_creator.dart'; +import 'widgets/routine_optimization_sheet.dart'; class RoutinesScreen extends StatelessWidget { const RoutinesScreen({super.key}); @@ -489,6 +490,26 @@ class _RoutineCard extends StatelessWidget { ], ), ), + // Optimize button + GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + showRoutineOptimizerSheet(context, routine); + }, + child: Container( + width: 34, + height: 34, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.35), + ), + ), + child: const Icon(Icons.auto_fix_high_rounded, size: 15, color: AppColors.secondary), + ), + ), // Edit button GestureDetector( onTap: () => Navigator.push( diff --git a/workout-logger/lib/screens/widgets/routine_optimization_sheet.dart b/workout-logger/lib/screens/widgets/routine_optimization_sheet.dart new file mode 100644 index 0000000..1d5019a --- /dev/null +++ b/workout-logger/lib/screens/widgets/routine_optimization_sheet.dart @@ -0,0 +1,535 @@ +// routine_optimization_sheet.dart — AI-driven routine optimization bottom sheet. +// +// Shows AI suggestions for reordering, replacing, or adding exercises based on +// past performance data. Each suggestion can be accepted or rejected before applying. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/ai/gemini_ai_service.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../viewmodels/routine_optimizer_view_model.dart'; +import 'rf_widgets.dart'; + +/// Entry point — shows the optimizer sheet or a SnackBar if AI is not configured. +Future showRoutineOptimizerSheet( + BuildContext context, + Routine routine, +) async { + final ai = context.read(); + if (!ai.isConfigured) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Add your Gemini API key in Profile → AI Features to use this feature.', + ), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + final wp = context.read(); + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => ChangeNotifierProvider( + create: (_) => RoutineOptimizerViewModel(ai: ai, wp: wp) + ..analyzeRoutine(routine), + child: _OptimizerSheetBody(routine: routine), + ), + ); +} + +// ── Sheet body ──────────────────────────────────────────────────────────────── + +class _OptimizerSheetBody extends StatelessWidget { + const _OptimizerSheetBody({required this.routine}); + final Routine routine; + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.92, + expand: false, + builder: (ctx, scrollCtrl) => SingleChildScrollView( + controller: scrollCtrl, + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + MediaQuery.of(context).viewInsets.bottom + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + + // Header + Row( + children: [ + const Icon(Icons.auto_fix_high_rounded, + color: AppColors.secondary, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Optimize "${routine.name}"', + style: GoogleFonts.geist( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.md), + + switch (vm.state) { + OptimizerState.idle => const SizedBox.shrink(), + OptimizerState.loading => _LoadingView(), + OptimizerState.error => _ErrorView( + message: vm.errorMessage ?? 'Unknown error', + onRetry: () => vm.analyzeRoutine(routine), + ), + OptimizerState.success => _SuccessView( + routine: routine, + vm: vm, + ), + }, + ], + ), + ), + ); + } +} + +// ── Loading state ───────────────────────────────────────────────────────────── + +class _LoadingView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xxl), + child: Column( + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.secondary), + strokeWidth: 2, + ), + const SizedBox(height: AppSpacing.lg), + Text( + 'Reviewing your performance data…', + style: GoogleFonts.geist( + fontSize: 14, + color: AppColors.textSoft, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'Analysing exercise trends and muscle coverage', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.textFaint, + ), + ), + ], + ), + ); + } +} + +// ── Error state ─────────────────────────────────────────────────────────────── + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Column( + children: [ + const Icon(Icons.error_outline_rounded, + color: AppColors.error, size: 40), + const SizedBox(height: AppSpacing.md), + Text( + 'Could not analyse routine', + style: GoogleFonts.geist( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + message, + textAlign: TextAlign.center, + style: GoogleFonts.geist(fontSize: 12, color: AppColors.textFaint), + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: 'Try Again', + icon: Icons.refresh_rounded, + color: AppColors.secondary, + onPressed: onRetry, + fullWidth: false, + small: true, + ), + ], + ), + ); + } +} + +// ── Success state ───────────────────────────────────────────────────────────── + +class _SuccessView extends StatelessWidget { + const _SuccessView({required this.routine, required this.vm}); + final Routine routine; + final RoutineOptimizerViewModel vm; + + @override + Widget build(BuildContext context) { + final result = vm.result!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary + if (result.summary.isNotEmpty) ...[ + GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Text( + result.summary, + style: GoogleFonts.geist( + fontSize: 13, + fontStyle: FontStyle.italic, + color: AppColors.textSoft, + height: 1.5, + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + ], + + if (result.suggestions.isEmpty) ...[ + Center( + child: Column( + children: [ + const Icon(Icons.check_circle_outline_rounded, + color: AppColors.success, size: 40), + const SizedBox(height: AppSpacing.sm), + Text( + 'Your routine looks well-balanced!', + style: GoogleFonts.geist( + fontSize: 15, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ] else ...[ + Text( + 'SUGGESTIONS (${result.suggestions.length})', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: AppColors.textFaint, + ), + ), + const SizedBox(height: AppSpacing.sm), + + for (var i = 0; i < result.suggestions.length; i++) ...[ + _SuggestionCard( + index: i, + suggestion: result.suggestions[i], + accepted: vm.isSuggestionAccepted(i), + routineExercises: routine.exerciseIds, + onToggle: () => vm.toggleSuggestion(i), + ), + if (i < result.suggestions.length - 1) + const SizedBox(height: AppSpacing.sm), + ], + + const SizedBox(height: AppSpacing.xl), + + GlowButton( + label: 'Apply Suggestions', + icon: Icons.check_rounded, + color: AppColors.primary, + onPressed: vm.applied + ? null + : () async { + HapticFeedback.heavyImpact(); + await vm.applyAccepted(routine); + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Routine updated successfully.'), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + ), + ], + ], + ); + } +} + +// ── Suggestion card ─────────────────────────────────────────────────────────── + +class _SuggestionCard extends StatelessWidget { + const _SuggestionCard({ + required this.index, + required this.suggestion, + required this.accepted, + required this.routineExercises, + required this.onToggle, + }); + + final int index; + final RoutineSuggestion suggestion; + final bool accepted; + final List routineExercises; + final VoidCallback onToggle; + + static const _typeConfig = { + SuggestionType.reorder: ( + icon: Icons.swap_vert_rounded, + color: AppColors.secondary, + label: 'REORDER', + ), + SuggestionType.replace: ( + icon: Icons.change_circle_outlined, + color: AppColors.warning, + label: 'REPLACE', + ), + SuggestionType.add: ( + icon: Icons.add_circle_outline_rounded, + color: AppColors.success, + label: 'ADD', + ), + }; + + @override + Widget build(BuildContext context) { + final cfg = _typeConfig[suggestion.type]!; + + return AnimatedOpacity( + opacity: accepted ? 1.0 : 0.45, + duration: const Duration(milliseconds: 200), + child: GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + borderColor: accepted + ? cfg.color.withValues(alpha: 0.4) + : AppColors.glassBorder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Type header + Row( + children: [ + Icon(cfg.icon, color: cfg.color, size: 16), + const SizedBox(width: AppSpacing.xs), + Text( + cfg.label, + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: cfg.color, + ), + ), + const Spacer(), + _AcceptToggle(accepted: accepted, onToggle: onToggle), + ], + ), + + const SizedBox(height: AppSpacing.sm), + + // Reasoning + Text( + suggestion.reasoning, + style: GoogleFonts.geist( + fontSize: 13, + color: AppColors.textSoft, + height: 1.4, + ), + ), + + const SizedBox(height: AppSpacing.sm), + + // Type-specific preview + _buildPreview(context), + ], + ), + ), + ); + } + + Widget _buildPreview(BuildContext context) { + final wp = context.read(); + + switch (suggestion.type) { + case SuggestionType.reorder: + final ids = suggestion.reorderedExerciseIds ?? []; + final shown = ids.take(5).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < shown.length; i++) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '${i + 1}. ${wp.getExerciseName(shown[i])}', + style: GoogleFonts.geistMono( + fontSize: 12, + color: AppColors.textFaint, + ), + ), + ), + if (ids.length > 5) + Text( + '…and ${ids.length - 5} more', + style: GoogleFonts.geist( + fontSize: 11, color: AppColors.textFaint), + ), + ], + ); + + case SuggestionType.replace: + final removeName = suggestion.removeExerciseId != null + ? wp.getExerciseName(suggestion.removeExerciseId!) + : '—'; + final addName = suggestion.replaceWithName ?? '—'; + return Row( + children: [ + Expanded( + child: Text( + removeName, + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.error, + decoration: TextDecoration.lineThrough, + decorationColor: AppColors.error, + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Icon(Icons.arrow_forward_rounded, + size: 14, color: AppColors.textFaint), + ), + Expanded( + child: Text( + addName, + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.success, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + + case SuggestionType.add: + return Text( + '+ ${suggestion.addExerciseName ?? '—'}', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.success, + fontWeight: FontWeight.w600, + ), + ); + } + } +} + +// ── Accept/Reject toggle ────────────────────────────────────────────────────── + +class _AcceptToggle extends StatelessWidget { + const _AcceptToggle({required this.accepted, required this.onToggle}); + final bool accepted; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + onToggle(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 4, + ), + decoration: BoxDecoration( + color: accepted + ? AppColors.success.withValues(alpha: 0.15) + : AppColors.error.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: accepted + ? AppColors.success.withValues(alpha: 0.4) + : AppColors.error.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + accepted ? Icons.check_rounded : Icons.close_rounded, + size: 12, + color: accepted ? AppColors.success : AppColors.error, + ), + const SizedBox(width: 4), + Text( + accepted ? 'Accept' : 'Reject', + style: GoogleFonts.geist( + fontSize: 11, + fontWeight: FontWeight.w600, + color: accepted ? AppColors.success : AppColors.error, + ), + ), + ], + ), + ), + ); + } +} diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index 846c3c9..4e6a31c 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -122,6 +122,62 @@ class CoachToolService { '"what can I train today".', Schema.object(properties: {}), ), + FunctionDeclaration( + 'get_all_routines', + 'List all saved routines with their exercise names and count. ' + 'Use when the user asks what routines they have or wants to ' + 'pick one to view or modify.', + Schema.object(properties: {}), + ), + FunctionDeclaration( + 'create_routine', + 'Create a new workout routine with a name and an ordered list of ' + 'exercises. Exercises are matched by name from the catalogue.', + Schema.object( + properties: { + 'name': Schema.string( + description: 'Name for the new routine, e.g. "Push Day".', + ), + 'exercise_names': Schema.array( + items: Schema.string(), + description: + 'Ordered list of exercise names to include in the routine.', + ), + }, + requiredProperties: ['name', 'exercise_names'], + ), + ), + FunctionDeclaration( + 'update_routine', + 'Modify an existing routine: add exercises, remove exercises, or ' + 'reorder them. Specify the routine by name. Exercises are ' + 'matched by name from the catalogue.', + Schema.object( + properties: { + 'routine_name': Schema.string( + description: 'Name of the routine to update.', + ), + 'add_exercise_names': Schema.array( + items: Schema.string(), + description: 'Optional. Exercise names to add.', + nullable: true, + ), + 'remove_exercise_names': Schema.array( + items: Schema.string(), + description: 'Optional. Exercise names to remove.', + nullable: true, + ), + 'reorder_exercise_names': Schema.array( + items: Schema.string(), + description: + 'Optional. Full new ordering of all exercise names in ' + 'the routine. Must include every exercise you want to keep.', + nullable: true, + ), + }, + requiredProperties: ['routine_name'], + ), + ), ]), ]; @@ -141,6 +197,12 @@ class CoachToolService { return _goalProgress(call.args); case 'get_muscle_recovery': return _muscleRecovery(); + case 'get_all_routines': + return _getAllRoutines(); + case 'create_routine': + return _createRoutine(call.args); + case 'update_routine': + return await _updateRoutine(call.args); default: return {'error': 'Unknown tool: ${call.name}'}; } @@ -415,6 +477,137 @@ class CoachToolService { }; } + // ── Routine CRUD tools ──────────────────────────────────────────────────── + + Map _getAllRoutines() { + return { + 'routines': [ + for (final r in _wp.routines) + { + 'id': r.id, + 'name': r.name, + 'exercise_count': r.exerciseIds.length, + 'exercises': [for (final id in r.exerciseIds) _wp.getExerciseName(id)], + }, + ], + }; + } + + Future> _createRoutine(Map args) async { + final name = ((args['name'] as String?)?.trim()) ?? ''; + if (name.isEmpty) return {'error': 'Routine name cannot be empty.'}; + + final rawNames = (args['exercise_names'] as List?)?.cast() ?? []; + final resolvedIds = []; + final unresolved = []; + + for (final n in rawNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex == null) { + unresolved.add(n); + } else { + resolvedIds.add(ex.id); + } + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Ambiguous exercise name "$n". Did you mean one of:', + 'candidates': e.candidates, + }; + } + } + + if (unresolved.isNotEmpty) { + return { + 'error': 'Could not find exercises: $unresolved', + 'available_examples': _exampleExerciseNames(), + }; + } + + await _wp.createRoutine(name, resolvedIds); + return { + 'created': true, + 'routine_name': name, + 'exercise_count': resolvedIds.length, + 'exercises': [for (final id in resolvedIds) _wp.getExerciseName(id)], + }; + } + + Future> _updateRoutine(Map args) async { + final routineName = (args['routine_name'] as String?)?.trim() ?? ''; + final Routine routine; + try { + final resolved = _resolveRoutine(routineName); + if (resolved == null) { + return {'error': 'No routine found matching "$routineName".'}; + } + routine = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple routines match "$routineName". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + + var ids = List.from(routine.exerciseIds); + + // Reorder (full replacement of order) + final reorderNames = (args['reorder_exercise_names'] as List?)?.cast(); + if (reorderNames != null && reorderNames.isNotEmpty) { + final reorderedIds = []; + for (final n in reorderNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex != null) reorderedIds.add(ex.id); + } on AmbiguousMatchException { + // skip ambiguous entries in reorder + } + } + if (reorderedIds.isNotEmpty) ids = reorderedIds; + } + + // Remove exercises + final removeNames = (args['remove_exercise_names'] as List?)?.cast(); + if (removeNames != null) { + for (final n in removeNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex != null) ids.remove(ex.id); + } on AmbiguousMatchException { + // skip ambiguous entries + } + } + } + + // Add exercises + final addNames = (args['add_exercise_names'] as List?)?.cast(); + if (addNames != null) { + for (final n in addNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex != null && !ids.contains(ex.id)) ids.add(ex.id); + } on AmbiguousMatchException { + // skip ambiguous entries + } + } + } + + final updated = Routine( + id: routine.id, + name: routine.name, + exerciseIds: ids, + createdAt: routine.createdAt, + ); + await _wp.updateRoutine(updated); + + return { + 'updated': true, + 'routine_name': routine.name, + 'exercise_count': ids.length, + 'exercises': [for (final id in ids) _wp.getExerciseName(id)], + }; + } + // ── Helpers ──────────────────────────────────────────────────────────────── Exercise? _resolveExercise(String query) { diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index 08f811c..65bc85e 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -326,6 +326,63 @@ Required JSON schema (follow exactly): } } + // ── Routine optimizer (structured JSON output) ──────────────────────────── + @override + Future generateOptimization({ + required String contextPayload, + }) async { + if (!isConfigured) { + throw StateError('Gemini API key not configured.'); + } + + const systemPrompt = + 'You are a certified strength coach analysing a workout routine for RepForge.\n' + 'Return ONLY raw JSON — no markdown fences, no comments, no explanation text.\n' + 'Produce 1–3 suggestions total, at most one of each type: reorder, replace, add.\n' + 'Base recommendations strictly on the performance data provided.\n' + 'For reorder and remove_exercise_id use ONLY exercise IDs already present in the routine.\n' + 'For replace_with_name and add_exercise_name use ONLY names from the Available exercises list.\n\n' + 'Required JSON schema (follow exactly):\n' + '{\n' + ' "summary": "<1-2 sentence overall assessment>",\n' + ' "suggestions": [\n' + ' {\n' + ' "type": "reorder",\n' + ' "reasoning": "",\n' + ' "reordered_exercise_ids": ["", "", ...]\n' + ' },\n' + ' {\n' + ' "type": "replace",\n' + ' "reasoning": "",\n' + ' "remove_exercise_id": "",\n' + ' "replace_with_name": ""\n' + ' },\n' + ' {\n' + ' "type": "add",\n' + ' "reasoning": "",\n' + ' "add_exercise_name": ""\n' + ' }\n' + ' ]\n' + '}\n' + 'Include only the suggestion types that are genuinely beneficial. ' + 'Omit a type entirely if no meaningful improvement can be made.'; + + try { + final response = + await _makeModel(jsonMode: true, system: systemPrompt) + .generateContent([Content.text(contextPayload)]); + _recordUsage(response.usageMetadata); + final raw = response.text ?? ''; + if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); + final data = jsonDecode(raw) as Map; + return RoutineOptimizationResult.fromJson(data); + } on GenerativeAIException catch (e) { + throw Exception('Gemini API error: ${e.message}'); + } on FormatException catch (e) { + throw Exception('Could not parse optimization JSON: $e'); + } + } + // ── Generic one-shot insight (contextual) ───────────────────────────────── @override Future generateInsight(String system, String context) async { diff --git a/workout-logger/lib/services/interfaces/ai_service_interface.dart b/workout-logger/lib/services/interfaces/ai_service_interface.dart index 6f0d2d1..b42bb3d 100644 --- a/workout-logger/lib/services/interfaces/ai_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ai_service_interface.dart @@ -49,4 +49,10 @@ abstract class IAiService { /// Generic one-shot contextual insight given a [system] instruction and /// [context] payload. Future generateInsight(String system, String context); + + /// Analyse a routine's performance context and return structured optimization + /// suggestions (reorder / replace / add exercises). + Future generateOptimization({ + required String contextPayload, + }); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 62418a3..5fd1b48 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -748,7 +748,7 @@ class WorkoutProvider extends ChangeNotifier { // ==================== ROUTINES ==================== - Future createRoutine(String name, List exerciseIds) async { + Future createRoutine(String name, List exerciseIds) async { final routine = Routine( id: _uuid.v4(), name: name, @@ -757,6 +757,7 @@ class WorkoutProvider extends ChangeNotifier { await _storage.saveRoutine(routine); _routines.add(routine); notifyListeners(); + return routine; } Future updateRoutine(Routine routine) async { diff --git a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart new file mode 100644 index 0000000..875b79f --- /dev/null +++ b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart @@ -0,0 +1,214 @@ +// routine_optimizer_view_model.dart — ViewModel for AI-driven routine optimization. +// +// Builds performance context from WorkoutProvider, calls IAiService.generateOptimization, +// resolves AI-supplied exercise names to IDs, and applies accepted suggestions +// back to the routine via WorkoutProvider.updateRoutine. + +import 'package:flutter/foundation.dart'; + +import '../models/models.dart'; +import '../services/interfaces/ai_service_interface.dart'; +import '../services/workout_provider.dart'; + +enum OptimizerState { idle, loading, success, error } + +class RoutineOptimizerViewModel extends ChangeNotifier { + final IAiService _ai; + final WorkoutProvider _wp; + + RoutineOptimizerViewModel({required IAiService ai, required WorkoutProvider wp}) + : _ai = ai, + _wp = wp; + + OptimizerState _state = OptimizerState.idle; + RoutineOptimizationResult? _result; + String? _errorMessage; + final Map _accepted = {}; + bool _applied = false; + + OptimizerState get state => _state; + RoutineOptimizationResult? get result => _result; + String? get errorMessage => _errorMessage; + Map get accepted => Map.unmodifiable(_accepted); + bool get applied => _applied; + + bool isSuggestionAccepted(int index) => _accepted[index] ?? true; + + void toggleSuggestion(int index) { + _accepted[index] = !(_accepted[index] ?? true); + notifyListeners(); + } + + Future analyzeRoutine(Routine routine) async { + _state = OptimizerState.loading; + _result = null; + _errorMessage = null; + _applied = false; + _accepted.clear(); + notifyListeners(); + + try { + final payload = _buildContext(routine); + final result = await _ai.generateOptimization(contextPayload: payload); + _resolveExerciseIds(result); + _result = result; + for (var i = 0; i < result.suggestions.length; i++) { + _accepted[i] = true; + } + _state = OptimizerState.success; + } catch (e) { + _errorMessage = e.toString(); + _state = OptimizerState.error; + } + notifyListeners(); + } + + Future applyAccepted(Routine routine) async { + final res = _result; + if (res == null) return; + + var ids = List.from(routine.exerciseIds); + + for (var i = 0; i < res.suggestions.length; i++) { + if (!(_accepted[i] ?? true)) continue; + final s = res.suggestions[i]; + switch (s.type) { + case SuggestionType.reorder: + final reordered = s.reorderedExerciseIds; + if (reordered != null && reordered.isNotEmpty) { + ids = List.from(reordered); + } + case SuggestionType.replace: + final removeId = s.removeExerciseId; + final addId = s.replaceWithId; + if (removeId != null && addId != null) { + final idx = ids.indexOf(removeId); + if (idx != -1) { + ids[idx] = addId; + } else { + ids.add(addId); + } + } + case SuggestionType.add: + final addId = s.addExerciseId; + if (addId != null && !ids.contains(addId)) { + ids.add(addId); + } + } + } + + await _wp.updateRoutine(routine.copyWith(exerciseIds: ids)); + _applied = true; + notifyListeners(); + } + + // ── Context builder ──────────────────────────────────────────────────────── + + String _buildContext(Routine routine) { + final buf = StringBuffer(); + + buf.writeln('Routine: "${routine.name}"'); + buf.writeln('Exercises (in current order):'); + + for (var i = 0; i < routine.exerciseIds.length; i++) { + final id = routine.exerciseIds[i]; + final name = _wp.getExerciseName(id); + final model = _wp.getGrowthModel(id); + final sessions = _wp.getVolumeProgression(id).length; + + if (model != null) { + final slope = model.slope >= 0 + ? '+${model.slope.toStringAsFixed(1)}' + : model.slope.toStringAsFixed(1); + buf.writeln( + ' ${i + 1}. [id: $id] $name — slope: $slope kg/session, ' + 'r²: ${model.r2.toStringAsFixed(2)}, sessions: $sessions', + ); + } else { + buf.writeln( + ' ${i + 1}. [id: $id] $name — no data (0 sessions)', + ); + } + } + + buf.writeln(); + + // Weekly muscle volume + final weeklyVolume = _wp.getWeeklyVolumeByMuscle(); + if (weeklyVolume.isNotEmpty) { + buf.writeln('Weekly muscle volume (last 7 days):'); + final sorted = weeklyVolume.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + for (final e in sorted) { + final muscleName = _wp.getMuscleGroupName(e.key); + buf.writeln(' $muscleName: ${e.value.toStringAsFixed(0)} kg'); + } + + // Detect muscles that appear in the exercise catalog but have zero weekly volume + final coveredMuscles = weeklyVolume.keys.toSet(); + final allMusclesInCatalog = {}; + for (final ex in _wp.allExercises) { + for (final a in ex.muscleActivations) { + allMusclesInCatalog.add(a.muscleGroupId); + } + } + final missing = allMusclesInCatalog + .difference(coveredMuscles) + .map(_wp.getMuscleGroupName) + .toSet(); + if (missing.isNotEmpty) { + buf.writeln('Missing coverage (zero weekly volume): ${missing.join(', ')}'); + } + } else { + buf.writeln('Weekly muscle volume: no data for last 7 days.'); + } + + buf.writeln(); + + // Available exercise catalogue grouped by primary muscle (first 60) + buf.writeln('Available exercises for replace/add suggestions:'); + final grouped = >{}; + for (final ex in _wp.allExercises.take(60)) { + final muscle = _wp.getMuscleGroupName( + ex.muscleActivations.isNotEmpty + ? ex.muscleActivations.first.muscleGroupId + : 'unknown', + ); + grouped.putIfAbsent(muscle, () => []).add(ex.name); + } + for (final entry in grouped.entries) { + buf.writeln(' [${entry.key}] ${entry.value.join(', ')}'); + } + + return buf.toString(); + } + + // ── Exercise name resolution ─────────────────────────────────────────────── + + void _resolveExerciseIds(RoutineOptimizationResult result) { + for (final s in result.suggestions) { + if (s.type == SuggestionType.replace && s.replaceWithName != null) { + s.replaceWithId = _findExerciseId(s.replaceWithName!); + } + if (s.type == SuggestionType.add && s.addExerciseName != null) { + s.addExerciseId = _findExerciseId(s.addExerciseName!); + } + } + } + + String? _findExerciseId(String name) { + final lower = name.toLowerCase(); + // Exact match first + for (final ex in _wp.allExercises) { + if (ex.name.toLowerCase() == lower) return ex.id; + } + // Partial match + for (final ex in _wp.allExercises) { + if (ex.name.toLowerCase().contains(lower) || + lower.contains(ex.name.toLowerCase())) { + return ex.id; + } + } + return null; + } +} diff --git a/workout-logger/test/ai_coach_view_model_test.dart b/workout-logger/test/ai_coach_view_model_test.dart index 2832a2a..71e4f29 100644 --- a/workout-logger/test/ai_coach_view_model_test.dart +++ b/workout-logger/test/ai_coach_view_model_test.dart @@ -58,6 +58,11 @@ class _FakeAiService implements IAiService { @override Future generateInsight(String system, String context) async => ''; + + @override + Future generateOptimization({ + required String contextPayload, + }) => throw UnimplementedError(); } void main() { From f5b1384bcb35a91756a94a4217ecd4e73d9be4fe Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:37:17 +0530 Subject: [PATCH 31/44] refactor: remove one-shot routine optimizer (replaced by conversational flow) Co-Authored-By: Claude Sonnet 4.6 --- workout-logger/lib/models/models.dart | 67 --- .../lib/screens/routines_screen.dart | 6 +- .../widgets/routine_optimization_sheet.dart | 535 ------------------ .../lib/services/ai/gemini_ai_service.dart | 57 -- .../interfaces/ai_service_interface.dart | 5 - .../routine_optimizer_view_model.dart | 214 ------- .../test/ai_coach_view_model_test.dart | 4 - 7 files changed, 1 insertion(+), 887 deletions(-) delete mode 100644 workout-logger/lib/screens/widgets/routine_optimization_sheet.dart delete mode 100644 workout-logger/lib/viewmodels/routine_optimizer_view_model.dart diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 383004f..1573649 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -332,73 +332,6 @@ class Routine { ); } -// ==================== Routine Optimization ==================== - -enum SuggestionType { reorder, replace, add } - -class RoutineSuggestion { - final SuggestionType type; - final String reasoning; - - /// reorder only: full new ordered list of the routine's existing exercise IDs. - final List? reorderedExerciseIds; - - /// replace only: ID of the exercise to remove from the routine. - final String? removeExerciseId; - - /// replace only: AI-supplied name of the replacement exercise. - final String? replaceWithName; - - /// replace only: resolved exercise ID (filled by ViewModel after parsing). - String? replaceWithId; - - /// add only: AI-supplied name of the exercise to append. - final String? addExerciseName; - - /// add only: resolved exercise ID (filled by ViewModel after parsing). - String? addExerciseId; - - RoutineSuggestion({ - required this.type, - required this.reasoning, - this.reorderedExerciseIds, - this.removeExerciseId, - this.replaceWithName, - this.replaceWithId, - this.addExerciseName, - this.addExerciseId, - }); - - factory RoutineSuggestion.fromJson(Map j) => - RoutineSuggestion( - type: SuggestionType.values.byName(j['type'] as String), - reasoning: j['reasoning'] as String? ?? '', - reorderedExerciseIds: - (j['reordered_exercise_ids'] as List?)?.cast(), - removeExerciseId: j['remove_exercise_id'] as String?, - replaceWithName: j['replace_with_name'] as String?, - addExerciseName: j['add_exercise_name'] as String?, - ); -} - -class RoutineOptimizationResult { - final String summary; - final List suggestions; - - const RoutineOptimizationResult({ - required this.summary, - required this.suggestions, - }); - - factory RoutineOptimizationResult.fromJson(Map j) => - RoutineOptimizationResult( - summary: j['summary'] as String? ?? '', - suggestions: (j['suggestions'] as List? ?? []) - .map((s) => RoutineSuggestion.fromJson(s as Map)) - .toList(), - ); -} - // ==================== Target ==================== class Target { diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 059bbae..28d95d5 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -11,7 +11,6 @@ import '../theme/app_theme.dart'; import 'programs/programs_screen.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/routine_creator.dart'; -import 'widgets/routine_optimization_sheet.dart'; class RoutinesScreen extends StatelessWidget { const RoutinesScreen({super.key}); @@ -492,10 +491,7 @@ class _RoutineCard extends StatelessWidget { ), // Optimize button GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - showRoutineOptimizerSheet(context, routine); - }, + onTap: () {}, child: Container( width: 34, height: 34, diff --git a/workout-logger/lib/screens/widgets/routine_optimization_sheet.dart b/workout-logger/lib/screens/widgets/routine_optimization_sheet.dart deleted file mode 100644 index 1d5019a..0000000 --- a/workout-logger/lib/screens/widgets/routine_optimization_sheet.dart +++ /dev/null @@ -1,535 +0,0 @@ -// routine_optimization_sheet.dart — AI-driven routine optimization bottom sheet. -// -// Shows AI suggestions for reordering, replacing, or adding exercises based on -// past performance data. Each suggestion can be accepted or rejected before applying. - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:provider/provider.dart'; - -import '../../models/models.dart'; -import '../../services/ai/gemini_ai_service.dart'; -import '../../services/workout_provider.dart'; -import '../../theme/app_theme.dart'; -import '../../viewmodels/routine_optimizer_view_model.dart'; -import 'rf_widgets.dart'; - -/// Entry point — shows the optimizer sheet or a SnackBar if AI is not configured. -Future showRoutineOptimizerSheet( - BuildContext context, - Routine routine, -) async { - final ai = context.read(); - if (!ai.isConfigured) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Add your Gemini API key in Profile → AI Features to use this feature.', - ), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - - final wp = context.read(); - - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: AppColors.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), - ), - builder: (_) => ChangeNotifierProvider( - create: (_) => RoutineOptimizerViewModel(ai: ai, wp: wp) - ..analyzeRoutine(routine), - child: _OptimizerSheetBody(routine: routine), - ), - ); -} - -// ── Sheet body ──────────────────────────────────────────────────────────────── - -class _OptimizerSheetBody extends StatelessWidget { - const _OptimizerSheetBody({required this.routine}); - final Routine routine; - - @override - Widget build(BuildContext context) { - final vm = context.watch(); - - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.92, - expand: false, - builder: (ctx, scrollCtrl) => SingleChildScrollView( - controller: scrollCtrl, - padding: EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.lg, - MediaQuery.of(context).viewInsets.bottom + AppSpacing.xxl, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Drag handle - Center( - child: Container( - width: 36, - height: 4, - margin: const EdgeInsets.only(bottom: AppSpacing.lg), - decoration: BoxDecoration( - color: AppColors.glassBorder, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - ), - ), - - // Header - Row( - children: [ - const Icon(Icons.auto_fix_high_rounded, - color: AppColors.secondary, size: 20), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - 'Optimize "${routine.name}"', - style: GoogleFonts.geist( - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ), - ], - ), - - const SizedBox(height: AppSpacing.md), - - switch (vm.state) { - OptimizerState.idle => const SizedBox.shrink(), - OptimizerState.loading => _LoadingView(), - OptimizerState.error => _ErrorView( - message: vm.errorMessage ?? 'Unknown error', - onRetry: () => vm.analyzeRoutine(routine), - ), - OptimizerState.success => _SuccessView( - routine: routine, - vm: vm, - ), - }, - ], - ), - ), - ); - } -} - -// ── Loading state ───────────────────────────────────────────────────────────── - -class _LoadingView extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xxl), - child: Column( - children: [ - const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.secondary), - strokeWidth: 2, - ), - const SizedBox(height: AppSpacing.lg), - Text( - 'Reviewing your performance data…', - style: GoogleFonts.geist( - fontSize: 14, - color: AppColors.textSoft, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'Analysing exercise trends and muscle coverage', - style: GoogleFonts.geist( - fontSize: 12, - color: AppColors.textFaint, - ), - ), - ], - ), - ); - } -} - -// ── Error state ─────────────────────────────────────────────────────────────── - -class _ErrorView extends StatelessWidget { - const _ErrorView({required this.message, required this.onRetry}); - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Column( - children: [ - const Icon(Icons.error_outline_rounded, - color: AppColors.error, size: 40), - const SizedBox(height: AppSpacing.md), - Text( - 'Could not analyse routine', - style: GoogleFonts.geist( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - message, - textAlign: TextAlign.center, - style: GoogleFonts.geist(fontSize: 12, color: AppColors.textFaint), - ), - const SizedBox(height: AppSpacing.lg), - GlowButton( - label: 'Try Again', - icon: Icons.refresh_rounded, - color: AppColors.secondary, - onPressed: onRetry, - fullWidth: false, - small: true, - ), - ], - ), - ); - } -} - -// ── Success state ───────────────────────────────────────────────────────────── - -class _SuccessView extends StatelessWidget { - const _SuccessView({required this.routine, required this.vm}); - final Routine routine; - final RoutineOptimizerViewModel vm; - - @override - Widget build(BuildContext context) { - final result = vm.result!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Summary - if (result.summary.isNotEmpty) ...[ - GlassCard( - padding: const EdgeInsets.all(AppSpacing.md), - child: Text( - result.summary, - style: GoogleFonts.geist( - fontSize: 13, - fontStyle: FontStyle.italic, - color: AppColors.textSoft, - height: 1.5, - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - ], - - if (result.suggestions.isEmpty) ...[ - Center( - child: Column( - children: [ - const Icon(Icons.check_circle_outline_rounded, - color: AppColors.success, size: 40), - const SizedBox(height: AppSpacing.sm), - Text( - 'Your routine looks well-balanced!', - style: GoogleFonts.geist( - fontSize: 15, - color: AppColors.textPrimary, - ), - ), - ], - ), - ), - ] else ...[ - Text( - 'SUGGESTIONS (${result.suggestions.length})', - style: GoogleFonts.geist( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 1.2, - color: AppColors.textFaint, - ), - ), - const SizedBox(height: AppSpacing.sm), - - for (var i = 0; i < result.suggestions.length; i++) ...[ - _SuggestionCard( - index: i, - suggestion: result.suggestions[i], - accepted: vm.isSuggestionAccepted(i), - routineExercises: routine.exerciseIds, - onToggle: () => vm.toggleSuggestion(i), - ), - if (i < result.suggestions.length - 1) - const SizedBox(height: AppSpacing.sm), - ], - - const SizedBox(height: AppSpacing.xl), - - GlowButton( - label: 'Apply Suggestions', - icon: Icons.check_rounded, - color: AppColors.primary, - onPressed: vm.applied - ? null - : () async { - HapticFeedback.heavyImpact(); - await vm.applyAccepted(routine); - if (context.mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Routine updated successfully.'), - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - ), - ], - ], - ); - } -} - -// ── Suggestion card ─────────────────────────────────────────────────────────── - -class _SuggestionCard extends StatelessWidget { - const _SuggestionCard({ - required this.index, - required this.suggestion, - required this.accepted, - required this.routineExercises, - required this.onToggle, - }); - - final int index; - final RoutineSuggestion suggestion; - final bool accepted; - final List routineExercises; - final VoidCallback onToggle; - - static const _typeConfig = { - SuggestionType.reorder: ( - icon: Icons.swap_vert_rounded, - color: AppColors.secondary, - label: 'REORDER', - ), - SuggestionType.replace: ( - icon: Icons.change_circle_outlined, - color: AppColors.warning, - label: 'REPLACE', - ), - SuggestionType.add: ( - icon: Icons.add_circle_outline_rounded, - color: AppColors.success, - label: 'ADD', - ), - }; - - @override - Widget build(BuildContext context) { - final cfg = _typeConfig[suggestion.type]!; - - return AnimatedOpacity( - opacity: accepted ? 1.0 : 0.45, - duration: const Duration(milliseconds: 200), - child: GlassCard( - padding: const EdgeInsets.all(AppSpacing.md), - borderColor: accepted - ? cfg.color.withValues(alpha: 0.4) - : AppColors.glassBorder, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Type header - Row( - children: [ - Icon(cfg.icon, color: cfg.color, size: 16), - const SizedBox(width: AppSpacing.xs), - Text( - cfg.label, - style: GoogleFonts.geist( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - color: cfg.color, - ), - ), - const Spacer(), - _AcceptToggle(accepted: accepted, onToggle: onToggle), - ], - ), - - const SizedBox(height: AppSpacing.sm), - - // Reasoning - Text( - suggestion.reasoning, - style: GoogleFonts.geist( - fontSize: 13, - color: AppColors.textSoft, - height: 1.4, - ), - ), - - const SizedBox(height: AppSpacing.sm), - - // Type-specific preview - _buildPreview(context), - ], - ), - ), - ); - } - - Widget _buildPreview(BuildContext context) { - final wp = context.read(); - - switch (suggestion.type) { - case SuggestionType.reorder: - final ids = suggestion.reorderedExerciseIds ?? []; - final shown = ids.take(5).toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < shown.length; i++) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - '${i + 1}. ${wp.getExerciseName(shown[i])}', - style: GoogleFonts.geistMono( - fontSize: 12, - color: AppColors.textFaint, - ), - ), - ), - if (ids.length > 5) - Text( - '…and ${ids.length - 5} more', - style: GoogleFonts.geist( - fontSize: 11, color: AppColors.textFaint), - ), - ], - ); - - case SuggestionType.replace: - final removeName = suggestion.removeExerciseId != null - ? wp.getExerciseName(suggestion.removeExerciseId!) - : '—'; - final addName = suggestion.replaceWithName ?? '—'; - return Row( - children: [ - Expanded( - child: Text( - removeName, - style: GoogleFonts.geist( - fontSize: 12, - color: AppColors.error, - decoration: TextDecoration.lineThrough, - decorationColor: AppColors.error, - ), - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: Icon(Icons.arrow_forward_rounded, - size: 14, color: AppColors.textFaint), - ), - Expanded( - child: Text( - addName, - style: GoogleFonts.geist( - fontSize: 12, - color: AppColors.success, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ); - - case SuggestionType.add: - return Text( - '+ ${suggestion.addExerciseName ?? '—'}', - style: GoogleFonts.geist( - fontSize: 12, - color: AppColors.success, - fontWeight: FontWeight.w600, - ), - ); - } - } -} - -// ── Accept/Reject toggle ────────────────────────────────────────────────────── - -class _AcceptToggle extends StatelessWidget { - const _AcceptToggle({required this.accepted, required this.onToggle}); - final bool accepted; - final VoidCallback onToggle; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - HapticFeedback.selectionClick(); - onToggle(); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 4, - ), - decoration: BoxDecoration( - color: accepted - ? AppColors.success.withValues(alpha: 0.15) - : AppColors.error.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all( - color: accepted - ? AppColors.success.withValues(alpha: 0.4) - : AppColors.error.withValues(alpha: 0.3), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - accepted ? Icons.check_rounded : Icons.close_rounded, - size: 12, - color: accepted ? AppColors.success : AppColors.error, - ), - const SizedBox(width: 4), - Text( - accepted ? 'Accept' : 'Reject', - style: GoogleFonts.geist( - fontSize: 11, - fontWeight: FontWeight.w600, - color: accepted ? AppColors.success : AppColors.error, - ), - ), - ], - ), - ), - ); - } -} diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index 65bc85e..08f811c 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -326,63 +326,6 @@ Required JSON schema (follow exactly): } } - // ── Routine optimizer (structured JSON output) ──────────────────────────── - @override - Future generateOptimization({ - required String contextPayload, - }) async { - if (!isConfigured) { - throw StateError('Gemini API key not configured.'); - } - - const systemPrompt = - 'You are a certified strength coach analysing a workout routine for RepForge.\n' - 'Return ONLY raw JSON — no markdown fences, no comments, no explanation text.\n' - 'Produce 1–3 suggestions total, at most one of each type: reorder, replace, add.\n' - 'Base recommendations strictly on the performance data provided.\n' - 'For reorder and remove_exercise_id use ONLY exercise IDs already present in the routine.\n' - 'For replace_with_name and add_exercise_name use ONLY names from the Available exercises list.\n\n' - 'Required JSON schema (follow exactly):\n' - '{\n' - ' "summary": "<1-2 sentence overall assessment>",\n' - ' "suggestions": [\n' - ' {\n' - ' "type": "reorder",\n' - ' "reasoning": "",\n' - ' "reordered_exercise_ids": ["", "", ...]\n' - ' },\n' - ' {\n' - ' "type": "replace",\n' - ' "reasoning": "",\n' - ' "remove_exercise_id": "",\n' - ' "replace_with_name": ""\n' - ' },\n' - ' {\n' - ' "type": "add",\n' - ' "reasoning": "",\n' - ' "add_exercise_name": ""\n' - ' }\n' - ' ]\n' - '}\n' - 'Include only the suggestion types that are genuinely beneficial. ' - 'Omit a type entirely if no meaningful improvement can be made.'; - - try { - final response = - await _makeModel(jsonMode: true, system: systemPrompt) - .generateContent([Content.text(contextPayload)]); - _recordUsage(response.usageMetadata); - final raw = response.text ?? ''; - if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); - final data = jsonDecode(raw) as Map; - return RoutineOptimizationResult.fromJson(data); - } on GenerativeAIException catch (e) { - throw Exception('Gemini API error: ${e.message}'); - } on FormatException catch (e) { - throw Exception('Could not parse optimization JSON: $e'); - } - } - // ── Generic one-shot insight (contextual) ───────────────────────────────── @override Future generateInsight(String system, String context) async { diff --git a/workout-logger/lib/services/interfaces/ai_service_interface.dart b/workout-logger/lib/services/interfaces/ai_service_interface.dart index b42bb3d..4a840ec 100644 --- a/workout-logger/lib/services/interfaces/ai_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ai_service_interface.dart @@ -50,9 +50,4 @@ abstract class IAiService { /// [context] payload. Future generateInsight(String system, String context); - /// Analyse a routine's performance context and return structured optimization - /// suggestions (reorder / replace / add exercises). - Future generateOptimization({ - required String contextPayload, - }); } diff --git a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart deleted file mode 100644 index 875b79f..0000000 --- a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart +++ /dev/null @@ -1,214 +0,0 @@ -// routine_optimizer_view_model.dart — ViewModel for AI-driven routine optimization. -// -// Builds performance context from WorkoutProvider, calls IAiService.generateOptimization, -// resolves AI-supplied exercise names to IDs, and applies accepted suggestions -// back to the routine via WorkoutProvider.updateRoutine. - -import 'package:flutter/foundation.dart'; - -import '../models/models.dart'; -import '../services/interfaces/ai_service_interface.dart'; -import '../services/workout_provider.dart'; - -enum OptimizerState { idle, loading, success, error } - -class RoutineOptimizerViewModel extends ChangeNotifier { - final IAiService _ai; - final WorkoutProvider _wp; - - RoutineOptimizerViewModel({required IAiService ai, required WorkoutProvider wp}) - : _ai = ai, - _wp = wp; - - OptimizerState _state = OptimizerState.idle; - RoutineOptimizationResult? _result; - String? _errorMessage; - final Map _accepted = {}; - bool _applied = false; - - OptimizerState get state => _state; - RoutineOptimizationResult? get result => _result; - String? get errorMessage => _errorMessage; - Map get accepted => Map.unmodifiable(_accepted); - bool get applied => _applied; - - bool isSuggestionAccepted(int index) => _accepted[index] ?? true; - - void toggleSuggestion(int index) { - _accepted[index] = !(_accepted[index] ?? true); - notifyListeners(); - } - - Future analyzeRoutine(Routine routine) async { - _state = OptimizerState.loading; - _result = null; - _errorMessage = null; - _applied = false; - _accepted.clear(); - notifyListeners(); - - try { - final payload = _buildContext(routine); - final result = await _ai.generateOptimization(contextPayload: payload); - _resolveExerciseIds(result); - _result = result; - for (var i = 0; i < result.suggestions.length; i++) { - _accepted[i] = true; - } - _state = OptimizerState.success; - } catch (e) { - _errorMessage = e.toString(); - _state = OptimizerState.error; - } - notifyListeners(); - } - - Future applyAccepted(Routine routine) async { - final res = _result; - if (res == null) return; - - var ids = List.from(routine.exerciseIds); - - for (var i = 0; i < res.suggestions.length; i++) { - if (!(_accepted[i] ?? true)) continue; - final s = res.suggestions[i]; - switch (s.type) { - case SuggestionType.reorder: - final reordered = s.reorderedExerciseIds; - if (reordered != null && reordered.isNotEmpty) { - ids = List.from(reordered); - } - case SuggestionType.replace: - final removeId = s.removeExerciseId; - final addId = s.replaceWithId; - if (removeId != null && addId != null) { - final idx = ids.indexOf(removeId); - if (idx != -1) { - ids[idx] = addId; - } else { - ids.add(addId); - } - } - case SuggestionType.add: - final addId = s.addExerciseId; - if (addId != null && !ids.contains(addId)) { - ids.add(addId); - } - } - } - - await _wp.updateRoutine(routine.copyWith(exerciseIds: ids)); - _applied = true; - notifyListeners(); - } - - // ── Context builder ──────────────────────────────────────────────────────── - - String _buildContext(Routine routine) { - final buf = StringBuffer(); - - buf.writeln('Routine: "${routine.name}"'); - buf.writeln('Exercises (in current order):'); - - for (var i = 0; i < routine.exerciseIds.length; i++) { - final id = routine.exerciseIds[i]; - final name = _wp.getExerciseName(id); - final model = _wp.getGrowthModel(id); - final sessions = _wp.getVolumeProgression(id).length; - - if (model != null) { - final slope = model.slope >= 0 - ? '+${model.slope.toStringAsFixed(1)}' - : model.slope.toStringAsFixed(1); - buf.writeln( - ' ${i + 1}. [id: $id] $name — slope: $slope kg/session, ' - 'r²: ${model.r2.toStringAsFixed(2)}, sessions: $sessions', - ); - } else { - buf.writeln( - ' ${i + 1}. [id: $id] $name — no data (0 sessions)', - ); - } - } - - buf.writeln(); - - // Weekly muscle volume - final weeklyVolume = _wp.getWeeklyVolumeByMuscle(); - if (weeklyVolume.isNotEmpty) { - buf.writeln('Weekly muscle volume (last 7 days):'); - final sorted = weeklyVolume.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - for (final e in sorted) { - final muscleName = _wp.getMuscleGroupName(e.key); - buf.writeln(' $muscleName: ${e.value.toStringAsFixed(0)} kg'); - } - - // Detect muscles that appear in the exercise catalog but have zero weekly volume - final coveredMuscles = weeklyVolume.keys.toSet(); - final allMusclesInCatalog = {}; - for (final ex in _wp.allExercises) { - for (final a in ex.muscleActivations) { - allMusclesInCatalog.add(a.muscleGroupId); - } - } - final missing = allMusclesInCatalog - .difference(coveredMuscles) - .map(_wp.getMuscleGroupName) - .toSet(); - if (missing.isNotEmpty) { - buf.writeln('Missing coverage (zero weekly volume): ${missing.join(', ')}'); - } - } else { - buf.writeln('Weekly muscle volume: no data for last 7 days.'); - } - - buf.writeln(); - - // Available exercise catalogue grouped by primary muscle (first 60) - buf.writeln('Available exercises for replace/add suggestions:'); - final grouped = >{}; - for (final ex in _wp.allExercises.take(60)) { - final muscle = _wp.getMuscleGroupName( - ex.muscleActivations.isNotEmpty - ? ex.muscleActivations.first.muscleGroupId - : 'unknown', - ); - grouped.putIfAbsent(muscle, () => []).add(ex.name); - } - for (final entry in grouped.entries) { - buf.writeln(' [${entry.key}] ${entry.value.join(', ')}'); - } - - return buf.toString(); - } - - // ── Exercise name resolution ─────────────────────────────────────────────── - - void _resolveExerciseIds(RoutineOptimizationResult result) { - for (final s in result.suggestions) { - if (s.type == SuggestionType.replace && s.replaceWithName != null) { - s.replaceWithId = _findExerciseId(s.replaceWithName!); - } - if (s.type == SuggestionType.add && s.addExerciseName != null) { - s.addExerciseId = _findExerciseId(s.addExerciseName!); - } - } - } - - String? _findExerciseId(String name) { - final lower = name.toLowerCase(); - // Exact match first - for (final ex in _wp.allExercises) { - if (ex.name.toLowerCase() == lower) return ex.id; - } - // Partial match - for (final ex in _wp.allExercises) { - if (ex.name.toLowerCase().contains(lower) || - lower.contains(ex.name.toLowerCase())) { - return ex.id; - } - } - return null; - } -} diff --git a/workout-logger/test/ai_coach_view_model_test.dart b/workout-logger/test/ai_coach_view_model_test.dart index 71e4f29..13bebbe 100644 --- a/workout-logger/test/ai_coach_view_model_test.dart +++ b/workout-logger/test/ai_coach_view_model_test.dart @@ -59,10 +59,6 @@ class _FakeAiService implements IAiService { @override Future generateInsight(String system, String context) async => ''; - @override - Future generateOptimization({ - required String contextPayload, - }) => throw UnimplementedError(); } void main() { From 4a8661fce60cfa713634bd55bb790e2146f54bf4 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:42:23 +0530 Subject: [PATCH 32/44] feat: add Conversation.kind and AI question models (QuestionSpec, AnswerSpec, PendingQuestions) Co-Authored-By: Claude Sonnet 4.6 --- workout-logger/lib/models/models.dart | 64 ++++++++++++++++ .../test/model_serialization_test.dart | 73 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 1573649..bad5c88 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -867,6 +867,7 @@ class ChatMessage { class Conversation { final String id; final String title; + final String kind; // 'coach' | 'optimizer' final DateTime createdAt; final DateTime updatedAt; final List messages; @@ -874,6 +875,7 @@ class Conversation { Conversation({ String? id, required this.title, + this.kind = 'coach', DateTime? createdAt, DateTime? updatedAt, List? messages, @@ -885,6 +887,7 @@ class Conversation { Map toJson() => { 'id': id, 'title': title, + 'kind': kind, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), 'messages': messages.map((m) => m.toJson()).toList(), @@ -893,6 +896,7 @@ class Conversation { factory Conversation.fromJson(Map json) => Conversation( id: json['id'] as String?, title: json['title'] as String, + kind: json['kind'] as String? ?? 'coach', createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) @@ -904,12 +908,14 @@ class Conversation { Conversation copyWith({ Object? title = _sentinel, + Object? kind = _sentinel, Object? createdAt = _sentinel, Object? updatedAt = _sentinel, Object? messages = _sentinel, }) => Conversation( id: id, title: title == _sentinel ? this.title : title as String, + kind: kind == _sentinel ? this.kind : kind as String, createdAt: createdAt == _sentinel ? this.createdAt : createdAt as DateTime, updatedAt: updatedAt == _sentinel ? this.updatedAt : updatedAt as DateTime, messages: messages == _sentinel @@ -917,3 +923,61 @@ class Conversation { : messages as List, ); } + +// ==================== AI Question Models ==================== + +/// One question the AI asks the user, with predefined options. +class QuestionSpec { + final String question; + final List options; + final bool multiSelect; + final bool allowCustom; + + const QuestionSpec({ + required this.question, + required this.options, + this.multiSelect = false, + this.allowCustom = true, + }); + + factory QuestionSpec.fromJson(Map j) => QuestionSpec( + question: j['question'] as String, + options: (j['options'] as List).cast(), + multiSelect: j['multiSelect'] as bool? ?? false, + allowCustom: j['allowCustom'] as bool? ?? true, + ); +} + +/// The user's answer to one [QuestionSpec]. +class AnswerSpec { + final String question; + final List selected; + final String? custom; + + const AnswerSpec({ + required this.question, + required this.selected, + this.custom, + }); + + Map toJson() => { + 'question': question, + 'selected': selected, + if (custom != null && custom!.isNotEmpty) 'custom': custom, + }; +} + +/// The structured payload from an `ask_user_questions` tool call. +class PendingQuestions { + final String? preamble; + final List questions; + + const PendingQuestions({this.preamble, required this.questions}); + + factory PendingQuestions.fromJson(Map j) => PendingQuestions( + preamble: j['preamble'] as String?, + questions: (j['questions'] as List) + .map((q) => QuestionSpec.fromJson(q as Map)) + .toList(), + ); +} diff --git a/workout-logger/test/model_serialization_test.dart b/workout-logger/test/model_serialization_test.dart index d96c132..13f922f 100644 --- a/workout-logger/test/model_serialization_test.dart +++ b/workout-logger/test/model_serialization_test.dart @@ -522,4 +522,77 @@ void main() { expect(copy.minReps, original.minReps); }); }); + + // ── Conversation.kind ───────────────────────────────────────────────────── + + group('Conversation.kind', () { + test('round-trips kind field', () { + final c = Conversation(title: 'test', kind: 'optimizer'); + final json = c.toJson(); + final restored = Conversation.fromJson(json); + expect(restored.kind, 'optimizer'); + }); + + test('missing kind in JSON defaults to coach', () { + final json = { + 'id': 'x', + 'title': 'legacy', + 'createdAt': DateTime.now().toIso8601String(), + 'messages': >[], + }; + final c = Conversation.fromJson(json); + expect(c.kind, 'coach'); + }); + }); + + // ── QuestionSpec / AnswerSpec / PendingQuestions ────────────────────────── + + group('QuestionSpec / AnswerSpec / PendingQuestions', () { + test('QuestionSpec round-trips from JSON with defaults', () { + final j = { + 'question': 'What is your goal?', + 'options': ['Strength', 'Hypertrophy'], + }; + final spec = QuestionSpec.fromJson(j); + expect(spec.question, 'What is your goal?'); + expect(spec.options, ['Strength', 'Hypertrophy']); + expect(spec.multiSelect, false); + expect(spec.allowCustom, true); + }); + + test('QuestionSpec reads multiSelect = true', () { + final j = { + 'question': 'Pick changes', + 'options': ['Reorder', 'Add exercise'], + 'multiSelect': true, + }; + expect(QuestionSpec.fromJson(j).multiSelect, true); + }); + + test('AnswerSpec.toJson omits null custom', () { + final a = AnswerSpec(question: 'q', selected: ['Strength']); + final json = a.toJson(); + expect(json.containsKey('custom'), false); + expect(json['selected'], ['Strength']); + }); + + test('AnswerSpec.toJson includes non-empty custom', () { + final a = AnswerSpec(question: 'q', selected: [], custom: 'Power lifting'); + final json = a.toJson(); + expect(json['custom'], 'Power lifting'); + }); + + test('PendingQuestions.fromJson parses preamble and questions', () { + final j = { + 'preamble': 'Let me understand your goals first.', + 'questions': [ + {'question': 'Goal?', 'options': ['Strength', 'Size']}, + ], + }; + final pq = PendingQuestions.fromJson(j); + expect(pq.preamble, 'Let me understand your goals first.'); + expect(pq.questions, hasLength(1)); + expect(pq.questions.first.question, 'Goal?'); + }); + }); } From cf8294420fb7e954a0f7c039ecdfbe37049a2338 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:46:19 +0530 Subject: [PATCH 33/44] feat: scope ConversationManager by kind ('coach' | 'optimizer') Co-Authored-By: Claude Sonnet 4.6 --- .../managers/conversation_manager.dart | 12 +++++-- .../test/conversation_manager_test.dart | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/workout-logger/lib/services/managers/conversation_manager.dart b/workout-logger/lib/services/managers/conversation_manager.dart index ed39a52..fb784d8 100644 --- a/workout-logger/lib/services/managers/conversation_manager.dart +++ b/workout-logger/lib/services/managers/conversation_manager.dart @@ -11,13 +11,18 @@ import '../interfaces/storage_service_interface.dart'; /// Manages the lifecycle of AI coach [Conversation]s (load, create, append, /// rename, delete) backed by [IStorageService]. +/// +/// [kind] scopes this manager to a specific conversation category +/// (e.g. `'coach'` or `'optimizer'`). Only conversations with a matching +/// [Conversation.kind] are loaded or created by this instance. class ConversationManager extends ChangeNotifier { final IStorageService _storage; + final String kind; List _conversations = []; Conversation? _active; - ConversationManager(this._storage); + ConversationManager(this._storage, {this.kind = 'coach'}); /// All conversations, most-recently-updated first. List get conversations => List.unmodifiable(_conversations); @@ -30,8 +35,10 @@ class ConversationManager extends ChangeNotifier { List get activeMessages => _active?.messages ?? const []; /// Load all conversations from storage. Does not change the active one. + /// Only conversations whose [Conversation.kind] matches [kind] are loaded. Future loadConversations() async { - _conversations = await _storage.getAllConversations(); + final all = await _storage.getAllConversations(); + _conversations = all.where((c) => c.kind == kind).toList(); notifyListeners(); } @@ -60,6 +67,7 @@ class ConversationManager extends ChangeNotifier { if (current == null) { updated = Conversation( title: _deriveTitle(message), + kind: kind, messages: [message], ); } else { diff --git a/workout-logger/test/conversation_manager_test.dart b/workout-logger/test/conversation_manager_test.dart index e533313..f5fb58b 100644 --- a/workout-logger/test/conversation_manager_test.dart +++ b/workout-logger/test/conversation_manager_test.dart @@ -113,5 +113,36 @@ void main() { final stored = await storage.getConversation(id); expect(stored!.title, 'My chat'); }); + + test('kind-scoped manager only loads matching conversations', () async { + // Seed two conversations directly into storage with different kinds. + final coachConv = Conversation(title: 'coach chat', kind: 'coach'); + final optimizerConv = + Conversation(title: 'optimizer chat', kind: 'optimizer'); + await storage.saveConversation(coachConv); + await storage.saveConversation(optimizerConv); + + final optimizerManager = ConversationManager(storage, kind: 'optimizer'); + await optimizerManager.loadConversations(); + + expect(optimizerManager.conversations, hasLength(1)); + expect(optimizerManager.conversations.first.title, 'optimizer chat'); + }); + + test('optimizer manager stamps kind on new conversations', () async { + final optimizerManager = ConversationManager(storage, kind: 'optimizer'); + await optimizerManager.appendMessage( + ChatMessage(role: 'user', text: 'Optimize Push Day'), + ); + expect(optimizerManager.active!.kind, 'optimizer'); + + final stored = await storage.getAllConversations(); + expect(stored.first.kind, 'optimizer'); + }); + + test('default manager uses coach kind', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + expect(manager.active!.kind, 'coach'); + }); }); } From c883253abc7ba69cf3136da0c93c1aa6718f3ca8 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:51:14 +0530 Subject: [PATCH 34/44] feat: add ask_user_questions tool declaration and optimizer system prompt Co-Authored-By: Claude Sonnet 4.6 --- .../lib/services/ai/coach_tool_service.dart | 45 +++++++++++++++ .../lib/services/gemini_context_builder.dart | 57 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index 4e6a31c..61e93c1 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -22,6 +22,51 @@ class CoachToolService { CoachToolService(this._wp, this._pr); + /// Tool declaration for the optimizer screen's `ask_user_questions` flow. + /// NOT included in the coach's tool list — only the optimizer adds it. + static FunctionDeclaration get askUserQuestionsDeclaration => + FunctionDeclaration( + 'ask_user_questions', + 'Ask the user 1–3 clarifying questions before proceeding. ' + 'Provide an optional preamble (short context sentence shown above the ' + 'questions). Each question has 3–4 option chips; set multiSelect:true ' + 'when the user should be able to pick multiple options. ' + 'allowCustom is always treated as true.', + Schema.object( + properties: { + 'preamble': Schema.string( + description: + 'Optional. A short sentence shown above the questions, ' + 'e.g. "Before I analyse your routine, I have a few quick ' + 'questions."', + nullable: true, + ), + 'questions': Schema.array( + items: Schema.object( + properties: { + 'question': Schema.string( + description: 'The question text, e.g. "What is your primary goal?"', + ), + 'options': Schema.array( + items: Schema.string(), + description: '3–4 answer chips, e.g. ["Strength","Hypertrophy","Fat loss","Endurance"].', + ), + 'multiSelect': Schema.boolean( + description: + 'If true the user can select multiple chips. ' + 'Use for confirmation questions (e.g. "Which changes should I apply?").', + nullable: true, + ), + }, + requiredProperties: ['question', 'options'], + ), + description: '1–3 questions to display.', + ), + }, + requiredProperties: ['questions'], + ), + ); + /// Tool declarations advertised to the model. List buildTools() => [ Tool(functionDeclarations: [ diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart index a017bb6..98071e9 100644 --- a/workout-logger/lib/services/gemini_context_builder.dart +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -49,6 +49,63 @@ class GeminiContextBuilder { return buf.toString(); } + // ── Routine optimizer system prompt ─────────────────────────────────────── + static String buildOptimizerSystemPrompt({ + String? userName, + String unitLabel = 'kg', + DateTime? now, + }) { + final n = now ?? DateTime.now(); + final today = '${n.year}-${n.month.toString().padLeft(2, '0')}-' + '${n.day.toString().padLeft(2, '0')}'; + + final buf = StringBuffer() + ..writeln( + 'You are a specialized routine optimizer embedded in RepForge. ' + 'Your only job is to analyse and improve a specific workout routine ' + 'based on the user\'s real performance data and stated preferences.', + ) + ..writeln('Today is $today. Weights are in $unitLabel.') + ..writeln() + ..writeln('STRICT WORKFLOW — execute in this order every time:') + ..writeln( + '1. QUESTIONS FIRST: Call ask_user_questions immediately. ' + 'Ask about (a) primary goal [Strength/Hypertrophy/Fat loss/Endurance], ' + '(b) sessions per week for this routine, and optionally (c) any exercises ' + 'they want to keep no matter what. Do NOT skip this step.', + ) + ..writeln( + '2. FETCH DATA: After answers arrive, call get_routine_performance ' + 'for the routine and get_exercise_performance for each exercise that ' + 'has data. Never invent numbers.', + ) + ..writeln( + '3. PROPOSE CHANGES: List proposed changes as short bullets: ' + 'reorder (give full new order), replace (which exercise → which ' + 'alternative and why), add (specific exercise to fill a gap). ' + 'Keep your analysis under 150 words.', + ) + ..writeln( + '4. CONFIRM: Call ask_user_questions with multiSelect:true listing ' + 'your proposed changes as chips so the user can pick which to apply.', + ) + ..writeln( + '5. APPLY: Call update_routine exactly once with only the confirmed ' + 'changes. Then confirm in one sentence what was changed.', + ) + ..writeln() + ..writeln( + 'Format replies with Markdown bold for exercise names. ' + 'Be specific — reference actual exercise names and trend numbers.', + ); + + if (userName != null && userName.isNotEmpty) { + buf.writeln('\nThe user\'s name is $userName.'); + } + + return buf.toString(); + } + // ── Weekly insights context ──────────────────────────────────────────────── static String buildWeeklyInsightsContext({ required List thisWeek, From 15fa743ac8950e89ad4219a4395d534323ebb23e Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:54:51 +0530 Subject: [PATCH 35/44] feat: add RFQuestionCard reusable widget (option chips + custom input) Co-Authored-By: Claude Sonnet 4.6 --- .../lib/screens/widgets/rf_question_card.dart | 255 ++++++++++++++++++ .../test/rf_question_card_test.dart | 115 ++++++++ 2 files changed, 370 insertions(+) create mode 100644 workout-logger/lib/screens/widgets/rf_question_card.dart create mode 100644 workout-logger/test/rf_question_card_test.dart diff --git a/workout-logger/lib/screens/widgets/rf_question_card.dart b/workout-logger/lib/screens/widgets/rf_question_card.dart new file mode 100644 index 0000000..fbdd0eb --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_question_card.dart @@ -0,0 +1,255 @@ +// rf_question_card.dart — Reusable AI question card (option chips + custom input). +// Used by RoutineOptimizerScreen when the AI calls ask_user_questions. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +/// Renders a list of [QuestionSpec]s as interactive chip cards and calls +/// [onSubmit] with all answers when the user taps "Continue". +class RFQuestionCard extends StatefulWidget { + const RFQuestionCard({ + super.key, + required this.questions, + required this.onSubmit, + }); + + final List questions; + final ValueChanged> onSubmit; + + @override + State createState() => _RFQuestionCardState(); +} + +class _RFQuestionCardState extends State { + late final List> _selected; + late final List _customCtrls; + + @override + void initState() { + super.initState(); + _selected = List.generate(widget.questions.length, (_) => {}); + _customCtrls = List.generate( + widget.questions.length, + (_) => TextEditingController(), + ); + } + + @override + void dispose() { + for (final c in _customCtrls) { + c.dispose(); + } + super.dispose(); + } + + void _submit() { + HapticFeedback.mediumImpact(); + final answers = [ + for (var i = 0; i < widget.questions.length; i++) + AnswerSpec( + question: widget.questions[i].question, + selected: _selected[i].toList(), + custom: _customCtrls[i].text.trim().isEmpty + ? null + : _customCtrls[i].text.trim(), + ), + ]; + widget.onSubmit(answers); + } + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.help_outline_rounded, + size: 14, color: AppColors.secondary), + const SizedBox(width: AppSpacing.xs), + Text( + 'QUICK QUESTIONS', + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: AppColors.secondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + for (var i = 0; i < widget.questions.length; i++) ...[ + if (i > 0) ...[ + const SizedBox(height: 1), + const Divider(color: AppColors.glassBorder, height: 24), + ], + _QuestionBlock( + spec: widget.questions[i], + selected: _selected[i], + controller: _customCtrls[i], + onToggle: (opt) => setState(() { + final spec = widget.questions[i]; + if (spec.multiSelect) { + if (_selected[i].contains(opt)) { + _selected[i].remove(opt); + } else { + _selected[i].add(opt); + } + } else { + _selected[i] = {opt}; + } + }), + ), + ], + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: GlowButton( + label: 'Continue', + onPressed: _submit, + ), + ), + ], + ), + ); + } +} + +class _QuestionBlock extends StatelessWidget { + const _QuestionBlock({ + required this.spec, + required this.selected, + required this.controller, + required this.onToggle, + }); + + final QuestionSpec spec; + final Set selected; + final TextEditingController controller; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + spec.question, + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: [ + for (final opt in spec.options) + _OptionChip( + label: opt, + selected: selected.contains(opt), + onTap: () => onToggle(opt), + ), + ], + ), + if (spec.allowCustom) ...[ + const SizedBox(height: AppSpacing.sm), + TextField( + controller: controller, + style: GoogleFonts.geist( + fontSize: 13, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: 'Or type your own answer…', + hintStyle: GoogleFonts.geist( + fontSize: 12, + color: AppColors.textFaint, + ), + filled: true, + fillColor: AppColors.glass2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: + const BorderSide(color: AppColors.secondary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + isDense: true, + ), + ), + ], + ], + ); + } +} + +class _OptionChip extends StatelessWidget { + const _OptionChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 6, + ), + decoration: BoxDecoration( + color: selected + ? AppColors.secondary.withValues(alpha: 0.18) + : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: selected + ? AppColors.secondary.withValues(alpha: 0.6) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Text( + label, + style: GoogleFonts.geist( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? AppColors.secondary : AppColors.textSoft, + ), + ), + ), + ); + } +} diff --git a/workout-logger/test/rf_question_card_test.dart b/workout-logger/test/rf_question_card_test.dart new file mode 100644 index 0000000..e1db6a5 --- /dev/null +++ b/workout-logger/test/rf_question_card_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/rf_question_card.dart'; +import 'package:repforge/theme/app_theme.dart'; + +Widget _wrap(Widget child) => MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold(body: SingleChildScrollView(child: child)), +); + +void main() { + group('RFQuestionCard', () { + final singleSpec = QuestionSpec( + question: 'What is your goal?', + options: ['Strength', 'Hypertrophy', 'Fat loss'], + ); + + final multiSpec = QuestionSpec( + question: 'Which changes to apply?', + options: ['Reorder', 'Add exercise', 'Replace exercise'], + multiSelect: true, + ); + + testWidgets('renders question text and options', (tester) async { + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec], + onSubmit: (_) {}, + ), + )); + expect(find.text('What is your goal?'), findsOneWidget); + expect(find.text('Strength'), findsOneWidget); + expect(find.text('Hypertrophy'), findsOneWidget); + expect(find.text('Fat loss'), findsOneWidget); + }); + + testWidgets('single-select: tapping second chip deselects first', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec], + onSubmit: (a) => submitted = a, + ), + )); + + await tester.tap(find.text('Strength')); + await tester.pump(); + await tester.tap(find.text('Hypertrophy')); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted, isNotNull); + expect(submitted!.first.selected, ['Hypertrophy']); + }); + + testWidgets('multi-select: multiple chips stay selected', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [multiSpec], + onSubmit: (a) => submitted = a, + ), + )); + + await tester.tap(find.text('Reorder')); + await tester.pump(); + await tester.tap(find.text('Add exercise')); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted!.first.selected, containsAll(['Reorder', 'Add exercise'])); + }); + + testWidgets('custom text is included in answer when typed', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec], + onSubmit: (a) => submitted = a, + ), + )); + + await tester.enterText(find.byType(TextField), 'Power lifting'); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted!.first.custom, 'Power lifting'); + }); + + testWidgets('multiple questions rendered and submitted together', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec, multiSpec], + onSubmit: (a) => submitted = a, + ), + )); + + expect(find.text('What is your goal?'), findsOneWidget); + expect(find.text('Which changes to apply?'), findsOneWidget); + + await tester.tap(find.text('Strength')); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted, hasLength(2)); + expect(submitted![0].question, 'What is your goal?'); + expect(submitted![1].question, 'Which changes to apply?'); + }); + }); +} From 6116d5850fe5fef7fb99624970d3a5ac73568b43 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:00:23 +0530 Subject: [PATCH 36/44] feat: rewrite RoutineOptimizerViewModel as conversational streaming VM with ask_user_questions pausing Co-Authored-By: Claude Sonnet 4.6 --- .../routine_optimizer_view_model.dart | 194 +++++++++++++++ .../routine_optimizer_view_model_test.dart | 234 ++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 workout-logger/lib/viewmodels/routine_optimizer_view_model.dart create mode 100644 workout-logger/test/routine_optimizer_view_model_test.dart diff --git a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart new file mode 100644 index 0000000..bfba6b2 --- /dev/null +++ b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart @@ -0,0 +1,194 @@ +// routine_optimizer_view_model.dart — Conversational routine optimizer VM. +// +// Drives IAiService.streamCoachReply with an optimizer-focused system prompt. +// Intercepts ask_user_questions tool calls — sets pendingQuestions and returns +// a Completer.future, suspending the stream until submitAnswers() is called. + +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, TextPart, FunctionCall, Tool; + +import '../models/models.dart'; +import '../services/interfaces/ai_service_interface.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/settings_provider.dart'; +import '../services/gemini_context_builder.dart'; + +class RoutineOptimizerViewModel extends ChangeNotifier { + final IAiService _ai; + final CoachToolService _coachTools; + final ConversationManager _conversations; + final SettingsProvider _settings; + + bool _loading = false; + bool _disposed = false; + String _streamingText = ''; + PendingQuestions? _pendingQuestions; + Completer>? _pendingCompleter; + + RoutineOptimizerViewModel({ + required IAiService ai, + required CoachToolService coachTools, + required ConversationManager conversations, + required SettingsProvider settings, + }) : _ai = ai, + _coachTools = coachTools, + _conversations = conversations, + _settings = settings { + _conversations.addListener(_notify); + } + + @override + void dispose() { + _disposed = true; + _pendingCompleter?.complete({'answers': [], 'aborted': true}); + _pendingCompleter = null; + _pendingQuestions = null; + _conversations.removeListener(_notify); + super.dispose(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + // ── State ────────────────────────────────────────────────────────────────── + + bool get isConfigured => _ai.isConfigured; + bool get isLoading => _loading; + String get streamingText => _streamingText; + PendingQuestions? get pendingQuestions => _pendingQuestions; + List get messages => _conversations.activeMessages; + List get conversations => _conversations.conversations; + String? get activeConversationId => _conversations.active?.id; + + // ── Commands ─────────────────────────────────────────────────────────────── + + Future loadConversations() => _conversations.loadConversations(); + + void selectConversation(String id) { + if (_loading) return; + _conversations.selectConversation(id); + } + + Future deleteConversation(String id) => + _conversations.deleteConversation(id); + + /// Begin a fresh conversation and auto-send the optimization seed prompt. + Future startForRoutine(Routine routine) async { + _conversations.startNewConversation(); + final seed = + 'Optimize my "${routine.name}" routine based on my past performance.'; + await sendMessage(seed); + } + + /// Submit the user's answers to the pending ask_user_questions call. + void submitAnswers(List answers) { + _pendingQuestions = null; + + final text = answers + .map((a) { + final parts = [...a.selected]; + if (a.custom != null && a.custom!.isNotEmpty) parts.add(a.custom!); + return '${a.question}: ${parts.join(', ')}'; + }) + .join(' · '); + + if (text.isNotEmpty) { + _conversations.appendMessage(ChatMessage(role: 'user', text: text)); + } + + _pendingCompleter?.complete({ + 'answers': [for (final a in answers) a.toJson()], + }); + _pendingCompleter = null; + _notify(); + } + + Future sendMessage(String text) async { + final trimmed = text.trim(); + if (trimmed.isEmpty || _loading) return; + + _loading = true; + _streamingText = ''; + _notify(); + + await _conversations.appendMessage(ChatMessage(role: 'user', text: trimmed)); + + final systemPrompt = GeminiContextBuilder.buildOptimizerSystemPrompt( + userName: _settings.userName, + unitLabel: _settings.unitLabel, + ); + final history = _buildHistory(); + final tools = [ + ..._coachTools.buildTools(), + Tool(functionDeclarations: [CoachToolService.askUserQuestionsDeclaration]), + ]; + + final buffer = StringBuffer(); + try { + await for (final chunk in _ai.streamCoachReply( + userMessage: trimmed, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: _routeToolCall, + )) { + buffer.write(chunk); + _streamingText = buffer.toString(); + _notify(); + } + final reply = buffer.toString().trim(); + if (reply.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: reply), + ); + } + } catch (e) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: 'Error: $e'), + ); + } finally { + _streamingText = ''; + _loading = false; + _pendingQuestions = null; + _notify(); + } + } + + Future> _routeToolCall(FunctionCall call) async { + if (call.name == 'ask_user_questions') { + return _handleAskUserQuestions(Map.from(call.args)); + } + return _coachTools.handleCall(call); + } + + Future> _handleAskUserQuestions( + Map args, + ) async { + final pending = PendingQuestions.fromJson(args); + + final preamble = pending.preamble; + if (preamble != null && preamble.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: preamble), + ); + } + + _pendingQuestions = pending; + final completer = Completer>(); + _pendingCompleter = completer; + _notify(); + + return completer.future; + } + + List _buildHistory() { + final msgs = _conversations.activeMessages; + final prior = + msgs.length > 1 ? msgs.sublist(0, msgs.length - 1) : []; + return prior.map((m) => Content(m.role, [TextPart(m.text)])).toList(); + } +} diff --git a/workout-logger/test/routine_optimizer_view_model_test.dart b/workout-logger/test/routine_optimizer_view_model_test.dart new file mode 100644 index 0000000..669b909 --- /dev/null +++ b/workout-logger/test/routine_optimizer_view_model_test.dart @@ -0,0 +1,234 @@ +// Unit tests for RoutineOptimizerViewModel + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, Tool, FunctionCall; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/ai_service_interface.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/viewmodels/routine_optimizer_view_model.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Fake IAiService ──────────────────────────────────────────────────────── + +class _SimpleAi implements IAiService { + _SimpleAi({this.chunks = const ['Done.'], this.toolCall}); + + final List chunks; + final FunctionCall? toolCall; + int calls = 0; + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + calls++; + final tc = toolCall; + if (tc != null && onToolCall != null) { + await onToolCall(tc); + } + for (final c in chunks) { + yield c; + } + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => + throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +class _ThrowingAi implements IAiService { + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + throw Exception('Network error'); + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => + throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) => + throw UnimplementedError(); + + @override + Future generateInsight(String system, String context) => + throw UnimplementedError(); +} + +// ── Helper ──────────────────────────────────────────────────────────────── + +RoutineOptimizerViewModel _buildVm({ + required MockStorageService storage, + required IAiService ai, +}) { + final wp = WorkoutProvider(storage, programManager: ProgramManager(storage)); + final pr = PRManager(storage); + final conversations = ConversationManager(storage, kind: 'optimizer'); + final settings = SettingsProvider(storage); + final coachTools = CoachToolService(wp, pr); + return RoutineOptimizerViewModel( + ai: ai, + coachTools: coachTools, + conversations: conversations, + settings: settings, + ); +} + +final _routine = Routine(id: 'r1', name: 'Push Day', exerciseIds: const []); + +// ── Tests ────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + setUp(() => storage = MockStorageService()); + + group('RoutineOptimizerViewModel', () { + test('startForRoutine auto-sends seed message', () async { + final ai = _SimpleAi(); + final vm = _buildVm(storage: storage, ai: ai); + await vm.startForRoutine(_routine); + expect(ai.calls, 1); + expect(vm.messages.length, greaterThanOrEqualTo(2)); + expect(vm.messages.first.role, 'user'); + expect(vm.messages.first.text, contains('Push Day')); + }); + + test('isLoading is true during streaming and false after', () async { + final ai = _SimpleAi(chunks: ['chunk']); + final vm = _buildVm(storage: storage, ai: ai); + bool wasLoading = false; + vm.addListener(() { + if (vm.isLoading) wasLoading = true; + }); + await vm.startForRoutine(_routine); + expect(wasLoading, isTrue); + expect(vm.isLoading, isFalse); + }); + + test('ask_user_questions sets pendingQuestions mid-stream', () async { + final questionCall = FunctionCall('ask_user_questions', { + 'preamble': 'Quick question.', + 'questions': [ + { + 'question': 'Your goal?', + 'options': ['Strength', 'Hypertrophy'], + }, + ], + }); + + PendingQuestions? captured; + + final ai = _SimpleAi(chunks: ['Applied.'], toolCall: questionCall); + final vm = _buildVm(storage: storage, ai: ai); + + vm.addListener(() { + if (vm.pendingQuestions != null && captured == null) { + captured = vm.pendingQuestions; + // Submit to unblock the stream + vm.submitAnswers([ + AnswerSpec(question: 'Your goal?', selected: ['Strength']), + ]); + } + }); + + await vm.startForRoutine(_routine); + expect(captured?.questions.first.question, 'Your goal?'); + }); + + test('submitAnswers persists answers as a user message', () async { + bool questionsSeen = false; + final questionCall = FunctionCall('ask_user_questions', { + 'questions': [ + {'question': 'Goal?', 'options': ['Strength']}, + ], + }); + final ai = _SimpleAi(chunks: ['Done.'], toolCall: questionCall); + final vm = _buildVm(storage: storage, ai: ai); + + vm.addListener(() { + if (vm.pendingQuestions != null && !questionsSeen) { + questionsSeen = true; + vm.submitAnswers([ + AnswerSpec(question: 'Goal?', selected: ['Strength']), + ]); + } + }); + + await vm.startForRoutine(_routine); + + final userMessages = vm.messages.where((m) => m.role == 'user').toList(); + expect(userMessages.any((m) => m.text.contains('Strength')), isTrue); + }); + + test('stream error appends error message and clears loading', () async { + final vm = _buildVm(storage: storage, ai: _ThrowingAi()); + await vm.startForRoutine(_routine); + expect(vm.isLoading, isFalse); + expect(vm.pendingQuestions, isNull); + final modelMsgs = vm.messages.where((m) => m.role == 'model').toList(); + expect(modelMsgs.any((m) => m.text.contains('Error')), isTrue); + }); + + test('dispose completes pending Completer without leaking', () async { + final questionCall = FunctionCall('ask_user_questions', { + 'questions': [ + {'question': 'Goal?', 'options': ['Strength']}, + ], + }); + final ai = _SimpleAi(chunks: ['Done.'], toolCall: questionCall); + final vm = _buildVm(storage: storage, ai: ai); + + // Start but DON'T submit answers + // We need to ensure dispose() doesn't hang + final future = vm.startForRoutine(_routine); + await Future.delayed(Duration.zero); // let it start + + // If pendingQuestions is set, dispose should complete the completer + vm.dispose(); + + // The future should complete (not hang) after dispose + await future.timeout(const Duration(seconds: 2)); + expect(vm.pendingQuestions, isNull); + }); + }); +} From f021429903185be8d9176bd1f736c17cdd527acc Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:05:04 +0530 Subject: [PATCH 37/44] fix: handle aborted completer and await appendMessage in submitAnswers Co-Authored-By: Claude Sonnet 4.6 --- .../routine_optimizer_view_model.dart | 22 ++++++++++++++----- .../routine_optimizer_view_model_test.dart | 8 +++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart index bfba6b2..0ec730b 100644 --- a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart +++ b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart @@ -85,7 +85,7 @@ class RoutineOptimizerViewModel extends ChangeNotifier { } /// Submit the user's answers to the pending ask_user_questions call. - void submitAnswers(List answers) { + Future submitAnswers(List answers) async { _pendingQuestions = null; final text = answers @@ -97,7 +97,7 @@ class RoutineOptimizerViewModel extends ChangeNotifier { .join(' · '); if (text.isNotEmpty) { - _conversations.appendMessage(ChatMessage(role: 'user', text: text)); + await _conversations.appendMessage(ChatMessage(role: 'user', text: text)); } _pendingCompleter?.complete({ @@ -147,9 +147,12 @@ class RoutineOptimizerViewModel extends ChangeNotifier { ); } } catch (e) { - await _conversations.appendMessage( - ChatMessage(role: 'model', text: 'Error: $e'), - ); + // Swallow internal abort signals from dispose(). + if (e is! StateError || e.message != 'optimizer_aborted') { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: 'Error: $e'), + ); + } } finally { _streamingText = ''; _loading = false; @@ -182,7 +185,14 @@ class RoutineOptimizerViewModel extends ChangeNotifier { _pendingCompleter = completer; _notify(); - return completer.future; + final result = await completer.future; + + // If the session was abandoned (e.g. dispose() was called), abort cleanly. + if (result['aborted'] == true) { + throw StateError('optimizer_aborted'); + } + + return result; } List _buildHistory() { diff --git a/workout-logger/test/routine_optimizer_view_model_test.dart b/workout-logger/test/routine_optimizer_view_model_test.dart index 669b909..4338cd0 100644 --- a/workout-logger/test/routine_optimizer_view_model_test.dart +++ b/workout-logger/test/routine_optimizer_view_model_test.dart @@ -161,11 +161,11 @@ void main() { final ai = _SimpleAi(chunks: ['Applied.'], toolCall: questionCall); final vm = _buildVm(storage: storage, ai: ai); - vm.addListener(() { + vm.addListener(() async { if (vm.pendingQuestions != null && captured == null) { captured = vm.pendingQuestions; // Submit to unblock the stream - vm.submitAnswers([ + await vm.submitAnswers([ AnswerSpec(question: 'Your goal?', selected: ['Strength']), ]); } @@ -185,10 +185,10 @@ void main() { final ai = _SimpleAi(chunks: ['Done.'], toolCall: questionCall); final vm = _buildVm(storage: storage, ai: ai); - vm.addListener(() { + vm.addListener(() async { if (vm.pendingQuestions != null && !questionsSeen) { questionsSeen = true; - vm.submitAnswers([ + await vm.submitAnswers([ AnswerSpec(question: 'Goal?', selected: ['Strength']), ]); } From cfb95b4ba0b5e782b2155945ffaa854cadbe8a18 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:08:52 +0530 Subject: [PATCH 38/44] feat: add RoutineOptimizerScreen (conversational UI with question card + history inbox) Co-Authored-By: Claude Sonnet 4.6 --- .../lib/screens/routine_optimizer_screen.dart | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 workout-logger/lib/screens/routine_optimizer_screen.dart diff --git a/workout-logger/lib/screens/routine_optimizer_screen.dart b/workout-logger/lib/screens/routine_optimizer_screen.dart new file mode 100644 index 0000000..c523150 --- /dev/null +++ b/workout-logger/lib/screens/routine_optimizer_screen.dart @@ -0,0 +1,649 @@ +// routine_optimizer_screen.dart — Full-screen conversational routine optimizer UI. +// +// This is a lean View: all orchestration (streaming, tool calls, question +// intercept, persistence) lives in RoutineOptimizerViewModel. The widget only +// renders state, forwards user intents, and holds UI-local controllers. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; + +import '../models/models.dart'; +import '../viewmodels/routine_optimizer_view_model.dart'; +import '../services/ai/gemini_ai_service.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/interfaces/storage_service_interface.dart'; +import '../services/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_question_card.dart'; + +/// Public entry point. Owns the screen-scoped [RoutineOptimizerViewModel]. +class RoutineOptimizerScreen extends StatelessWidget { + const RoutineOptimizerScreen({super.key, required this.routine}); + + final Routine routine; + + @override + Widget build(BuildContext context) { + final storage = context.read(); + return ChangeNotifierProvider( + create: (ctx) { + final conversations = + ConversationManager(storage, kind: 'optimizer'); + return RoutineOptimizerViewModel( + ai: ctx.read(), + coachTools: ctx.read(), + conversations: conversations, + settings: ctx.read(), + ) + ..loadConversations() + ..startForRoutine(routine); + }, + child: _OptimizerView(routine: routine), + ); + } +} + +// ── View ────────────────────────────────────────────────────────────────────── + +class _OptimizerView extends StatefulWidget { + const _OptimizerView({required this.routine}); + final Routine routine; + + @override + State<_OptimizerView> createState() => _OptimizerViewState(); +} + +class _OptimizerViewState extends State<_OptimizerView> { + final _scrollCtrl = ScrollController(); + RoutineOptimizerViewModel? _vm; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final vm = context.read(); + if (!identical(vm, _vm)) { + _vm?.removeListener(_onVmChanged); + _vm = vm..addListener(_onVmChanged); + } + } + + void _onVmChanged() => _scrollToBottom(); + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _vm?.removeListener(_onVmChanged); + _scrollCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Column( + children: [ + _buildHeader(context, vm), + Expanded(child: _buildChatArea(vm)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context, RoutineOptimizerViewModel vm) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 0, + ), + child: Row( + children: [ + // Back button + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.arrow_back_rounded, + color: AppColors.textSoft, + size: 18, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + // Icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.secondary, Color(0xFF0097A7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.secondaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.auto_fix_high_rounded, + color: Colors.white, + size: 16, + ), + ), + const SizedBox(width: AppSpacing.sm), + // Title + subtitle + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Optimize Routine', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + Text( + widget.routine.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + // History button + _HeaderIconButton( + icon: Icons.history_rounded, + onTap: () => _openHistory(context, vm), + ), + ], + ), + ); + } + + Future _openHistory( + BuildContext context, + RoutineOptimizerViewModel vm, + ) async { + HapticFeedback.lightImpact(); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => _ConversationsSheet(vm: vm), + ); + } + + Widget _buildChatArea(RoutineOptimizerViewModel vm) { + final messages = vm.messages; + final hasContent = messages.isNotEmpty || vm.isLoading; + if (!hasContent) return _buildEmpty(); + + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + itemCount: messages.length + (vm.isLoading ? 1 : 0), + itemBuilder: (_, i) { + if (i == messages.length) { + // Loading slot — show question card if pending, else streaming bubble + if (vm.pendingQuestions != null) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: RFQuestionCard( + questions: vm.pendingQuestions!.questions, + onSubmit: vm.submitAnswers, + ), + ); + } + return _StreamingBubble(text: vm.streamingText); + } + return _MessageBubble(message: messages[i]); + }, + ); + } + + Widget _buildEmpty() { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.secondary, Color(0xFF0097A7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: [ + BoxShadow( + color: AppColors.secondaryGlow(0.45), + blurRadius: 28, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.auto_fix_high_rounded, + color: Colors.white, + size: 32, + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + 'Analyzing your routine…', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Reviewing your history and building a personalized plan.', + textAlign: TextAlign.center, + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 14, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} + +// ── Header icon button ────────────────────────────────────────────────────── + +class _HeaderIconButton extends StatelessWidget { + const _HeaderIconButton({required this.icon, required this.onTap}); + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, color: AppColors.textSoft, size: 18), + ), + ); + } +} + +// ── Message bubble ──────────────────────────────────────────────────────────── + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message}); + final ChatMessage message; + + @override + Widget build(BuildContext context) { + final isUser = message.role == 'user'; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isUser) ...[ + _OptimizerAvatar(), + const SizedBox(width: AppSpacing.sm), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + gradient: isUser + ? const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + color: isUser ? null : AppColors.glass3, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(AppRadius.lg), + topRight: const Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), + bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), + ), + border: isUser ? null : Border.all(color: AppColors.glassBorder), + boxShadow: isUser + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.25), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: isUser + ? Text( + message.text, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ) + : _OptimizerMarkdown(text: message.text), + ), + ), + ], + ), + ); + } +} + +// ── Streaming bubble ────────────────────────────────────────────────────────── + +class _StreamingBubble extends StatelessWidget { + const _StreamingBubble({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _OptimizerAvatar(), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppRadius.lg), + topRight: Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(AppRadius.lg), + ), + border: Border.all(color: AppColors.glassBorder), + ), + child: text.isEmpty + ? const RFLoadingDots(color: AppColors.secondary) + : _OptimizerMarkdown(text: text), + ), + ), + ], + ), + ); + } +} + +/// Markdown renderer styled to the app theme. +class _OptimizerMarkdown extends StatelessWidget { + const _OptimizerMarkdown({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return GptMarkdown( + text, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ); + } +} + +class _OptimizerAvatar extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.secondary, Color(0xFF0097A7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.secondaryGlow(0.35), + blurRadius: 8, + spreadRadius: -2, + ), + ], + ), + child: const Icon(Icons.auto_fix_high_rounded, color: Colors.white, size: 14), + ); + } +} + +// ── Conversations history sheet ─────────────────────────────────────────────── + +class _ConversationsSheet extends StatelessWidget { + const _ConversationsSheet({required this.vm}); + final RoutineOptimizerViewModel vm; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: vm, + builder: (context, _) { + final conversations = vm.conversations; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Optimization History', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: AppSpacing.md), + if (conversations.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Text( + 'No saved optimization sessions yet.', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: conversations.length, + separatorBuilder: (_, __) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (_, i) { + final c = conversations[i]; + final isActive = c.id == vm.activeConversationId; + return _ConversationTile( + conversation: c, + isActive: isActive, + onTap: () { + vm.selectConversation(c.id); + Navigator.pop(context); + }, + onDelete: () => vm.deleteConversation(c.id), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ConversationTile extends StatelessWidget { + const _ConversationTile({ + required this.conversation, + required this.isActive, + required this.onTap, + required this.onDelete, + }); + + final Conversation conversation; + final bool isActive; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: isActive + ? AppColors.secondary.withValues(alpha: 0.12) + : AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isActive + ? AppColors.secondary.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + const Icon(Icons.auto_fix_high_rounded, + color: AppColors.textMuted, size: 16), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + conversation.title.isEmpty + ? 'Optimization session' + : conversation.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + Text( + '${conversation.messages.length} messages', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + ), + GestureDetector( + onTap: onDelete, + child: const Padding( + padding: EdgeInsets.only(left: AppSpacing.sm), + child: Icon(Icons.delete_outline_rounded, + color: AppColors.textFaint, size: 18), + ), + ), + ], + ), + ), + ); + } +} From 4b85ef324481d1ced34bb5a19b463f5511a7a301 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:12:59 +0530 Subject: [PATCH 39/44] style: fix magic padding values and header button consistency in RoutineOptimizerScreen --- .../lib/screens/routine_optimizer_screen.dart | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/workout-logger/lib/screens/routine_optimizer_screen.dart b/workout-logger/lib/screens/routine_optimizer_screen.dart index c523150..290fa04 100644 --- a/workout-logger/lib/screens/routine_optimizer_screen.dart +++ b/workout-logger/lib/screens/routine_optimizer_screen.dart @@ -325,7 +325,7 @@ class _HeaderIconButton extends StatelessWidget { child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.glass, + color: AppColors.glass3, borderRadius: BorderRadius.circular(AppRadius.sm), border: Border.all(color: AppColors.glassBorder), ), @@ -359,7 +359,7 @@ class _MessageBubble extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, + vertical: AppSpacing.sm, ), decoration: BoxDecoration( gradient: isUser @@ -424,7 +424,7 @@ class _StreamingBubble extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, + vertical: AppSpacing.sm, ), decoration: BoxDecoration( color: AppColors.glass3, @@ -510,18 +510,13 @@ class _ConversationsSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - 'Optimization History', - style: GoogleFonts.geist( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - ], + Text( + 'Optimization History', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: AppSpacing.md), if (conversations.isEmpty) @@ -589,7 +584,7 @@ class _ConversationTile extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, + vertical: AppSpacing.sm, ), decoration: BoxDecoration( color: isActive From 1ea67e4aa33134ae413acdca1c25689d428702d2 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:14:30 +0530 Subject: [PATCH 40/44] feat: add data gate (<3 sessions) and navigate to RoutineOptimizerScreen from routine card --- .../lib/screens/routines_screen.dart | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 28d95d5..bad08e6 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -9,6 +9,7 @@ import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import 'programs/programs_screen.dart'; +import 'routine_optimizer_screen.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/routine_creator.dart'; @@ -491,7 +492,30 @@ class _RoutineCard extends StatelessWidget { ), // Optimize button GestureDetector( - onTap: () {}, + onTap: () { + HapticFeedback.lightImpact(); + final sessionCount = provider.sessions + .where((s) => s.routineId == routine.id) + .length; + if (sessionCount < 3) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Log "${routine.name}" at least 3 times so the ' + 'optimizer has enough data to work with.', + ), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RoutineOptimizerScreen(routine: routine), + ), + ); + }, child: Container( width: 34, height: 34, From 339b185317aa528835437fe621f21ae185de80a7 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:28:42 +0530 Subject: [PATCH 41/44] feat: add testBody method for RoutineOptimizerScreen to facilitate widget testing --- .../lib/screens/routine_optimizer_screen.dart | 5 + .../test/routine_optimizer_screen_test.dart | 320 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 workout-logger/test/routine_optimizer_screen_test.dart diff --git a/workout-logger/lib/screens/routine_optimizer_screen.dart b/workout-logger/lib/screens/routine_optimizer_screen.dart index 290fa04..366170c 100644 --- a/workout-logger/lib/screens/routine_optimizer_screen.dart +++ b/workout-logger/lib/screens/routine_optimizer_screen.dart @@ -27,6 +27,11 @@ class RoutineOptimizerScreen extends StatelessWidget { final Routine routine; + /// Renders only the view body with an externally provided VM. + /// Use this in widget tests to avoid wiring up real AI services. + @visibleForTesting + static Widget testBody(Routine routine) => _OptimizerView(routine: routine); + @override Widget build(BuildContext context) { final storage = context.read(); diff --git a/workout-logger/test/routine_optimizer_screen_test.dart b/workout-logger/test/routine_optimizer_screen_test.dart new file mode 100644 index 0000000..1be665d --- /dev/null +++ b/workout-logger/test/routine_optimizer_screen_test.dart @@ -0,0 +1,320 @@ +// Widget tests for RoutineOptimizerScreen. +// +// Tests the view layer by injecting a pre-built RoutineOptimizerViewModel +// via ChangeNotifierProvider.value, bypassing the real AI service setup. +// Uses RoutineOptimizerScreen.testBody() to render the inner view directly. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, Tool, FunctionCall; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/routine_optimizer_screen.dart'; +import 'package:repforge/screens/widgets/rf_question_card.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/interfaces/ai_service_interface.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/theme/app_theme.dart'; +import 'package:repforge/viewmodels/routine_optimizer_view_model.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Fake AI services ─────────────────────────────────────────────────────── + +/// AI that immediately yields a single reply chunk and completes. +class _ImmediateAi implements IAiService { + const _ImmediateAi({this.reply = 'All done!'}); + final String reply; + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + yield reply; + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +/// AI that hangs indefinitely — keeps `isLoading` true for the entire test. +class _HangingAi implements IAiService { + final _done = Completer(); + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + void complete() => _done.complete(); + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + await _done.future; + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +/// AI that fires an `ask_user_questions` tool call before yielding a reply. +class _QuestionAi implements IAiService { + const _QuestionAi(); + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + if (onToolCall != null) { + await onToolCall(FunctionCall('ask_user_questions', { + 'preamble': 'Before I start, a quick question.', + 'questions': [ + { + 'question': 'What is your primary goal?', + 'options': ['Strength', 'Hypertrophy', 'Fat loss'], + }, + ], + })); + } + yield 'Done.'; + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +// ── Test helpers ─────────────────────────────────────────────────────────── + +final _pushDay = Routine(id: 'r1', name: 'Push Day', exerciseIds: const []); + +RoutineOptimizerViewModel _buildVm(IAiService ai) { + final storage = MockStorageService(); + final wp = WorkoutProvider(storage, programManager: ProgramManager(storage)); + final pr = PRManager(storage); + final conversations = ConversationManager(storage, kind: 'optimizer'); + final settings = SettingsProvider(storage); + final coachTools = CoachToolService(wp, pr); + return RoutineOptimizerViewModel( + ai: ai, + coachTools: coachTools, + conversations: conversations, + settings: settings, + ); +} + +Widget _wrap(RoutineOptimizerViewModel vm) => MaterialApp( + theme: AppTheme.darkTheme, + home: ChangeNotifierProvider.value( + value: vm, + child: RoutineOptimizerScreen.testBody(_pushDay), + ), + ); + +// ── Tests ────────────────────────────────────────────────────────────────── + +void main() { + group('RoutineOptimizerScreen', () { + testWidgets('shows title and routine name in header', (tester) async { + final vm = _buildVm(const _ImmediateAi()); + await tester.pumpWidget(_wrap(vm)); + await tester.pump(); + + expect(find.text('Optimize Routine'), findsOneWidget); + expect(find.text('Push Day'), findsOneWidget); + }); + + testWidgets('shows loading indicator while AI is streaming', (tester) async { + final ai = _HangingAi(); + final vm = _buildVm(ai); + await tester.pumpWidget(_wrap(vm)); + + // Trigger streaming without awaiting — keeps isLoading = true. + unawaited(vm.startForRoutine(_pushDay)); + await tester.pump(); + + // Streaming bubble with loading dots should be visible. + expect(find.byType(CircularProgressIndicator).evaluate().isNotEmpty || + // RFLoadingDots is the animated dot indicator used in the bubble. + find.byWidgetPredicate( + (w) => w.runtimeType.toString() == 'RFLoadingDots', + ).evaluate().isNotEmpty || + find.byIcon(Icons.auto_fix_high_rounded).evaluate().isNotEmpty, + isTrue, + reason: 'A streaming/loading indicator should be visible'); + + // Verify we are in a loading state overall + expect(vm.isLoading, isTrue); + + ai.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('renders seed user message and AI reply', (tester) async { + final vm = _buildVm(const _ImmediateAi(reply: 'Great plan!')); + await tester.pumpWidget(_wrap(vm)); + + await vm.startForRoutine(_pushDay); + await tester.pump(); + + // Seed user message + expect( + find.textContaining('Push Day'), + findsWidgets, + reason: 'Routine name should appear in seed message or subtitle', + ); + + // AI reply + expect(find.textContaining('Great plan!'), findsOneWidget); + }); + + testWidgets('user messages align to the right', (tester) async { + final vm = _buildVm(const _ImmediateAi()); + await tester.pumpWidget(_wrap(vm)); + await vm.startForRoutine(_pushDay); + await tester.pump(); + + // There should be at least one message in the list. + expect(vm.messages.isNotEmpty, isTrue); + // User messages have role 'user' + expect(vm.messages.any((m) => m.role == 'user'), isTrue); + }); + + testWidgets('shows RFQuestionCard when AI asks questions', (tester) async { + final ai = _QuestionAi(); + final vm = _buildVm(ai); + await tester.pumpWidget(_wrap(vm)); + + // Start without awaiting so we can catch the pending state. + unawaited(vm.startForRoutine(_pushDay)); + + // Pump a few frames so the tool call fires and pendingQuestions is set. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + if (vm.pendingQuestions != null) { + await tester.pump(); + expect(find.byType(RFQuestionCard), findsOneWidget); + expect(find.text('What is your primary goal?'), findsOneWidget); + + // Submitting an answer unblocks the stream. + vm.submitAnswers([ + AnswerSpec(question: 'What is your primary goal?', selected: ['Strength']), + ]); + await tester.pumpAndSettle(); + expect(find.byType(RFQuestionCard), findsNothing); + } else { + // If the stream already completed (fast machine), just verify no crash. + await tester.pumpAndSettle(); + } + }); + + testWidgets('back button pops the route', (tester) async { + bool popped = false; + final vm = _buildVm(const _ImmediateAi()); + + await tester.pumpWidget(MaterialApp( + theme: AppTheme.darkTheme, + home: Builder(builder: (ctx) { + return Scaffold( + body: ElevatedButton( + onPressed: () => Navigator.push( + ctx, + MaterialPageRoute( + builder: (_) => ChangeNotifierProvider< + RoutineOptimizerViewModel>.value( + value: vm, + child: RoutineOptimizerScreen.testBody(_pushDay), + ), + ), + ).then((_) => popped = true), + child: const Text('Open'), + ), + ); + }), + )); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Now on the optimizer screen — tap back. + await tester.tap(find.byIcon(Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + + expect(popped, isTrue); + }); + + testWidgets('history sheet shows empty state when no conversations', + (tester) async { + final vm = _buildVm(const _ImmediateAi()); + await tester.pumpWidget(_wrap(vm)); + await tester.pump(); + + // Open the history sheet via the history button. + await tester.tap(find.byIcon(Icons.history_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Optimization History'), findsOneWidget); + expect( + find.text('No saved optimization sessions yet.'), + findsOneWidget, + ); + }); + }); +} From ff1761c956f4b6f5540740336f00481b907e7b3d Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:18:50 +0530 Subject: [PATCH 42/44] feat: add workout summary screen and enhance app theme colors (#50) feat: add workout summary screen, health analytics, and production APK signing - Workout summary screen with session details - Sleep HR chart and debug log buffer - HealthHistoryManager for sleep/heart rate data - Workout heart rate analysis and recovery metrics - EC P-256 production keystore signing via GitHub Secrets - Split-per-ABI APK builds for arm64, armeabi-v7a, x86_64 - FUTURE_IMPROVEMENTS.md documenting Options B & C (F-Droid, fastlane) --- .github/workflows/release.yml | 24 +- docs/FUTURE_IMPROVEMENTS.md | 92 +++ .../specs/2026-06-11-sleep-hr-chart-design.md | 209 ++++++ workout-logger/android/app/build.gradle.kts | 27 +- .../android/app/src/main/AndroidManifest.xml | 4 + workout-logger/lib/main.dart | 18 + workout-logger/lib/models/models.dart | 241 +++++- .../lib/models/sleep_hr_models.dart | 214 ++++++ .../lib/models/workout_hr_models.dart | 105 +++ .../lib/screens/heart_rate_detail_screen.dart | 299 ++++++++ workout-logger/lib/screens/home_screen.dart | 21 +- .../lib/screens/profile_screen.dart | 87 ++- .../lib/screens/sleep_detail_screen.dart | 259 +++++++ .../screens/widgets/analytics_overview.dart | 9 +- .../widgets/exercise_details_sheet.dart | 2 +- .../widgets/exercise_progress_view.dart | 20 +- .../lib/screens/widgets/health_bar_chart.dart | 617 +++++++++++++++ .../screens/widgets/health_detail_shell.dart | 205 +++++ .../lib/screens/widgets/heart_rate_card.dart | 193 +++++ .../lib/screens/widgets/profile_sections.dart | 174 ++++- .../lib/screens/widgets/readiness_card.dart | 326 ++++++++ .../lib/screens/widgets/rf_widgets.dart | 16 + .../widgets/session_details_sheet.dart | 4 + .../lib/screens/widgets/sleep_hr_card.dart | 260 +++++++ .../lib/screens/widgets/sleep_hr_charts.dart | 708 ++++++++++++++++++ .../screens/widgets/workout_hr_section.dart | 436 +++++++++++ .../lib/services/ai/coach_tool_service.dart | 200 ++++- .../lib/services/ai/gemini_ai_service.dart | 330 ++++++-- .../lib/services/debug_log_buffer.dart | 35 + .../lib/services/gemini_context_builder.dart | 45 +- .../lib/services/health_connect_service.dart | 194 ++++- .../health_connect_service_interface.dart | 19 + .../lib/services/interfaces/interfaces.dart | 1 + .../readiness_manager_interface.dart | 25 + .../managers/health_history_manager.dart | 296 ++++++++ .../lib/services/managers/managers.dart | 1 + .../services/managers/readiness_manager.dart | 395 ++++++++++ workout-logger/lib/services/ml_service.dart | 269 ++++++- .../lib/services/settings_provider.dart | 11 + .../services/utils/readiness_calculator.dart | 147 ++++ .../lib/services/utils/sleep_hr_builder.dart | 210 ++++++ .../services/utils/workout_hr_builder.dart | 169 +++++ .../test/health_history_manager_test.dart | 200 +++++ .../test/health_sync_manager_test.dart | 22 + workout-logger/test/ml_service_test.dart | 190 +++++ .../test/readiness_calculator_test.dart | 221 ++++++ .../test/readiness_manager_test.dart | 346 +++++++++ .../test/workout_hr_builder_test.dart | 110 +++ 48 files changed, 7833 insertions(+), 173 deletions(-) create mode 100644 docs/FUTURE_IMPROVEMENTS.md create mode 100644 docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md create mode 100644 workout-logger/lib/models/sleep_hr_models.dart create mode 100644 workout-logger/lib/models/workout_hr_models.dart create mode 100644 workout-logger/lib/screens/heart_rate_detail_screen.dart create mode 100644 workout-logger/lib/screens/sleep_detail_screen.dart create mode 100644 workout-logger/lib/screens/widgets/health_bar_chart.dart create mode 100644 workout-logger/lib/screens/widgets/health_detail_shell.dart create mode 100644 workout-logger/lib/screens/widgets/heart_rate_card.dart create mode 100644 workout-logger/lib/screens/widgets/readiness_card.dart create mode 100644 workout-logger/lib/screens/widgets/sleep_hr_card.dart create mode 100644 workout-logger/lib/screens/widgets/sleep_hr_charts.dart create mode 100644 workout-logger/lib/screens/widgets/workout_hr_section.dart create mode 100644 workout-logger/lib/services/debug_log_buffer.dart create mode 100644 workout-logger/lib/services/interfaces/readiness_manager_interface.dart create mode 100644 workout-logger/lib/services/managers/health_history_manager.dart create mode 100644 workout-logger/lib/services/managers/readiness_manager.dart create mode 100644 workout-logger/lib/services/utils/readiness_calculator.dart create mode 100644 workout-logger/lib/services/utils/sleep_hr_builder.dart create mode 100644 workout-logger/lib/services/utils/workout_hr_builder.dart create mode 100644 workout-logger/test/health_history_manager_test.dart create mode 100644 workout-logger/test/readiness_calculator_test.dart create mode 100644 workout-logger/test/readiness_manager_test.dart create mode 100644 workout-logger/test/workout_hr_builder_test.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4dda78..393c5ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,14 +115,26 @@ jobs: echo "EOF" } >> $GITHUB_OUTPUT + - name: Decode release keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/repforge-release.jks + - name: Build APK working-directory: ./workout-logger - run: flutter build apk --release + env: + KEYSTORE_PATH: /tmp/repforge-release.jks + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: flutter build apk --release --split-per-abi - - name: Rename APK + - name: Rename APKs run: | - mv workout-logger/build/app/outputs/flutter-apk/app-release.apk \ - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + V="${{ steps.version.outputs.value }}" + DIR="workout-logger/build/app/outputs/flutter-apk" + mv "$DIR/app-arm64-v8a-release.apk" "$DIR/repforge-v${V}-arm64-v8a.apk" 2>/dev/null || true + mv "$DIR/app-armeabi-v7a-release.apk" "$DIR/repforge-v${V}-armeabi-v7a.apk" 2>/dev/null || true + mv "$DIR/app-x86_64-release.apk" "$DIR/repforge-v${V}-x86_64.apk" 2>/dev/null || true - name: Sanitize ref name for artifact id: sanitize_ref @@ -133,7 +145,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: repforge-v${{ steps.version.outputs.value }}-${{ steps.sanitize_ref.outputs.ref_name }} - path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk retention-days: 7 - name: Create GitHub Release @@ -156,7 +168,7 @@ jobs: - **Build Date**: ${{ github.event.head_commit.timestamp }} - **Commit**: ${{ github.sha }} files: | - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk draft: false prerelease: false env: diff --git a/docs/FUTURE_IMPROVEMENTS.md b/docs/FUTURE_IMPROVEMENTS.md new file mode 100644 index 0000000..8db491b --- /dev/null +++ b/docs/FUTURE_IMPROVEMENTS.md @@ -0,0 +1,92 @@ +# Future Improvements — Open Source Store Launch + +This file tracks the remaining work for Options B and C of the open-source store launch plan. +Option A (production signing + IzzyOnDroid/Obtainium) is complete. + +--- + +## Option B — F-Droid Readiness + +### 1. Bundle Geist fonts locally (google_fonts) + +Currently `google_fonts` may fetch font files from Google's CDN at first launch. F-Droid requires +all network access to be under user control — a silent font download at startup fails that bar. + +**Fix:** Download the Geist Sans and Geist Mono `.ttf` files, add them to `assets/fonts/`, declare +them in `pubspec.yaml` under `flutter.fonts`, and replace `GoogleFonts.geist(...)` calls with +`TextStyle(fontFamily: 'Geist')`. Then remove the `google_fonts` package. + +### 2. F-Droid metadata file + +Create `fdroid/metadata/com.devasy.repforge.yml` following the F-Droid metadata spec: + +```yaml +Categories: + - Sports & Health +License: Apache-2.0 +SourceCode: https://github.com//repforge +IssueTracker: https://github.com//repforge/issues + +AutoName: RepForge +Summary: Workout logger with AI-powered coaching +Description: |- + RepForge is an open-source workout logging app with set/rep/weight tracking, + progress analytics, AI coaching (optional, requires user-supplied Gemini API key), + and Health Connect integration. + +AntiFeatures: + NonFreeNet: + - description: > + Optional AI Coach and Routine Optimizer features send data to Google's Gemini API. + These features are disabled unless the user provides their own API key in Settings. + +Builds: + - versionName: 2.x.x + versionCode: xx + commit: vX.X.X + subdir: workout-logger + gradle: + - release +``` + +### 3. Fastlane store metadata + +Create `fastlane/metadata/android/en-US/` with: +- `title.txt` — "RepForge" +- `short_description.txt` — one-line summary (≤80 chars) +- `full_description.txt` — full store description +- `changelogs/.txt` — per-release changelog + +IzzyOnDroid also reads fastlane metadata for its store listing. + +--- + +## Option C — Strict F-Droid Compliance + +### 4. Health Connect graceful degradation + +Health Connect is an OS API (not Google Play Services) so F-Droid accepts it. However, for +maximum compatibility on AOSP/custom ROMs without Health Connect: + +- Add an `isHealthConnectAvailable()` check at startup +- Show a "Health Connect not available" state in the Readiness screen instead of crashing +- Make daily readiness score optional in the Home screen when Health Connect is absent + +### 5. Replace google_fonts package entirely + +After completing item B.1, the `google_fonts` package can be removed from `pubspec.yaml` entirely. +This eliminates any risk of runtime Google CDN fetches and removes a transitive dependency. + +--- + +## IzzyOnDroid Submission Checklist + +- [ ] Merge this branch to main and confirm a production-signed release appears on GitHub Releases +- [ ] Submit via: https://gitlab.com/IzzyOnDroid/repo/-/issues (open a new issue, "App submission" template) +- [ ] Provide: repo URL, anti-features (NonFreeNet), brief description +- [ ] Wait for review (typically 1–7 days) + +## Obtainium + +No submission needed. Users add the GitHub repo URL directly in Obtainium and install the latest +release APK automatically. Share the repo URL in your README. diff --git a/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md b/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md new file mode 100644 index 0000000..353d9dc --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md @@ -0,0 +1,209 @@ +# Sleep HR Chart — Design Spec + +**Date:** 2026-06-11 +**Status:** Approved +**Feature area:** Readiness → Sleep heart-rate visualization + +--- + +## 1. Problem + +The Readiness feature currently reads resting HR and sleep duration from Health Connect. HRV is unavailable (Samsung Health writer has no HRV permission on this device). Minute-level heart-rate data during sleep is already accessible via `heartRateSeries`, and sleep stage timeline data is already extracted per `SleepPeriod` (`lightMinutes`, `deepMinutes`, `remMinutes`, `awakeMinutes`). Neither is surfaced to the user. + +Users want to understand how their heart behaved overnight — specifically whether deep sleep reached a true low, whether REM stayed elevated, and what a clean P95 "resting proxy" looks like — without needing to open Samsung Health. + +--- + +## 2. Goal + +Two new surfaces: +1. **Compact card** on the home screen (below the readiness ring card) showing a sparkline + three key numbers. +2. **Full detail bottom sheet** accessible by tapping the compact card, showing: + - A 10-minute bar chart (low/high per segment, color-coded by sleep stage, moving-average trend line) + - A "HR range by stage" horizontal distribution chart (min–max + P25–P75 + avg for each of Awake, REM, Light, Deep) + +--- + +## 3. Data Models + +### 3.1 `SleepHrSegment` (new) + +Represents one 10-minute window of the sleep period. + +```dart +class SleepHrSegment { + final DateTime windowStart; // truncated to 10-min boundary + final int minBpm; + final int maxBpm; + final double avgBpm; + final String stage; // 'deep' | 'rem' | 'light' | 'awake' +} +``` + +### 3.2 `SleepStageStats` (new) + +Aggregate stats for one stage, used by the distribution chart. + +```dart +class SleepStageStats { + final String stage; + final int minBpm; + final int p25Bpm; + final double avgBpm; + final int p75Bpm; + final int maxBpm; + final int sampleCount; +} +``` + +### 3.3 `SleepHrSnapshot` (new) + +Container stored in `ReadinessManager` and passed to both widgets. + +```dart +class SleepHrSnapshot { + final DateTime sleepStart; + final DateTime sleepEnd; + final int p95Bpm; // P95 of all overnight HR samples + final List segments; // ordered by windowStart + final List stageStats; // one entry per stage present +} +``` + +No persistence required — recomputed each `refresh()`. If the snapshot is null the compact card hides itself (`SizedBox.shrink()`). + +--- + +## 4. Data Pipeline + +### 4.1 New Health Connect service method + +```dart +// IHealthConnectService +Future> readHeartRateSamples(DateTime start, DateTime end); +// Already exists — no interface change needed. +``` + +`ReadinessManager.refresh()` calls `readHeartRateSamples(sleepStart - 30min, sleepEnd + 30min)` **only when** `HealthReadType.heartRate` is granted and at least one sleep period exists for last night. + +### 4.2 Stage assignment per sample + +Each `HealthSample` is tagged with the sleep stage active at its timestamp by walking the `SleepPeriod.samples` stage timeline (from `SleepSessionRecord.samples`, already loaded). Samples outside any stage window → tagged `'awake'`. + +### 4.3 Segment aggregation + +Samples are bucketed into 10-minute windows aligned to `sleepStart`. For each window: `minBpm`, `maxBpm`, `avgBpm` are computed. The stage for the window is the **mode** of sample stages in that window (most-frequent). Windows with zero samples are omitted. + +### 4.4 P95 and stage stats + +- **P95:** Sort all sample bpms → take the value at index `floor(0.95 * n)`. +- **Stage stats:** Group samples by stage → compute min, P25, avg, P75, max via sort-and-index. + +### 4.5 Where it lives in `ReadinessManager` + +```dart +SleepHrSnapshot? _sleepHrSnapshot; +SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; +``` + +Computed and stored at the end of `refresh()`, alongside the readiness score. Triggers `notifyListeners()` once (same call as the score update). + +--- + +## 5. UI Components + +### 5.1 `SleepHrCard` (compact, home screen) + +**File:** `lib/screens/widgets/sleep_hr_card.dart` + +Layout: +``` +┌─────────────────────────────────┐ +│ Sleep heart rate 1:24–8:17 │ ← header row +│ P95 67bpm REM 64bpm Deep 52bpm│ ← three mini-stats +│ [sparkline bar chart] │ ← canvas, 38dp tall +└─────────────────────────────────┘ +``` + +- Tapping the card opens `SleepHrSheet` via `showModalBottomSheet`. +- Hidden (`SizedBox.shrink()`) when `snapshot.sleepHrSnapshot == null`. +- Placed in `HomeScreen` body, directly below `ReadinessCard`. + +### 5.2 `SleepHrSheet` (full detail bottom sheet) + +**File:** `lib/screens/widgets/sleep_hr_sheet.dart` + +Sections top → bottom: +1. **Handle + title + subtitle** ("Sleep heart rate · 1:24 AM – 8:17 AM") +2. **Three key stats** (P95 HR, Deep avg, REM avg) in pill chips +3. **Bar chart** — `CustomPainter`, 140dp tall + - Y-axis: BPM labels (50, 60, 70, 80) with horizontal grid lines + - X-axis: time labels every 60 min + - Each bar: low→high range, fill color = stage color at 73% opacity + - Moving-average line (window=5 segments): `#00D9FF`, dashed +4. **Stage timeline bar** — thin colored strip below chart, same proportions +5. **Legend** (Deep / REM / Light / Awake / Avg line) +6. **"HR range by stage" section** + - Title label + - Four horizontal range rows: Awake → REM → Light → Deep (top → bottom) + - Each row: full-range bar (22% opacity) + IQR bar (72% opacity) + avg dot + avg bpm label + - Shared BPM x-axis with vertical grid lines (45, 50 … 85) + - Sub-legend: min–max / P25–P75 / Avg + +Scrollable (`SingleChildScrollView`) so it fits all screen sizes. + +--- + +## 6. Painting Strategy + +Both the bar chart and the distribution chart use `CustomPainter` (not canvas HTML). Stage colors are sourced from a local constant map in the widget file; no dependency on `AppColors.muscleGroupColors`. + +Stage color map: +```dart +const _stageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; +``` + +--- + +## 7. Error / Empty States + +| Condition | Behavior | +|-----------|----------| +| `heartRate` not granted | `sleepHrSnapshot` = null → compact card hidden | +| Sleep period missing | `sleepHrSnapshot` = null → compact card hidden | +| < 5 HR samples in a segment | Segment omitted from chart | +| Stage has < 3 samples | `SleepStageStats` for that stage omitted from distribution | +| Sheet opened with null snapshot | Should not happen (card hidden); guard with early return | + +--- + +## 8. HRV Lookback Cleanup + +`_todayHrv()` in `ReadinessManager` currently uses a 30-day diagnostic window. This should be reverted to 48 hours once the Sleep HR feature ships (confirms the device never writes HRV, so the wide window has no ongoing value). + +--- + +## 9. Files Changed / Created + +| Action | File | +|--------|------| +| New | `lib/models/sleep_hr_models.dart` — `SleepHrSegment`, `SleepStageStats`, `SleepHrSnapshot` | +| Modified | `lib/services/managers/readiness_manager.dart` — add `_buildSleepHrSnapshot()`, store result | +| New | `lib/screens/widgets/sleep_hr_card.dart` | +| New | `lib/screens/widgets/sleep_hr_sheet.dart` | +| Modified | `lib/screens/home_screen.dart` (or equivalent) — insert `SleepHrCard` below `ReadinessCard` | +| Modified | `lib/services/managers/readiness_manager.dart` — revert HRV window to 48h | + +--- + +## 10. Out of Scope + +- Trend over multiple nights (tonight vs last 7 nights) — future feature +- Tap-to-see-segment detail in the bar chart — future feature +- P95 participating in the readiness score formula — deferred; it replaces HRV only if baseline data accumulates +- Exporting or sharing the chart diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index d478d13..727fd5e 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -20,11 +20,23 @@ android { jvmTarget = JavaVersion.VERSION_11.toString() } + signingConfigs { + create("release") { + val keystorePath = System.getenv("KEYSTORE_PATH") + val storePass = System.getenv("KEY_STORE_PASSWORD") + val alias = System.getenv("KEY_ALIAS") + val keyPass = System.getenv("KEY_PASSWORD") + if (keystorePath != null && storePass != null && alias != null && keyPass != null) { + storeFile = file(keystorePath) + storePassword = storePass + keyAlias = alias + keyPassword = keyPass + } + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.devasy.repforge" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. // MIGRATION NOTE: minSdk is intentionally set to 26 (Android 8.0 Oreo). // Health Connect requires API 26+. Devices running API <26 are no longer // supported. If downgrading, remove the health_connector dependency and @@ -48,9 +60,12 @@ android { manifestPlaceholders["appLabel"] = "RepForge (Debug)" } release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Uses the production EC P-256 keystore when KEYSTORE_PATH env var is set + // (CI injects it via GitHub Secrets). Falls back to debug key for local + // flutter run --release without env vars configured. + val releaseConfig = signingConfigs.getByName("release") + signingConfig = if (releaseConfig.storeFile != null) releaseConfig + else signingConfigs.getByName("debug") } } } diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 9fe9f1e..0c0b757 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + .value(value: _historyManager), ChangeNotifierProvider.value(value: _prManager), + ChangeNotifierProvider.value(value: _readinessManager), + Provider.value(value: _healthHistoryManager), // GeminiAiService is the single AI backend instance. It's a ChangeNotifier // (settings UI watches isConfigured/model), so it's provided as such. // Consumers that should depend on the abstraction (the coach ViewModel, @@ -156,6 +169,7 @@ class _AppInitializerState extends State { final prManager = context.read(); final api = context.read(); final gemini = context.read(); + final readiness = context.read(); try { await provider.init(); @@ -176,6 +190,10 @@ class _AppInitializerState extends State { settings.lastSeenVersion != null && settings.lastSeenVersion != version; + // Fire-and-forget readiness refresh — must run after settings.init() + // so the opt-in flag is loaded; never blocks or fails app init. + readiness.refresh(); + // Fire-and-forget analytics in background. api.sendHeartbeat(); api.trackEvent('app_open'); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index bad5c88..493013a 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -1,5 +1,7 @@ // Data Models for Workout Logger App +import 'dart:math' show log, max; + import 'package:uuid/uuid.dart'; // Sentinel value for copyWith methods to distinguish "not provided" from "null" @@ -401,21 +403,61 @@ class SetRecommendation { // ==================== Growth Model ==================== +/// Functional form of a fitted growth curve. +/// +/// - [linear]: steady volume gains (typical for newer lifters / new exercises) +/// - [logarithmic]: diminishing returns, y = a + b·ln(1+x) — typical as an +/// exercise matures and progress saturates +enum GrowthCurve { linear, logarithmic } + class GrowthModel { - final double slope; // Growth rate per session - final double intercept; // Starting baseline + /// Instantaneous growth rate (volume per day) at the most recent data point. + /// For linear fits this equals the curve coefficient; for logarithmic fits + /// it is the tangent slope b/(1+lastX), which decays as training history grows. + final double slope; + final double intercept; // Curve intercept a final double r2; // Model fit quality (0-1) final DateTime lastTrained; + final GrowthCurve curve; + + /// Curve coefficient b. Equals [slope] for linear fits. + final double coefficient; + + /// x (days since first session) of the newest point used in training. + final double lastX; + + /// Weighted residual standard error in volume units (0 = unknown/perfect). + final double stdError; GrowthModel({ required this.slope, required this.intercept, required this.r2, required this.lastTrained, - }); + this.curve = GrowthCurve.linear, + double? coefficient, + this.lastX = 0, + this.stdError = 0, + }) : coefficient = coefficient ?? slope; + + double predict(num x) { + switch (curve) { + case GrowthCurve.linear: + return intercept + coefficient * x; + case GrowthCurve.logarithmic: + return intercept + coefficient * log(1 + max(0, x.toDouble())); + } + } - double predict(int sessionNumber) { - return slope * sessionNumber + intercept; + /// Model's volume estimate at the newest training point ("today's level"). + double get currentEstimate => predict(lastX); + + /// Expected volume growth over the next 7 days as a percentage of the + /// current level. The plateau/decline signal used by recommendations. + double get weeklyGrowthPercent { + final current = currentEstimate; + if (current <= 0) return 0; + return slope * 7 / current * 100; } } @@ -981,3 +1023,192 @@ class PendingQuestions { .toList(), ); } + +// ==================== Readiness ==================== + +/// Coarse training-readiness classification derived from [ReadinessSnapshot]. +enum ReadinessBand { high, moderate, low } + +/// A single point-in-time health measurement read from Health Connect. +class HealthSample { + final DateTime time; + final double value; + + const HealthSample({required this.time, required this.value}); +} + +/// One continuous sleep-stage segment within a `SleepPeriod`. +/// +/// Stage is one of: `'deep'`, `'rem'`, `'light'`, `'awake'`. +class SleepStageInterval { + final DateTime start; + final DateTime end; + final String stage; + + const SleepStageInterval({ + required this.start, + required this.end, + required this.stage, + }); +} + +/// A sleep session interval read from Health Connect. +/// +/// When stage data is available (from `SleepSessionRecord.samples`), +/// `lightMinutes`, `deepMinutes`, `remMinutes`, and `awakeMinutes` are +/// populated and `minutes` returns actual sleep time (light + deep + rem), +/// excluding awake/out-of-bed spans. Without stage data `minutes` falls back +/// to the raw session duration. +/// +/// `stageTimeline` carries the ordered list of stage segments when available, +/// used by the Sleep HR chart to colour-code each 10-minute bar. +class SleepPeriod { + final DateTime start; + final DateTime end; + + /// Minutes in light (or unspecified) sleep. Null when no stage data. + final int? lightMinutes; + final int? deepMinutes; + final int? remMinutes; + + /// Awake/out-of-bed minutes within the session window. + final int? awakeMinutes; + + /// Ordered stage segments, populated from `SleepSessionRecord.samples`. + /// Empty when the session record carries no stage breakdown. + final List stageTimeline; + + const SleepPeriod({ + required this.start, + required this.end, + this.lightMinutes, + this.deepMinutes, + this.remMinutes, + this.awakeMinutes, + this.stageTimeline = const [], + }); + + bool get hasStages => + lightMinutes != null || deepMinutes != null || remMinutes != null; + + /// Actual sleep minutes: light + deep + rem when stage data exists, + /// otherwise the raw session span (start → end). + int get minutes => hasStages + ? (lightMinutes ?? 0) + (deepMinutes ?? 0) + (remMinutes ?? 0) + : end.difference(start).inMinutes; +} + +/// Rolling per-component averages used as the personal reference point +/// when scoring today's readiness. Recomputed at most once per day. +class ReadinessBaseline { + final String dateKey; // yyyy-MM-dd the baseline was computed for + final double? avgSleepMinutes; + final int sleepNights; + final double? avgRestingHr; + final int rhrDays; + final double? avgHrvMs; + final int hrvDays; + + const ReadinessBaseline({ + required this.dateKey, + this.avgSleepMinutes, + this.sleepNights = 0, + this.avgRestingHr, + this.rhrDays = 0, + this.avgHrvMs, + this.hrvDays = 0, + }); + + Map toJson() => { + 'dateKey': dateKey, + 'avgSleepMinutes': avgSleepMinutes, + 'sleepNights': sleepNights, + 'avgRestingHr': avgRestingHr, + 'rhrDays': rhrDays, + 'avgHrvMs': avgHrvMs, + 'hrvDays': hrvDays, + }; + + factory ReadinessBaseline.fromJson(Map json) => + ReadinessBaseline( + dateKey: json['dateKey'] as String, + avgSleepMinutes: (json['avgSleepMinutes'] as num?)?.toDouble(), + sleepNights: json['sleepNights'] as int? ?? 0, + avgRestingHr: (json['avgRestingHr'] as num?)?.toDouble(), + rhrDays: json['rhrDays'] as int? ?? 0, + avgHrvMs: (json['avgHrvMs'] as num?)?.toDouble(), + hrvDays: json['hrvDays'] as int? ?? 0, + ); +} + +/// One day's computed readiness with the per-component evidence behind it. +/// +/// Any component (sleep / resting HR / HRV) may be null when the data or a +/// reliable baseline is unavailable; [score] is null when no component could +/// be scored at all, in which case the UI hides readiness entirely. +class ReadinessSnapshot { + final String dateKey; // yyyy-MM-dd this snapshot describes + final int? score; // 0–100 overall, null = nothing scorable + final ReadinessBand? band; + final int? sleepMinutes; + final double? sleepBaselineMinutes; + final int? sleepScore; + final double? restingHr; + final double? rhrBaseline; + final int? rhrScore; + final double? hrvMs; + final double? hrvBaseline; + final int? hrvScore; + final DateTime computedAt; + + ReadinessSnapshot({ + required this.dateKey, + this.score, + this.band, + this.sleepMinutes, + this.sleepBaselineMinutes, + this.sleepScore, + this.restingHr, + this.rhrBaseline, + this.rhrScore, + this.hrvMs, + this.hrvBaseline, + this.hrvScore, + DateTime? computedAt, + }) : computedAt = computedAt ?? DateTime.now(); + + Map toJson() => { + 'dateKey': dateKey, + 'score': score, + 'band': band?.name, + 'sleepMinutes': sleepMinutes, + 'sleepBaselineMinutes': sleepBaselineMinutes, + 'sleepScore': sleepScore, + 'restingHr': restingHr, + 'rhrBaseline': rhrBaseline, + 'rhrScore': rhrScore, + 'hrvMs': hrvMs, + 'hrvBaseline': hrvBaseline, + 'hrvScore': hrvScore, + 'computedAt': computedAt.toIso8601String(), + }; + + factory ReadinessSnapshot.fromJson(Map json) => + ReadinessSnapshot( + dateKey: json['dateKey'] as String, + score: json['score'] as int?, + band: json['band'] != null + ? ReadinessBand.values.byName(json['band'] as String) + : null, + sleepMinutes: json['sleepMinutes'] as int?, + sleepBaselineMinutes: (json['sleepBaselineMinutes'] as num?)?.toDouble(), + sleepScore: json['sleepScore'] as int?, + restingHr: (json['restingHr'] as num?)?.toDouble(), + rhrBaseline: (json['rhrBaseline'] as num?)?.toDouble(), + rhrScore: json['rhrScore'] as int?, + hrvMs: (json['hrvMs'] as num?)?.toDouble(), + hrvBaseline: (json['hrvBaseline'] as num?)?.toDouble(), + hrvScore: json['hrvScore'] as int?, + computedAt: DateTime.parse(json['computedAt'] as String), + ); +} diff --git a/workout-logger/lib/models/sleep_hr_models.dart b/workout-logger/lib/models/sleep_hr_models.dart new file mode 100644 index 0000000..f7a82d7 --- /dev/null +++ b/workout-logger/lib/models/sleep_hr_models.dart @@ -0,0 +1,214 @@ +/// Data models for the Sleep HR chart feature. +/// +/// These are computed at runtime from Health Connect HR + sleep-stage data +/// and are never persisted. If the snapshot is null the compact card hides. +library; + +/// One 10-minute window of overnight HR data, colour-coded by sleep stage. +class SleepHrSegment { + final DateTime windowStart; + + /// BPM floor of all samples in this window. + final int minBpm; + + /// BPM ceiling of all samples in this window. + final int maxBpm; + + /// Mean BPM across all samples in this window. + final double avgBpm; + + /// Dominant sleep stage: 'deep' | 'rem' | 'light' | 'awake'. + final String stage; + + const SleepHrSegment({ + required this.windowStart, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.stage, + }); +} + +/// Aggregate HR statistics for one sleep stage. +class SleepStageStats { + /// 'deep' | 'rem' | 'light' | 'awake' + final String stage; + final int minBpm; + final int p25Bpm; + final double avgBpm; + final int p75Bpm; + final int maxBpm; + final int sampleCount; + + const SleepStageStats({ + required this.stage, + required this.minBpm, + required this.p25Bpm, + required this.avgBpm, + required this.p75Bpm, + required this.maxBpm, + required this.sampleCount, + }); +} + +/// Complete overnight HR picture — carried by ReadinessManager and consumed +/// by SleepHrCard (compact) and SleepHrSheet (full detail). +class SleepHrSnapshot { + final DateTime sleepStart; + final DateTime sleepEnd; + + /// 5th-percentile — overnight HR floor. + final int p5Bpm; + + /// 95th-percentile of all overnight HR samples — used as an RHR proxy. + final int p95Bpm; + + /// 10-minute segments ordered chronologically. + final List segments; + + /// One entry per stage present (deep / rem / light / awake). + final List stageStats; + + const SleepHrSnapshot({ + required this.sleepStart, + required this.sleepEnd, + required this.p5Bpm, + required this.p95Bpm, + required this.segments, + required this.stageStats, + }); + + SleepStageStats? statsFor(String stage) => + stageStats.where((s) => s.stage == stage).firstOrNull; +} + +// ───────────────────────────────────────────────────────────────────────────── +// History & granularity models — added for the Sleep/HR detail screens. +// Like the snapshots above, these are computed at runtime from Health Connect +// and are not persisted (the heavy per-day HR results may be cached as JSON, +// but that is the manager's concern, not a contract here). +// ───────────────────────────────────────────────────────────────────────────── + +/// Granularity for the Sleep / Heart-rate detail screens. +enum HealthGranularity { day, week, month, year } + +extension HealthGranularityX on HealthGranularity { + /// Short toggle label. + String get label => switch (this) { + HealthGranularity.day => 'Day', + HealthGranularity.week => 'Week', + HealthGranularity.month => 'Month', + HealthGranularity.year => 'Year', + }; +} + +/// One ~30-minute window of all-day HR (min / max / avg). +class HrBucket { + final DateTime windowStart; + final int minBpm; + final int maxBpm; + final double avgBpm; + + const HrBucket({ + required this.windowStart, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + }); + + Map toJson() => { + 't': windowStart.toIso8601String(), + 'mn': minBpm, + 'mx': maxBpm, + 'av': avgBpm, + }; + + factory HrBucket.fromJson(Map j) => HrBucket( + windowStart: DateTime.parse(j['t'] as String), + minBpm: (j['mn'] as num).toInt(), + maxBpm: (j['mx'] as num).toInt(), + avgBpm: (j['av'] as num).toDouble(), + ); +} + +/// Complete all-day HR picture for one calendar day — backs the Heart-rate +/// card (compact) and the Day tab of HeartRateDetailScreen. +class HrDaySnapshot { + final DateTime day; + + /// Resting HR for the day (RHR record if present, else morning-min fallback). + final int? restingBpm; + final int minBpm; + final int maxBpm; + final double avgBpm; + + /// ~30-minute buckets ordered chronologically. + final List buckets; + + const HrDaySnapshot({ + required this.day, + required this.restingBpm, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.buckets, + }); + + Map toJson() => { + 'day': day.toIso8601String(), + 'rest': restingBpm, + 'mn': minBpm, + 'mx': maxBpm, + 'av': avgBpm, + 'b': buckets.map((b) => b.toJson()).toList(), + }; + + factory HrDaySnapshot.fromJson(Map j) => HrDaySnapshot( + day: DateTime.parse(j['day'] as String), + restingBpm: (j['rest'] as num?)?.toInt(), + minBpm: (j['mn'] as num).toInt(), + maxBpm: (j['mx'] as num).toInt(), + avgBpm: (j['av'] as num).toDouble(), + buckets: (j['b'] as List) + .map((e) => HrBucket.fromJson(e as Map)) + .toList(), + ); +} + +/// One aggregated sleep bar (a night, or a month in the year view). +class SleepDayBar { + final DateTime date; + final int totalMinutes; + final int deepMin; + final int remMin; + final int lightMin; + final int awakeMin; + + const SleepDayBar({ + required this.date, + required this.totalMinutes, + required this.deepMin, + required this.remMin, + required this.lightMin, + required this.awakeMin, + }); +} + +/// One aggregated HR range bar (a day, or a month in the year view). +class HrRangeBar { + final DateTime date; + final String label; + final int minBpm; + final int maxBpm; + final double avgBpm; + final int? restingBpm; + + const HrRangeBar({ + required this.date, + required this.label, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.restingBpm, + }); +} diff --git a/workout-logger/lib/models/workout_hr_models.dart b/workout-logger/lib/models/workout_hr_models.dart new file mode 100644 index 0000000..f756b28 --- /dev/null +++ b/workout-logger/lib/models/workout_hr_models.dart @@ -0,0 +1,105 @@ +// Data models for the per-workout heart-rate breakdown shown in the History +// session-details sheet. Computed at runtime from Health Connect HR samples + +// the session's set timestamps; never persisted. +library; + +/// One point on the workout HR curve (~30-second bucket average). +class HrCurvePoint { + final DateTime time; + final double bpm; + const HrCurvePoint({required this.time, required this.bpm}); +} + +/// HR recovery across one rest gap between two sets. +class RestRecovery { + /// 1-based index of the set this rest follows (global across the session). + final int afterSet; + final DateTime restStart; + final int durationSec; + + /// HR at the end of the preceding set (local peak). + final int peakBpm; + + /// Lowest HR reached during the rest. + final int troughBpm; + + /// peakBpm − troughBpm (positive means HR came down). + final int recoveryBpm; + + /// True when the drop met the recovery threshold. + final bool recovered; + + const RestRecovery({ + required this.afterSet, + required this.restStart, + required this.durationSec, + required this.peakBpm, + required this.troughBpm, + required this.recoveryBpm, + required this.recovered, + }); +} + +/// Time span of one exercise within the session — drawn as a labelled flag / +/// section on the HR curve so you can see which part of the workout is which. +class ExerciseHrSpan { + final String exerciseId; + final DateTime start; + final DateTime end; + final int setCount; + + const ExerciseHrSpan({ + required this.exerciseId, + required this.start, + required this.end, + required this.setCount, + }); +} + +/// Complete HR picture for one recorded workout. +class WorkoutHrAnalysis { + final DateTime start; + final DateTime end; + final int avgBpm; + final int peakBpm; + final int minBpm; + + /// Ordered curve points across the session. + final List curve; + + /// Per-rest recovery. Empty when set timestamps aren't trustworthy + /// ([hasRestAnalysis] is false) — the curve still renders. + final List rests; + + /// Exercise sections across the session, ordered in time. Empty when set + /// timestamps aren't trustworthy. + final List exercises; + + /// Whether rest/section analysis was computed (set timestamps spanned the + /// session). + final bool hasRestAnalysis; + + const WorkoutHrAnalysis({ + required this.start, + required this.end, + required this.avgBpm, + required this.peakBpm, + required this.minBpm, + required this.curve, + required this.rests, + required this.exercises, + required this.hasRestAnalysis, + }); + + int get restsRecovered => rests.where((r) => r.recovered).length; + int get restCount => rests.length; + + /// Mean recovery (bpm) across the rests that recovered; 0 when none did. + int get avgRecoveryBpm { + final ok = rests.where((r) => r.recovered).toList(); + if (ok.isEmpty) return 0; + return (ok.fold(0, (s, r) => s + r.recoveryBpm) / ok.length).round(); + } + + int get restsTooShort => rests.where((r) => !r.recovered).length; +} diff --git a/workout-logger/lib/screens/heart_rate_detail_screen.dart b/workout-logger/lib/screens/heart_rate_detail_screen.dart new file mode 100644 index 0000000..047209e --- /dev/null +++ b/workout-logger/lib/screens/heart_rate_detail_screen.dart @@ -0,0 +1,299 @@ +// heart_rate_detail_screen.dart — full-screen all-day heart-rate history. +// +// Day : ~30-min min–max HR bars + resting line for the selected day. +// Week / Month / Year : daily/monthly min–max range bars with resting markers. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/sleep_hr_models.dart'; +import '../services/managers/health_history_manager.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/health_bar_chart.dart'; +import 'widgets/health_detail_shell.dart'; +import 'widgets/rf_widgets.dart'; + +class HeartRateDetailScreen extends StatefulWidget { + const HeartRateDetailScreen({super.key, this.initialDate}); + + final DateTime? initialDate; + + @override + State createState() => _HeartRateDetailScreenState(); +} + +class _HeartRateDetailScreenState extends State { + late HealthHistoryManager _mgr; + HealthGranularity _g = HealthGranularity.day; + late DateTime _anchor; + late Set _workoutDays; + Future? _future; + + @override + void initState() { + super.initState(); + final now = widget.initialDate ?? DateTime.now(); + _anchor = DateTime(now.year, now.month, now.day); + final sessions = context.read().sessions; + _workoutDays = sessions.map((s) => HealthHistoryManager.dateKey(s.date)).toSet(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mgr = context.read(); + _future ??= _load(); + } + + Future _load() => _g == HealthGranularity.day + ? _mgr.hrDay(_anchor) + : _mgr.hrBars(_anchor, _g); + + bool get _canGoNext { + final today = DateTime.now(); + return HealthHistoryManager.stepBy(_anchor, _g, 1) + .isBefore(DateTime(today.year, today.month, today.day + 1)); + } + + void _step(int dir) => setState(() { + _anchor = HealthHistoryManager.stepBy(_anchor, _g, dir); + _future = _load(); + }); + + void _setG(HealthGranularity g) => setState(() { + _g = g; + _future = _load(); + }); + + String get _dateLabel { + switch (_g) { + case HealthGranularity.day: + return DateFormat('EEE · MMM d').format(_anchor); + case HealthGranularity.week: + final start = _anchor.subtract(const Duration(days: 6)); + return '${DateFormat('MMM d').format(start)} – ${DateFormat('MMM d').format(_anchor)}'; + case HealthGranularity.month: + return DateFormat('MMMM yyyy').format(_anchor); + case HealthGranularity.year: + return DateFormat('yyyy').format(_anchor); + } + } + + @override + Widget build(BuildContext context) { + return HealthDetailShell( + title: 'Heart rate', + icon: Icons.favorite_rounded, + iconColor: AppColors.accent, + dateLabel: _dateLabel, + granularity: _g, + onGranularityChanged: _setG, + onPrev: () => _step(-1), + onNext: () => _step(1), + canGoNext: _canGoNext, + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const SizedBox(height: 220, child: Center(child: RFLoadingDots())); + } + if (_g == HealthGranularity.day) { + final data = snap.data as HrDaySnapshot?; + if (data == null) return const _Empty('No heart-rate data for this day.'); + return _DayBody(snapshot: data); + } + final bars = (snap.data as List?) ?? const []; + return _AggBody(bars: bars, workoutDays: _workoutDays, granularity: _g); + }, + ), + ); + } +} + +class _DayBody extends StatelessWidget { + const _DayBody({required this.snapshot}); + final HrDaySnapshot snapshot; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _Pill(label: 'Resting', value: snapshot.restingBpm?.toString() ?? '—', color: AppColors.secondary), + const SizedBox(width: 6), + _Pill(label: 'Min', value: '${snapshot.minBpm}', color: AppColors.textMuted), + const SizedBox(width: 6), + _Pill(label: 'Max', value: '${snapshot.maxBpm}', color: AppColors.accent), + const SizedBox(width: 6), + _Pill(label: 'Avg', value: '${snapshot.avgBpm.round()}', color: AppColors.primary), + ], + ), + const SizedBox(height: 12), + GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'All-day heart rate · 30-min bars', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + Text('bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11)), + ], + ), + const SizedBox(height: 8), + HrDayChart(snapshot: snapshot), + const SizedBox(height: 10), + Wrap( + spacing: 12, + children: [ + _legend('Min–max', AppColors.secondary), + _legendDash('Resting', AppColors.secondary), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); + + Widget _legendDash(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 14, height: 2, color: c), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _AggBody extends StatelessWidget { + const _AggBody({required this.bars, required this.workoutDays, required this.granularity}); + + final List bars; + final Set workoutDays; + final HealthGranularity granularity; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.maxBpm > 0).toList(); + final resting = withData.where((b) => b.restingBpm != null).map((b) => b.restingBpm!).toList(); + final avgRest = resting.isEmpty ? null : (resting.reduce((a, b) => a + b) / resting.length).round(); + final mn = withData.isEmpty ? null : withData.map((b) => b.minBpm).reduce((a, b) => a < b ? a : b); + final mx = withData.isEmpty ? null : withData.map((b) => b.maxBpm).reduce((a, b) => a > b ? a : b); + final unit = granularity == HealthGranularity.year ? 'monthly' : 'daily'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _Pill(label: 'Avg resting', value: avgRest?.toString() ?? '—', color: AppColors.secondary), + const SizedBox(width: 6), + _Pill(label: 'Min', value: mn?.toString() ?? '—', color: AppColors.textMuted), + const SizedBox(width: 6), + _Pill(label: 'Max', value: mx?.toString() ?? '—', color: AppColors.accent), + ], + ), + const SizedBox(height: 12), + GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$unit range · resting ●', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 12), + HrRangeChart(bars: bars, workoutDays: workoutDays), + const SizedBox(height: 10), + Wrap( + spacing: 12, + children: [ + _legend('Min–max', AppColors.primary), + _legend('Resting', AppColors.secondary), + _legend('Workout day', AppColors.accent), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + Text( + value, + style: GoogleFonts.geistMono(color: color, fontSize: 16, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(this.message); + final String message; + @override + Widget build(BuildContext context) => GlassCard( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), + child: Center( + child: Text(message, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13)), + ), + ); +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index b55a3e9..f46439f 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -19,6 +19,9 @@ import 'analytics_screen.dart'; import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; import 'ai_coach_screen.dart'; +import 'widgets/readiness_card.dart'; +import 'widgets/sleep_hr_card.dart'; +import 'widgets/heart_rate_card.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; @@ -205,6 +208,9 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 24), _buildStreakHero(context: context, provider: provider, homeState: homeState), const SizedBox(height: 16), + const ReadinessCard(), + const SleepHrCard(), + const HeartRateCard(), _buildStatsGrid(context, provider), const SizedBox(height: 16), _buildHeatmapCard(context, provider), @@ -1180,19 +1186,8 @@ class _RoutineSelectorSheet extends StatelessWidget { // ── Route helper ────────────────────────────────────────────────────────────── -PageRouteBuilder _slide(Widget page) { - return PageRouteBuilder( - pageBuilder: (_, __, ___) => page, - transitionsBuilder: (_, anim, __, child) => SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), - child: child, - ), - transitionDuration: const Duration(milliseconds: 300), - ); -} +// Thin alias to the shared slideRoute helper in rf_widgets.dart. +PageRouteBuilder _slide(Widget page) => slideRoute(page); // ── Weekly Insights Card ────────────────────────────────────────────────────── diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index ebb0b9c..112c9bf 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -1,5 +1,6 @@ // profile_screen.dart — User preferences, data management, and about +import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -16,6 +17,7 @@ import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; +import '../services/managers/readiness_manager.dart'; import '../theme/app_theme.dart'; import 'widgets/profile_sections.dart'; @@ -32,6 +34,7 @@ class _ProfileScreenState extends State bool _isImporting = false; bool _isBackingUp = false; bool _isRequestingHcPermission = false; + bool _isRequestingReadinessPermission = false; String _appVersion = ''; @override @@ -62,21 +65,31 @@ class _ProfileScreenState extends State Future _reconcileHealthConnectState() async { if (!mounted) return; final settings = context.read(); - if (!settings.healthConnectEnabled) return; + if (!settings.healthConnectEnabled && !settings.readinessEnabled) return; try { final hc = context.read(); final available = await hc.isAvailable(); if (!available) { if (mounted) await settings.setHealthConnectEnabled(false); + if (mounted) await settings.setReadinessEnabled(false); return; } - final hasPerms = await hc.hasPermissions(); - if (!hasPerms) { - if (mounted) await settings.setHealthConnectEnabled(false); + if (settings.healthConnectEnabled) { + final hasPerms = await hc.hasPermissions(); + if (!hasPerms && mounted) { + await settings.setHealthConnectEnabled(false); + } + } + if (settings.readinessEnabled) { + final granted = await hc.grantedReadTypes(); + if (granted.isEmpty && mounted) { + await settings.setReadinessEnabled(false); + } } } catch (e) { debugPrint('HC reconciliation error: $e'); if (mounted) await settings.setHealthConnectEnabled(false); + if (mounted) await settings.setReadinessEnabled(false); } } @@ -124,6 +137,64 @@ class _ProfileScreenState extends State } } + Future _requestReadinessPermission() async { + debugPrint('[Readiness] toggle tapped — starting permission flow'); + setState(() => _isRequestingReadinessPermission = true); + try { + final hc = context.read(); + final available = await hc.isAvailable(); + debugPrint('[Readiness] isAvailable = $available'); + if (!available) { + if (mounted) { + _showSnack( + 'Health Connect is not available on this device.', + AppColors.error, + ); + } + return; + } + + // Any single granted read type is enough — readiness components + // degrade independently when data is missing. + var granted = await hc.grantedReadTypes(); + debugPrint('[Readiness] granted before request = $granted'); + if (granted.isEmpty) { + debugPrint('[Readiness] requesting read permissions…'); + try { + await hc.requestReadPermissions(); + } catch (e) { + debugPrint('[Readiness] requestReadPermissions threw: $e'); + } + granted = await hc.grantedReadTypes(); + debugPrint('[Readiness] granted after request = $granted'); + } + + if (!mounted) return; + if (granted.isNotEmpty) { + debugPrint('[Readiness] permissions granted — enabling readiness'); + final settings = context.read(); + await settings.setReadinessEnabled(true); + if (!mounted) return; + // Compute the first snapshot right away so the home card appears. + unawaited(context.read().refresh(force: true)); + _showSnack('Readiness insights enabled!', AppColors.success); + } else { + debugPrint('[Readiness] still no granted types — showing manual instructions'); + _showSnack( + 'Open Health Connect → App permissions → RepForge and allow Sleep and Heart rate.', + AppColors.warning, + ); + } + } catch (e) { + debugPrint('[Readiness] unexpected error: $e'); + if (mounted) { + _showSnack('Could not connect to Health Connect.', AppColors.error); + } + } finally { + if (mounted) setState(() => _isRequestingReadinessPermission = false); + } + } + Future _exportToFile() async { setState(() => _isExporting = true); try { @@ -296,6 +367,14 @@ class _ProfileScreenState extends State await settings.setHealthConnectEnabled(false); } }, + isReadinessLoading: _isRequestingReadinessPermission, + onReadinessToggle: (value) async { + if (value) { + await _requestReadinessPermission(); + } else { + await settings.setReadinessEnabled(false); + } + }, ), const SizedBox(height: AppSpacing.md), DataManagementSection( diff --git a/workout-logger/lib/screens/sleep_detail_screen.dart b/workout-logger/lib/screens/sleep_detail_screen.dart new file mode 100644 index 0000000..ccc6f1c --- /dev/null +++ b/workout-logger/lib/screens/sleep_detail_screen.dart @@ -0,0 +1,259 @@ +// sleep_detail_screen.dart — full-screen sleep history. +// +// Day : overnight HR breakdown (SleepHrDayView) for the selected night. +// Week / Month / Year : stacked sleep-duration bars (SleepBarsChart) with an +// 8h goal line and workout-day highlights. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/sleep_hr_models.dart'; +import '../services/managers/health_history_manager.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/health_bar_chart.dart'; +import 'widgets/health_detail_shell.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/sleep_hr_charts.dart'; + +class SleepDetailScreen extends StatefulWidget { + const SleepDetailScreen({super.key, this.initialDate}); + + final DateTime? initialDate; + + @override + State createState() => _SleepDetailScreenState(); +} + +class _SleepDetailScreenState extends State { + late HealthHistoryManager _mgr; + HealthGranularity _g = HealthGranularity.day; + late DateTime _anchor; + late Set _workoutDays; + Future? _future; + + @override + void initState() { + super.initState(); + final now = widget.initialDate ?? DateTime.now(); + _anchor = DateTime(now.year, now.month, now.day); + final sessions = context.read().sessions; + _workoutDays = sessions.map((s) => HealthHistoryManager.dateKey(s.date)).toSet(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mgr = context.read(); + _future ??= _load(); + } + + Future _load() => _g == HealthGranularity.day + ? _mgr.sleepNight(_anchor) + : _mgr.sleepBars(_anchor, _g); + + bool get _canGoNext { + final today = DateTime.now(); + return HealthHistoryManager.stepBy(_anchor, _g, 1) + .isBefore(DateTime(today.year, today.month, today.day + 1)); + } + + void _step(int dir) { + setState(() { + _anchor = HealthHistoryManager.stepBy(_anchor, _g, dir); + _future = _load(); + }); + } + + void _setG(HealthGranularity g) { + setState(() { + _g = g; + _future = _load(); + }); + } + + String get _dateLabel { + switch (_g) { + case HealthGranularity.day: + final prev = _anchor.subtract(const Duration(days: 1)); + return '${DateFormat('MMM d').format(prev)} → ${DateFormat('d').format(_anchor)}'; + case HealthGranularity.week: + final start = _anchor.subtract(const Duration(days: 6)); + return '${DateFormat('MMM d').format(start)} – ${DateFormat('MMM d').format(_anchor)}'; + case HealthGranularity.month: + return DateFormat('MMMM yyyy').format(_anchor); + case HealthGranularity.year: + return DateFormat('yyyy').format(_anchor); + } + } + + @override + Widget build(BuildContext context) { + return HealthDetailShell( + title: 'Sleep', + icon: Icons.nightlight_round, + iconColor: kSleepStageColors['rem']!, + dateLabel: _dateLabel, + granularity: _g, + onGranularityChanged: _setG, + onPrev: () => _step(-1), + onNext: () => _step(1), + canGoNext: _canGoNext, + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const _Loading(); + } + if (_g == HealthGranularity.day) { + final data = snap.data as SleepHrSnapshot?; + if (data == null) return const _Empty('No sleep data for this night.'); + return _DayBody(snapshot: data); + } + final bars = (snap.data as List?) ?? const []; + return _AggBody(bars: bars, workoutDays: _workoutDays, granularity: _g); + }, + ), + ); + } +} + +class _DayBody extends StatelessWidget { + const _DayBody({required this.snapshot}); + final SleepHrSnapshot snapshot; + + static DateTime _ist(DateTime dt) => dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + static String _fmt(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m ${dt.hour < 12 ? 'AM' : 'PM'}'; + } + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Asleep · ${_fmt(_ist(snapshot.sleepStart))} – ${_fmt(_ist(snapshot.sleepEnd))} IST', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 16), + SleepHrDayView(snapshot: snapshot), + ], + ), + ); + } +} + +class _AggBody extends StatelessWidget { + const _AggBody({ + required this.bars, + required this.workoutDays, + required this.granularity, + }); + + final List bars; + final Set workoutDays; + final HealthGranularity granularity; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.totalMinutes > 0).toList(); + final avg = withData.isEmpty + ? 0 + : withData.fold(0, (s, b) => s + b.totalMinutes) ~/ withData.length; + final avgLabel = '${avg ~/ 60}h${(avg % 60).toString().padLeft(2, '0')}'; + final unit = granularity == HealthGranularity.year ? 'per month' : 'per night'; + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sleep duration · $unit', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + Text( + withData.isEmpty ? '—' : 'avg $avgLabel', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 12), + SleepBarsChart(bars: bars, workoutDays: workoutDays), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _legend('Deep', kSleepStageColors['deep']!), + _legend('REM', kSleepStageColors['rem']!), + _legend('Light', kSleepStageColors['light']!), + _legendDash('8h goal', kSleepStageColors['awake']!), + _legend('Workout day', AppColors.accent), + ], + ), + ], + ), + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); + + Widget _legendDash(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 14, height: 2, color: c), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => const SizedBox( + height: 220, + child: Center(child: RFLoadingDots()), + ); +} + +class _Empty extends StatelessWidget { + const _Empty(this.message); + final String message; + @override + Widget build(BuildContext context) => GlassCard( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), + child: Center( + child: Text( + message, + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13), + ), + ), + ); +} diff --git a/workout-logger/lib/screens/widgets/analytics_overview.dart b/workout-logger/lib/screens/widgets/analytics_overview.dart index 509b488..8c4065f 100644 --- a/workout-logger/lib/screens/widgets/analytics_overview.dart +++ b/workout-logger/lib/screens/widgets/analytics_overview.dart @@ -514,13 +514,16 @@ class _MuscleFocusRow extends StatelessWidget { if (model == null) { return (color: AppColors.textFaint, icon: Icons.remove_rounded); } - if (model.slope > 2) { + // Relative weekly growth so small muscles (low effective volume) use the + // same bar as large ones — +2 %/week is strong progress on any muscle. + final weekly = model.weeklyGrowthPercent; + if (weekly > 2) { return (color: AppColors.success, icon: Icons.trending_up_rounded); } - if (model.slope > 0) { + if (weekly > 0.5) { return (color: AppColors.secondary, icon: Icons.trending_up_rounded); } - if (model.slope < -2) { + if (weekly < -2) { return (color: AppColors.error, icon: Icons.trending_down_rounded); } return (color: AppColors.warning, icon: Icons.trending_flat_rounded); diff --git a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart index 5c1d51e..2ae2d08 100644 --- a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart @@ -231,7 +231,7 @@ class ExerciseDetailsSheet extends StatelessWidget { const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - '+${settings.toDisplay(growthModel.slope).toStringAsFixed(1)} ${settings.unitLabel} volume/session', + '+${settings.toDisplay(growthModel.slope * 7).toStringAsFixed(1)} ${settings.unitLabel} volume/week', style: const TextStyle( color: AppColors.success, fontSize: 13, diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index 640a664..db7508a 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -586,7 +586,7 @@ class _GrowthCard extends StatelessWidget { ), Text( isGrowing - ? '+${settings.toDisplay(model.slope.abs()).toStringAsFixed(1)} ${settings.unitLabel}/session' + ? '+${settings.toDisplay(model.slope.abs() * 7).toStringAsFixed(1)} ${settings.unitLabel}/week' : 'Volume trend is flat', style: GoogleFonts.geist( color: AppColors.textSoft, @@ -734,11 +734,20 @@ class _VolumeChart extends StatelessWidget { final settings = context.read(); final n = progression.length; + // Chart x is the session index, but the model is trained on days since + // the first session — map each index to its day offset before predicting. + double dayAt(int i) => progression[i] + .date + .difference(progression.first.date) + .inDays + .toDouble(); + final avgGapDays = n > 1 ? dayAt(n - 1) / (n - 1) : 7.0; + double rse = 0.0; if (growthModel != null && n >= 3) { double ssRes = 0.0; for (int i = 0; i < n; i++) { - final r = progression[i].volume - growthModel!.predict(i); + final r = progression[i].volume - growthModel!.predict(dayAt(i)); ssRes += r * r; } rse = sqrt(ssRes / (n - 2)); @@ -757,8 +766,9 @@ class _VolumeChart extends StatelessWidget { n + 2, (i) => FlSpot( i.toDouble(), - settings.toDisplay( - growthModel!.predict(i).clamp(0.0, double.infinity)), + settings.toDisplay(growthModel! + .predict(i < n ? dayAt(i) : dayAt(n - 1) + avgGapDays * (i - n + 1)) + .clamp(0.0, double.infinity)), ), ) : []; @@ -1493,7 +1503,7 @@ class _AskCoachButton extends StatelessWidget { if (!gemini.isConfigured) return const SizedBox.shrink(); final isPlateauing = - growthModel != null && growthModel!.slope <= 0; + growthModel != null && growthModel!.weeklyGrowthPercent < 0.5; final seed = isPlateauing ? 'I\'ve been plateauing on $exerciseName. How can I break through and start progressing again?' : 'How can I continue to progress on $exerciseName and make the most of my current momentum?'; diff --git a/workout-logger/lib/screens/widgets/health_bar_chart.dart b/workout-logger/lib/screens/widgets/health_bar_chart.dart new file mode 100644 index 0000000..1974cd3 --- /dev/null +++ b/workout-logger/lib/screens/widgets/health_bar_chart.dart @@ -0,0 +1,617 @@ +// health_bar_chart.dart — aggregated vertical bar chart for the Week / Month / +// Year tabs of the Sleep & Heart-rate detail screens. +// +// Two public widgets share one interactive painter: +// • SleepBarsChart — stacked sleep-stage duration bars + 8h goal line. +// • HrRangeChart — daily/monthly min–max range bars + resting-HR markers. +// Both highlight bars that fall on a logged-workout day. + +import 'dart:math' show max; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'sleep_hr_charts.dart' show kSleepStageColors; + +/// Default sleep goal used for the dashed reference line (8h). +const int kSleepGoalMinutes = 480; + +// ── Shared bar model ────────────────────────────────────────────────────────── + +class _Segment { + final Color color; + final double from; + final double to; + const _Segment(this.color, this.from, this.to); +} + +class _AggBar { + final String label; + final List<_Segment> segments; // drawn against the value axis + final double? marker; // e.g. resting-HR dot + final bool isWorkout; + final bool hasData; + final List tooltip; + + const _AggBar({ + required this.label, + required this.segments, + required this.tooltip, + this.marker, + this.isWorkout = false, + this.hasData = true, + }); +} + +String _hm(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return m == 0 ? '${h}h' : '${h}h${m.toString().padLeft(2, '0')}'; +} + +// ── Sleep stacked bars ──────────────────────────────────────────────────────── + +class SleepBarsChart extends StatelessWidget { + const SleepBarsChart({ + super.key, + required this.bars, + required this.workoutDays, + this.goalMinutes = kSleepGoalMinutes, + this.height = 180, + }); + + final List bars; + final Set workoutDays; + final int goalMinutes; + final double height; + + @override + Widget build(BuildContext context) { + final aggBars = bars.map((b) { + final light = b.lightMin.toDouble(); + final rem = b.remMin.toDouble(); + final deep = b.deepMin.toDouble(); + // Stack order from baseline up: deep, rem, light. + final segs = <_Segment>[ + _Segment(kSleepStageColors['deep']!, 0, deep), + _Segment(kSleepStageColors['rem']!, deep, deep + rem), + _Segment(kSleepStageColors['light']!, deep + rem, deep + rem + light), + ]; + return _AggBar( + label: _labelFor(b.date), + segments: segs, + hasData: b.totalMinutes > 0, + isWorkout: workoutDays.contains(_key(b.date)), + tooltip: [ + _labelFor(b.date), + '${_hm(b.totalMinutes)} total', + 'Deep ${_hm(b.deepMin)} · REM ${_hm(b.remMin)}', + 'Light ${_hm(b.lightMin)}', + ], + ); + }).toList(); + + final maxTotal = bars.fold(0, (m, b) => max(m, b.totalMinutes)); + final axisMax = (max(maxTotal, goalMinutes) / 60).ceil() * 60.0 + 30; + + return _AggBarChart( + bars: aggBars, + axisMin: 0, + axisMax: axisMax, + gridStep: 120, // every 2h + axisLabel: (v) => '${v ~/ 60}h', + goalLine: goalMinutes.toDouble(), + height: height, + ); + } + + String _labelFor(DateTime d) => + d.day == 1 && _isMonthBar(d) ? _months[d.month - 1] : '${d.day}'; + + // Year bars use the first-of-month date; show month initials there. + bool _isMonthBar(DateTime d) => bars.length == 12; + + static const _months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; +} + +// ── HR range bars ───────────────────────────────────────────────────────────── + +class HrRangeChart extends StatelessWidget { + const HrRangeChart({ + super.key, + required this.bars, + required this.workoutDays, + this.height = 180, + }); + + final List bars; + final Set workoutDays; + final double height; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.maxBpm > 0).toList(); + final dataMin = withData.isEmpty + ? 40 + : withData.map((b) => b.minBpm).reduce((a, b) => a < b ? a : b); + final dataMax = withData.isEmpty + ? 160 + : withData.map((b) => b.maxBpm).reduce((a, b) => a > b ? a : b); + final axisMin = (dataMin / 10).floor() * 10.0 - 5; + final axisMax = (dataMax / 10).ceil() * 10.0 + 5; + + final aggBars = bars.map((b) { + final hasData = b.maxBpm > 0; + return _AggBar( + label: b.label, + hasData: hasData, + isWorkout: workoutDays.contains(_key(b.date)), + marker: b.restingBpm?.toDouble(), + segments: hasData + ? [_Segment(AppColors.primary, b.minBpm.toDouble(), b.maxBpm.toDouble())] + : const [], + tooltip: hasData + ? [ + b.label, + '${b.minBpm}–${b.maxBpm} bpm', + if (b.restingBpm != null) 'resting ${b.restingBpm}', + ] + : [b.label, 'no data'], + ); + }).toList(); + + return _AggBarChart( + bars: aggBars, + axisMin: axisMin, + axisMax: axisMax, + gridStep: 30, + axisLabel: (v) => '${v.round()}', + rangeGradient: true, + height: height, + ); + } +} + +String _key(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +// ── All-day HR (Day tab) ────────────────────────────────────────────────────── + +/// ~30-minute min–max HR bars across one day, with a dashed resting line. +class HrDayChart extends StatefulWidget { + const HrDayChart({super.key, required this.snapshot, this.height = 180}); + + final HrDaySnapshot snapshot; + final double height; + + @override + State createState() => _HrDayChartState(); +} + +class _HrDayChartState extends State { + int? _hovered; + static const _padLeft = 26.0; + + int? _indexAt(Offset local, double width) { + final buckets = widget.snapshot.buckets; + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW || buckets.isEmpty) return null; + return (x / chartW * buckets.length).floor().clamp(0, buckets.length - 1); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hovered = null), + onPanUpdate: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hovered = null), + onPanCancel: () => setState(() => _hovered = null), + child: CustomPaint( + size: Size(width, widget.height), + painter: _HrDayPainter(widget.snapshot, _hovered), + ), + ); + }, + ), + ); + } +} + +class _HrDayPainter extends CustomPainter { + _HrDayPainter(this.snap, this.hovered); + final HrDaySnapshot snap; + final int? hovered; + + static const _padLeft = 26.0; + static const _padTop = 8.0; + static const _padBottom = 20.0; + + @override + void paint(Canvas canvas, Size size) { + final buckets = snap.buckets; + if (buckets.isEmpty) return; + + final axisMin = (snap.minBpm / 10).floor() * 10.0 - 5; + final axisMax = (snap.maxBpm / 10).ceil() * 10.0 + 5; + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + + double yFor(double v) => + _padTop + chartH - ((v - axisMin) / (axisMax - axisMin)) * chartH; + + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var v = (axisMin / 30).ceil() * 30.0; v <= axisMax; v += 30) { + final y = yFor(v); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}', style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + final slotW = chartW / buckets.length; + final barW = (slotW - 1).clamp(1.4, slotW); + + for (var i = 0; i < buckets.length; i++) { + final b = buckets[i]; + final x = _padLeft + i * slotW; + final dim = hovered != null && hovered != i; + final rect = Rect.fromLTWH(x + 0.5, yFor(b.maxBpm.toDouble()), barW, + max(yFor(b.minBpm.toDouble()) - yFor(b.maxBpm.toDouble()), 2)); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(rect) + ..color = Colors.white.withValues(alpha: dim ? 0.3 : 0.8); + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1.5)), paint); + } + + // Resting line. + if (snap.restingBpm != null) { + final ry = yFor(snap.restingBpm!.toDouble()); + final p = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.7) + ..strokeWidth = 1; + for (var x = _padLeft; x < size.width - 4; x += 8) { + canvas.drawLine(Offset(x, ry), Offset(x + 5, ry), p); + } + } + + // X-axis time labels (12a / 6a / 12p / 6p / 11p). + final labelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + const marks = ['12a', '6a', '12p', '6p', '11p']; + for (var i = 0; i < marks.length; i++) { + final x = _padLeft + (i / (marks.length - 1)) * chartW; + final tp = TextPainter( + text: TextSpan(text: marks[i], style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((x - tp.width / 2).clamp(0, size.width - tp.width), size.height - _padBottom + 5)); + } + + // Tooltip. + if (hovered != null) { + final b = buckets[hovered!]; + final t = b.windowStart.toUtc().add(const Duration(hours: 5, minutes: 30)); + final h12 = t.hour == 0 ? 12 : (t.hour > 12 ? t.hour - 12 : t.hour); + final mm = t.minute.toString().padLeft(2, '0'); + final ap = t.hour < 12 ? 'AM' : 'PM'; + final lines = ['$h12:$mm $ap', '${b.minBpm}–${b.maxBpm} bpm', 'avg ${b.avgBpm.round()}']; + final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final painters = lines + .map((l) => TextPainter(text: TextSpan(text: l, style: lineStyle), textDirection: TextDirection.ltr)..layout()) + .toList(); + const padH = 8.0, padV = 6.0, lineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + padH * 2; + final ttH = painters.length * lineH + padV * 2; + final cx = _padLeft + hovered! * slotW + slotW / 2; + final ttX = (cx - ttW / 2).clamp(_padLeft, size.width - 4 - ttW); + const ttY = _padTop + 2.0; + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = AppColors.secondary.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + for (var i = 0; i < painters.length; i++) { + painters[i].paint(canvas, Offset(ttX + padH, ttY + padV + i * lineH)); + } + } + } + + @override + bool shouldRepaint(_HrDayPainter old) => old.snap != snap || old.hovered != hovered; +} + +// ── Interactive chart shell + painter ───────────────────────────────────────── + +class _AggBarChart extends StatefulWidget { + const _AggBarChart({ + required this.bars, + required this.axisMin, + required this.axisMax, + required this.gridStep, + required this.axisLabel, + required this.height, + this.goalLine, + this.rangeGradient = false, + }); + + final List<_AggBar> bars; + final double axisMin; + final double axisMax; + final double gridStep; + final String Function(double) axisLabel; + final double? goalLine; + final bool rangeGradient; + final double height; + + @override + State<_AggBarChart> createState() => _AggBarChartState(); +} + +class _AggBarChartState extends State<_AggBarChart> { + int? _hovered; + + static const _padLeft = 26.0; + + int? _indexAt(Offset local, double width) { + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW || widget.bars.isEmpty) return null; + final idx = (x / chartW * widget.bars.length).floor(); + return idx.clamp(0, widget.bars.length - 1); + } + + @override + Widget build(BuildContext context) { + if (widget.bars.isEmpty) { + return SizedBox( + height: widget.height, + child: Center( + child: Text( + 'No data for this range.', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + ), + ), + ); + } + return SizedBox( + height: widget.height, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hovered = null), + onPanUpdate: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hovered = null), + onPanCancel: () => setState(() => _hovered = null), + child: CustomPaint( + size: Size(width, widget.height), + painter: _AggPainter( + bars: widget.bars, + axisMin: widget.axisMin, + axisMax: widget.axisMax, + gridStep: widget.gridStep, + axisLabel: widget.axisLabel, + goalLine: widget.goalLine, + rangeGradient: widget.rangeGradient, + hovered: _hovered, + ), + ), + ); + }, + ), + ); + } +} + +class _AggPainter extends CustomPainter { + _AggPainter({ + required this.bars, + required this.axisMin, + required this.axisMax, + required this.gridStep, + required this.axisLabel, + required this.goalLine, + required this.rangeGradient, + required this.hovered, + }); + + final List<_AggBar> bars; + final double axisMin; + final double axisMax; + final double gridStep; + final String Function(double) axisLabel; + final double? goalLine; + final bool rangeGradient; + final int? hovered; + + static const _padLeft = 26.0; + static const _padTop = 8.0; + static const _padBottom = 20.0; + + @override + void paint(Canvas canvas, Size size) { + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + final n = bars.length; + final slotW = chartW / n; + final gap = (slotW * 0.32).clamp(2.0, 7.0); + final barW = slotW - gap; + + double yFor(double v) => + _padTop + chartH - ((v - axisMin) / (axisMax - axisMin)) * chartH; + + // Grid + Y labels. + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var v = (axisMin / gridStep).ceil() * gridStep; v <= axisMax; v += gridStep) { + final y = yFor(v); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: axisLabel(v), style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + // Goal line (sleep). + if (goalLine != null && goalLine! >= axisMin && goalLine! <= axisMax) { + final gy = yFor(goalLine!); + final p = Paint() + ..color = kSleepStageColors['awake']!.withValues(alpha: 0.8) + ..strokeWidth = 1; + for (var x = _padLeft; x < size.width - 4; x += 7) { + canvas.drawLine(Offset(x, gy), Offset(x + 4, gy), p); + } + } + + final baselineY = yFor(axisMin); + final labelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final labelEvery = n > 16 ? 5 : (n > 10 ? 2 : 1); + + for (var i = 0; i < n; i++) { + final bar = bars[i]; + final x = _padLeft + i * slotW + gap / 2; + final dim = hovered != null && hovered != i; + + if (bar.hasData) { + for (final seg in bar.segments) { + final yTop = yFor(seg.to); + final yBot = yFor(seg.from); + final paint = Paint()..style = PaintingStyle.fill; + if (rangeGradient) { + paint.shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(Rect.fromLTWH(x, yTop, barW, max(yBot - yTop, 2))); + paint.color = Colors.white.withValues(alpha: dim ? 0.3 : 0.85); + } else { + paint.color = seg.color.withValues(alpha: dim ? 0.3 : 0.88); + } + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, yTop, barW, max(yBot - yTop, 2)), + const Radius.circular(2), + ), + paint, + ); + } + + // Resting marker dot. + if (bar.marker != null) { + final my = yFor(bar.marker!); + canvas.drawCircle( + Offset(x + barW / 2, my), + 2.6, + Paint()..color = AppColors.secondary.withValues(alpha: dim ? 0.4 : 1), + ); + canvas.drawCircle( + Offset(x + barW / 2, my), + 2.6, + Paint() + ..color = AppColors.background + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + } + } + + // Workout-day highlight underline. + if (bar.isWorkout) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x - 1, baselineY + 2, barW + 2, 2.5), + const Radius.circular(1), + ), + Paint()..color = AppColors.accent.withValues(alpha: 0.9), + ); + } + + // X label (subset). + if (i % labelEvery == 0) { + final tp = TextPainter( + text: TextSpan(text: bar.label, style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(x + barW / 2 - tp.width / 2, size.height - _padBottom + 5), + ); + } + } + + // Tooltip. + if (hovered != null) { + _paintTooltip(canvas, size, hovered!, slotW, yFor); + } + } + + void _paintTooltip(Canvas canvas, Size size, int idx, double slotW, double Function(double) yFor) { + final bar = bars[idx]; + final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final painters = bar.tooltip + .map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()) + .toList(); + + const padH = 8.0, padV = 6.0, lineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + padH * 2; + final ttH = painters.length * lineH + padV * 2; + + final barCx = _padLeft + idx * slotW + slotW / 2; + var ttX = (barCx - ttW / 2).clamp(_padLeft, size.width - 4 - ttW); + var ttY = _padTop + 2.0; + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = AppColors.primary.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + for (var i = 0; i < painters.length; i++) { + painters[i].paint(canvas, Offset(ttX + padH, ttY + padV + i * lineH)); + } + } + + @override + bool shouldRepaint(_AggPainter old) => old.bars != bars || old.hovered != hovered; +} diff --git a/workout-logger/lib/screens/widgets/health_detail_shell.dart b/workout-logger/lib/screens/widgets/health_detail_shell.dart new file mode 100644 index 0000000..0529699 --- /dev/null +++ b/workout-logger/lib/screens/widgets/health_detail_shell.dart @@ -0,0 +1,205 @@ +// health_detail_shell.dart — shared scaffold for the Sleep & Heart-rate detail +// screens: ambient background, back button, title, prev/next date nav, and the +// Day/Week/Month/Year granularity toggle. The body is supplied by each screen. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class HealthDetailShell extends StatelessWidget { + const HealthDetailShell({ + super.key, + required this.title, + required this.icon, + required this.iconColor, + required this.dateLabel, + required this.granularity, + required this.onGranularityChanged, + required this.onPrev, + required this.onNext, + required this.canGoNext, + required this.child, + }); + + final String title; + final IconData icon; + final Color iconColor; + final String dateLabel; + final HealthGranularity granularity; + final ValueChanged onGranularityChanged; + final VoidCallback onPrev; + final VoidCallback onNext; + final bool canGoNext; + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const Positioned.fill(child: AmbientGlow()), + SafeArea( + child: Column( + children: [ + _header(context), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _GranularityToggle( + value: granularity, + onChanged: onGranularityChanged, + ), + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), + child: child, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, color: AppColors.textSoft), + tooltip: 'Back', + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _NavArrow(icon: Icons.chevron_left_rounded, onTap: onPrev), + const SizedBox(width: 12), + Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: iconColor), + const SizedBox(width: 5), + Text( + title, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ], + ), + const SizedBox(height: 1), + Text( + dateLabel, + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + const SizedBox(width: 12), + _NavArrow( + icon: Icons.chevron_right_rounded, + onTap: canGoNext ? onNext : null, + ), + ], + ), + ), + const SizedBox(width: 40), // balance the back button + ], + ), + ); + } +} + +class _NavArrow extends StatelessWidget { + const _NavArrow({required this.icon, this.onTap}); + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return GestureDetector( + onTap: onTap, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + size: 18, + color: enabled ? AppColors.textMuted : AppColors.textFaint.withValues(alpha: 0.4), + ), + ), + ); + } +} + +class _GranularityToggle extends StatelessWidget { + const _GranularityToggle({required this.value, required this.onChanged}); + + final HealthGranularity value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: HealthGranularity.values.map((g) { + final active = g == value; + return Expanded( + child: GestureDetector( + onTap: () => onChanged(g), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: active ? AppColors.primary.withValues(alpha: 0.16) : Colors.transparent, + borderRadius: BorderRadius.circular(9), + border: active + ? Border.all(color: AppColors.primary.withValues(alpha: 0.5)) + : Border.all(color: Colors.transparent), + ), + alignment: Alignment.center, + child: Text( + g.label, + style: GoogleFonts.geist( + fontSize: 12, + fontWeight: FontWeight.w600, + color: active ? AppColors.textPrimary : AppColors.textMuted, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/heart_rate_card.dart b/workout-logger/lib/screens/widgets/heart_rate_card.dart new file mode 100644 index 0000000..4487caf --- /dev/null +++ b/workout-logger/lib/screens/widgets/heart_rate_card.dart @@ -0,0 +1,193 @@ +// HeartRateCard — compact all-day HR summary on the dashboard. +// +// Self-hiding: renders SizedBox.shrink() when ReadinessManager has no +// HrDaySnapshot, mirroring SleepHrCard. + +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import '../heart_rate_detail_screen.dart'; +import 'rf_widgets.dart'; + +class HeartRateCard extends StatelessWidget { + const HeartRateCard({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snap = manager.hrDaySnapshot; + if (snap == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + borderColor: AppColors.secondary.withValues(alpha: 0.20), + onTap: () => Navigator.of(context).push( + slideRoute(const HeartRateDetailScreen()), + ), + semanticsLabel: 'Heart rate, resting ${snap.restingBpm ?? '--'} bpm', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.favorite_rounded, size: 13, color: AppColors.accent), + const SizedBox(width: 5), + Text( + 'Heart rate', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Today · all-day', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textFaint, size: 20), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _MiniStat( + label: 'Resting', + value: snap.restingBpm?.toString() ?? '—', + unit: 'bpm', + color: AppColors.secondary, + ), + _MiniStat(label: 'Min', value: '${snap.minBpm}', unit: 'bpm', color: AppColors.textMuted), + _MiniStat(label: 'Max', value: '${snap.maxBpm}', unit: 'bpm', color: AppColors.accent), + _MiniStat(label: 'Avg', value: '${snap.avgBpm.round()}', unit: 'bpm', color: AppColors.primary), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 44, + child: CustomPaint( + size: const Size(double.infinity, 44), + painter: _HrSparkline(snap), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: ['12a', '6a', '12p', '6p', 'now'] + .map((l) => Text(l, style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8))) + .toList(), + ), + ], + ), + ), + ); + } +} + +class _MiniStat extends StatelessWidget { + const _MiniStat({required this.label, required this.value, required this.unit, required this.color}); + + final String label; + final String value; + final String unit; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + const SizedBox(height: 1), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: GoogleFonts.geistMono( + color: color, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + TextSpan( + text: ' $unit', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Compact all-day HR sparkline: min–max range bars + resting baseline. +class _HrSparkline extends CustomPainter { + const _HrSparkline(this.snap); + + final HrDaySnapshot snap; + + @override + void paint(Canvas canvas, Size size) { + final buckets = snap.buckets; + if (buckets.isEmpty) return; + + final lo = snap.minBpm.toDouble() - 4; + final hi = snap.maxBpm.toDouble() + 4; + double yFor(double v) => size.height - ((v - lo) / (hi - lo)) * size.height; + + final n = buckets.length; + final barW = size.width / n; + + for (var i = 0; i < n; i++) { + final b = buckets[i]; + final x = i * barW; + final yTop = yFor(b.maxBpm.toDouble()); + final yBot = yFor(b.minBpm.toDouble()); + final rect = Rect.fromLTWH(x + 0.5, yTop, max(barW - 1, 1), max(yBot - yTop, 2)); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(rect) + ..color = Colors.white.withValues(alpha: 0.7); + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1)), paint); + } + + if (snap.restingBpm != null) { + final ry = yFor(snap.restingBpm!.toDouble()); + final p = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.6) + ..strokeWidth = 1; + for (var x = 0.0; x < size.width; x += 6) { + canvas.drawLine(Offset(x, ry), Offset(min(x + 3, size.width), ry), p); + } + } + } + + @override + bool shouldRepaint(_HrSparkline old) => old.snap != snap; +} diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index d86bfd0..78000c5 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; +import '../../services/debug_log_buffer.dart'; import '../../services/settings_provider.dart'; import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; @@ -237,11 +238,15 @@ class HealthConnectSection extends StatelessWidget { required this.settings, required this.isLoading, required this.onToggle, + required this.isReadinessLoading, + required this.onReadinessToggle, }); final SettingsProvider settings; final bool isLoading; final Future Function(bool) onToggle; + final bool isReadinessLoading; + final Future Function(bool) onReadinessToggle; static const _hcColor = Color(0xFF00BFA5); @@ -303,6 +308,43 @@ class HealthConnectSection extends StatelessWidget { ], ), ], + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Readiness insights', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Reads sleep & heart data to score daily recovery', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: settings.readinessEnabled, + onChanged: + isReadinessLoading ? null : (v) => onReadinessToggle(v), + activeThumbColor: _hcColor, + activeTrackColor: _hcColor.withValues(alpha: 0.35), + ), + ], + ), ], ), ); @@ -433,10 +475,37 @@ class CloudSyncSection extends StatelessWidget { } // ── About section ───────────────────────────────────────────────────────────── -class AboutSection extends StatelessWidget { +class AboutSection extends StatefulWidget { const AboutSection({super.key, required this.appVersion}); final String appVersion; + @override + State createState() => _AboutSectionState(); +} + +class _AboutSectionState extends State { + int _versionTaps = 0; + + void _onVersionTap() { + _versionTaps++; + if (_versionTaps >= 5) { + _versionTaps = 0; + _showDebugLogs(context); + } + } + + void _showDebugLogs(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => const _DebugLogSheet(), + ); + } + @override Widget build(BuildContext context) { return _ProfileSection( @@ -446,10 +515,13 @@ class AboutSection extends StatelessWidget { subtitle: 'RepForge Workout Logger', child: Column( children: [ - _InfoTile( - label: 'Version', - value: appVersion, - icon: Icons.tag_rounded, + GestureDetector( + onTap: _onVersionTap, + child: _InfoTile( + label: 'Version', + value: widget.appVersion, + icon: Icons.tag_rounded, + ), ), const _SectionDivider(), _InfoTile( @@ -992,6 +1064,98 @@ String _formatInt(int n) { return buf.toString(); } +// ── Debug log viewer (tap version 5× to open) ──────────────────────────────── +class _DebugLogSheet extends StatelessWidget { + const _DebugLogSheet(); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.md, AppSpacing.sm, AppSpacing.sm, 0), + child: Row( + children: [ + Text( + 'Debug Logs', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const Spacer(), + TextButton( + onPressed: () => DebugLogBuffer.instance.clear(), + child: Text( + 'Clear', + style: GoogleFonts.geist( + color: AppColors.accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: AppColors.textMuted, size: 18), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: AppColors.glassBorder, height: 1), + Expanded( + child: ListenableBuilder( + listenable: DebugLogBuffer.instance, + builder: (context, _) { + final lines = DebugLogBuffer.instance.lines; + if (lines.isEmpty) { + return Center( + child: Text( + 'No logs yet', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13), + ), + ); + } + return ListView.builder( + controller: scrollController, + reverse: true, + padding: const EdgeInsets.all(AppSpacing.sm), + itemCount: lines.length, + itemBuilder: (context, i) { + final line = lines[lines.length - 1 - i]; + final isHc = line.contains('[HC]'); + final isReadiness = line.contains('[Readiness]'); + final color = isHc + ? AppColors.secondary + : isReadiness + ? AppColors.primary + : AppColors.textSoft; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + line, + style: GoogleFonts.geistMono(fontSize: 10, color: color), + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +} + class _ComingSoonBadge extends StatelessWidget { const _ComingSoonBadge(); diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart new file mode 100644 index 0000000..2c42625 --- /dev/null +++ b/workout-logger/lib/screens/widgets/readiness_card.dart @@ -0,0 +1,326 @@ +// ReadinessCard — daily training-readiness summary on the dashboard. +// +// Self-hiding: renders nothing until ReadinessManager has a scored snapshot, +// so the dashboard needs no conditional logic and users without watch data +// (or with the feature disabled) never see an empty state. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/interfaces/readiness_manager_interface.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class ReadinessCard extends StatelessWidget { + const ReadinessCard({super.key}); + + // Per-component color thresholds, aligned with ReadinessCalculator bands. + static const int _goodScore = 75; + static const int _okScore = 50; + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snapshot = manager.snapshot; + debugPrint('[ReadinessCard] build: status=${manager.status} score=${snapshot?.score} band=${snapshot?.band}'); + + final bool hasScore = manager.status == ReadinessStatus.ready && + snapshot != null && + snapshot.score != null && + snapshot.band != null; + + if (!hasScore) { + return const SizedBox.shrink(); + } + + final color = _bandColor(snapshot.band!); + + return _buildMainCard(context, snapshot, color); + } + + Widget _buildMainCard( + BuildContext context, + ReadinessSnapshot snapshot, + Color color, + ) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + glowColor: color, + onTap: () => _showDetails(context, snapshot), + semanticsLabel: 'Readiness ${snapshot.score} out of 100', + child: Row( + children: [ + _ScoreRing(score: snapshot.score!, color: color), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headline(snapshot.band!), + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 3), + Text( + _subtitle(snapshot), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 20, + ), + ], + ), + ), + ); + } + + static Color _bandColor(ReadinessBand band) => switch (band) { + ReadinessBand.high => AppColors.success, + ReadinessBand.moderate => AppColors.warning, + ReadinessBand.low => AppColors.error, + }; + + static String _headline(ReadinessBand band) => switch (band) { + ReadinessBand.high => 'Primed — good day to push', + ReadinessBand.moderate => 'Train as planned', + ReadinessBand.low => 'Take it easy today', + }; + + /// One line of evidence from the weakest available component. + static String _subtitle(ReadinessSnapshot s) { + final parts = <(int, String)>[ + if (s.sleepScore != null) + ( + s.sleepScore!, + 'Sleep ${_fmtSleep(s.sleepMinutes!)} vs ${_fmtSleep(s.sleepBaselineMinutes!.round())} avg' + ), + if (s.rhrScore != null) + ( + s.rhrScore!, + 'Resting HR ${s.restingHr!.round()} vs ${s.rhrBaseline!.round()} avg' + ), + if (s.hrvScore != null) + ( + s.hrvScore!, + 'HRV ${s.hrvMs!.round()}ms vs ${s.hrvBaseline!.round()}ms avg' + ), + ]; + parts.sort((a, b) => a.$1.compareTo(b.$1)); + return parts.first.$2; + } + + static String _fmtSleep(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return m == 0 ? '${h}h' : '${h}h ${m.toString().padLeft(2, '0')}m'; + } + + void _showDetails(BuildContext context, ReadinessSnapshot snapshot) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => _ReadinessDetailsSheet(snapshot: snapshot), + ); + } +} + +class _ScoreRing extends StatelessWidget { + const _ScoreRing({required this.score, required this.color}); + + final int score; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 52, + height: 52, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 52, + height: 52, + child: CircularProgressIndicator( + value: score / 100, + strokeWidth: 4, + strokeCap: StrokeCap.round, + backgroundColor: AppColors.glass3, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + Text( + '$score', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _ReadinessDetailsSheet extends StatelessWidget { + const _ReadinessDetailsSheet({required this.snapshot}); + + final ReadinessSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final time = TimeOfDay.fromDateTime(snapshot.computedAt).format(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Readiness · ${snapshot.score}', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + 'As of $time, from your watch via Health Connect', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 18), + if (snapshot.sleepScore != null) + _ComponentRow( + label: 'Sleep', + value: + '${ReadinessCard._fmtSleep(snapshot.sleepMinutes!)} · avg ${ReadinessCard._fmtSleep(snapshot.sleepBaselineMinutes!.round())}', + score: snapshot.sleepScore!, + ), + if (snapshot.rhrScore != null) + _ComponentRow( + label: 'Resting heart rate', + value: + '${snapshot.restingHr!.round()} bpm · avg ${snapshot.rhrBaseline!.round()} bpm', + score: snapshot.rhrScore!, + ), + if (snapshot.hrvScore != null) + _ComponentRow( + label: 'HRV (RMSSD)', + value: + '${snapshot.hrvMs!.round()} ms · avg ${snapshot.hrvBaseline!.round()} ms', + score: snapshot.hrvScore!, + ), + const SizedBox(height: 14), + Text( + 'Each factor compares last night and this morning to your own ' + '14-day average — only dips below your normal lower the score. ' + 'Accuracy improves after about 5 nights of watch data.', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} + + +class _ComponentRow extends StatelessWidget { + const _ComponentRow({ + required this.label, + required this.value, + required this.score, + }); + + final String label; + final String value; + final int score; + + @override + Widget build(BuildContext context) { + final color = score >= ReadinessCard._goodScore + ? AppColors.success + : score >= ReadinessCard._okScore + ? AppColors.warning + : AppColors.error; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + value, + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: score / 100, + minHeight: 5, + backgroundColor: AppColors.glass2, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index c470f99..c99ea32 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -8,6 +8,22 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; +// ── Route helper ────────────────────────────────────────────────────────────── +// Right-to-left slide push, shared by the home screen and detail entry points. +PageRouteBuilder slideRoute(Widget page) { + return PageRouteBuilder( + pageBuilder: (_, __, ___) => page, + transitionsBuilder: (_, anim, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 300), + ); +} + // ── GlassCard ─────────────────────────────────────────────────────────────── // Soft-futurist glass card — gradient top-to-bottom + subtle inner highlight. class GlassCard extends StatelessWidget { diff --git a/workout-logger/lib/screens/widgets/session_details_sheet.dart b/workout-logger/lib/screens/widgets/session_details_sheet.dart index 9e20c3e..2e9fb71 100644 --- a/workout-logger/lib/screens/widgets/session_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/session_details_sheet.dart @@ -9,6 +9,7 @@ import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import 'workout_hr_section.dart'; const Color _hcColor = Color(0xFF4ECDC4); @@ -163,6 +164,9 @@ class SessionDetailsSheet extends StatelessWidget { (log) => _ExerciseDetailCard(log: log, provider: provider), ), + // HR + rest-recovery breakdown (self-hides when no HR data). + WorkoutHrSection(session: session, provider: provider), + if (session.notes != null && session.notes!.isNotEmpty) ...[ const SizedBox(height: AppSpacing.md), const RFSectionHeader('Notes'), diff --git a/workout-logger/lib/screens/widgets/sleep_hr_card.dart b/workout-logger/lib/screens/widgets/sleep_hr_card.dart new file mode 100644 index 0000000..3e1af45 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sleep_hr_card.dart @@ -0,0 +1,260 @@ +// SleepHrCard — compact overnight-HR summary on the dashboard. +// +// Self-hiding: renders SizedBox.shrink() when ReadinessManager has no +// SleepHrSnapshot, so the dashboard needs no conditional logic. + +import 'dart:math' show min; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import '../sleep_detail_screen.dart'; +import 'rf_widgets.dart'; +import 'sleep_hr_charts.dart' show kSleepStageColors; + +class SleepHrCard extends StatelessWidget { + const SleepHrCard({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snap = manager.sleepHrSnapshot; + if (snap == null) return const SizedBox.shrink(); + + final remAvg = snap.statsFor('rem')?.avgBpm; + final deepAvg = snap.statsFor('deep')?.avgBpm; + + final startFmt = _fmtTime(_toIst(snap.sleepStart)); + final endFmt = _fmtTime(_toIst(snap.sleepEnd)); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + onTap: () => _openSheet(context, snap), + semanticsLabel: 'Sleep heart rate, P95 ${snap.p95Bpm} bpm', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sleep heart rate', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 2), + Text( + 'Last night · $startFmt – $endFmt', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 20, + ), + ], + ), + const SizedBox(height: 10), + // Mini-stats row + Row( + children: [ + _MiniStat( + label: 'P5', + value: '${snap.p5Bpm}', + unit: 'bpm', + color: AppColors.success, + ), + _MiniStat( + label: 'P95', + value: '${snap.p95Bpm}', + unit: 'bpm', + color: AppColors.primary, + ), + if (deepAvg != null) + _MiniStat( + label: 'Deep avg', + value: deepAvg.round().toString(), + unit: 'bpm', + color: kSleepStageColors['deep']!, + ), + if (remAvg != null) + _MiniStat( + label: 'REM avg', + value: remAvg.round().toString(), + unit: 'bpm', + color: kSleepStageColors['rem']!, + ), + ], + ), + const SizedBox(height: 8), + // Sparkline + SizedBox( + height: 44, + child: CustomPaint( + size: const Size(double.infinity, 44), + painter: _SparklinePainter(snap.segments), + ), + ), + ], + ), + ), + ); + } + + void _openSheet(BuildContext context, SleepHrSnapshot snap) { + // Land on the night this snapshot represents (handles the watch-not-synced + // fallback where it's the night before last). + Navigator.of(context).push( + slideRoute(SleepDetailScreen(initialDate: snap.sleepEnd)), + ); + } + + static DateTime _toIst(DateTime dt) => + dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + + static String _fmtTime(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + return '$h:$m $period'; + } +} + +class _MiniStat extends StatelessWidget { + const _MiniStat({ + required this.label, + required this.value, + required this.unit, + required this.color, + }); + + final String label; + final String value; + final String unit; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 10, + ), + ), + const SizedBox(height: 1), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: GoogleFonts.geistMono( + color: color, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + TextSpan( + text: ' $unit', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Draws the compact sparkline: coloured low/high bars + moving-average line. +class _SparklinePainter extends CustomPainter { + const _SparklinePainter(this.segments); + + final List segments; + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); + final bpmMin = allBpms.reduce(min).toDouble() - 4; + final bpmMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble() + 4; + + double yFor(double bpm) => + size.height - ((bpm - bpmMin) / (bpmMax - bpmMin)) * size.height; + + final n = segments.length; + final barW = size.width / n; + + // Draw bars + for (var i = 0; i < n; i++) { + final seg = segments[i]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final paint = Paint() + ..color = color.withValues(alpha: 0.75) + ..style = PaintingStyle.fill; + final x = i * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x + 0.5, yTop, barW - 1, (yBot - yTop).clamp(2, double.infinity)), + const Radius.circular(1.5), + ); + canvas.drawRRect(rect, paint); + } + + // Moving-average trend line (window = 5) + final linePaint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.85) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final path = Path(); + for (var i = 0; i < n; i++) { + final start = (i - 4).clamp(0, n - 1); + final slice = segments.sublist(start, i + 1); + final ma = slice.map((s) => s.avgBpm).reduce((a, b) => a + b) / slice.length; + final x = i * barW + barW / 2; + final y = yFor(ma); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + // Draw as solid for the compact sparkline — dashes not worth the complexity at 44dp. + canvas.drawPath(path, linePaint..style = PaintingStyle.stroke); + } + + @override + bool shouldRepaint(_SparklinePainter old) => old.segments != segments; +} diff --git a/workout-logger/lib/screens/widgets/sleep_hr_charts.dart b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart new file mode 100644 index 0000000..1c2c1e2 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart @@ -0,0 +1,708 @@ +// sleep_hr_charts.dart — reusable overnight-HR chart widgets. +// +// Extracted from the old SleepHrSheet so the Day tab of SleepDetailScreen and +// the dashboard card can share the same painters. `SleepHrDayView` composes the +// full day breakdown (stat pills + interactive bar chart + stage timeline + +// legend + HR-range-by-stage distribution). + +import 'dart:math' show min, max; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; + +/// Stage colour map — shared across the sleep widgets. +const Map kSleepStageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; + +const _stageOrder = ['awake', 'rem', 'light', 'deep']; +const _stageLabels = { + 'awake': 'Awake', + 'rem': 'REM', + 'light': 'Light', + 'deep': 'Deep', +}; + +DateTime _toIst(DateTime dt) => dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + +/// Full Day-view breakdown for one overnight HR snapshot. +class SleepHrDayView extends StatelessWidget { + const SleepHrDayView({super.key, required this.snapshot}); + + final SleepHrSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final remAvg = snapshot.statsFor('rem')?.avgBpm; + final deepAvg = snapshot.statsFor('deep')?.avgBpm; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stat pills row + Row( + children: [ + _StatPill(label: 'P5', value: '${snapshot.p5Bpm} bpm', color: AppColors.success), + const SizedBox(width: 6), + _StatPill(label: 'P95', value: '${snapshot.p95Bpm} bpm', color: AppColors.primary), + if (deepAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'Deep avg', + value: '${deepAvg.round()} bpm', + color: kSleepStageColors['deep']!, + ), + ], + if (remAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'REM avg', + value: '${remAvg.round()} bpm', + color: kSleepStageColors['rem']!, + ), + ], + ], + ), + const SizedBox(height: 20), + + Text( + 'Heart rate during sleep · 10-min bars', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 8), + _InteractiveBarChart(segments: snapshot.segments), + const SizedBox(height: 6), + _StageTimelineStrip(segments: snapshot.segments), + const SizedBox(height: 8), + _Legend(), + const SizedBox(height: 24), + + Text( + 'HR range by stage', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 10), + _StageDistributionChart( + stats: snapshot.stageStats, + stageOrder: _stageOrder, + stageLabels: _stageLabels, + ), + const SizedBox(height: 10), + _DistLegend(), + ], + ); + } +} + +// ── Stat pill ───────────────────────────────────────────────────────────────── + +class _StatPill extends StatelessWidget { + const _StatPill({required this.label, required this.value, required this.color}); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + Text( + value, + style: GoogleFonts.geistMono(color: color, fontSize: 14, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +// ── Interactive bar chart ───────────────────────────────────────────────────── + +class _InteractiveBarChart extends StatefulWidget { + const _InteractiveBarChart({required this.segments}); + final List segments; + + @override + State<_InteractiveBarChart> createState() => _InteractiveBarChartState(); +} + +class _InteractiveBarChartState extends State<_InteractiveBarChart> { + int? _hoveredIndex; + + static const _chartHeight = 160.0; + static const _padLeft = 28.0; + + int? _indexAt(Offset local, double width) { + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW) return null; + final idx = (x / chartW * widget.segments.length).floor(); + return idx.clamp(0, widget.segments.length - 1); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _chartHeight, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hoveredIndex = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hoveredIndex = null), + onPanUpdate: (d) => setState(() => _hoveredIndex = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hoveredIndex = null), + onPanCancel: () => setState(() => _hoveredIndex = null), + child: CustomPaint( + size: Size(width, _chartHeight), + painter: _BarChartPainter( + segments: widget.segments, + hoveredIndex: _hoveredIndex, + ), + ), + ); + }, + ), + ); + } +} + +class _BarChartPainter extends CustomPainter { + const _BarChartPainter({required this.segments, this.hoveredIndex}); + + final List segments; + final int? hoveredIndex; + + static const _padLeft = 28.0; + static const _padTop = 6.0; + static const _padBottom = 22.0; + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); + final rawMin = allBpms.reduce(min).toDouble(); + final rawMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble(); + final bpmMin = (rawMin / 10).floor() * 10.0 - 5; + final bpmMax = (rawMax / 10).ceil() * 10.0 + 5; + + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + final n = segments.length; + final barW = chartW / n; + + double yFor(double bpm) => + _padTop + chartH - ((bpm - bpmMin) / (bpmMax - bpmMin)) * chartH; + + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + + final gridBpms = []; + for (var b = (bpmMin ~/ 10) * 10; b <= bpmMax; b += 10) { + gridBpms.add(b); + } + for (final bpm in gridBpms) { + final y = yFor(bpm.toDouble()); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '$bpm', style: yLabelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + for (var i = 0; i < n; i++) { + final seg = segments[i]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final alpha = (hoveredIndex == null || hoveredIndex == i) ? 0.78 : 0.28; + final paint = Paint() + ..color = color.withValues(alpha: alpha) + ..style = PaintingStyle.fill; + final x = _padLeft + i * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), + const Radius.circular(1.5), + ), + paint, + ); + } + + final avgPaint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.9) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path(); + for (var i = 0; i < n; i++) { + final sl = segments.sublist(max(0, i - 4), i + 1); + final ma = sl.map((s) => s.avgBpm).reduce((a, b) => a + b) / sl.length; + final x = _padLeft + i * barW + barW / 2; + final y = yFor(ma); + i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); + } + canvas.drawPath(path, avgPaint); + + final xLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var i = 0; i < n; i += 6) { + final t = _toIst(segments[i].windowStart); + final h = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final m = t.minute.toString().padLeft(2, '0'); + final tp = TextPainter( + text: TextSpan(text: '$h:$m', style: xLabelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(_padLeft + i * barW + barW / 2 - tp.width / 2, size.height - _padBottom + 5), + ); + } + + if (hoveredIndex != null) { + final idx = hoveredIndex!; + final seg = segments[idx]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final barX = _padLeft + idx * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(barX + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), + const Radius.circular(1.5), + ), + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + + final t = _toIst(seg.windowStart); + final tEnd = _toIst(seg.windowStart.add(const Duration(minutes: 10))); + final th = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final tm = t.minute.toString().padLeft(2, '0'); + final eh = tEnd.hour == 0 ? 12 : tEnd.hour > 12 ? tEnd.hour - 12 : tEnd.hour; + final em = tEnd.minute.toString().padLeft(2, '0'); + final stageName = const { + 'deep': 'Deep', + 'rem': 'REM', + 'light': 'Light', + 'awake': 'Awake', + }[seg.stage] ?? seg.stage; + + final lines = ['$th:$tm–$eh:$em IST', '${seg.minBpm}–${seg.maxBpm} bpm', stageName]; + final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final painters = lines + .map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()) + .toList(); + + const ttPadH = 8.0, ttPadV = 6.0, ttLineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + ttPadH * 2; + final ttH = painters.length * ttLineH + ttPadV * 2; + + var ttX = barX + barW / 2 - ttW / 2; + ttX = ttX.clamp(_padLeft, size.width - 4 - ttW); + var ttY = yTop - ttH - 6; + if (ttY < _padTop) ttY = yBot + 6; + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = color.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + + for (var i = 0; i < painters.length; i++) { + final p = painters[i]; + if (i == 2) { + final stagePainter = TextPainter( + text: TextSpan( + text: stageName, + style: GoogleFonts.geistMono(color: color, fontSize: 9.5, fontWeight: FontWeight.w700), + ), + textDirection: TextDirection.ltr, + )..layout(); + stagePainter.paint(canvas, Offset(ttX + ttPadH, ttY + ttPadV + i * ttLineH)); + } else { + p.paint(canvas, Offset(ttX + ttPadH, ttY + ttPadV + i * ttLineH)); + } + } + } + } + + @override + bool shouldRepaint(_BarChartPainter old) => + old.segments != segments || old.hoveredIndex != hoveredIndex; +} + +// ── Stage timeline strip ────────────────────────────────────────────────────── + +class _StageTimelineStrip extends StatelessWidget { + const _StageTimelineStrip({required this.segments}); + final List segments; + + @override + Widget build(BuildContext context) { + if (segments.isEmpty) return const SizedBox.shrink(); + return SizedBox( + height: 5, + child: Row( + children: segments.map((s) { + final color = kSleepStageColors[s.stage] ?? AppColors.primary; + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 0.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(1), + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +class _Legend extends StatelessWidget { + @override + Widget build(BuildContext context) { + final items = [ + ('Deep', kSleepStageColors['deep']!), + ('REM', kSleepStageColors['rem']!), + ('Light', kSleepStageColors['light']!), + ('Awake', kSleepStageColors['awake']!), + ]; + return Wrap( + spacing: 12, + runSpacing: 4, + children: [ + ...items.map((e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: e.$2, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 4), + Text(e.$1, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + )), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 14, height: 10, child: CustomPaint(painter: _DashLinePainter())), + const SizedBox(width: 4), + Text('Avg trend', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ), + ], + ); + } +} + +class _DashLinePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.85) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final y = size.height / 2; + for (var x = 0.0; x < size.width; x += 4) { + canvas.drawLine(Offset(x, y), Offset(min(x + 2.5, size.width), y), paint); + } + } + + @override + bool shouldRepaint(_DashLinePainter _) => false; +} + +// ── Stage distribution chart ────────────────────────────────────────────────── + +class _StageDistributionChart extends StatelessWidget { + const _StageDistributionChart({ + required this.stats, + required this.stageOrder, + required this.stageLabels, + }); + + final List stats; + final List stageOrder; + final Map stageLabels; + + @override + Widget build(BuildContext context) { + if (stats.isEmpty) { + return Text( + 'No stage HR data available.', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + ); + } + + final allMin = stats.map((s) => s.minBpm).reduce(min).toDouble() - 4; + final allMax = stats.map((s) => s.maxBpm).reduce(max).toDouble() + 4; + + final orderedStats = stageOrder + .map((k) => stats.where((s) => s.stage == k).firstOrNull) + .whereType() + .toList(); + + return Column( + children: [ + ...orderedStats.map((s) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _DistRow( + stats: s, + label: stageLabels[s.stage] ?? s.stage, + color: kSleepStageColors[s.stage] ?? AppColors.primary, + bpmMin: allMin, + bpmMax: allMax, + ), + )), + _DistAxis(bpmMin: allMin, bpmMax: allMax), + ], + ); + } +} + +class _DistRow extends StatelessWidget { + const _DistRow({ + required this.stats, + required this.label, + required this.color, + required this.bpmMin, + required this.bpmMax, + }); + + final SleepStageStats stats; + final String label; + final Color color; + final double bpmMin; + final double bpmMax; + + @override + Widget build(BuildContext context) { + double pct(double bpm) => ((bpm - bpmMin) / (bpmMax - bpmMin)).clamp(0.0, 1.0); + + return Row( + children: [ + SizedBox( + width: 40, + child: Text( + label, + textAlign: TextAlign.right, + style: GoogleFonts.geist(color: color, fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 28, + child: LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + return Stack( + children: [ + Positioned( + left: pct(stats.minBpm.toDouble()) * w, + width: (pct(stats.maxBpm.toDouble()) - pct(stats.minBpm.toDouble())) * w, + top: 7, + height: 14, + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(7), + ), + ), + ), + Positioned( + left: pct(stats.p25Bpm.toDouble()) * w, + width: (pct(stats.p75Bpm.toDouble()) - pct(stats.p25Bpm.toDouble())) * w, + top: 7, + height: 14, + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(7), + ), + ), + ), + Positioned( + left: pct(stats.avgBpm) * w - 4, + top: 10, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: AppColors.card, width: 1.5), + ), + ), + ), + Positioned( + left: (pct(stats.avgBpm) * w - 16).clamp(0, w - 32), + top: 0, + child: Text( + '${stats.avgBpm.round()} bpm', + style: GoogleFonts.geistMono( + color: color, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ], + ); + } +} + +class _DistAxis extends StatelessWidget { + const _DistAxis({required this.bpmMin, required this.bpmMax}); + + final double bpmMin; + final double bpmMax; + + @override + Widget build(BuildContext context) { + final ticks = []; + for (var b = (bpmMin / 5).ceil() * 5; b <= bpmMax; b += 5) { + ticks.add(b); + } + return Padding( + padding: const EdgeInsets.only(left: 48), + child: LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + double pct(double bpm) => ((bpm - bpmMin) / (bpmMax - bpmMin)).clamp(0.0, 1.0); + return SizedBox( + height: 16, + child: Stack( + children: ticks + .map((t) => Positioned( + left: (pct(t.toDouble()) * w - 10).clamp(0, w - 20), + child: Text( + '$t', + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8), + ), + )) + .toList(), + ), + ); + }, + ), + ); + } +} + +class _DistLegend extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _DistLi( + swatch: Container( + width: 16, + height: 8, + decoration: BoxDecoration( + color: AppColors.textMuted.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(4), + ), + ), + label: 'Min–max', + ), + _DistLi( + swatch: Container( + width: 16, + height: 8, + decoration: BoxDecoration( + color: AppColors.textMuted.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(4), + ), + ), + label: 'P25–P75', + ), + _DistLi( + swatch: Container( + width: 8, + height: 8, + decoration: const BoxDecoration(color: AppColors.textSoft, shape: BoxShape.circle), + ), + label: 'Avg', + ), + ], + ); + } +} + +class _DistLi extends StatelessWidget { + const _DistLi({required this.swatch, required this.label}); + final Widget swatch; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + swatch, + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/workout_hr_section.dart b/workout-logger/lib/screens/widgets/workout_hr_section.dart new file mode 100644 index 0000000..c4b6df7 --- /dev/null +++ b/workout-logger/lib/screens/widgets/workout_hr_section.dart @@ -0,0 +1,436 @@ +// workout_hr_section.dart — per-workout HR breakdown for the History session +// sheet. Self-hides when Health Connect has no HR data for the workout window. +// +// Shows: avg/peak/min pills, an HR curve with exercise-section flags + rest +// shading (green = HR recovered, amber = didn't), a recovery summary, and an +// expandable per-rest table. + +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../models/workout_hr_models.dart'; +import '../../services/managers/health_history_manager.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class WorkoutHrSection extends StatefulWidget { + const WorkoutHrSection({super.key, required this.session, required this.provider}); + + final WorkoutSession session; + final WorkoutProvider provider; + + @override + State createState() => _WorkoutHrSectionState(); +} + +class _WorkoutHrSectionState extends State { + Future? _future; + bool _expanded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= context.read().workoutHr(widget.session); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done || snap.data == null) { + // Self-hide while loading and when there's no HR data. + return const SizedBox.shrink(); + } + final a = snap.data!; + final sections = a.exercises + .map((e) => _Section( + label: _shortName(widget.provider.getExercise(e.exerciseId)?.name ?? '—'), + start: e.start, + end: e.end, + )) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Heart rate'), + const SizedBox(height: AppSpacing.sm), + GlassCard( + padding: const EdgeInsets.all(14), + borderColor: AppColors.accent.withValues(alpha: 0.18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Pill(label: 'Avg', value: '${a.avgBpm}', color: AppColors.primary), + const SizedBox(width: 6), + _Pill(label: 'Peak', value: '${a.peakBpm}', color: AppColors.accent), + const SizedBox(width: 6), + _Pill(label: 'Min', value: '${a.minBpm}', color: AppColors.secondary), + ], + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'HR across the session · ⚑ = exercise', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + ), + Text('bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11)), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 150, + child: CustomPaint( + size: const Size(double.infinity, 150), + painter: _CurvePainter(analysis: a, sections: sections), + ), + ), + if (a.hasRestAnalysis && a.restCount > 0) ...[ + const SizedBox(height: 12), + _RecoverySummary(analysis: a), + const SizedBox(height: 10), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: AppColors.glassBorder), + ), + alignment: Alignment.center, + child: Text( + _expanded ? 'Hide per-rest breakdown ▴' : 'Show per-rest breakdown ▾', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (_expanded) ...[ + const SizedBox(height: 8), + ...a.rests.map((r) => _RestRow(rest: r)), + ], + ] else if (!a.hasRestAnalysis) ...[ + const SizedBox(height: 10), + Text( + 'Per-rest recovery needs per-set timing, which this workout ' + 'didn\'t record.', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, height: 1.4), + ), + ], + ], + ), + ), + ], + ); + }, + ); + } + + static String _shortName(String name) { + if (name.length <= 14) return name; + final words = name.split(' '); + if (words.length >= 2) return '${words.first} ${words[1][0]}.'; + return '${name.substring(0, 12)}…'; + } +} + +class _Section { + final String label; + final DateTime start; + final DateTime end; + const _Section({required this.label, required this.start, required this.end}); +} + +// ── Recovery summary ────────────────────────────────────────────────────────── + +class _RecoverySummary extends StatelessWidget { + const _RecoverySummary({required this.analysis}); + final WorkoutHrAnalysis analysis; + + @override + Widget build(BuildContext context) { + final tooShort = analysis.restsTooShort; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Text( + '${analysis.restsRecovered}/${analysis.restCount}', + style: GoogleFonts.geistMono( + color: AppColors.success, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11, height: 1.4), + children: [ + const TextSpan( + text: 'rests brought your HR down\n', + style: TextStyle(color: AppColors.textSoft, fontWeight: FontWeight.w600), + ), + TextSpan(text: 'avg '), + TextSpan( + text: '−${analysis.avgRecoveryBpm} bpm', + style: const TextStyle(color: AppColors.success, fontWeight: FontWeight.w700), + ), + TextSpan(text: ' per rest'), + if (tooShort > 0) TextSpan(text: ' · $tooShort too short to drop'), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RestRow extends StatelessWidget { + const _RestRow({required this.rest}); + final RestRecovery rest; + + @override + Widget build(BuildContext context) { + final ok = rest.recovered; + final color = ok ? AppColors.success : AppColors.warning; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(ok ? Icons.check_rounded : Icons.priority_high_rounded, size: 13, color: color), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'After set ${rest.afterSet} · rest ${rest.durationSec}s', + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 1), + Text( + 'peak ${rest.peakBpm} → low ${rest.troughBpm} bpm${ok ? '' : ' · too short'}', + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 10), + ), + ], + ), + ), + Text( + '−${rest.recoveryBpm} bpm', + style: GoogleFonts.geistMono(color: color, fontSize: 14, fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + +// ── Pill ────────────────────────────────────────────────────────────────────── + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + RichText( + text: TextSpan(children: [ + TextSpan( + text: value, + style: GoogleFonts.geistMono(color: color, fontSize: 16, fontWeight: FontWeight.w700), + ), + TextSpan(text: ' bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9)), + ]), + ), + ], + ), + ), + ); + } +} + +// ── Curve painter ───────────────────────────────────────────────────────────── + +class _CurvePainter extends CustomPainter { + _CurvePainter({required this.analysis, required this.sections}); + + final WorkoutHrAnalysis analysis; + final List<_Section> sections; + + static const _padLeft = 24.0; + static const _padTop = 14.0; + static const _padBottom = 16.0; + + @override + void paint(Canvas canvas, Size size) { + final curve = analysis.curve; + if (curve.isEmpty) return; + + final startMs = analysis.start.millisecondsSinceEpoch; + final spanMs = max(analysis.end.millisecondsSinceEpoch - startMs, 1); + final vmin = (analysis.minBpm - 6).toDouble(); + final vmax = (analysis.peakBpm + 6).toDouble(); + + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + + double x(DateTime t) => + _padLeft + ((t.millisecondsSinceEpoch - startMs) / spanMs).clamp(0.0, 1.0) * chartW; + double y(double v) => _padTop + chartH - ((v - vmin) / (vmax - vmin)) * chartH; + + // Grid + Y labels. + final grid = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var v = (vmin / 20).ceil() * 20; v <= vmax; v += 20) { + final yy = y(v.toDouble()); + canvas.drawLine(Offset(_padLeft, yy), Offset(size.width - 4, yy), grid); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}', style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, yy - tp.height / 2)); + } + + // Rest shading (green = recovered, amber = not). + for (final r in analysis.rests) { + final rx = x(r.restStart); + final rEnd = x(r.restStart.add(Duration(seconds: r.durationSec))); + final c = (r.recovered ? AppColors.success : AppColors.warning).withValues(alpha: 0.14); + canvas.drawRect(Rect.fromLTRB(rx, _padTop, max(rEnd, rx + 1), _padTop + chartH), Paint()..color = c); + } + + // Area + line. + final path = Path(); + final area = Path(); + for (var i = 0; i < curve.length; i++) { + final px = x(curve[i].time); + final py = y(curve[i].bpm); + if (i == 0) { + path.moveTo(px, py); + area.moveTo(px, y(vmin)); + area.lineTo(px, py); + } else { + path.lineTo(px, py); + area.lineTo(px, py); + } + } + area.lineTo(x(curve.last.time), y(vmin)); + area.close(); + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent.withValues(alpha: 0.3), AppColors.accent.withValues(alpha: 0.0)], + ).createShader(Rect.fromLTWH(_padLeft, _padTop, chartW, chartH)), + ); + canvas.drawPath( + path, + Paint() + ..color = AppColors.accent + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6 + ..strokeJoin = StrokeJoin.round, + ); + + // Exercise-section flags. + final flagPaint = Paint() + ..color = AppColors.textMuted.withValues(alpha: 0.5) + ..strokeWidth = 1; + for (final s in sections) { + final fx = x(s.start); + canvas.drawLine(Offset(fx, _padTop), Offset(fx, _padTop + chartH), flagPaint); + // Flag label chip at top. + final tp = TextPainter( + text: TextSpan( + text: s.label, + style: GoogleFonts.geist(color: AppColors.textSoft, fontSize: 8, fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: 64); + final lx = min(fx + 3, size.width - 4 - tp.width - 6); + final chip = Rect.fromLTWH(lx, _padTop - 1, tp.width + 6, 11); + canvas.drawRRect( + RRect.fromRectAndRadius(chip, const Radius.circular(3)), + Paint()..color = AppColors.card.withValues(alpha: 0.92), + ); + tp.paint(canvas, Offset(lx + 3, _padTop - 0.5)); + } + + // X labels (minutes). + final xStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final totalMin = (spanMs / 60000).round(); + final stepMin = totalMin <= 0 ? 1 : (totalMin / 4).ceil(); + for (var m = 0; m <= totalMin; m += stepMin) { + final tx = _padLeft + (m * 60000 / spanMs).clamp(0.0, 1.0) * chartW; + final tp = TextPainter( + text: TextSpan(text: '${m}m', style: xStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((tx - tp.width / 2).clamp(0, size.width - tp.width), size.height - _padBottom + 4)); + } + } + + @override + bool shouldRepaint(_CurvePainter old) => old.analysis != analysis; +} diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index 61e93c1..1e929cf 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -73,9 +73,11 @@ class CoachToolService { FunctionDeclaration( 'get_exercise_performance', 'Get how a specific exercise has progressed: per-session volume ' - 'trend, growth slope, best estimated 1RM, last logged sets, and ' - 'personal record. Use for questions like "how is my bench press ' - 'progressing".', + 'trend, the full per-session weight×reps set history, growth ' + 'slope, best estimated 1RM, last logged sets, and personal ' + 'record. Use for questions like "how is my bench press ' + 'progressing" or "what weight and reps did I do for squats ' + 'last month".', Schema.object( properties: { 'exercise_name': Schema.string( @@ -87,6 +89,14 @@ class CoachToolService { 'Optional. Only consider sessions from the last N days.', nullable: true, ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent sessions to return ' + 'in set_history and volume_trend. Use a small value (e.g. ' + '1–5) when you only need recent sessions, to save tokens. ' + 'Defaults to 20; capped at 40.', + nullable: true, + ), }, requiredProperties: ['exercise_name'], ), @@ -112,6 +122,14 @@ class CoachToolService { 'Defaults to 30 if no dates are provided.', nullable: true, ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent sessions to include ' + 'in the per-session breakdown. The session_count and ' + 'total_volume totals always cover the full range. Use a ' + 'small value to save tokens. Defaults to 40; capped at 40.', + nullable: true, + ), }, ), ), @@ -130,6 +148,13 @@ class CoachToolService { 'Optional. Only consider sessions from the last N days.', nullable: true, ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent points to include in ' + 'volume_over_time. session_count and total_volume always ' + 'cover all matching sessions. Defaults to 40; capped at 40.', + nullable: true, + ), }, requiredProperties: ['routine_name'], ), @@ -223,6 +248,31 @@ class CoachToolService { requiredProperties: ['routine_name'], ), ), + FunctionDeclaration( + 'add_custom_exercise', + 'Create a new custom exercise in the catalogue when the one the user ' + 'wants does not already exist. Match the muscle to an existing ' + 'muscle group (call get_muscle_recovery or list routines first ' + 'if unsure of the available muscle names). After creating it you ' + 'can reference it by name in create_routine / update_routine.', + Schema.object( + properties: { + 'name': Schema.string( + description: 'Name of the new exercise, e.g. "Cable Crossover".', + ), + 'category': Schema.string( + description: + 'Either "compound" (multi-joint) or "isolation" (single-joint).', + ), + 'primary_muscle': Schema.string( + description: + 'Primary muscle group this exercise targets, e.g. "Chest" ' + 'or "Biceps". Must match an existing muscle group.', + ), + }, + requiredProperties: ['name', 'category', 'primary_muscle'], + ), + ), ]), ]; @@ -248,6 +298,8 @@ class CoachToolService { return _createRoutine(call.args); case 'update_routine': return await _updateRoutine(call.args); + case 'add_custom_exercise': + return await _addCustomExercise(call.args); default: return {'error': 'Unknown tool: ${call.name}'}; } @@ -287,26 +339,37 @@ class CoachToolService { final lastLog = _wp.getLastSessionForExercise(exercise.id); final pr = _pr.getRecord(exercise.id); + // Optional model-supplied cap; defaults preserve prior behaviour + // (40 trend points, 20 set-history sessions). + final hasLimit = args['limit'] != null; + final trendCap = hasLimit ? _limitArg(args, 40) : 40; + final setCap = hasLimit ? _limitArg(args, 20) : 20; + return { 'exercise': exercise.name, 'session_count': progression.length, if (days != null) 'window_days': days, 'volume_trend': [ - for (final p in progression.length > 40 - ? progression.sublist(progression.length - 40) + for (final p in progression.length > trendCap + ? progression.sublist(progression.length - trendCap) : progression) {'date': _d(p.date), 'volume': _round(p.volume)}, ], + // Per-session weight×reps breakdown (most recent first), so the model can + // answer "what weight/reps did I do" rather than only volume totals. + 'set_history': _setHistory(exercise.id, cutoff, setCap), 'growth': growth == null ? null : { - 'slope_per_session': _round(growth.slope), + 'slope_per_day': _round(growth.slope), + 'weekly_growth_percent': _round(growth.weeklyGrowthPercent), + 'curve': growth.curve.name, 'r2': _round(growth.r2), - 'trend': growth.slope > 0 + 'trend': growth.weeklyGrowthPercent > 0.5 ? 'improving' - : growth.slope < 0 + : growth.weeklyGrowthPercent < -2 ? 'declining' - : 'flat', + : 'plateauing', }, 'best_estimated_1rm': _roundOrNull(_wp.getBestOneRM(exercise.id)), 'last_session': lastLog == null @@ -359,7 +422,7 @@ class CoachToolService { 'session_count': sessions.length, 'total_volume': _round(totalVolume), 'sessions': [ - for (final s in sessions.take(40)) + for (final s in sessions.take(_limitArg(args, 40))) { 'date': _d(s.date), 'duration_min': s.duration, @@ -412,8 +475,8 @@ class CoachToolService { if (days != null) 'window_days': days, 'total_volume': _round(totalVolume), 'volume_over_time': [ - for (final s in sessions.length > 40 - ? sessions.sublist(sessions.length - 40) + for (final s in sessions.length > _limitArg(args, 40) + ? sessions.sublist(sessions.length - _limitArg(args, 40)) : sessions) {'date': _d(s.date), 'volume': _round(s.totalVolume)}, ], @@ -653,8 +716,100 @@ class CoachToolService { }; } + Future> _addCustomExercise( + Map args) async { + final name = (args['name'] as String?)?.trim() ?? ''; + if (name.isEmpty) return {'error': 'Exercise name cannot be empty.'}; + + // Reject duplicates so the model reuses the existing exercise instead. + final existing = _wp.allExercises.where( + (e) => e.name.toLowerCase() == name.toLowerCase(), + ); + if (existing.isNotEmpty) { + return { + 'error': 'An exercise named "${existing.first.name}" already exists. ' + 'Use it by name instead of creating a duplicate.', + }; + } + + final category = (args['category'] as String?)?.trim().toLowerCase() ?? ''; + if (category != 'compound' && category != 'isolation') { + return { + 'error': 'category must be "compound" or "isolation", got "$category".', + }; + } + + final muscleName = (args['primary_muscle'] as String?)?.trim() ?? ''; + final MuscleGroup muscle; + try { + final resolved = _resolveMuscleGroup(muscleName); + if (resolved == null) { + return { + 'error': 'No muscle group found matching "$muscleName".', + 'available_muscles': [for (final m in _wp.muscleGroups) m.name], + }; + } + muscle = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple muscle groups match "$muscleName". Did you mean:', + 'ambiguous_matches': e.candidates, + }; + } + + try { + await _wp.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscle.id, + ); + } catch (e) { + return {'error': 'Could not create exercise: $e'}; + } + + return { + 'created': true, + 'exercise_name': name, + 'category': category, + 'primary_muscle': muscle.name, + }; + } + // ── Helpers ──────────────────────────────────────────────────────────────── + /// Per-session weight×reps breakdown for [exerciseId], newest first. + /// Bounded to the most recent [limit] sessions (after the optional [cutoff]) + /// to keep the tool payload small. + List> _setHistory( + String exerciseId, DateTime? cutoff, int limit) { + final sessions = _wp.sessions + .where((s) => cutoff == null || !s.date.isBefore(cutoff)) + .where((s) => s.exercises.any((e) => e.exerciseId == exerciseId)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + return [ + for (final s in sessions.take(limit)) + { + 'date': _d(s.date), + 'sets': [ + for (final log in s.exercises.where((e) => e.exerciseId == exerciseId)) + for (final set in log.sets) + { + 'weight': _round(set.weight), + 'reps': set.reps, + if (set.isDropset) 'dropset': true, + if (set.isDropset && set.drops != null) + 'drops': [ + for (final d in set.drops!) + {'weight': _round(d.weight), 'reps': d.reps}, + ], + }, + ], + }, + ]; + } + Exercise? _resolveExercise(String query) { final q = query.toLowerCase().trim(); if (q.isEmpty) return null; @@ -682,6 +837,20 @@ class CoachToolService { throw AmbiguousMatchException([for (final r in partials) r.name]); } + MuscleGroup? _resolveMuscleGroup(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + for (final m in _wp.muscleGroups) { + if (m.name.toLowerCase() == q) return m; + } + final partials = [ + for (final m in _wp.muscleGroups) if (m.name.toLowerCase().contains(q)) m + ]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final m in partials) m.name]); + } + List _exampleExerciseNames() => _wp.allExercises.take(8).map((e) => e.name).toList(); @@ -693,4 +862,11 @@ class CoachToolService { double _round(double v) => (v * 10).round() / 10; double? _roundOrNull(double? v) => v == null ? null : _round(v); + + /// Read an optional `limit` arg, clamped to [1, 40]; [fallback] when absent. + int _limitArg(Map args, int fallback) { + final n = (args['limit'] as num?)?.toInt(); + if (n == null) return fallback; + return n.clamp(1, 40); + } } diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index 08f811c..de8729c 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -2,12 +2,19 @@ // // Backs the AI coach chat (streaming + tool calling), program generation, and // insights. Uses a user-supplied Google AI Studio API key (free-tier friendly). -// Implements [IAiService] so the backend can be swapped (e.g. firebase_ai) -// without touching consumers. +// Implements [IAiService] so the backend can be swapped without touching consumers. +// +// Uses direct HTTP calls (rather than the SDK's chat helpers) so we can pass +// thinkingConfig: {thinkingBudget: 0} and avoid the SDK crashing on the +// `thoughtSignature` parts that Gemini 3.x models return when thinking is active. +// The SDK is still used for its type definitions (Content, Tool, FunctionCall) +// and their toJson() serialisers which are part of the public API. import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, FunctionCall, Tool; +import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../../models/models.dart'; @@ -17,18 +24,46 @@ import '../interfaces/storage_service_interface.dart'; // Ordered list of available Gemini models shown in the picker. const kGeminiModels = [ ('gemini-2.5-flash', 'Gemini 2.5 Flash'), - ('gemini-3.0-flash', 'Gemini 3.0 Flash'), + ('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'), ('gemini-3.1-flash-lite', 'Gemini 3.1 Flash Lite'), ('gemini-3.5-flash', 'Gemini 3.5 Flash'), ]; -// Default to a fast, free-tier 3.x model. gemini-3.5-flash is selectable and -// preferable when heavy tool-calling reliability matters. -const kDefaultGeminiModel = 'gemini-3.1-flash-lite'; +// Default to the latest GA model. +const kDefaultGeminiModel = 'gemini-3.5-flash'; // Upper bound on tool-resolution rounds per user turn, to bound runaway loops. const int _kMaxToolRounds = 5; +// Retry policy for transient (5xx / 429) errors. Total attempts = 1 + retries. +const int _kMaxRetries = 2; + +const String _apiBase = + 'https://generativelanguage.googleapis.com/v1beta/models'; + +// 429 (rate limit) and 5xx (server/overload, e.g. 503 "high demand") are +// transient and worth retrying; 4xx (bad key, bad request) are not. +bool _isRetryableStatus(int code) => code == 429 || (code >= 500 && code < 600); + +// Exponential backoff: 500ms, 1s, 2s … +Duration _retryBackoff(int attempt) => + Duration(milliseconds: 500 * (1 << attempt)); + +// Gemini error bodies look like {"error":{"code":503,"message":"…","status":"…"}}. +// Surface just the human-readable message rather than the whole JSON blob. +String _errorMessage(int code, String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map && decoded['error'] is Map) { + final msg = (decoded['error'] as Map)['message']; + if (msg is String && msg.isNotEmpty) return msg; + } + } catch (_) { + // Body wasn't JSON — fall through to a generic message. + } + return 'request failed (HTTP $code).'; +} + class GeminiAiService extends ChangeNotifier implements IAiService { // Optional storage so cumulative token usage survives restarts. final IStorageService? _storage; @@ -96,7 +131,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { } /// Accumulate one request's token counts. Exposed for testing; normally - /// fed from a response's [UsageMetadata] via [_recordUsage]. + /// fed from the raw usageMetadata JSON via [_recordRawUsage]. @visibleForTesting Future recordUsage({ required int prompt, @@ -111,11 +146,12 @@ class GeminiAiService extends ChangeNotifier implements IAiService { notifyListeners(); } - void _recordUsage(UsageMetadata? m) { - if (m == null) return; - final p = m.promptTokenCount ?? 0; - final r = m.candidatesTokenCount ?? 0; - recordUsage(prompt: p, response: r, total: m.totalTokenCount ?? (p + r)); + void _recordRawUsage(Map? usage) { + if (usage == null) return; + final p = (usage['promptTokenCount'] as num?)?.toInt() ?? 0; + final r = (usage['candidatesTokenCount'] as num?)?.toInt() ?? 0; + final t = (usage['totalTokenCount'] as num?)?.toInt() ?? (p + r); + recordUsage(prompt: p, response: r, total: t); } Future _persistUsage() async { @@ -142,20 +178,131 @@ class GeminiAiService extends ChangeNotifier implements IAiService { notifyListeners(); } - GenerativeModel _makeModel({ - bool jsonMode = false, + // ── Raw HTTP helpers ──────────────────────────────────────────────────────── + + Map _makeBody({ + required List contents, String? system, List? tools, - }) { - return GenerativeModel( - model: _model, - apiKey: _apiKey, - systemInstruction: system != null ? Content.system(system) : null, - tools: tools, - generationConfig: jsonMode - ? GenerationConfig(responseMimeType: 'application/json') - : null, + bool jsonMode = false, + }) => + { + 'contents': contents, + if (system != null) + 'systemInstruction': { + 'parts': [ + {'text': system} + ] + }, + if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), + 'generationConfig': { + // Disable thinking tokens so SDK-incompatible thoughtSignature parts + // are never returned by Gemini 3.x models. + 'thinkingConfig': {'thinkingBudget': 0}, + if (jsonMode) 'responseMimeType': 'application/json', + }, + }; + + // Extracts non-thought text strings from a candidate object. + Iterable _textFromCandidate(Map candidate) sync* { + final content = candidate['content'] as Map?; + final parts = content?['parts'] as List? ?? []; + for (final part in parts) { + if (part is Map && + part.containsKey('text') && + part['thought'] != true) { + final t = part['text'] as String? ?? ''; + if (t.isNotEmpty) yield t; + } + } + } + + // Streams parsed SSE chunks from the streamGenerateContent endpoint. + Stream> _streamSse(Map body) async* { + final uri = Uri.parse( + '$_apiBase/$_model:streamGenerateContent?alt=sse&key=$_apiKey', ); + + // Establish the connection with retries. Retrying is only safe here — + // before any bytes are yielded — so a transient 503 never reaches the user, + // but a mid-stream failure is not retried (it would duplicate output). + http.Client client = http.Client(); + http.StreamedResponse streamed; + for (var attempt = 0;; attempt++) { + final request = http.Request('POST', uri) + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body); + final resp = await client.send(request); + if (resp.statusCode == 200) { + streamed = resp; + break; + } + final err = await resp.stream.bytesToString(); + if (_isRetryableStatus(resp.statusCode) && attempt < _kMaxRetries) { + client.close(); + await Future.delayed(_retryBackoff(attempt)); + client = http.Client(); + continue; + } + client.close(); + throw Exception(_errorMessage(resp.statusCode, err)); + } + + try { + final lineBuf = StringBuffer(); + await for (final raw in streamed.stream.transform(utf8.decoder)) { + lineBuf.write(raw); + final text = lineBuf.toString(); + final lines = text.split('\n'); + lineBuf + ..clear() + ..write(lines.last); // keep potentially incomplete last line + for (var i = 0; i < lines.length - 1; i++) { + final line = lines[i].trim(); + if (!line.startsWith('data: ')) continue; + final payload = line.substring(6).trim(); + if (payload.isEmpty || payload == '[DONE]') continue; + yield jsonDecode(payload) as Map; + } + } + // Flush any remaining buffered line. + final tail = lineBuf.toString().trim(); + if (tail.startsWith('data: ')) { + final payload = tail.substring(6).trim(); + if (payload.isNotEmpty && payload != '[DONE]') { + yield jsonDecode(payload) as Map; + } + } + } finally { + client.close(); + } + } + + // Single-shot (non-streaming) generateContent call, with retry on 5xx/429. + Future> _generate(Map body) async { + final uri = Uri.parse('$_apiBase/$_model:generateContent?key=$_apiKey'); + final payload = jsonEncode(body); + for (var attempt = 0;; attempt++) { + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: payload, + ); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + if (_isRetryableStatus(response.statusCode) && attempt < _kMaxRetries) { + await Future.delayed(_retryBackoff(attempt)); + continue; + } + throw Exception(_errorMessage(response.statusCode, response.body)); + } + } + + String _textFromResponse(Map data) { + final candidates = data['candidates'] as List? ?? []; + if (candidates.isEmpty) return ''; + return _textFromCandidate(candidates[0] as Map).join(); } // ── Coach chat (streaming + optional tool-call loop) ─────────────────────── @@ -175,42 +322,82 @@ class GeminiAiService extends ChangeNotifier implements IAiService { return; } try { - final chat = _makeModel(system: systemPrompt, tools: tools) - .startChat(history: history); - - Content next = Content.text(userMessage); + // Build the mutable contents list; grows with each tool-call round. + final contents = [ + ...history.map((c) => c.toJson()), + Content.text(userMessage).toJson(), + ]; for (var round = 0; round < _kMaxToolRounds; round++) { + final body = _makeBody( + contents: contents, + system: systemPrompt, + tools: tools, + ); + + // Raw parts from the model turn — preserved verbatim so that any + // thought_signature fields on functionCall parts are not dropped when + // we echo this turn back to the API in the next round. + final rawModelParts = >[]; final calls = []; - UsageMetadata? roundUsage; - await for (final chunk in chat.sendMessageStream(next)) { - final t = chunk.text; - if (t != null && t.isNotEmpty) yield t; - calls.addAll(chunk.functionCalls); - if (chunk.usageMetadata != null) roundUsage = chunk.usageMetadata; + Map? lastUsage; + + await for (final chunk in _streamSse(body)) { + final candidates = chunk['candidates'] as List? ?? []; + for (final raw in candidates) { + final c = raw as Map; + for (final t in _textFromCandidate(c)) { + yield t; + } + // Collect raw parts for the model-turn echo. + final content = c['content'] as Map?; + final parts = content?['parts'] as List? ?? []; + for (final part in parts) { + if (part is! Map) continue; + rawModelParts.add(part); + if (part.containsKey('functionCall')) { + final fc = part['functionCall'] as Map; + calls.add(FunctionCall( + fc['name'] as String, + (fc['args'] as Map? ?? {}) + .cast(), + )); + } + } + } + if (chunk['usageMetadata'] != null) { + lastUsage = chunk['usageMetadata'] as Map; + } } - // The final chunk of each round carries that round's cumulative usage. - _recordUsage(roundUsage); + _recordRawUsage(lastUsage); // No tools requested (or no handler) → the streamed text is the answer. if (calls.isEmpty || onToolCall == null) return; - // Resolve every requested call and feed the results back as one turn. - final responses = []; + // Echo the model turn back verbatim (preserves thought_signature). + contents.add({'role': 'model', 'parts': rawModelParts}); + + // Resolve every call and feed the results back as one function turn. + final responseParts = >[]; for (final call in calls) { try { final result = await onToolCall(call); - responses.add(FunctionResponse(call.name, result)); + responseParts.add({ + 'functionResponse': {'name': call.name, 'response': result} + }); } catch (e) { - responses.add(FunctionResponse(call.name, {'error': '$e'})); + responseParts.add({ + 'functionResponse': { + 'name': call.name, + 'response': {'error': '$e'} + } + }); } } - next = Content.functionResponses(responses); + contents.add({'role': 'function', 'parts': responseParts}); } // Exhausted the tool-round budget without a final text answer. yield '\n\n_(Stopped after $_kMaxToolRounds tool steps — try rephrasing.)_'; - } on GenerativeAIException catch (e) { - yield 'AI error: ${e.message}'; } catch (e) { yield 'Error: $e'; } @@ -283,22 +470,27 @@ Required JSON schema (follow exactly): 'Available exercises (ID: name [primary muscle]):\n$exerciseList\n\nUser request: $userPrompt'; try { - final response = await _makeModel(jsonMode: true, system: systemPrompt) - .generateContent([Content.text(prompt)]); - _recordUsage(response.usageMetadata); - final raw = response.text ?? ''; + final data = await _generate( + _makeBody( + contents: [Content.text(prompt).toJson()], + system: systemPrompt, + jsonMode: true, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final raw = _textFromResponse(data); if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); - final data = jsonDecode(raw) as Map; + final map = jsonDecode(raw) as Map; // Ensure a fresh UUID so it never collides with an existing program. - data['id'] = const Uuid().v4(); - data['isImported'] = true; - data['author'] = 'AI Coach'; - return TrainingProgram.fromJson(data); - } on GenerativeAIException catch (e) { - throw Exception('Gemini API error: ${e.message}'); + map['id'] = const Uuid().v4(); + map['isImported'] = true; + map['author'] = 'AI Coach'; + return TrainingProgram.fromJson(map); } on FormatException catch (e) { throw Exception('Could not parse program JSON: $e'); + } catch (e) { + throw Exception('Gemini API error: $e'); } } @@ -315,12 +507,15 @@ Required JSON schema (follow exactly): 'Cover: biggest win, one thing to watch, one tip for next week. ' 'No bullet points, no headers — natural flowing prose only.'; try { - final response = await _makeModel(system: systemPrompt) - .generateContent([Content.text(contextText)]); - _recordUsage(response.usageMetadata); - return response.text?.trim() ?? 'No insights generated.'; - } on GenerativeAIException catch (e) { - return 'AI error: ${e.message}'; + final data = await _generate( + _makeBody( + contents: [Content.text(contextText).toJson()], + system: systemPrompt, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final text = _textFromResponse(data).trim(); + return text.isNotEmpty ? text : 'No insights generated.'; } catch (e) { return 'Could not generate insights: $e'; } @@ -333,12 +528,15 @@ Required JSON schema (follow exactly): return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; } try { - final response = await _makeModel(system: system) - .generateContent([Content.text(context)]); - _recordUsage(response.usageMetadata); - return response.text?.trim() ?? 'No insight generated.'; - } on GenerativeAIException catch (e) { - return 'AI error: ${e.message}'; + final data = await _generate( + _makeBody( + contents: [Content.text(context).toJson()], + system: system, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final text = _textFromResponse(data).trim(); + return text.isNotEmpty ? text : 'No insight generated.'; } catch (e) { return 'Could not generate insight: $e'; } diff --git a/workout-logger/lib/services/debug_log_buffer.dart b/workout-logger/lib/services/debug_log_buffer.dart new file mode 100644 index 0000000..edff98b --- /dev/null +++ b/workout-logger/lib/services/debug_log_buffer.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; + +/// Captures every [debugPrint] call into a fixed-size circular buffer. +/// Wire up once in main() via [DebugLogBuffer.attach]. +class DebugLogBuffer extends ChangeNotifier { + DebugLogBuffer._(); + static final instance = DebugLogBuffer._(); + + static const _maxLines = 500; + final List _lines = []; + + List get lines => List.unmodifiable(_lines); + + static void attach() { + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + original(message, wrapWidth: wrapWidth); + instance._append(message ?? ''); + }; + } + + void _append(String line) { + final ts = DateTime.now(); + final stamp = + '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; + _lines.add('[$stamp] $line'); + if (_lines.length > _maxLines) _lines.removeAt(0); + notifyListeners(); + } + + void clear() { + _lines.clear(); + notifyListeners(); + } +} diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart index 98071e9..083ce5d 100644 --- a/workout-logger/lib/services/gemini_context_builder.dart +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -37,6 +37,14 @@ class GeminiContextBuilder { 'recovery — CALL THE PROVIDED TOOLS rather than guessing or inventing ' 'numbers. Pass ISO dates (YYYY-MM-DD) or a day count to the tools.', ) + ..writeln( + 'You can also MODIFY the user\'s data with tools: create or update ' + 'routines, and add a new custom exercise when one does not already ' + 'exist. You do not need to ask permission before calling a write tool ' + 'the user clearly requested, but confirm what you did in your reply. ' + 'If a routine needs an exercise that is not in the catalogue, create it ' + 'with add_custom_exercise first, then reference it by name.', + ) ..writeln( 'Weights are in $unitLabel. Format replies with Markdown (lists, bold, ' 'tables) where it aids clarity.', @@ -69,28 +77,37 @@ class GeminiContextBuilder { ..writeln() ..writeln('STRICT WORKFLOW — execute in this order every time:') ..writeln( - '1. QUESTIONS FIRST: Call ask_user_questions immediately. ' - 'Ask about (a) primary goal [Strength/Hypertrophy/Fat loss/Endurance], ' - '(b) sessions per week for this routine, and optionally (c) any exercises ' - 'they want to keep no matter what. Do NOT skip this step.', + '1. FETCH DATA FIRST: Before saying anything or asking anything, ' + 'call get_routine_performance for the routine, then call ' + 'get_exercise_performance for EVERY exercise in that routine (use ' + 'the exercise list from the routine response), and call ' + 'get_muscle_recovery. Never skip this step and never invent numbers.', + ) + ..writeln( + '2. ANALYSE SILENTLY: Identify issues — stalling or declining ' + 'exercises (negative slope or r²<0.5), missing muscle groups, ' + 'recovery conflicts, poor ordering. Do not output this analysis.', ) ..writeln( - '2. FETCH DATA: After answers arrive, call get_routine_performance ' - 'for the routine and get_exercise_performance for each exercise that ' - 'has data. Never invent numbers.', + '3. ASK ONLY IF AMBIGUOUS: Call ask_user_questions only if ' + 'the data alone cannot determine the best changes — e.g. the user ' + 'goal (strength vs hypertrophy) would flip which exercise to suggest, ' + 'or you need to know which exercises they want to keep. ' + 'Skip this step entirely if the data makes the answer obvious. ' + 'Never ask questions whose answers would not change your recommendations.', ) ..writeln( - '3. PROPOSE CHANGES: List proposed changes as short bullets: ' - 'reorder (give full new order), replace (which exercise → which ' - 'alternative and why), add (specific exercise to fill a gap). ' - 'Keep your analysis under 150 words.', + '4. PROPOSE CHANGES: List proposed changes as short bullets with ' + 'specific numbers from the data (e.g. "Overhead Press slope −0.3 kg/session"): ' + 'reorder (give full new order), replace (which → which and why), ' + 'add (specific exercise to fill a muscle gap). Under 150 words.', ) ..writeln( - '4. CONFIRM: Call ask_user_questions with multiSelect:true listing ' - 'your proposed changes as chips so the user can pick which to apply.', + '5. CONFIRM: Call ask_user_questions with multiSelect:true listing ' + 'each proposed change as a chip. The user picks which to apply.', ) ..writeln( - '5. APPLY: Call update_routine exactly once with only the confirmed ' + '6. APPLY: Call update_routine exactly once with only the confirmed ' 'changes. Then confirm in one sentence what was changed.', ) ..writeln() diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 275b1e4..b278b66 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -62,8 +62,10 @@ class HealthConnectService implements IHealthConnectService { Future isAvailable() async { try { final status = await HealthConnector.getHealthPlatformStatus(); + debugPrint('[HC] isAvailable: platform status = $status'); return status == HealthPlatformStatus.available; - } catch (_) { + } catch (e) { + debugPrint('[HC] isAvailable: exception = $e'); return false; } } @@ -96,6 +98,196 @@ class HealthConnectService implements IHealthConnectService { } } + static final Map _readPermissions = { + HealthReadType.sleep: HealthDataType.sleepSession.readPermission, + // heartRateSeries maps to Android HeartRateRecord (series with samples). + // heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect. + HealthReadType.heartRate: HealthDataType.heartRateSeries.readPermission, + HealthReadType.restingHeartRate: + HealthDataType.restingHeartRate.readPermission, + HealthReadType.hrv: HealthDataType.heartRateVariabilityRMSSD.readPermission, + }; + + @override + Future requestReadPermissions() async { + debugPrint('[HC] requestReadPermissions: requesting ${_readPermissions.length} permissions individually'); + _connector ??= await HealthConnector.create(); + var anyGranted = false; + for (final entry in _readPermissions.entries) { + try { + final results = await _connector!.requestPermissions([entry.value]); + final granted = results.any((r) => r.status == PermissionStatus.granted); + debugPrint('[HC] requestReadPermissions: ${entry.key} → granted=$granted'); + if (granted) anyGranted = true; + } catch (e) { + debugPrint('[HC] requestReadPermissions: ${entry.key} unsupported, skipping ($e)'); + } + } + debugPrint('[HC] requestReadPermissions: anyGranted = $anyGranted'); + return anyGranted; + } + + @override + Future> grantedReadTypes() async { + _connector ??= await HealthConnector.create(); + final granted = {}; + for (final entry in _readPermissions.entries) { + try { + final status = await _connector!.getPermissionStatus(entry.value); + debugPrint('[HC] grantedReadTypes: ${entry.key} → $status'); + if (status == PermissionStatus.granted) granted.add(entry.key); + } catch (e) { + debugPrint('[HC] grantedReadTypes: ${entry.key} unsupported, skipping ($e)'); + } + } + debugPrint('[HC] grantedReadTypes: result = $granted'); + return granted; + } + + @override + Future> readSleepSessions( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.sleepSession.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + final result = response.records.map((r) { + // Tally stage durations from embedded SleepStageSamples and build + // an ordered stage timeline for HR segment colouring. + var light = 0, deep = 0, rem = 0, awake = 0; + final timeline = []; + var cursor = r.startTime; + for (final s in r.samples) { + final segEnd = cursor.add(s.duration); + final mins = s.duration.inMinutes; + switch (s.stageType) { + case SleepStage.light: + case SleepStage.sleeping: // generic "asleep" — count as light + light += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'light')); + case SleepStage.deep: + deep += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'deep')); + case SleepStage.rem: + rem += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'rem')); + case SleepStage.awake: + case SleepStage.outOfBed: + case SleepStage.inBed: + awake += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'awake')); + case SleepStage.unknown: + break; + } + cursor = segEnd; + } + final hasStages = r.samples.isNotEmpty; + final period = SleepPeriod( + start: r.startTime, + end: r.endTime, + lightMinutes: hasStages ? light : null, + deepMinutes: hasStages ? deep : null, + remMinutes: hasStages ? rem : null, + awakeMinutes: hasStages ? awake : null, + stageTimeline: timeline, + ); + debugPrint('[HC] sleep ${r.startTime.toLocal().hour}:${r.startTime.toLocal().minute.toString().padLeft(2, '0')}' + '→${r.endTime.toLocal().hour}:${r.endTime.toLocal().minute.toString().padLeft(2, '0')}' + ' actual=${period.minutes}min' + '${hasStages ? " (L=$light D=$deep R=$rem A=$awake)" : " (no stages)"}'); + return period; + }).toList(); + debugPrint('[HC] readSleepSessions [$start → $end]: ${result.length} records'); + return result; + } catch (e) { + debugPrint('[HC] readSleepSessions failed: $e'); + return const []; + } + } + + @override + Future> readRestingHeartRate( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.restingHeartRate.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + final result = response.records + .map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute)) + .toList(); + debugPrint('[HC] readRestingHeartRate [$start → $end]: ${result.length} records'); + return result; + } catch (e) { + debugPrint('[HC] readRestingHeartRate failed: $e'); + return const []; + } + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.heartRateVariabilityRMSSD.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + final result = response.records + .map((r) => HealthSample(time: r.time, value: r.rmssd.inMilliseconds)) + .toList(); + debugPrint('[HC] readHrvRmssd [$start → $end]: ${result.length} records'); + return result; + } catch (e) { + debugPrint('[HC] readHrvRmssd failed: $e'); + return const []; + } + } + + @override + Future> readHeartRateSamples( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + // heartRateSeries = Android HeartRateRecord (container with BPM samples). + // heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect. + final response = await _connector!.readRecords( + HealthDataType.heartRateSeries.readInTimeRange( + startTime: start, + endTime: end, + pageSize: 5000, + ), + ); + final samples = response.records + .expand( + (r) => r.samples.map( + (s) => HealthSample(time: s.time, value: s.rate.inPerMinute), + ), + ) + .toList(); + debugPrint('[HC] readHeartRateSamples [$start → $end]: ' + '${response.records.length} series records, ${samples.length} samples'); + return samples; + } catch (e) { + debugPrint('[HC] readHeartRateSamples failed: $e'); + return const []; + } + } + @override Future syncWorkoutSession(WorkoutSession session, {String? title}) async { try { diff --git a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart index 14b6c9a..ae62973 100644 --- a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart +++ b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart @@ -1,8 +1,27 @@ import '../../models/models.dart'; +/// Read-side Health Connect data categories used for readiness scoring. +enum HealthReadType { sleep, heartRate, restingHeartRate, hrv } + abstract class IHealthConnectService { Future isAvailable(); Future requestPermissions(); Future hasPermissions(); Future syncWorkoutSession(WorkoutSession session, {String? title}); + + /// Requests all readiness read permissions (sleep, HR, resting HR, HRV) + /// in one dialog. Returns true if at least one was granted — partial + /// grants are usable because readiness components are independent. + Future requestReadPermissions(); + + /// The subset of readiness read permissions currently granted. + Future> grantedReadTypes(); + + Future> readSleepSessions(DateTime start, DateTime end); + Future> readRestingHeartRate(DateTime start, DateTime end); + Future> readHrvRmssd(DateTime start, DateTime end); + + /// Raw heart-rate samples. Only used as a morning-RHR fallback over a + /// narrow window when no [readRestingHeartRate] records exist. + Future> readHeartRateSamples(DateTime start, DateTime end); } diff --git a/workout-logger/lib/services/interfaces/interfaces.dart b/workout-logger/lib/services/interfaces/interfaces.dart index 55819d6..ce56ee8 100644 --- a/workout-logger/lib/services/interfaces/interfaces.dart +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -8,3 +8,4 @@ export 'storage_service_interface.dart'; export 'ml_service_interface.dart'; export 'health_connect_service_interface.dart'; export 'health_sync_manager_interface.dart'; +export 'readiness_manager_interface.dart'; diff --git a/workout-logger/lib/services/interfaces/readiness_manager_interface.dart b/workout-logger/lib/services/interfaces/readiness_manager_interface.dart new file mode 100644 index 0000000..7ea4741 --- /dev/null +++ b/workout-logger/lib/services/interfaces/readiness_manager_interface.dart @@ -0,0 +1,25 @@ +// Readiness Manager Interface (Dependency Inversion Principle) +// +// Abstracts daily readiness computation from Health Connect sleep/heart data. +// UI widgets depend on this abstraction so the data source and scoring can be +// swapped or mocked in tests. + +import '../../models/models.dart'; + +enum ReadinessStatus { idle, loading, ready, noData } + +/// Contract for computing and caching the user's daily readiness score. +abstract class IReadinessManager { + ReadinessStatus get status; + + /// Today's readiness, or null when nothing has been computed yet. + ReadinessSnapshot? get snapshot; + + /// Recomputes today's readiness from Health Connect. + /// + /// - No-op when the readiness setting is disabled. + /// - Serves a same-day cached snapshot (within a freshness TTL) unless + /// [force] is true. + /// - Never throws: any failure results in [ReadinessStatus.noData]. + Future refresh({bool force = false}); +} diff --git a/workout-logger/lib/services/managers/health_history_manager.dart b/workout-logger/lib/services/managers/health_history_manager.dart new file mode 100644 index 0000000..deb661e --- /dev/null +++ b/workout-logger/lib/services/managers/health_history_manager.dart @@ -0,0 +1,296 @@ +// Health History Manager +// +// Serves arbitrary-range sleep & heart-rate data for the detail screens. +// Stateless w.r.t. UI (not a ChangeNotifier) — screens drive it via +// FutureBuilder. The Health Connect service already reads any date range; +// this manager owns the windowing, bucketing and light caching on top. +// +// Performance: Day/Week use full HR samples (heavy, cached per immutable past +// day). Month/Year use restingHeartRate records (one/day, light) so a year +// never fans out into 365 sample queries. + +import 'dart:convert'; + +import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; +import '../../models/workout_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../utils/sleep_hr_builder.dart'; +import '../utils/workout_hr_builder.dart'; + +class HealthHistoryManager { + final IHealthConnectService _hc; + final IStorageService _storage; + + HealthHistoryManager(this._hc, this._storage); + + // ── Date helpers ──────────────────────────────────────────────────────────── + + static String dateKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + static DateTime _midnight(DateTime d) => DateTime(d.year, d.month, d.day); + + /// The [start, end) window covered by [g] anchored at [anchor]. + /// Day → that day. Week → 7 days ending on anchor. Month/Year → calendar unit. + static ({DateTime start, DateTime end}) rangeFor( + DateTime anchor, + HealthGranularity g, + ) { + final day = _midnight(anchor); + switch (g) { + case HealthGranularity.day: + return (start: day, end: day.add(const Duration(days: 1))); + case HealthGranularity.week: + final start = day.subtract(const Duration(days: 6)); + return (start: start, end: day.add(const Duration(days: 1))); + case HealthGranularity.month: + final start = DateTime(day.year, day.month, 1); + final end = DateTime(day.year, day.month + 1, 1); + return (start: start, end: end); + case HealthGranularity.year: + return (start: DateTime(day.year, 1, 1), end: DateTime(day.year + 1, 1, 1)); + } + } + + /// Steps the anchor by one unit of [g] in [dir] (+1 forward, -1 back). + static DateTime stepBy(DateTime anchor, HealthGranularity g, int dir) { + final day = _midnight(anchor); + switch (g) { + case HealthGranularity.day: + return day.add(Duration(days: dir)); + case HealthGranularity.week: + return day.add(Duration(days: 7 * dir)); + case HealthGranularity.month: + return DateTime(day.year, day.month + dir, day.day); + case HealthGranularity.year: + return DateTime(day.year + dir, day.month, day.day); + } + } + + Future> _granted() => _hc.grantedReadTypes(); + + /// HR breakdown for one recorded workout: curve, peak/avg/min, exercise + /// sections, and per-rest HR recovery. Null when no HR data covers the window. + Future workoutHr(WorkoutSession session) async { + final granted = await _granted(); + return buildWorkoutHrAnalysis(_hc, session, granted); + } + + // ── Day detail ────────────────────────────────────────────────────────────── + + /// Overnight HR snapshot for the night ending the morning of [morning]. + Future sleepNight(DateTime morning) async { + final granted = await _granted(); + return buildSleepHrSnapshot(_hc, morning, granted); + } + + /// All-day HR snapshot for [day]. Immutable past days are cached permanently; + /// today is always rebuilt (data is still accumulating). + Future hrDay(DateTime day) async { + final d = _midnight(day); + final isPast = d.isBefore(_midnight(DateTime.now())); + final cacheKey = 'hr.day.${dateKey(d)}'; + + if (isPast) { + final cached = await _readCachedHrDay(cacheKey); + if (cached != null) return cached; + } + + final granted = await _granted(); + final snap = await buildHrDaySnapshot(_hc, d, granted); + if (snap != null && isPast) { + try { + await _storage.saveSetting(cacheKey, jsonEncode(snap.toJson())); + } catch (_) {/* cache best-effort */} + } + return snap; + } + + Future _readCachedHrDay(String key) async { + try { + final raw = await _storage.getSetting(key); + if (raw == null) return null; + return HrDaySnapshot.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return null; + } + } + + // ── Sleep aggregation ───────────────────────────────────────────────────────── + + /// Aggregated sleep-duration bars for [g] anchored at [anchor]. + /// Day/Week/Month → one bar per night; Year → 12 monthly averages. + /// Bars are emitted for every calendar slot in range (zero-filled) so the + /// chart axis stays stable. + Future> sleepBars( + DateTime anchor, + HealthGranularity g, + ) async { + final granted = await _granted(); + if (!granted.contains(HealthReadType.sleep)) return const []; + + final r = rangeFor(anchor, g); + // Pad the end so sleep ending the morning after the last day is captured. + final periods = + await _hc.readSleepSessions(r.start, r.end.add(const Duration(hours: 12))); + + // Group nightly totals by the day the session ENDS on (handles fragmented + // Pixel-Watch records — sum, don't max). + final byNight = {}; + for (final p in periods) { + final key = dateKey(p.end); + final t = byNight.putIfAbsent(key, () => _StageTally()); + t.add(p); + } + + if (g == HealthGranularity.year) { + // Average each month's nightly totals. + final byMonth = >{}; + byNight.forEach((key, tally) { + final d = DateTime.parse(key); + byMonth.putIfAbsent(d.month, () => []).add(tally); + }); + return List.generate(12, (i) { + final month = i + 1; + final tallies = byMonth[month] ?? const []; + final date = DateTime(_midnight(anchor).year, month, 1); + if (tallies.isEmpty) { + return SleepDayBar( + date: date, totalMinutes: 0, deepMin: 0, remMin: 0, lightMin: 0, awakeMin: 0); + } + final n = tallies.length; + return SleepDayBar( + date: date, + totalMinutes: tallies.fold(0, (s, t) => s + t.total) ~/ n, + deepMin: tallies.fold(0, (s, t) => s + t.deep) ~/ n, + remMin: tallies.fold(0, (s, t) => s + t.rem) ~/ n, + lightMin: tallies.fold(0, (s, t) => s + t.light) ~/ n, + awakeMin: tallies.fold(0, (s, t) => s + t.awake) ~/ n, + ); + }); + } + + // Per-night bars for each day in the range. + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final t = byNight[dateKey(d)]; + bars.add(SleepDayBar( + date: d, + totalMinutes: t?.total ?? 0, + deepMin: t?.deep ?? 0, + remMin: t?.rem ?? 0, + lightMin: t?.light ?? 0, + awakeMin: t?.awake ?? 0, + )); + } + return bars; + } + + // ── HR aggregation ──────────────────────────────────────────────────────────── + + /// Aggregated HR range bars for [g] anchored at [anchor]. + /// Week → per-day min/max from full samples (cached). Month/Year → daily / + /// monthly min–max of resting-HR records (light query path). + Future> hrBars( + DateTime anchor, + HealthGranularity g, + ) async { + final granted = await _granted(); + if (!granted.contains(HealthReadType.heartRate) && + !granted.contains(HealthReadType.restingHeartRate)) { + return const []; + } + final r = rangeFor(anchor, g); + + if (g == HealthGranularity.week) { + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final snap = await hrDay(d); + bars.add(HrRangeBar( + date: d, + label: _weekdayLabel(d), + minBpm: snap?.minBpm ?? 0, + maxBpm: snap?.maxBpm ?? 0, + avgBpm: snap?.avgBpm ?? 0, + restingBpm: snap?.restingBpm, + )); + } + return bars; + } + + // Month / Year → resting-HR records only. + final rhr = granted.contains(HealthReadType.restingHeartRate) + ? await _hc.readRestingHeartRate(r.start, r.end) + : []; + + final byDay = >{}; + for (final s in rhr) { + byDay.putIfAbsent(dateKey(s.time), () => []).add(s.value); + } + + if (g == HealthGranularity.month) { + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final vals = byDay[dateKey(d)] ?? const []; + bars.add(_rangeBar(d, '${d.day}', vals)); + } + return bars; + } + + // Year → 12 monthly bars. + final byMonth = >{}; + byDay.forEach((key, vals) { + final m = DateTime.parse(key).month; + byMonth.putIfAbsent(m, () => []).addAll(vals); + }); + return List.generate(12, (i) { + final month = i + 1; + final date = DateTime(_midnight(anchor).year, month, 1); + return _rangeBar(date, _monthLabel(month), byMonth[month] ?? const []); + }); + } + + HrRangeBar _rangeBar(DateTime date, String label, List vals) { + if (vals.isEmpty) { + return HrRangeBar( + date: date, label: label, minBpm: 0, maxBpm: 0, avgBpm: 0, restingBpm: null); + } + final mn = vals.reduce((a, b) => a < b ? a : b); + final mx = vals.reduce((a, b) => a > b ? a : b); + final avg = vals.reduce((a, b) => a + b) / vals.length; + return HrRangeBar( + date: date, + label: label, + minBpm: mn.round(), + maxBpm: mx.round(), + avgBpm: avg, + restingBpm: avg.round(), + ); + } + + static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + static const _months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + String _weekdayLabel(DateTime d) => _weekdays[d.weekday - 1]; + String _monthLabel(int month) => _months[month - 1]; +} + +/// Accumulates stage minutes for one night across fragmented records. +class _StageTally { + int total = 0; + int deep = 0; + int rem = 0; + int light = 0; + int awake = 0; + + void add(SleepPeriod p) { + total += p.minutes; + deep += p.deepMinutes ?? 0; + rem += p.remMinutes ?? 0; + light += p.lightMinutes ?? 0; + awake += p.awakeMinutes ?? 0; + } +} diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index fb59b87..621badc 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -18,3 +18,4 @@ export 'analytics_manager.dart'; export 'program_manager.dart'; export 'health_sync_manager.dart'; export 'pr_manager.dart'; +export 'readiness_manager.dart'; diff --git a/workout-logger/lib/services/managers/readiness_manager.dart b/workout-logger/lib/services/managers/readiness_manager.dart new file mode 100644 index 0000000..7b8f9f1 --- /dev/null +++ b/workout-logger/lib/services/managers/readiness_manager.dart @@ -0,0 +1,395 @@ +// Readiness Manager (Single Responsibility Principle) +// +// Owns the daily readiness slice of state: reads sleep/heart data from +// Health Connect, maintains a rolling 14-day personal baseline (recomputed +// at most once per day), scores today via ReadinessCalculator, and caches +// the result in settings storage so the home screen renders instantly. +// +// Failure policy: this feature is strictly additive — every error path +// degrades to ReadinessStatus.noData and never throws or blocks app init. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/readiness_manager_interface.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../settings_provider.dart'; +import '../utils/readiness_calculator.dart'; +import '../utils/sleep_hr_builder.dart'; + +class ReadinessManager extends ChangeNotifier implements IReadinessManager { + final IHealthConnectService _hc; + final IStorageService _storage; + final SettingsProvider _settings; + final ReadinessCalculator _calculator; + + static const _snapshotKey = 'readiness.snapshot'; + static const _baselineKey = 'readiness.baseline'; + static const _snapshotTtl = Duration(minutes: 30); + static const _baselineDays = 14; + + ReadinessStatus _status = ReadinessStatus.idle; + ReadinessSnapshot? _snapshot; + SleepHrSnapshot? _sleepHrSnapshot; + HrDaySnapshot? _hrDaySnapshot; + + SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; + + /// Today's all-day HR snapshot — backs the dashboard Heart-rate card. + /// Built best-effort during [refresh]; null when no HR data/permission. + HrDaySnapshot? get hrDaySnapshot => _hrDaySnapshot; + + // Debug-only: human-readable trace of the last refresh() execution. + // Empty until refresh() runs for the first time. + String _debugTrace = ''; + String get debugTrace => _debugTrace; + + ReadinessManager( + this._hc, + this._storage, + this._settings, { + ReadinessCalculator calculator = const ReadinessCalculator(), + }) : _calculator = calculator; + + @override + ReadinessStatus get status => _status; + + @override + ReadinessSnapshot? get snapshot => _snapshot; + + @override + Future refresh({bool force = false}) async { + if (!_settings.readinessEnabled) { + _debugTrace = 'readinessEnabled=false — refresh skipped'; + return; + } + + try { + final now = DateTime.now(); + final todayKey = ReadinessCalculator.dateKey(now); + debugPrint('[Readiness] refresh: todayKey=$todayKey force=$force'); + + // Fetch permissions first — needed on both the cached and live paths. + final granted = await _hc.grantedReadTypes(); + debugPrint('[Readiness] refresh: granted=$granted'); + if (granted.isEmpty) { + debugPrint('[Readiness] refresh: no permissions → noData'); + _debugTrace = 'NO PERMISSIONS granted\n' + 'Open Health Connect → App permissions → RepForge\n' + 'and allow Sleep and Heart rate.'; + _setNoData(); + return; + } + + final cached = await _loadSnapshot(); + if (cached != null && cached.dateKey == todayKey) { + // Same-day cache renders immediately; skip the re-fetch inside TTL. + _snapshot = cached; + _status = ReadinessStatus.ready; + notifyListeners(); + debugPrint('[Readiness] refresh: serving cached snapshot score=${cached.score}'); + if (!force && now.difference(cached.computedAt) < _snapshotTtl) { + _debugTrace = 'Serving cached snapshot (within ${_snapshotTtl.inMinutes}min TTL)\n' + 'score=${cached.score} band=${cached.band}\n' + 'computedAt=${cached.computedAt.toLocal()}'; + // Still build the HR snapshots if we don't have them yet. + if (_sleepHrSnapshot == null || _hrDaySnapshot == null) { + _sleepHrSnapshot ??= await _buildSleepHrSnapshot(now, granted); + _hrDaySnapshot ??= await _buildHrDaySnapshot(now, granted); + notifyListeners(); + } + return; + } + } + + final sleepMinutes = await _lastNightSleepMinutes(now, granted); + final restingHr = await _todayRestingHr(now, granted); + final hrv = await _todayHrv(now, granted); + + // Build HR snapshots (best-effort; failure must not affect score). + try { + _sleepHrSnapshot = await _buildSleepHrSnapshot(now, granted); + } catch (e) { + debugPrint('[Readiness] _buildSleepHrSnapshot failed (non-fatal): $e'); + _sleepHrSnapshot = null; + } + try { + _hrDaySnapshot = await _buildHrDaySnapshot(now, granted); + } catch (e) { + debugPrint('[Readiness] _buildHrDaySnapshot failed (non-fatal): $e'); + _hrDaySnapshot = null; + } + debugPrint('[Readiness] refresh: today → sleepMinutes=$sleepMinutes restingHr=$restingHr hrv=$hrv'); + + final baseline = await _baselineFor(todayKey, now, granted); + debugPrint('[Readiness] refresh: baseline → ' + 'avgSleep=${baseline.avgSleepMinutes?.toStringAsFixed(0)} (${baseline.sleepNights} nights) ' + 'avgRhr=${baseline.avgRestingHr?.toStringAsFixed(1)} (${baseline.rhrDays} days) ' + 'avgHrv=${baseline.avgHrvMs?.toStringAsFixed(1)} (${baseline.hrvDays} days)'); + + final snapshot = _calculator.compute( + today: now, + baseline: baseline, + lastNightSleepMinutes: sleepMinutes, + todayRestingHr: restingHr, + todayHrvMs: hrv, + ); + debugPrint('[Readiness] refresh: snapshot score=${snapshot.score} band=${snapshot.band} ' + 'sleepScore=${snapshot.sleepScore} rhrScore=${snapshot.rhrScore} hrvScore=${snapshot.hrvScore}'); + + // Build human-readable trace for the in-app debug panel. + final need = ReadinessCalculator.minBaselineSamples; + final buf = StringBuffer(); + buf.writeln('Granted: ${granted.map((e) => e.name).join(', ')}'); + buf.writeln(''); + buf.writeln('TODAY:'); + buf.writeln(' sleep : ${sleepMinutes != null ? "${sleepMinutes}min" : "— (no data)"}' + '${!granted.contains(HealthReadType.sleep) ? " [no perm]" : ""}'); + buf.writeln(' RHR : ${restingHr != null ? "${restingHr.toStringAsFixed(1)} bpm" : "— (no data)"}' + '${!granted.contains(HealthReadType.restingHeartRate) ? " [no perm]" : ""}'); + buf.writeln(' HRV : ${hrv != null ? "${hrv.toStringAsFixed(1)} ms" : "— (no data)"}' + '${!granted.contains(HealthReadType.hrv) ? " [no perm]" : ""}'); + buf.writeln(''); + buf.writeln('BASELINE (14d, need ≥$need samples):'); + buf.writeln(' sleep : ${baseline.avgSleepMinutes?.toStringAsFixed(0) ?? "—"}min' + ' · ${baseline.sleepNights} nights' + ' ${baseline.sleepNights >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(' RHR : ${baseline.avgRestingHr?.toStringAsFixed(1) ?? "—"} bpm' + ' · ${baseline.rhrDays} days' + ' ${baseline.rhrDays >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(' HRV : ${baseline.avgHrvMs?.toStringAsFixed(1) ?? "—"} ms' + ' · ${baseline.hrvDays} days' + ' ${baseline.hrvDays >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(''); + buf.writeln('SLEEP HR:'); + if (_sleepHrSnapshot != null) { + final sh = _sleepHrSnapshot!; + buf.writeln(' segments=${sh.segments.length} p95=${sh.p95Bpm}bpm' + ' stages=${sh.stageStats.map((s) => s.stage).join(",")}'); + } else { + buf.writeln(' — no snapshot (need heartRate perm + sleep data)'); + } + buf.writeln(''); + buf.writeln('SCORES:'); + buf.writeln(' sleep=${snapshot.sleepScore ?? "—"} rhr=${snapshot.rhrScore ?? "—"} hrv=${snapshot.hrvScore ?? "—"}'); + buf.writeln(' overall=${snapshot.score ?? "null"} band=${snapshot.band?.name ?? "—"}'); + if (snapshot.score == null) { + buf.writeln(''); + buf.writeln('⚠ Score null: a component needs both today\'s data'); + buf.writeln(' AND ≥$need baseline days to contribute.'); + } + _debugTrace = buf.toString().trimRight(); + + if (snapshot.score == null) { + debugPrint('[Readiness] refresh: score null → noData ' + '(need ${ReadinessCalculator.minBaselineSamples}+ baseline days; ' + 'have sleep=${baseline.sleepNights} rhr=${baseline.rhrDays} hrv=${baseline.hrvDays})'); + _setNoData(); + return; + } + + _snapshot = snapshot; + _status = ReadinessStatus.ready; + await _storage.saveSetting(_snapshotKey, jsonEncode(snapshot.toJson())); + notifyListeners(); + } catch (e) { + debugPrint('[Readiness] refresh failed: $e'); + _debugTrace = 'refresh() threw: $e'; + _setNoData(); + } + } + + /// Builds last night's overnight HR snapshot for the Sleep HR chart, + /// falling back to the night before when the watch hasn't synced yet. + Future _buildSleepHrSnapshot( + DateTime now, + Set granted, + ) => + buildSleepHrSnapshot(_hc, now, granted, fallbackToPriorNight: true); + + /// Builds today's all-day HR snapshot for the Heart-rate card. + Future _buildHrDaySnapshot( + DateTime now, + Set granted, + ) => + buildHrDaySnapshot(_hc, now, granted); + + void _setNoData() { + _snapshot = null; + _status = ReadinessStatus.noData; + notifyListeners(); + } + + Future _loadSnapshot() async { + try { + final raw = await _storage.getSetting(_snapshotKey); + if (raw == null) return null; + return ReadinessSnapshot.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + return null; + } + } + + /// Returns the cached baseline when it was already computed today, + /// otherwise rebuilds it from the trailing [_baselineDays] window + /// (excluding last night / today, which are what we score). + Future _baselineFor( + String todayKey, + DateTime now, + Set granted, + ) async { + try { + final raw = await _storage.getSetting(_baselineKey); + if (raw != null) { + final cached = + ReadinessBaseline.fromJson(jsonDecode(raw) as Map); + if (cached.dateKey == todayKey) return cached; + } + } catch (_) { + // Corrupt cache — fall through to recompute. + } + + final day = DateTime(now.year, now.month, now.day); + final windowStart = day.subtract(const Duration(days: _baselineDays)); + + double? avgSleep; + var sleepNights = 0; + if (granted.contains(HealthReadType.sleep)) { + // End the window at yesterday 18:00 so last night isn't in its own baseline. + final periods = await _hc.readSleepSessions( + windowStart, + day.subtract(const Duration(hours: 6)), + ); + final nightly = _nightlySleepMinutes(periods); + sleepNights = nightly.length; + if (sleepNights > 0) { + avgSleep = nightly.reduce((a, b) => a + b) / sleepNights; + } + } + + double? avgRhr; + var rhrDays = 0; + if (granted.contains(HealthReadType.restingHeartRate)) { + final samples = await _hc.readRestingHeartRate(windowStart, day); + final daily = _dailyAverages(samples); + rhrDays = daily.length; + if (rhrDays > 0) avgRhr = daily.reduce((a, b) => a + b) / rhrDays; + } + + double? avgHrv; + var hrvDays = 0; + if (granted.contains(HealthReadType.hrv)) { + final samples = await _hc.readHrvRmssd(windowStart, day); + final daily = _dailyAverages(samples); + hrvDays = daily.length; + if (hrvDays > 0) avgHrv = daily.reduce((a, b) => a + b) / hrvDays; + } + + final baseline = ReadinessBaseline( + dateKey: todayKey, + avgSleepMinutes: avgSleep, + sleepNights: sleepNights, + avgRestingHr: avgRhr, + rhrDays: rhrDays, + avgHrvMs: avgHrv, + hrvDays: hrvDays, + ); + await _storage.saveSetting(_baselineKey, jsonEncode(baseline.toJson())); + return baseline; + } + + /// Total minutes per night, bucketed by the day the session ENDS on. + /// + /// Health Connect (Pixel Watch, etc.) writes multiple records per night — + /// one per sleep stage or one per awakening gap. Summing gives the real + /// nightly total; taking max severely under-counts fragmented recordings. + List _nightlySleepMinutes(List periods) { + final byNight = {}; + for (final p in periods) { + final key = ReadinessCalculator.dateKey(p.end); + byNight[key] = (byNight[key] ?? 0) + p.minutes; + } + return byNight.values.map((m) => m.toDouble()).toList(); + } + + /// One average per calendar day a sample exists on. + List _dailyAverages(List samples) { + final sums = {}; + final counts = {}; + for (final s in samples) { + final key = ReadinessCalculator.dateKey(s.time); + sums[key] = (sums[key] ?? 0) + s.value; + counts[key] = (counts[key] ?? 0) + 1; + } + return sums.entries.map((e) => e.value / counts[e.key]!).toList(); + } + + Future _lastNightSleepMinutes( + DateTime now, + Set granted, + ) async { + if (!granted.contains(HealthReadType.sleep)) return null; + final day = DateTime(now.year, now.month, now.day); + final periods = await _hc.readSleepSessions( + day.subtract(const Duration(hours: 6)), + day.add(const Duration(hours: 12)), + ); + for (final p in periods) { + final stageInfo = p.hasStages + ? 'L=${p.lightMinutes} D=${p.deepMinutes} R=${p.remMinutes} A=${p.awakeMinutes}' + : 'no stages'; + debugPrint('[Readiness] period ${p.start.toLocal().hour}:${p.start.toLocal().minute.toString().padLeft(2, '0')}' + '→${p.end.toLocal().hour}:${p.end.toLocal().minute.toString().padLeft(2, '0')}' + ' actual=${p.minutes}min ($stageInfo)'); + } + return _calculator.lastNightSleepMinutes(now, periods); + } + + /// Latest resting-HR record in the past 24h; falls back to the minimum + /// raw heart-rate sample between 02:00–10:00 today. The fallback is the + /// only minute-level query and only runs when no RHR record exists. + Future _todayRestingHr( + DateTime now, + Set granted, + ) async { + if (granted.contains(HealthReadType.restingHeartRate)) { + final samples = await _hc.readRestingHeartRate( + now.subtract(const Duration(hours: 24)), + now, + ); + if (samples.isNotEmpty) { + samples.sort((a, b) => a.time.compareTo(b.time)); + return samples.last.value; + } + } + if (granted.contains(HealthReadType.heartRate)) { + final day = DateTime(now.year, now.month, now.day); + final samples = await _hc.readHeartRateSamples( + day.add(const Duration(hours: 2)), + day.add(const Duration(hours: 10)), + ); + if (samples.isNotEmpty) { + return samples.map((s) => s.value).reduce((a, b) => a < b ? a : b); + } + } + return null; + } + + Future _todayHrv(DateTime now, Set granted) async { + if (!granted.contains(HealthReadType.hrv)) return null; + final samples = await _hc.readHrvRmssd( + now.subtract(const Duration(hours: 48)), + now, + ); + debugPrint('[Readiness] HRV samples (48h): ${samples.length}'); + if (samples.isEmpty) return null; + samples.sort((a, b) => a.time.compareTo(b.time)); + return samples.last.value; + } +} diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index 1b75327..ffae2a4 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -4,13 +4,33 @@ import 'interfaces/ml_service_interface.dart'; export 'interfaces/ml_service_interface.dart' show DataPoint, MuscleRecoveryStatus; -/// Exponentially-weighted linear regression + double-progression recommendations -/// + per-muscle recovery scoring. +/// Growth modelling + double-progression recommendations + per-muscle +/// recovery scoring. +/// +/// Growth model: exponentially-weighted least squares fit of two candidate +/// curves — linear and logarithmic (saturating) — each refined with one +/// robust (Tukey bisquare) re-weighting pass so single outlier sessions +/// (deloads, cut-short workouts) don't tilt the trend. The better-fitting +/// curve wins; the logarithmic form captures the diminishing returns real +/// muscle growth follows, which a straight line systematically overshoots. class MLService implements IMLService { // Decay constant for recency weights. At λ=0.15, a session 10 sessions ago // carries exp(−1.5) ≈ 22 % of the weight of the most recent session. static const _lambda = 0.15; + // Logarithmic candidate is considered only with enough history for + // curvature to be identifiable; over short spans log ≈ linear. + static const _minPointsForLogCurve = 6; + static const _minSpanDaysForLogCurve = 14.0; + + // The log curve must beat linear by this fraction of weighted RSS to win, + // preventing flip-flopping between near-identical fits. + static const _logSelectionMargin = 0.02; + + // Robust pass: points beyond c·σ̂ get fully rejected by Tukey's bisquare. + static const _tukeyC = 4.685; + static const _minPointsForRobustPass = 5; + // Recovery time constants τ (hours) per muscle group. // Full recovery (~95 %) occurs at ≈ 3τ. static const _tauHours = { @@ -39,8 +59,9 @@ class MLService implements IMLService { return MLService.trainGrowthModelStatic(dataPoints); } - /// Exponentially-weighted least squares. - /// Weight for point i (0-indexed, n total): exp(−λ · (n−1−i)). + /// Fits linear and logarithmic candidates with exponential recency weights + /// (weight for point i of n: exp(−λ·(n−1−i))) plus one robust re-weighting + /// pass each, then selects the better curve by weighted residual error. static GrowthModel trainGrowthModelStatic(List dataPoints) { if (dataPoints.isEmpty) { return GrowthModel(slope: 0, intercept: 0, r2: 0, lastTrained: DateTime.now()); @@ -51,50 +72,149 @@ class MLService implements IMLService { intercept: dataPoints.first.y, r2: 1, lastTrained: DateTime.now(), + lastX: dataPoints.first.x, ); } final n = dataPoints.length; - final weights = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); + final recency = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); + final xs = dataPoints.map((p) => p.x).toList(); + final ys = dataPoints.map((p) => p.y).toList(); + final lastX = xs.reduce(max); + final spanDays = lastX - xs.reduce(min); + + final linear = _robustWeightedFit(xs, ys, recency); + + _Fit? logFit; + if (n >= _minPointsForLogCurve && spanDays >= _minSpanDaysForLogCurve) { + final logXs = xs.map((x) => log(1 + max(0.0, x))).toList(); + logFit = _robustWeightedFit(logXs, ys, recency); + } + + final useLog = logFit != null && + logFit.rss < linear.rss * (1 - _logSelectionMargin); + final fit = useLog ? logFit : linear; + final curve = useLog ? GrowthCurve.logarithmic : GrowthCurve.linear; + + // Instantaneous daily rate at the newest point: d/dx [a + b·ln(1+x)]. + final slope = useLog ? fit.slope / (1 + lastX) : fit.slope; + + return GrowthModel( + slope: slope, + intercept: fit.intercept, + r2: fit.r2.clamp(0.0, 1.0), + lastTrained: DateTime.now(), + curve: curve, + coefficient: fit.slope, + lastX: lastX, + stdError: fit.stdError, + ); + } + + /// Weighted least squares with one Tukey-bisquare re-weighting pass. + /// + /// The robust pass estimates residual scale via the weighted MAD, then + /// refits with outliers down-weighted by (1 − (r/cσ̂)²)², so a single + /// deload or cut-short session cannot tilt the trend. Skipped for tiny + /// samples or when residuals are too uniform to identify outliers. + static _Fit _robustWeightedFit( + List xs, + List ys, + List recency, + ) { + var fit = _weightedLeastSquares(xs, ys, recency); + + if (xs.length < _minPointsForRobustPass) return fit; + + final residuals = [ + for (var i = 0; i < xs.length; i++) + (ys[i] - (fit.intercept + fit.slope * xs[i])).abs(), + ]; + final mad = _median(residuals); + if (mad <= 0) return fit; + final scale = 1.4826 * mad; // MAD → σ̂ for normal residuals + + final robust = []; + for (var i = 0; i < xs.length; i++) { + final u = residuals[i] / (_tukeyC * scale); + final tukey = u >= 1 ? 0.0 : pow(1 - u * u, 2).toDouble(); + robust.add(recency[i] * tukey); + } + // Refit only if the pass actually rejected/damped something and enough + // effective weight survives to keep the fit identifiable. + final kept = robust.where((w) => w > 0).length; + if (kept < 3) return fit; + final refit = _weightedLeastSquares(xs, ys, robust); + return refit.degenerate ? fit : refit; + } + + static _Fit _weightedLeastSquares( + List xs, + List ys, + List weights, + ) { + final n = xs.length; final wSum = weights.fold(0.0, (s, w) => s + w); double wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0; for (var i = 0; i < n; i++) { final w = weights[i]; - final x = dataPoints[i].x; - final y = dataPoints[i].y; - wSumX += w * x; - wSumY += w * y; - wSumXY += w * x * y; - wSumX2 += w * x * x; + wSumX += w * xs[i]; + wSumY += w * ys[i]; + wSumXY += w * xs[i] * ys[i]; + wSumX2 += w * xs[i] * xs[i]; } final denom = wSum * wSumX2 - wSumX * wSumX; - if (denom == 0) { - return GrowthModel(slope: 0, intercept: wSumY / wSum, r2: 0, lastTrained: DateTime.now()); + if (denom.abs() < 1e-12 || wSum <= 0) { + final mean = wSum > 0 ? wSumY / wSum : 0.0; + return _Fit( + slope: 0, + intercept: mean, + r2: 0, + rss: double.infinity, + stdError: 0, + degenerate: true, + ); } final slope = (wSum * wSumXY - wSumX * wSumY) / denom; final intercept = (wSumY - slope * wSumX) / wSum; final yBar = wSumY / wSum; - double ssTotal = 0, ssResidual = 0; + double ssTotal = 0, ssResidual = 0, wSqSum = 0; for (var i = 0; i < n; i++) { final w = weights[i]; - final predicted = slope * dataPoints[i].x + intercept; - ssTotal += w * pow(dataPoints[i].y - yBar, 2); - ssResidual += w * pow(dataPoints[i].y - predicted, 2); + final predicted = slope * xs[i] + intercept; + ssTotal += w * pow(ys[i] - yBar, 2); + ssResidual += w * pow(ys[i] - predicted, 2); + wSqSum += w * w; } - final r2 = ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0; - return GrowthModel( + // Weighted mean squared residual, dof-corrected via the Kish effective + // sample size (recency weights make n optimistic). + final nEff = wSqSum > 0 ? (wSum * wSum) / wSqSum : 0.0; + final dof = max(1.0, nEff - 2); + final stdError = sqrt(max(0.0, ssResidual / wSum) * (nEff / dof)); + + return _Fit( slope: slope, intercept: intercept, - r2: r2.clamp(0.0, 1.0), - lastTrained: DateTime.now(), + r2: ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0, + rss: ssResidual, + stdError: stdError, + degenerate: false, ); } + static double _median(List values) { + final sorted = List.from(values)..sort(); + final mid = sorted.length ~/ 2; + return sorted.length.isOdd + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + } + // ==================== DATA EXTRACTION ==================== /// x = days since first session for this exercise, y = total volume. @@ -215,13 +335,25 @@ class MLService implements IMLService { // ==================== RECOMMENDATIONS ==================== - /// Double-progression with optional recovery awareness. + // Weekly relative growth thresholds (% of current volume per week). + // Below _plateauWeeklyPct the curve is effectively flat; below + // _declineWeeklyPct volume is genuinely regressing and a deload pays off. + static const _plateauWeeklyPct = 0.5; + static const _declineWeeklyPct = -2.0; + static const _minR2ForTrendSignal = 0.2; + + /// Double-progression with trend- and recovery-aware modulation. /// /// Priority order: /// 1. Under-recovered primary muscle → maintenance (hold weight & reps). - /// 2. Plateau (model slope ≤ 0, R² > 0.25) → maintenance. - /// 3. reps ≥ maxReps → bump weight, reset to minReps. - /// 4. Otherwise → add 1 rep, hold weight. + /// 2. Decline (weekly growth < −2 %, trustworthy fit) → 10 % deload. + /// 3. Plateau (weekly growth < 0.5 %, trustworthy fit) → maintenance. + /// 4. reps ≥ maxReps → bump weight, reset to minReps. + /// 5. Otherwise → add 1 rep, hold weight. + /// + /// Trend checks use [GrowthModel.weeklyGrowthPercent] — growth relative to + /// the lifter's current volume — so the same thresholds work for a 60 kg + /// novice bench and a 10 t weekly squat volume. @override List recommendSets({ required List lastSession, @@ -233,9 +365,12 @@ class MLService implements IMLService { }) { if (lastSession.isEmpty) return []; - final isPlateau = growthModel != null && - growthModel.slope <= 0 && - growthModel.r2 > 0.25; + final trendIsTrustworthy = + growthModel != null && growthModel.r2 > _minR2ForTrendSignal; + final weeklyPct = trendIsTrustworthy ? growthModel.weeklyGrowthPercent : null; + final isDeclining = weeklyPct != null && weeklyPct < _declineWeeklyPct; + final isPlateau = + weeklyPct != null && !isDeclining && weeklyPct < _plateauWeeklyPct; final isUnderRecovered = primaryMuscleIds != null && recoveryScores != null && @@ -255,6 +390,7 @@ class MLService implements IMLService { minReps: minReps, maxReps: maxReps, isPlateau: isPlateau, + isDeclining: isDeclining, isUnderRecovered: isUnderRecovered, recoveryPercent: worstRecovery, )) @@ -266,6 +402,7 @@ class MLService implements IMLService { required int minReps, required int maxReps, required bool isPlateau, + required bool isDeclining, required bool isUnderRecovered, int? recoveryPercent, }) { @@ -279,6 +416,18 @@ class MLService implements IMLService { ); } + if (isDeclining) { + // Round the deload to the plate increment users can actually load. + final deloaded = max(0.0, ((set.weight * 0.9) / 2.5).round() * 2.5); + return SetRecommendation( + weight: deloaded, + reps: set.reps, + confidence: 'medium', + reasoning: + 'Volume trending down — deload ~10% for a session or two, then rebuild', + ); + } + if (isPlateau) { return SetRecommendation( weight: set.weight, @@ -322,7 +471,15 @@ class MLService implements IMLService { // ==================== TARGET PREDICTIONS ==================== - /// Slope is volume/day (x = days since first session). + // Predictions further out than this are noise, not information. + static const _maxPredictionDays = 365 * 2; + + /// Projects the fitted curve forward to the target (x = days). + /// + /// Linear fits extrapolate at the constant rate; logarithmic fits invert + /// the curve, so the flattening trajectory honestly pushes the date out + /// instead of promising linear gains forever. Predictions beyond two years + /// return null — too uncertain to show. @override DateTime? predictTargetCompletion({ required double currentValue, @@ -332,11 +489,33 @@ class MLService implements IMLService { }) { if (currentValue >= targetValue) return DateTime.now(); if (growthModel.slope <= 0) return null; - final days = ((targetValue - currentValue) / growthModel.slope).ceil(); - return DateTime.now().add(Duration(days: days)); + + final double daysFromNow; + switch (growthModel.curve) { + case GrowthCurve.linear: + daysFromNow = (targetValue - currentValue) / growthModel.slope; + case GrowthCurve.logarithmic: + // Map the live current value and the target through the curve's + // inverse x(y) = exp((y−a)/b) − 1 and take the day difference, so + // drift between the live value and the fitted curve cancels out. + final b = growthModel.coefficient; + if (b <= 0) return null; + final xTarget = exp((targetValue - growthModel.intercept) / b) - 1; + final xCurrent = exp((currentValue - growthModel.intercept) / b) - 1; + daysFromNow = xTarget - xCurrent; + } + + if (daysFromNow <= 0) return DateTime.now(); + if (!daysFromNow.isFinite || daysFromNow > _maxPredictionDays) return null; + return DateTime.now().add(Duration(days: daysFromNow.ceil())); } /// Confidence interval around the predicted completion date. + /// + /// Width comes from the model's residual standard error converted to days + /// at the current growth rate (± how long the typical session-to-session + /// scatter could shift the crossing point), falling back to an R²-scaled + /// margin for legacy models without a stored error. static ({DateTime optimistic, DateTime expected, DateTime pessimistic})? predictTargetWithConfidence({ required double currentValue, @@ -353,7 +532,14 @@ class MLService implements IMLService { if (expected == null) return null; final daysToTarget = expected.difference(DateTime.now()).inDays; - final uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); + final int uncertainty; + if (growthModel.stdError > 0 && growthModel.slope > 0) { + uncertainty = (growthModel.stdError / growthModel.slope) + .ceil() + .clamp(0, max(1, daysToTarget)); + } else { + uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); + } return ( optimistic: expected.subtract(Duration(days: uncertainty)), expected: expected, @@ -361,3 +547,22 @@ class MLService implements IMLService { ); } } + +/// Internal weighted-least-squares result for one candidate curve. +class _Fit { + final double slope; + final double intercept; + final double r2; + final double rss; // weighted residual sum of squares (selection criterion) + final double stdError; + final bool degenerate; + + const _Fit({ + required this.slope, + required this.intercept, + required this.r2, + required this.rss, + required this.stdError, + required this.degenerate, + }); +} diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index d295d65..164df92 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -12,6 +12,7 @@ class SettingsProvider extends ChangeNotifier { WeightUnit _weightUnit = WeightUnit.kg; double _weightIncrement = 2.5; bool _healthConnectEnabled = false; + bool _readinessEnabled = false; String? _userName; String? _lastSeenVersion; String _geminiApiKey = ''; @@ -24,6 +25,7 @@ class SettingsProvider extends ChangeNotifier { double get weightIncrement => _weightIncrement; String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; bool get healthConnectEnabled => _healthConnectEnabled; + bool get readinessEnabled => _readinessEnabled; String? get userName => _userName; String? get lastSeenVersion => _lastSeenVersion; String get geminiApiKey => _geminiApiKey; @@ -46,6 +48,9 @@ class SettingsProvider extends ChangeNotifier { final hcEnabled = await _storage.getSetting('healthConnectEnabled'); _healthConnectEnabled = hcEnabled == 'true'; + final readiness = await _storage.getSetting('readinessEnabled'); + _readinessEnabled = readiness == 'true'; + _userName = await _storage.getSetting('userName'); _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; @@ -101,6 +106,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setReadinessEnabled(bool enabled) async { + _readinessEnabled = enabled; + await _storage.saveSetting('readinessEnabled', enabled.toString()); + notifyListeners(); + } + Future setGeminiModel(String model) async { _geminiModel = model; await _storage.saveSetting('geminiModel', model); diff --git a/workout-logger/lib/services/utils/readiness_calculator.dart b/workout-logger/lib/services/utils/readiness_calculator.dart new file mode 100644 index 0000000..74a1259 --- /dev/null +++ b/workout-logger/lib/services/utils/readiness_calculator.dart @@ -0,0 +1,147 @@ +// Readiness Calculator (pure, no I/O) +// +// Scores today's training readiness against the user's own rolling baseline. +// Each component (sleep, resting HR, HRV) is scored 0–100 independently and +// only penalizes adverse deviation — being at or better than baseline is 100. +// The overall score is a weighted average renormalized over the components +// that are actually available, so sleep-only users get a first-class score. + +import '../../models/models.dart'; + +class ReadinessCalculator { + const ReadinessCalculator(); + + /// Minimum baseline samples before a component participates in scoring. + static const int minBaselineSamples = 5; + + /// Component weights, renormalized over available components. + static const double sleepWeight = 0.5; + static const double rhrWeight = 0.3; + static const double hrvWeight = 0.2; + + /// Sleep under this many minutes is capped at [shortSleepMaxScore] + /// regardless of the user's baseline (guards chronically short baselines). + static const int shortSleepMinutes = 300; + static const int shortSleepMaxScore = 40; + + static const int highBandThreshold = 75; + static const int moderateBandThreshold = 50; + + /// Returns total minutes of sleep in the window yesterday 18:00 → today 12:00. + /// + /// Health Connect (especially Pixel Watch) writes sleep as multiple records + /// per night — one per stage or one per awakening gap. Summing gives the true + /// sleep total; taking the longest single record under-counts badly. + int? lastNightSleepMinutes(DateTime today, List periods) { + final day = DateTime(today.year, today.month, today.day); + final windowStart = day.subtract(const Duration(hours: 6)); // 18:00 prev day + final windowEnd = day.add(const Duration(hours: 12)); + + var totalMinutes = 0; + for (final p in periods) { + if (!p.end.isAfter(windowStart) || !p.start.isBefore(windowEnd)) continue; + totalMinutes += p.minutes; + } + return totalMinutes > 0 ? totalMinutes : null; + } + + // Keep backward-compatible name used in tests; delegates to the new method. + SleepPeriod? lastNightSleep(DateTime today, List periods) { + final minutes = lastNightSleepMinutes(today, periods); + if (minutes == null) return null; + // Return a synthetic period whose .minutes equals the summed total. + final now = DateTime(today.year, today.month, today.day); + return SleepPeriod(start: now, end: now.add(Duration(minutes: minutes))); + } + + ReadinessSnapshot compute({ + required DateTime today, + required ReadinessBaseline baseline, + int? lastNightSleepMinutes, + double? todayRestingHr, + double? todayHrvMs, + }) { + final sleepBaseline = + baseline.sleepNights >= minBaselineSamples ? baseline.avgSleepMinutes : null; + final rhrBaseline = + baseline.rhrDays >= minBaselineSamples ? baseline.avgRestingHr : null; + final hrvBaseline = + baseline.hrvDays >= minBaselineSamples ? baseline.avgHrvMs : null; + + final sleepScore = _sleepScore(lastNightSleepMinutes, sleepBaseline); + final rhrScore = _rhrScore(todayRestingHr, rhrBaseline); + final hrvScore = _hrvScore(todayHrvMs, hrvBaseline); + + int? score; + ReadinessBand? band; + var weighted = 0.0; + var totalWeight = 0.0; + if (sleepScore != null) { + weighted += sleepScore * sleepWeight; + totalWeight += sleepWeight; + } + if (rhrScore != null) { + weighted += rhrScore * rhrWeight; + totalWeight += rhrWeight; + } + if (hrvScore != null) { + weighted += hrvScore * hrvWeight; + totalWeight += hrvWeight; + } + if (totalWeight > 0) { + score = (weighted / totalWeight).round().clamp(0, 100); + band = score >= highBandThreshold + ? ReadinessBand.high + : score >= moderateBandThreshold + ? ReadinessBand.moderate + : ReadinessBand.low; + } + + return ReadinessSnapshot( + dateKey: dateKey(today), + score: score, + band: band, + sleepMinutes: sleepScore != null ? lastNightSleepMinutes : null, + sleepBaselineMinutes: sleepScore != null ? sleepBaseline : null, + sleepScore: sleepScore, + restingHr: rhrScore != null ? todayRestingHr : null, + rhrBaseline: rhrScore != null ? rhrBaseline : null, + rhrScore: rhrScore, + hrvMs: hrvScore != null ? todayHrvMs : null, + hrvBaseline: hrvScore != null ? hrvBaseline : null, + hrvScore: hrvScore, + ); + } + + // Every 10% of sleep below the personal average costs 20 points. + int? _sleepScore(int? minutes, double? avgMinutes) { + if (minutes == null || avgMinutes == null || avgMinutes <= 0) return null; + final ratio = minutes / avgMinutes; + var score = (100 - _adverse(1 - ratio) * 200).round().clamp(0, 100); + if (minutes < shortSleepMinutes && score > shortSleepMaxScore) { + score = shortSleepMaxScore; + } + return score; + } + + // Elevated resting HR is the penalty: +10% over baseline scores 50. + int? _rhrScore(double? rhr, double? avgRhr) { + if (rhr == null || avgRhr == null || avgRhr <= 0) return null; + final deviation = (rhr - avgRhr) / avgRhr; + return (100 - _adverse(deviation) * 500).round().clamp(0, 100); + } + + // Suppressed HRV is the penalty: −20% under baseline scores 50. + int? _hrvScore(double? hrv, double? avgHrv) { + if (hrv == null || avgHrv == null || avgHrv <= 0) return null; + final ratio = hrv / avgHrv; + return (100 - _adverse(1 - ratio) * 250).round().clamp(0, 100); + } + + double _adverse(double deviation) => deviation > 0 ? deviation : 0; + + static String dateKey(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; +} diff --git a/workout-logger/lib/services/utils/sleep_hr_builder.dart b/workout-logger/lib/services/utils/sleep_hr_builder.dart new file mode 100644 index 0000000..02f6ffa --- /dev/null +++ b/workout-logger/lib/services/utils/sleep_hr_builder.dart @@ -0,0 +1,210 @@ +// Sleep-HR snapshot builder. +// +// Extracted from ReadinessManager so the overnight-HR snapshot can be built +// for ANY night, not just last night. ReadinessManager builds it for "today" +// (with a prior-night fallback for un-synced mornings); HealthHistoryManager +// builds it for arbitrary historical dates as the user navigates. +// +// Pure function over IHealthConnectService — no state, no caching here. + +import 'package:flutter/foundation.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; + +/// Builds an overnight HR snapshot for the night that ENDS on the morning of +/// [morning] (i.e. the local calendar day [morning]). +/// +/// Returns null when HR/sleep permission is missing or no samples exist. +/// When [fallbackToPriorNight] is true and the target night has no sleep data, +/// it retries the night before (covers mornings where the watch hasn't synced). +Future buildSleepHrSnapshot( + IHealthConnectService hc, + DateTime morning, + Set granted, { + bool fallbackToPriorNight = false, +}) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + if (!granted.contains(HealthReadType.sleep)) return null; + + final day = DateTime(morning.year, morning.month, morning.day); + + var windowStart = day.subtract(const Duration(hours: 6)); + var windowEnd = day.add(const Duration(hours: 12)); + + var periods = await hc.readSleepSessions(windowStart, windowEnd); + if (periods.isEmpty && fallbackToPriorNight) { + windowStart = windowStart.subtract(const Duration(days: 1)); + windowEnd = windowEnd.subtract(const Duration(days: 1)); + periods = await hc.readSleepSessions(windowStart, windowEnd); + debugPrint('[SleepHr] no data for target night — fell back to night before'); + } + if (periods.isEmpty) return null; + + // Use the earliest start and latest end across all records. + final sleepStart = periods.map((p) => p.start).reduce((a, b) => a.isBefore(b) ? a : b); + final sleepEnd = periods.map((p) => p.end).reduce((a, b) => a.isAfter(b) ? a : b); + + // Read HR samples covering the full sleep window (+ 15 min buffer). + final samples = await hc.readHeartRateSamples( + sleepStart.subtract(const Duration(minutes: 15)), + sleepEnd.add(const Duration(minutes: 15)), + ); + if (samples.isEmpty) return null; + + // Flatten all stage intervals from all periods into one sorted list. + final allIntervals = periods + .expand((p) => p.stageTimeline) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + + String stageAt(DateTime t) { + for (final iv in allIntervals) { + if (!t.isBefore(iv.start) && t.isBefore(iv.end)) return iv.stage; + } + return 'awake'; + } + + // Bucket samples into 10-minute windows aligned to sleepStart. + final segmentMap = >{}; + for (final s in samples) { + final offsetMin = s.time.difference(sleepStart).inMinutes; + if (offsetMin < 0) continue; + final bucket = (offsetMin ~/ 10) * 10; + segmentMap.putIfAbsent(bucket, () => []); + segmentMap[bucket]!.add((bpm: s.value.round(), stage: stageAt(s.time))); + } + + final segments = []; + final sortedBuckets = segmentMap.keys.toList()..sort(); + for (final bucket in sortedBuckets) { + final entries = segmentMap[bucket]!; + if (entries.length < 2) continue; + final bpms = entries.map((e) => e.bpm).toList()..sort(); + final stageCounts = {}; + for (final e in entries) { + stageCounts[e.stage] = (stageCounts[e.stage] ?? 0) + 1; + } + final dominantStage = stageCounts.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + segments.add(SleepHrSegment( + windowStart: sleepStart.add(Duration(minutes: bucket)), + minBpm: bpms.first, + maxBpm: bpms.last, + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + stage: dominantStage, + )); + } + if (segments.isEmpty) return null; + + // P5 / P95 across all samples. + final allBpms = samples.map((s) => s.value.round()).toList()..sort(); + final p5Bpm = allBpms[(allBpms.length * 0.05).floor().clamp(0, allBpms.length - 1)]; + final p95Bpm = allBpms[(allBpms.length * 0.95).floor().clamp(0, allBpms.length - 1)]; + + // Per-stage stats (min 3 samples required). + final byStage = >{}; + for (final s in samples) { + final stage = stageAt(s.time); + byStage.putIfAbsent(stage, () => []); + byStage[stage]!.add(s.value.round()); + } + final stageStats = []; + for (final entry in byStage.entries) { + final bpms = entry.value..sort(); + if (bpms.length < 3) continue; + stageStats.add(SleepStageStats( + stage: entry.key, + minBpm: bpms.first, + p25Bpm: bpms[(bpms.length * 0.25).floor()], + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + p75Bpm: bpms[(bpms.length * 0.75).floor()], + maxBpm: bpms.last, + sampleCount: bpms.length, + )); + } + + return SleepHrSnapshot( + sleepStart: sleepStart, + sleepEnd: sleepEnd, + p5Bpm: p5Bpm, + p95Bpm: p95Bpm, + segments: segments, + stageStats: stageStats, + ); +} + +/// Builds an all-day HR snapshot for the local calendar day [day]: ~30-minute +/// min/max/avg buckets plus a resting-HR figure. +/// +/// Returns null when HR permission is missing or no samples exist for the day. +/// Resting HR = latest restingHeartRate record that day, else the minimum +/// raw sample between 02:00–10:00 (same fallback ReadinessManager uses). +Future buildHrDaySnapshot( + IHealthConnectService hc, + DateTime day, + Set granted, { + Duration bucket = const Duration(minutes: 30), +}) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + + final start = DateTime(day.year, day.month, day.day); + final end = start.add(const Duration(days: 1)); + + final samples = await hc.readHeartRateSamples(start, end); + if (samples.isEmpty) return null; + + final bucketMin = bucket.inMinutes; + final byBucket = >{}; + for (final s in samples) { + final offset = s.time.difference(start).inMinutes; + if (offset < 0 || offset >= 1440) continue; + final key = (offset ~/ bucketMin) * bucketMin; + byBucket.putIfAbsent(key, () => []).add(s.value.round()); + } + + final buckets = []; + for (final key in byBucket.keys.toList()..sort()) { + final bpms = byBucket[key]!; + buckets.add(HrBucket( + windowStart: start.add(Duration(minutes: key)), + minBpm: bpms.reduce((a, b) => a < b ? a : b), + maxBpm: bpms.reduce((a, b) => a > b ? a : b), + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + )); + } + if (buckets.isEmpty) return null; + + final allBpms = samples.map((s) => s.value.round()).toList(); + final minBpm = allBpms.reduce((a, b) => a < b ? a : b); + final maxBpm = allBpms.reduce((a, b) => a > b ? a : b); + final avgBpm = allBpms.reduce((a, b) => a + b) / allBpms.length; + + // Resting HR. + int? restingBpm; + if (granted.contains(HealthReadType.restingHeartRate)) { + final rhr = await hc.readRestingHeartRate(start, end); + if (rhr.isNotEmpty) { + rhr.sort((a, b) => a.time.compareTo(b.time)); + restingBpm = rhr.last.value.round(); + } + } + restingBpm ??= () { + final morning = samples.where((s) { + final h = s.time.difference(start).inMinutes; + return h >= 120 && h <= 600; // 02:00–10:00 + }); + if (morning.isEmpty) return null; + return morning.map((s) => s.value).reduce((a, b) => a < b ? a : b).round(); + }(); + + return HrDaySnapshot( + day: start, + restingBpm: restingBpm, + minBpm: minBpm, + maxBpm: maxBpm, + avgBpm: avgBpm, + buckets: buckets, + ); +} diff --git a/workout-logger/lib/services/utils/workout_hr_builder.dart b/workout-logger/lib/services/utils/workout_hr_builder.dart new file mode 100644 index 0000000..23dba10 --- /dev/null +++ b/workout-logger/lib/services/utils/workout_hr_builder.dart @@ -0,0 +1,169 @@ +// Workout HR analysis builder. +// +// Pure function over IHealthConnectService: pulls HR samples for a recorded +// workout window, builds a downsampled curve, and measures HR recovery across +// each rest gap (reconstructed from set timestamps + timeTaken). + +import '../../models/models.dart'; +import '../../models/workout_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; + +/// Minimum HR drop (bpm) for a rest to count as "recovered". +const int kRestRecoveryThreshold = 5; + +/// Minimum HR samples in-window before an analysis is worthwhile. +const int _minSamples = 5; + +/// Builds the per-workout HR analysis, or null when HR permission is missing +/// or too few samples cover the workout window. +Future buildWorkoutHrAnalysis( + IHealthConnectService hc, + WorkoutSession session, + Set granted, +) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + + final start = session.date; + final end = start.add(Duration(minutes: session.duration)); + + // Read with a small buffer so set-end peaks near the edges are covered. + final raw = await hc.readHeartRateSamples( + start.subtract(const Duration(minutes: 1)), + end.add(const Duration(minutes: 1)), + ); + final samples = raw.where((s) => !s.time.isBefore(start) && !s.time.isAfter(end)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + if (samples.length < _minSamples) return null; + + final bpms = samples.map((s) => s.value).toList(); + final avg = (bpms.reduce((a, b) => a + b) / bpms.length).round(); + final peak = bpms.reduce((a, b) => a > b ? a : b).round(); + final lo = bpms.reduce((a, b) => a < b ? a : b).round(); + + // Curve: 30-second bucket averages. + final curve = _buildCurve(samples, start); + + // Rest + exercise-section analysis from set timestamps (shared validity gate). + final valid = _timestampsValid(session, start, end); + final rests = valid ? _buildRests(session, samples) : const []; + final exercises = valid ? _buildExerciseSpans(session) : const []; + + return WorkoutHrAnalysis( + start: start, + end: end, + avgBpm: avg, + peakBpm: peak, + minBpm: lo, + curve: curve, + rests: rests, + exercises: exercises, + hasRestAnalysis: valid, + ); +} + +/// Set timestamps must actually span the session, otherwise they're +/// placeholders (old/imported sessions) and gaps/sections are meaningless. +bool _timestampsValid(WorkoutSession session, DateTime start, DateTime end) { + final sets = session.exercises.expand((e) => e.sets).toList(); + if (sets.length < 2) return false; + final ts = sets.map((s) => s.timestamp).toList()..sort(); + if (ts.last.difference(ts.first).inMinutes < 5) return false; + if (ts.first.isBefore(start.subtract(const Duration(minutes: 5))) || + ts.last.isAfter(end.add(const Duration(minutes: 5)))) { + return false; + } + return true; +} + +/// One section per exercise, spanning its first set's start to its last set. +List _buildExerciseSpans(WorkoutSession session) { + final spans = []; + for (final log in session.exercises) { + if (log.sets.isEmpty) continue; + final times = log.sets.map((s) => s.timestamp).toList()..sort(); + final firstSet = log.sets.reduce((a, b) => a.timestamp.isBefore(b.timestamp) ? a : b); + final start = firstSet.timestamp.subtract(Duration(seconds: firstSet.timeTaken ?? 0)); + spans.add(ExerciseHrSpan( + exerciseId: log.exerciseId, + start: start, + end: times.last, + setCount: log.sets.length, + )); + } + spans.sort((a, b) => a.start.compareTo(b.start)); + return spans; +} + +List _buildCurve(List samples, DateTime start) { + const bucketSec = 30; + final byBucket = >{}; + for (final s in samples) { + final off = s.time.difference(start).inSeconds; + if (off < 0) continue; + byBucket.putIfAbsent((off ~/ bucketSec) * bucketSec, () => []).add(s.value); + } + final points = []; + for (final key in byBucket.keys.toList()..sort()) { + final vals = byBucket[key]!; + points.add(HrCurvePoint( + time: start.add(Duration(seconds: key)), + bpm: vals.reduce((a, b) => a + b) / vals.length, + )); + } + return points; +} + +List _buildRests( + WorkoutSession session, + List samples, +) { + // Flatten all sets across exercises, ordered by timestamp. + final sets = session.exercises.expand((e) => e.sets).toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + if (sets.length < 2) return const []; + + double? maxIn(DateTime a, DateTime b) { + final vs = samples + .where((s) => !s.time.isBefore(a) && !s.time.isAfter(b)) + .map((s) => s.value); + return vs.isEmpty ? null : vs.reduce((x, y) => x > y ? x : y); + } + + double? minIn(DateTime a, DateTime b) { + final vs = samples + .where((s) => !s.time.isBefore(a) && !s.time.isAfter(b)) + .map((s) => s.value); + return vs.isEmpty ? null : vs.reduce((x, y) => x < y ? x : y); + } + + final rests = []; + for (var i = 0; i < sets.length - 1; i++) { + final a = sets[i]; + final b = sets[i + 1]; + final restStart = a.timestamp; + // Next set begins after subtracting how long it took to perform. + final nextStart = b.timestamp.subtract(Duration(seconds: b.timeTaken ?? 0)); + final restEnd = nextStart.isAfter(restStart) ? nextStart : b.timestamp; + final durSec = restEnd.difference(restStart).inSeconds; + if (durSec < 5) continue; + + // Peak HR around the set's end; trough during the rest. + final peak = maxIn(restStart.subtract(const Duration(seconds: 20)), + restStart.add(const Duration(seconds: 20))) ?? + minIn(restStart, restEnd); + final trough = minIn(restStart, restEnd); + if (peak == null || trough == null) continue; + + final recovery = (peak - trough).round(); + rests.add(RestRecovery( + afterSet: i + 1, + restStart: restStart, + durationSec: durSec, + peakBpm: peak.round(), + troughBpm: trough.round(), + recoveryBpm: recovery, + recovered: recovery >= kRestRecoveryThreshold, + )); + } + return rests; +} diff --git a/workout-logger/test/health_history_manager_test.dart b/workout-logger/test/health_history_manager_test.dart new file mode 100644 index 0000000..293fb0d --- /dev/null +++ b/workout-logger/test/health_history_manager_test.dart @@ -0,0 +1,200 @@ +// Unit tests for HealthHistoryManager (windowing + aggregation). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +class _StubHc implements IHealthConnectService { + Set granted; + List sleep; + List resting; + List heartRate; + + _StubHc({ + this.granted = const {}, + this.sleep = const [], + this.resting = const [], + this.heartRate = const [], + }); + + @override + Future> grantedReadTypes() async => granted; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => + sleep.where((p) => p.end.isAfter(start) && p.start.isBefore(end)).toList(); + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => + resting.where((s) => !s.time.isBefore(start) && s.time.isBefore(end)).toList(); + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + heartRate.where((s) => !s.time.isBefore(start) && s.time.isBefore(end)).toList(); + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => const []; + + // Unused by these tests. + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +SleepPeriod _night(DateTime end, {int deep = 0, int rem = 0, int light = 0, int awake = 0}) { + final total = deep + rem + light; + return SleepPeriod( + start: end.subtract(Duration(minutes: total + awake)), + end: end, + deepMinutes: deep, + remMinutes: rem, + lightMinutes: light, + awakeMinutes: awake, + ); +} + +void main() { + group('rangeFor / stepBy', () { + final anchor = DateTime(2026, 6, 14); // a Sunday + + test('day window is the single day', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.day); + expect(r.start, DateTime(2026, 6, 14)); + expect(r.end, DateTime(2026, 6, 15)); + }); + + test('week is the 7 days ending on the anchor', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.week); + expect(r.start, DateTime(2026, 6, 8)); + expect(r.end, DateTime(2026, 6, 15)); + }); + + test('month is the calendar month', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.month); + expect(r.start, DateTime(2026, 6, 1)); + expect(r.end, DateTime(2026, 7, 1)); + }); + + test('year is the calendar year', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.year); + expect(r.start, DateTime(2026, 1, 1)); + expect(r.end, DateTime(2027, 1, 1)); + }); + + test('stepBy moves by the active unit', () { + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.day, 1), + DateTime(2026, 6, 15)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.week, -1), + DateTime(2026, 6, 7)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.month, 1), + DateTime(2026, 7, 14)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.year, -1), + DateTime(2025, 6, 14)); + }); + }); + + group('sleepBars', () { + test('sums fragmented same-night records into one bar and zero-fills', () async { + // Two fragments ending the morning of Jun 14. + final hc = _StubHc( + granted: {HealthReadType.sleep}, + sleep: [ + _night(DateTime(2026, 6, 14, 3, 0), deep: 40, rem: 30, light: 60), + _night(DateTime(2026, 6, 14, 6, 30), deep: 20, rem: 50, light: 90), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.sleepBars(DateTime(2026, 6, 14), HealthGranularity.week); + expect(bars.length, 7); + + final night = bars.firstWhere((b) => b.date == DateTime(2026, 6, 14)); + expect(night.deepMin, 60); // 40 + 20 + expect(night.remMin, 80); // 30 + 50 + expect(night.lightMin, 150); // 60 + 90 + expect(night.totalMinutes, 290); + + // Other nights are zero-filled, keeping a stable 7-slot axis. + final empty = bars.firstWhere((b) => b.date == DateTime(2026, 6, 10)); + expect(empty.totalMinutes, 0); + }); + + test('year view returns 12 monthly average bars', () async { + final hc = _StubHc( + granted: {HealthReadType.sleep}, + sleep: [ + // Two nights in March averaging to 400 total min. + _night(DateTime(2026, 3, 10, 6), deep: 60, rem: 60, light: 180), // 300 + _night(DateTime(2026, 3, 20, 6), deep: 100, rem: 100, light: 300), // 500 + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.sleepBars(DateTime(2026, 6, 14), HealthGranularity.year); + expect(bars.length, 12); + final march = bars[2]; + expect(march.date, DateTime(2026, 3, 1)); + expect(march.totalMinutes, 400); // (300 + 500) / 2 + expect(bars[0].totalMinutes, 0); // January empty + }); + }); + + group('hrBars (week, full-sample path)', () { + test('builds per-day min/max from HR samples and zero-fills', () async { + final hc = _StubHc( + granted: {HealthReadType.heartRate}, + heartRate: [ + HealthSample(time: DateTime(2026, 6, 13, 9), value: 70), + HealthSample(time: DateTime(2026, 6, 13, 14), value: 120), + HealthSample(time: DateTime(2026, 6, 13, 22), value: 60), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.hrBars(DateTime(2026, 6, 14), HealthGranularity.week); + expect(bars.length, 7); + + final d13 = bars.firstWhere((b) => b.date == DateTime(2026, 6, 13)); + expect(d13.minBpm, 60); + expect(d13.maxBpm, 120); + + final empty = bars.firstWhere((b) => b.date == DateTime(2026, 6, 9)); + expect(empty.maxBpm, 0); + }); + }); + + group('hrBars (month, resting-HR path)', () { + test('yields one range bar per day from resting records', () async { + final hc = _StubHc( + granted: {HealthReadType.restingHeartRate}, + resting: [ + HealthSample(time: DateTime(2026, 6, 5, 8), value: 56), + HealthSample(time: DateTime(2026, 6, 5, 9), value: 60), + HealthSample(time: DateTime(2026, 6, 12, 8), value: 52), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.hrBars(DateTime(2026, 6, 14), HealthGranularity.month); + expect(bars.length, 30); // June + + final d5 = bars[4]; + expect(d5.minBpm, 56); + expect(d5.maxBpm, 60); + expect(d5.restingBpm, 58); // mean of 56 & 60 + + final d1 = bars[0]; + expect(d1.maxBpm, 0); // no data → empty bar + }); + }); +} diff --git a/workout-logger/test/health_sync_manager_test.dart b/workout-logger/test/health_sync_manager_test.dart index 9597f8d..279432f 100644 --- a/workout-logger/test/health_sync_manager_test.dart +++ b/workout-logger/test/health_sync_manager_test.dart @@ -28,6 +28,28 @@ class _MockHcService implements IHealthConnectService { @override Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => false; + + @override + Future> grantedReadTypes() async => const {}; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => + const []; + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => + const []; + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => + const []; + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + const []; + @override Future syncWorkoutSession( WorkoutSession session, { diff --git a/workout-logger/test/ml_service_test.dart b/workout-logger/test/ml_service_test.dart index c33f4cd..ff68406 100644 --- a/workout-logger/test/ml_service_test.dart +++ b/workout-logger/test/ml_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' show log; + import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/ml_service.dart'; @@ -91,6 +93,97 @@ void main() { final expected = model.slope * 3 + model.intercept; expect(model.predict(3), closeTo(expected, 0.001)); }); + + test('linear data over a long span still selects the linear curve', () { + // 10 sessions spread over 63 days — log candidate is eligible but + // must not beat a genuinely linear trend. + final points = List.generate(10, (i) => dp(i * 7.0, 100 + 8.0 * i)); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.linear); + expect(model.r2, closeTo(1.0, 0.01)); + }); + + test('saturating data selects the logarithmic curve', () { + // y = 100 + 80·ln(1+x): fast early gains, then diminishing returns. + final points = List.generate(12, (i) { + final x = i * 5.0; + return dp(x, 100 + 80 * log(1 + x)); + }); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.logarithmic); + expect(model.r2, greaterThan(0.95)); + // predict() reproduces the generating curve. + expect(model.predict(30), closeTo(100 + 80 * log(31), 5.0)); + // Instantaneous slope at the newest point is the tangent, far below + // the early-history rate a linear fit would average in. + expect(model.slope, closeTo(80 / (1 + 55), 0.5)); + }); + + test('log curve is not considered for short histories', () { + // Strongly saturating but only 5 points over 8 days. + final points = List.generate(5, (i) { + final x = i * 2.0; + return dp(x, 100 + 80 * log(1 + x)); + }); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.linear); + }); + + test('a single deload outlier does not tilt the trend (robust pass)', () { + // Clean linear trend with one cut-short session at 40% volume. + final clean = List.generate(10, (i) => dp(i * 7.0, 200 + 5.0 * i * 7)); + final withOutlier = List.of(clean)..[5] = dp(35, (200 + 5.0 * 35) * 0.4); + + final robust = ml.trainGrowthModel(withOutlier); + final reference = ml.trainGrowthModel(clean); + // Slope recovered to within 10% of the outlier-free fit. + expect( + robust.slope, + closeTo(reference.slope, reference.slope.abs() * 0.10), + ); + }); + + test('model exposes lastX and a positive stdError on noisy data', () { + final points = [ + dp(0, 100), + dp(7, 130), + dp(14, 118), + dp(21, 150), + dp(28, 141), + dp(35, 168), + ]; + final model = ml.trainGrowthModel(points); + expect(model.lastX, 35); + expect(model.stdError, greaterThan(0)); + }); + }); + + group('GrowthModel - derived metrics', () { + test('weeklyGrowthPercent is growth relative to current level', () { + final model = GrowthModel( + slope: 2.0, // +2 volume/day + intercept: 600.0, + r2: 0.9, + lastTrained: DateTime.now(), + lastX: 50, + ); + // current = 600 + 2·50 = 700; weekly = 14/700 = 2% + expect(model.currentEstimate, closeTo(700, 0.001)); + expect(model.weeklyGrowthPercent, closeTo(2.0, 0.001)); + }); + + test('legacy four-field constructor stays linear and backward compatible', + () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + expect(model.curve, GrowthCurve.linear); + expect(model.coefficient, 5.0); + expect(model.predict(3), closeTo(115.0, 0.001)); + }); }); group('MLService - recommendSets', () { @@ -139,6 +232,43 @@ void main() { expect(recs.first.confidence, 'medium'); }); + test('declining trend → ~10% deload rounded to 2.5 kg', () { + final set = wset(weight: 100.0, reps: 8); + final decliningModel = GrowthModel( + slope: -3.0, // −21/week on ~600 volume ≈ −3.5%/week + intercept: 600.0, + r2: 0.8, + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: decliningModel, + maxReps: 12, + ); + expect(recs.first.weight, closeTo(90.0, 0.001)); + expect(recs.first.reps, 8); + expect(recs.first.confidence, 'medium'); + expect(recs.first.reasoning, contains('deload')); + }); + + test('untrustworthy fit (low r2) never triggers plateau or deload', () { + final set = wset(weight: 60.0, reps: 10); + final noisyModel = GrowthModel( + slope: -5.0, + intercept: 600.0, + r2: 0.1, // below the trust threshold + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: noisyModel, + maxReps: 12, + ); + // Falls through to normal double progression. + expect(recs.first.reps, 11); + expect(recs.first.weight, closeTo(60.0, 0.001)); + }); + test('under-recovered muscle → maintenance recommendation (low confidence)', () { final set = wset(weight: 80.0, reps: 8); @@ -240,6 +370,66 @@ void main() { ); expect(result, isNotNull); }); + + test('logarithmic curve pushes the date out vs naive linear extrapolation', + () { + // Curve y = 100 + 80·ln(1+x), currently at x=55 (y ≈ 422). + final model = GrowthModel( + slope: 80 / 56, // tangent at x=55 + intercept: 100.0, + r2: 0.95, + lastTrained: DateTime.now(), + curve: GrowthCurve.logarithmic, + coefficient: 80.0, + lastX: 55, + ); + final current = model.currentEstimate; + final target = current + 50; + + final curveAware = ml.predictTargetCompletion( + currentValue: current, + targetValue: target, + growthModel: model, + )!; + // Exact inversion: Δx = (1+x)·(e^(50/80) − 1) ≈ 48.5 days, while the + // tangent rate promises 50/(80/56) = 35 days. + final days = curveAware.difference(DateTime.now()).inDays; + expect(days, greaterThan(40)); + expect(days, lessThan(55)); + }); + + test('returns null when the curve cannot reach the target within 2 years', + () { + final model = GrowthModel( + slope: 0.01, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 500.0, // 40,000 days away at 0.01/day + growthModel: model, + ); + expect(result, isNull); + }); + + test('confidence interval uses stdError when available', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + stdError: 25.0, // → ±5 days at 5 volume/day + ); + final result = MLService.predictTargetWithConfidence( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + )!; + expect(result.expected.difference(result.optimistic).inDays, 5); + expect(result.pessimistic.difference(result.expected).inDays, 5); + }); }); group('MLService - computeMuscleRecoveryScores', () { diff --git a/workout-logger/test/readiness_calculator_test.dart b/workout-logger/test/readiness_calculator_test.dart new file mode 100644 index 0000000..0642e05 --- /dev/null +++ b/workout-logger/test/readiness_calculator_test.dart @@ -0,0 +1,221 @@ +// Unit tests for ReadinessCalculator (pure scoring logic) + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/utils/readiness_calculator.dart'; + +void main() { + const calc = ReadinessCalculator(); + final today = DateTime(2026, 6, 10, 8); // 08:00 local + + ReadinessBaseline baseline({ + double? sleep = 420, // 7h average + int sleepNights = 14, + double? rhr = 55, + int rhrDays = 14, + double? hrv = 60, + int hrvDays = 14, + }) => + ReadinessBaseline( + dateKey: '2026-06-10', + avgSleepMinutes: sleep, + sleepNights: sleepNights, + avgRestingHr: rhr, + rhrDays: rhrDays, + avgHrvMs: hrv, + hrvDays: hrvDays, + ); + + group('component formulas', () { + test('at-baseline values all score 100 and band is high', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 420, + todayRestingHr: 55, + todayHrvMs: 60, + ); + expect(s.sleepScore, 100); + expect(s.rhrScore, 100); + expect(s.hrvScore, 100); + expect(s.score, 100); + expect(s.band, ReadinessBand.high); + }); + + test('better-than-baseline values are not rewarded above 100', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 540, // way over average + todayRestingHr: 48, // lower (better) than baseline + todayHrvMs: 90, // higher (better) than baseline + ); + expect(s.score, 100); + }); + + test('sleep at 75% of average scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 315, // 420 * 0.75 + ); + expect(s.sleepScore, 50); + }); + + test('resting HR +10% over baseline scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + todayRestingHr: 60.5, // 55 * 1.10 + ); + expect(s.rhrScore, 50); + }); + + test('HRV −20% under baseline scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + todayHrvMs: 48, // 60 * 0.8 + ); + expect(s.hrvScore, 50); + }); + + test('extreme deviations clamp at 0', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 60, + todayRestingHr: 90, + todayHrvMs: 10, + ); + expect(s.sleepScore, 0); + expect(s.rhrScore, 0); + expect(s.hrvScore, 0); + expect(s.score, 0); + expect(s.band, ReadinessBand.low); + }); + + test('short absolute sleep is capped even with a short baseline', () { + // 280 min sleep vs a 290 min average would naively score ~93. + final s = calc.compute( + today: today, + baseline: baseline(sleep: 290), + lastNightSleepMinutes: 280, + ); + expect(s.sleepScore, ReadinessCalculator.shortSleepMaxScore); + }); + }); + + group('weighting and partial data', () { + test('weights renormalize: sleep-only score equals sleep score', () { + final s = calc.compute( + today: today, + baseline: baseline(rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 315, // sleep score 50 + ); + expect(s.score, 50); + expect(s.rhrScore, isNull); + expect(s.hrvScore, isNull); + }); + + test('sleep+RHR uses 0.5/0.3 weights renormalized', () { + final s = calc.compute( + today: today, + baseline: baseline(hrv: null, hrvDays: 0), + lastNightSleepMinutes: 315, // 50 + todayRestingHr: 55, // 100 + ); + // (50*0.5 + 100*0.3) / 0.8 = 68.75 → 69 + expect(s.score, 69); + expect(s.band, ReadinessBand.moderate); + }); + + test('component with fewer than 5 baseline samples is excluded', () { + final s = calc.compute( + today: today, + baseline: baseline(sleepNights: 4), + lastNightSleepMinutes: 100, // would tank the score if included + todayRestingHr: 55, + ); + expect(s.sleepScore, isNull); + expect(s.sleepMinutes, isNull); + expect(s.score, 100); // RHR only + }); + + test('no scorable components yields null score and band', () { + final s = calc.compute( + today: today, + baseline: const ReadinessBaseline(dateKey: '2026-06-10'), + lastNightSleepMinutes: 400, + ); + expect(s.score, isNull); + expect(s.band, isNull); + }); + }); + + group('bands', () { + test('75 is high and 74 is moderate', () { + // sleep ratio 0.875 → score 75 + final high = calc.compute( + today: today, + baseline: baseline(rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: (420 * 0.875).round(), + ); + expect(high.score, 75); + expect(high.band, ReadinessBand.high); + + final moderate = calc.compute( + today: today, + baseline: baseline(sleep: 400, rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 348, // ratio 0.87 → 74 + ); + expect(moderate.score, 74); + expect(moderate.band, ReadinessBand.moderate); + }); + + test('49 is low', () { + final s = calc.compute( + today: today, + baseline: baseline(sleep: 480, rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 358, // ratio ~0.746 → 49, above short-sleep cap + ); + expect(s.score, 49); + expect(s.band, ReadinessBand.low); + }); + }); + + group('lastNightSleep', () { + test('picks the longest period overlapping the night window', () { + final periods = [ + // 90-min nap yesterday afternoon — outside window + SleepPeriod( + start: DateTime(2026, 6, 9, 14), + end: DateTime(2026, 6, 9, 15, 30), + ), + // Main sleep 23:00–06:30 + SleepPeriod( + start: DateTime(2026, 6, 9, 23), + end: DateTime(2026, 6, 10, 6, 30), + ), + // Short morning doze + SleepPeriod( + start: DateTime(2026, 6, 10, 7), + end: DateTime(2026, 6, 10, 7, 45), + ), + ]; + final picked = calc.lastNightSleep(today, periods); + expect(picked, isNotNull); + expect(picked!.minutes, 450); + }); + + test('returns null when nothing overlaps the window', () { + final periods = [ + SleepPeriod( + start: DateTime(2026, 6, 7, 23), + end: DateTime(2026, 6, 8, 7), + ), + ]; + expect(calc.lastNightSleep(today, periods), isNull); + }); + }); +} diff --git a/workout-logger/test/readiness_manager_test.dart b/workout-logger/test/readiness_manager_test.dart new file mode 100644 index 0000000..d469aa1 --- /dev/null +++ b/workout-logger/test/readiness_manager_test.dart @@ -0,0 +1,346 @@ +// Unit tests for ReadinessManager (orchestration, caching, degradation) + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/interfaces/readiness_manager_interface.dart'; +import 'package:repforge/services/managers/readiness_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/utils/readiness_calculator.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +class _MockHcService implements IHealthConnectService { + Set granted; + List sleepPeriods; + List restingHr; + List hrv = const []; + List heartRate; + bool shouldThrow; + + int grantedCallCount = 0; + int sleepReadCount = 0; + int rhrReadCount = 0; + int hrvReadCount = 0; + int hrReadCount = 0; + + _MockHcService({ + this.granted = const {}, + this.sleepPeriods = const [], + this.restingHr = const [], + this.heartRate = const [], + this.shouldThrow = false, + }); + + void _maybeThrow() { + if (shouldThrow) throw Exception('mock HC error'); + } + + @override + Future isAvailable() async => true; + + @override + Future requestPermissions() async => true; + + @override + Future hasPermissions() async => true; + + @override + Future requestReadPermissions() async => granted.isNotEmpty; + + @override + Future> grantedReadTypes() async { + _maybeThrow(); + grantedCallCount++; + return granted; + } + + @override + Future> readSleepSessions(DateTime start, DateTime end) async { + _maybeThrow(); + sleepReadCount++; + return sleepPeriods + .where((p) => p.end.isAfter(start) && p.start.isBefore(end)) + .toList(); + } + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async { + _maybeThrow(); + rhrReadCount++; + return restingHr + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + _maybeThrow(); + hrvReadCount++; + return hrv + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async { + _maybeThrow(); + hrReadCount++; + return heartRate + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => + true; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/// 15 nights of 23:00–06:00 sleep (420 min each): 14 baseline nights plus +/// last night, which the manager scores against that baseline. +List _twoWeeksOfSleep(DateTime now) { + final day = DateTime(now.year, now.month, now.day); + return [ + for (var i = 0; i <= 14; i++) + SleepPeriod( + start: day.subtract(Duration(days: i)).subtract(const Duration(hours: 1)), + end: day.subtract(Duration(days: i)).add(const Duration(hours: 6)), + ), + ]; +} + +List _dailyRhr(DateTime now, double value, {double? todayValue}) { + final day = DateTime(now.year, now.month, now.day); + return [ + for (var i = 1; i <= 14; i++) + HealthSample(time: day.subtract(Duration(days: i, hours: -7)), value: value), + // "Today's" reading is stamped at test-setup time so it always falls + // inside the manager's trailing-24h query regardless of wall clock. + if (todayValue != null) HealthSample(time: now, value: todayValue), + ]; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late SettingsProvider settings; + + Future makeManager( + _MockHcService hc, { + bool enabled = true, + }) async { + storage = MockStorageService(); + settings = SettingsProvider(storage); + if (enabled) await storage.saveSetting('readinessEnabled', 'true'); + await settings.init(); + return ReadinessManager(hc, storage, settings); + } + + group('ReadinessManager.refresh', () { + test('is a no-op when the readiness setting is disabled', () async { + final hc = _MockHcService(granted: {HealthReadType.sleep}); + final manager = await makeManager(hc, enabled: false); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.idle); + expect(hc.grantedCallCount, 0); + }); + + test('goes to noData when no read permissions are granted', () async { + final hc = _MockHcService(); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + expect(manager.snapshot, isNull); + }); + + test('computes a sleep-only snapshot with partial permissions', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.ready); + final s = manager.snapshot!; + expect(s.sleepScore, isNotNull); + expect(s.rhrScore, isNull); + expect(s.hrvScore, isNull); + expect(s.score, isNotNull); + // Persisted for instant render next launch. + final cached = await storage.getSetting('readiness.snapshot'); + expect(cached, isNotNull); + }); + + test('serves the same-day cache inside the TTL without re-fetching', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + final fetchesAfterFirst = hc.sleepReadCount; + await manager.refresh(); + + expect(hc.sleepReadCount, fetchesAfterFirst); + expect(manager.status, ReadinessStatus.ready); + }); + + test('force=true bypasses the snapshot cache', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + final fetchesAfterFirst = hc.sleepReadCount; + await manager.refresh(force: true); + + expect(hc.sleepReadCount, greaterThan(fetchesAfterFirst)); + }); + + test('reuses the same-day baseline instead of recomputing', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + // First refresh: 1 baseline read + 1 last-night read. + expect(hc.sleepReadCount, 2); + await manager.refresh(force: true); + // Forced refresh re-reads last night only — baseline is cached for today. + expect(hc.sleepReadCount, 3); + }); + + test('uses latest resting HR record and skips the minute-level fallback', + () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: { + HealthReadType.restingHeartRate, + HealthReadType.heartRate, + }, + restingHr: _dailyRhr(now, 55, todayValue: 60.5), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.snapshot!.restingHr, 60.5); + expect(manager.snapshot!.rhrScore, 50); + // The one HR-sample read is the all-day Heart-rate-card snapshot; the + // scoring path still uses the RHR record and skips its minute-level + // fallback (verified by restingHr above coming from the RHR record). + expect(hc.hrReadCount, 1); + }); + + test('falls back to minimum morning heart rate when no RHR record today', + () async { + final now = DateTime.now(); + final day = DateTime(now.year, now.month, now.day); + final hc = _MockHcService( + granted: { + HealthReadType.restingHeartRate, + HealthReadType.heartRate, + }, + // Baseline records exist on past days but none in the last 24h. + restingHr: _dailyRhr(day.subtract(const Duration(days: 2)), 55), + heartRate: [ + HealthSample(time: day.add(const Duration(hours: 3)), value: 62), + HealthSample(time: day.add(const Duration(hours: 4)), value: 55), + HealthSample(time: day.add(const Duration(hours: 5)), value: 58), + ], + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + // Two HR-sample reads now: the all-day HR snapshot (for the Heart-rate + // card) plus the morning-RHR fallback used for scoring. + expect(hc.hrReadCount, 2); + expect(manager.snapshot?.restingHr, 55); + }); + + test('goes to noData when permissions exist but no data is scorable', + () async { + final hc = _MockHcService(granted: {HealthReadType.sleep}); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + expect(manager.snapshot, isNull); + }); + + test('never throws: HC errors degrade to noData', () async { + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + shouldThrow: true, + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + }); + + test('ignores a corrupt cached snapshot', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + await storage.saveSetting('readiness.snapshot', 'not json'); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.ready); + }); + + test('discards a stale snapshot from a previous day', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + final yesterday = now.subtract(const Duration(days: 1)); + await storage.saveSetting( + 'readiness.snapshot', + jsonEncode( + ReadinessSnapshot( + dateKey: ReadinessCalculator.dateKey(yesterday), + score: 12, + band: ReadinessBand.low, + computedAt: yesterday, + ).toJson(), + ), + ); + + await manager.refresh(); + + expect(manager.snapshot!.dateKey, ReadinessCalculator.dateKey(now)); + expect(manager.snapshot!.score, isNot(12)); + }); + }); +} diff --git a/workout-logger/test/workout_hr_builder_test.dart b/workout-logger/test/workout_hr_builder_test.dart new file mode 100644 index 0000000..b0ce186 --- /dev/null +++ b/workout-logger/test/workout_hr_builder_test.dart @@ -0,0 +1,110 @@ +// Unit tests for the workout HR analysis builder (rest recovery + guards). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/utils/workout_hr_builder.dart'; + +class _Hc implements IHealthConnectService { + final List hr; + _Hc(this.hr); + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + hr.where((s) => !s.time.isBefore(start) && !s.time.isAfter(end)).toList(); + + @override + Future> grantedReadTypes() async => {HealthReadType.heartRate}; + @override + Future> readSleepSessions(DateTime s, DateTime e) async => const []; + @override + Future> readRestingHeartRate(DateTime s, DateTime e) async => const []; + @override + Future> readHrvRmssd(DateTime s, DateTime e) async => const []; + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +DateTime _t(int h, int m, [int s = 0]) => DateTime(2026, 6, 9, h, m, s); + +WorkoutSet _set(DateTime ts, {int timeTaken = 30}) => + WorkoutSet(weight: 100, reps: 8, timestamp: ts, timeTaken: timeTaken); + +void main() { + final session = WorkoutSession( + id: 'w1', + date: _t(18, 0), + duration: 20, // ends 18:20 + exercises: [ + ExerciseLog(exerciseId: 'bench', sets: [_set(_t(18, 2)), _set(_t(18, 5))]), + ExerciseLog(exerciseId: 'row', sets: [_set(_t(18, 10)), _set(_t(18, 14))]), + ], + ); + + // Crafted HR: drops during the first two rests, stays high in the third. + final samples = [ + HealthSample(time: _t(18, 2), value: 160), // set A1 end (peak) + HealthSample(time: _t(18, 3), value: 140), // rest 1 trough + HealthSample(time: _t(18, 4), value: 142), + HealthSample(time: _t(18, 5), value: 158), // set A2 end + HealthSample(time: _t(18, 7), value: 130), // rest 2 trough + HealthSample(time: _t(18, 10), value: 162), // set B1 end + HealthSample(time: _t(18, 12), value: 159), // rest 3 stays high + HealthSample(time: _t(18, 14), value: 150), // set B2 end + ]; + + test('computes per-rest recovery and flags short rests', () async { + final a = await buildWorkoutHrAnalysis(_Hc(samples), session, {HealthReadType.heartRate}); + expect(a, isNotNull); + expect(a!.peakBpm, 162); + expect(a.minBpm, 130); + expect(a.hasRestAnalysis, true); + + expect(a.restCount, 3); + expect(a.restsRecovered, 2); + expect(a.rests[0].recoveryBpm, 20); // 160 → 140 + expect(a.rests[0].recovered, true); + expect(a.rests[2].recoveryBpm, 3); // 162 → 159 + expect(a.rests[2].recovered, false); + expect(a.avgRecoveryBpm, 24); // (20 + 28) / 2 + + expect(a.exercises.length, 2); + expect(a.exercises.first.setCount, 2); + }); + + test('guards against placeholder timestamps (no per-set timing)', () async { + final flat = WorkoutSession( + id: 'w2', + date: _t(18, 0), + duration: 20, + exercises: [ + ExerciseLog(exerciseId: 'bench', sets: [_set(_t(18, 0)), _set(_t(18, 0))]), + ], + ); + final a = await buildWorkoutHrAnalysis(_Hc(samples), flat, {HealthReadType.heartRate}); + expect(a, isNotNull); + expect(a!.hasRestAnalysis, false); + expect(a.rests, isEmpty); + expect(a.exercises, isEmpty); + expect(a.curve, isNotEmpty); // curve still renders + }); + + test('returns null without HR permission', () async { + final a = await buildWorkoutHrAnalysis(_Hc(samples), session, {HealthReadType.sleep}); + expect(a, isNull); + }); + + test('returns null when too few samples cover the window', () async { + final sparse = _Hc([HealthSample(time: _t(18, 5), value: 150)]); + final a = await buildWorkoutHrAnalysis(sparse, session, {HealthReadType.heartRate}); + expect(a, isNull); + }); +} From c77d690a0be3edf96986899e5e9da5e6ea5a03c0 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:27:08 +0530 Subject: [PATCH 43/44] test: update lastNightSleep test to sum all periods in the night window --- workout-logger/test/readiness_calculator_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/workout-logger/test/readiness_calculator_test.dart b/workout-logger/test/readiness_calculator_test.dart index 0642e05..5a93167 100644 --- a/workout-logger/test/readiness_calculator_test.dart +++ b/workout-logger/test/readiness_calculator_test.dart @@ -185,19 +185,19 @@ void main() { }); group('lastNightSleep', () { - test('picks the longest period overlapping the night window', () { + test('sums all periods in the night window (18:00 prev day → 12:00 today)', () { final periods = [ - // 90-min nap yesterday afternoon — outside window + // 90-min nap yesterday afternoon — outside window (ends before 18:00) SleepPeriod( start: DateTime(2026, 6, 9, 14), end: DateTime(2026, 6, 9, 15, 30), ), - // Main sleep 23:00–06:30 + // Main sleep 23:00–06:30 = 450 min SleepPeriod( start: DateTime(2026, 6, 9, 23), end: DateTime(2026, 6, 10, 6, 30), ), - // Short morning doze + // Short morning doze 07:00–07:45 = 45 min (inside window) SleepPeriod( start: DateTime(2026, 6, 10, 7), end: DateTime(2026, 6, 10, 7, 45), @@ -205,7 +205,7 @@ void main() { ]; final picked = calc.lastNightSleep(today, periods); expect(picked, isNotNull); - expect(picked!.minutes, 450); + expect(picked!.minutes, 495); // 450 + 45 }); test('returns null when nothing overlaps the window', () { From 7126bc664aee1cd52e73b592f7d785a3650e1073 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:59:38 +0530 Subject: [PATCH 44/44] feat: bundle Geist fonts locally and add F-Droid metadata (Option B) (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove google_fonts dependency; replace with bundled variable font files (Geist-Variable.ttf + GeistMono-Variable.ttf from Vercel v1.7.2, MIT licensed) - Replace all 377 GoogleFonts.geist/geistMono() calls with TextStyle(fontFamily:) across 31 dart files — no runtime Google CDN fetch, F-Droid build-compatible - Declare fonts in pubspec.yaml flutter.fonts section - Add fdroid/metadata/com.devasy.repforge.yml with anti-features (NonFreeNet) and auto-update config for F-Droid submission Co-authored-by: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- fdroid/metadata/com.devasy.repforge.yml | 56 ++++++++++++++ .../assets/fonts/Geist-Variable.ttf | Bin 0 -> 169056 bytes .../assets/fonts/GeistMono-Variable.ttf | Bin 0 -> 171200 bytes .../lib/screens/ai_coach_screen.dart | 27 ++++--- .../screens/ai_program_generator_screen.dart | 31 ++++---- .../lib/screens/analytics_screen.dart | 39 +++++----- .../lib/screens/heart_rate_detail_screen.dart | 19 +++-- .../lib/screens/history_screen.dart | 39 +++++----- workout-logger/lib/screens/home_screen.dart | 69 ++++++++--------- .../lib/screens/onboarding_screen.dart | 19 +++-- .../lib/screens/profile_screen.dart | 17 ++-- .../lib/screens/routine_optimizer_screen.dart | 21 +++-- .../lib/screens/routines_screen.dart | 35 ++++----- .../lib/screens/sleep_detail_screen.dart | 13 ++-- .../screens/widgets/analytics_overview.dart | 37 +++++---- .../lib/screens/widgets/calendar_grid.dart | 5 +- .../widgets/exercise_input_section.dart | 9 +-- .../widgets/exercise_progress_view.dart | 73 +++++++++--------- .../lib/screens/widgets/health_bar_chart.dart | 15 ++-- .../screens/widgets/health_detail_shell.dart | 7 +- .../lib/screens/widgets/heart_rate_card.dart | 13 ++-- .../screens/widgets/muscle_detail_sheet.dart | 31 ++++---- .../lib/screens/widgets/profile_sections.dart | 69 ++++++++--------- .../lib/screens/widgets/readiness_card.dart | 17 ++-- .../lib/screens/widgets/rf_question_card.dart | 11 ++- .../lib/screens/widgets/rf_widgets.dart | 3 +- .../lib/screens/widgets/sleep_hr_card.dart | 11 ++- .../lib/screens/widgets/sleep_hr_charts.dart | 31 ++++---- .../lib/screens/widgets/targets_tab.dart | 43 +++++------ .../lib/screens/widgets/volume_chart.dart | 3 +- .../lib/screens/widgets/wheel_picker.dart | 7 +- .../lib/screens/widgets/workout_header.dart | 7 +- .../screens/widgets/workout_hr_section.dart | 31 ++++---- .../lib/screens/workout_flow_screen.dart | 5 +- workout-logger/lib/theme/app_theme.dart | 29 ++++--- workout-logger/pubspec.yaml | 29 ++----- 36 files changed, 440 insertions(+), 431 deletions(-) create mode 100644 fdroid/metadata/com.devasy.repforge.yml create mode 100644 workout-logger/assets/fonts/Geist-Variable.ttf create mode 100644 workout-logger/assets/fonts/GeistMono-Variable.ttf diff --git a/fdroid/metadata/com.devasy.repforge.yml b/fdroid/metadata/com.devasy.repforge.yml new file mode 100644 index 0000000..7b0ab30 --- /dev/null +++ b/fdroid/metadata/com.devasy.repforge.yml @@ -0,0 +1,56 @@ +Categories: + - Sports & Health +License: Apache-2.0 +AuthorName: Devasy Patel +SourceCode: https://github.com/Devasy23/RepForge +IssueTracker: https://github.com/Devasy23/RepForge/issues + +AutoName: RepForge +Summary: Open-source workout logger with AI coaching and progress analytics +Description: |- + RepForge is a privacy-first workout logging app that helps you track sets, + reps, and weights across customizable routines. + + Features: + * Log workout sessions with sets, reps, and weights + * Visualise progress with charts (volume, strength curves) + * AI-powered set recommendations using linear regression + * Customisable exercise library with 50+ built-in exercises + * Goal tracking with ML-estimated completion dates + * Reusable workout routines + * Health Connect integration for sleep and heart rate readiness scores + * Optional AI Coach powered by Google Gemini (requires user-supplied API key) + * Full data export/import for portability + + All workout data is stored locally on-device using Hive. No account required. + No data is sent to any server unless you enable the optional AI Coach feature. + +RepoType: git +Repo: https://github.com/Devasy23/RepForge + +AntiFeatures: + NonFreeNet: + - description: > + The optional AI Coach and Routine Optimizer features send data to + Google's Gemini API (a proprietary cloud service). These features are + fully disabled unless the user provides their own Gemini API key in + Settings → AI Settings. The app is fully functional as an offline + workout logger without configuring a key. + +Builds: + - versionName: 2.0.1 + versionCode: 21 + commit: v2.0.1 + subdir: workout-logger + gradle: + - release + prebuild: + - flutter pub get + build: + - flutter build apk --release --split-per-abi + +AutoUpdateMode: Version v%v +UpdateCheckMode: Tags +UpdateCheckData: workout-logger/pubspec.yaml|^version:\s+([\d.]+)\+|.| +CurrentVersion: 2.0.1 +CurrentVersionCode: 21 diff --git a/workout-logger/assets/fonts/Geist-Variable.ttf b/workout-logger/assets/fonts/Geist-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..59f91f054d4f7ab5dd50544bf391dcc6b9930a81 GIT binary patch literal 169056 zcmcG12Ygh=@&E37r#toDyL6IHy-TOw>#3cjlMs^VAbK%igBt;28+T&^vcZ6{vBBNA z#DH5I7fcezj-AB41md_Q#<(|h`hRElnuP4+^ZDl|*n9Wp?at25&d$!v?!IH3F&2Uk z4Kp`4G&ZRRyJj->!+OTt<~N(WdJla5;1i5l-(l?Xs^;Fly1LERp3nG}XvTu?Zt1V> zIdR>Y^^D(qoH5T|ci=wbRD2fW2ik$PqN_J2zv7aWZ!yN-!2RrAOReSmpZ<0mo?njp zBlDKeSv6X!9AhkCEo1H*N6wqG`uevoXkq+d4W4gUIA_f&T>U{0UELQhJAb5R-rPqR zOW4iWJv|GN|GO(*+KK#6GxqOQix$k8UodmiE2wW6`HL3;;BE3L#Qk>MCoNjOc75|- zkNl1?rGzoRie)S3&FQ)MqPrN2IK!B3-|{)@SE-*c(8`Y>fBfrF?+mZtidBu zmXiA4a}y~X00Cvc`JyIJu6JndmYgj0PHxbI}c%5PcL zl<84ClPMV2U}XsR|G(+?sH+orp1}3z(#LrAd*t~&3!p3aWy5NhymH@hEABN&L$>6L zYdI1}O2qR|ls;DPNE?C2o*M9dVhUE51NPC+q_cF7Ri0f3T7w}P$CsV1!HSd3u_*JYz=>e{vORMm#5p*>|7d4K5!MMaE_GCz8?P?*I-=V;ip(9*Np$phu>1T#FQUJfAAHA>ZZ9h*ZR{6Ic3fRH}jNmr^~RPnD)B6V5`_g0qv- z%5szl7WyAj0iHV&`%>_^L-0478I=EJA{qT3ChiS=PpL*+Yw|u`>;6GtzI+_t&#jzTY-qZ-=g< zc{SQ44X{Gh8_!+zl)4!@bp$Z`{Bx2`Q`VvkrT199n44%Gq&bMLG-r^mD~6S@7V|NZ z+K+qE&EMkLqpS+DJgk0-@01^OsKZF1tWdLr)oU<^ih1QY${c3~=tY`aqiC*FbC|dK z5T4%z94Ywj%?g#j0x#8l4E5ZM?^}V(3wiAGz((=BifjbJg*vlDTc`|Zo1jb4ZsbWI znCQn$TuBGK=uAoqcrl;Fy7s9$06EtH-heSGfIPilP#tL9Ep!Z_Mnr97sL}gZ|pJ!p1G@6BL6b< znFZHmX2i2Bi-M)(ShWSdnBbrCe3DCNymdGqtA1?u6X`0XU&! zRX60B2N>z3Y{1-s=UZH(T~a*yBOdqmYb3sV$;7`#{<|SN<+xseKE49us1vdt3|n?P z#-}fM3>q{ypuN=3vvEa&{>)OAvnZxsx7%b zOPqu^9`}@gs=T5cbV4Tlg-rInOt8D8tdhL0MDj=4hBO}ux)ZizCSQiUN044(1sJ2L z>@dp_dJj4cbtydoq1>J#eI>hCp?8oj1WQ?Hq& znWtH*IajkybED>F&3?_pn!}pMHBW0^*1W0thvpN_=f2gk39*;P?v4E+J~%!yJ~lov zJ|#XaJ~zG~zAU~WepUSW@mIv(9e;29gYl2VAB}%9AuJ&=p)X-(!p?*}33n$vnDCo~ zeo9@EMAI9eXha#+s=VQ+@@jAA2><&BR>rd(1i4Pe&`IUs7Ja+Q&e-!+~{p2SnH=JCE ze;p@pIeF8`9Vf3pdELn?kuMbaS0MfM#2-%_JMsL9Yfo%HG3&&%6J3Bcov1vKcOv_Q z-`~Gr?7h-=?|=6?drgeJd-$Di-i~+EEoGYHQziaMBK1%ma@oWN2bDPYe*{j(H$+}&0z*$1G zPkvWUD4(fG$~S7gI#F9^&)l#)zc~f~yeM;G^7ARk+7<>HRYJzf1c~*HrIn4s0 z%RE^a;*g;%oW)=iCo==);#Stc8sXpQ5uaSb=CHZ!T((H-i$s zlpnBvvmaoSeGff34tdvc5AFxOm%C7$1&>8^ zE{4@|J*(k~tb^yk6U$=lJexK1WLD2};Q^}Hz4`6P&pWnciaWlJtpT|7dzgQKI zXQe!f_3&c$Yrc?O!Iwk({Z4&ReFmEUS@i|=IrX>d4s}%BsqRttLO*U-x2d;A60O82#n8?rN~uz*R4KJeol>tfC{0R_(yLgMKBZro zrVJ^An6YLn3zQM1TB%VMDrL$nWu`JmnXAlGhLsu0e5F}wRJxS`B~DqS#4C%H1Z9bm zs4V4QD&_nur9#mw%akN#xst4`P*RkYia}YWq$=kqY07FPU0I`KD(5O$&@kD`IweP0 z&nkHwyA3wxE&SK)R@k2b>?;<;zF}VMGv>oSXTH!&e(Ve8%}y~5`w(;Q$B5g1!ra-X z%#D4-a(DoIh9FkRLt%q?vUKjvGPn=scVCumc{f|hd)X>(VdwBZwu1Mt>-ZXWJwKOi39)6t~tH!C3YP1@I`MpZ5R=!kgRHO2o^1O0fU9K)v7pqIvmFg;Wg}O*x zqApWssU2#cYEiFLuT`&7uT!s9x2o5u*Q-~kThz>_UWA?h0eEwUyYpZk&*29^kC^!yeh2>>KLOeJL2*OxXQK}r6*GEt zwXy*#45P{c==P_e+g~HSqWmak=~!s^1?mR%DD=rI>bvSE>Ngs$sniT=7HH1VT&%f5 zvqN*M=043)&2ySRYu>@^{FP?hEzT{&&FI$cHp6YP+xc#nx?S(K$L&tHXWZU!`@rqA z+ZnC9HcLB8yHvYQ`)lns?Je4Sw1>6NYG2mAt^G**l}^GZk+UA?YXH%qrfcb@K1 z-Rrs^+)Le;xS!{~+5KAg*W6FKpK|}vL+cUfQSV{#nB}q5<2;Ye9{W8W@Oa$g1&_aY zyzlWZkMBHH&md2|XMtzEXRqfx&y}7RdT#OD=6TTbS2-(KgI-U2z3BCZH}m%P4);#-F7Ifq z-TN)?552$iw)%Mbg!-iTa`2x!c zKE4sY$-a5MmA%@SEqi z!tVmVOZ~3*+wFI!-y?p%^-uO6@ZaLU&3~`|UH*^wKkfgO|9F5`Kxe?lfU5&;47e@e zzJMnJUI=(K;N5_a1HKMqfnI@Ofr)`@0yhLc82G24z@V6*)S!Z(>Y%ou{-9ffo)3B@ z=-r@?gT4+L5B3ZW362lW4lWID2<{4=5xgk)!r;q-cLyH~ekk}4!LJ3M4E`=e4e<+! z3`q`|8*+Kb_K;gb?g@D`DYLCUj@$9ihJoeLnQB zp&x{P6{d#yhDC%Wh2@4V4BH*{m#|Y|KZI+-{ldG$XM`^bUlYC|{L1j>!%s%IMWja* zMbt#hjaU(Je#9ja*G1eMaUkNMh^Hc6ig+*Lvxu=s@5ty#Lu5f@b)+eBIC6dDC6QYr zcSqhC`Mb!!NB%qVyC@~fH!313IVv}*DXJ%GM%3b{^P?__+8Xs@)EiO%h&mND7VRD# z6dfC#7F`%!AKe)}J$iohs^|-&FN?k*dT;by(I=umi~c@FjR}m2iAjwqh^dNcjTwlU z7qcSf{FqB)w#M8Xb1>%Nn5Sa?5c68h$(YY#zK_+#hQj{NiY<+8iS3P@6}vQcUF_!A zYhrJTJrMgy>J_&Pmypax~?4DeoG%A;-{Xm|<98ILC0e;Zeg=hCdiy zHN0o|)bOprn%bDUF7@)%?WqS-pGy5G_4Cvp)7;Wh(?-%ROuH=Y`m}q}4yXM!?Td8J z^uY9_^vv|a^v3j#^nvti(s!gEO8;B>sSN)NLq=;xU&e(Q_h-DD@z2bt%&yGA%mtaN zGB3(}IP(46L+o}9s)xjD;o z*5~{>=h~cGbKcJN%+1g3%e^G`rrbZ~dFL7OX6N0J_hi0zeoy|c{Nn{d1&a%I7kp4y zQn;k>_eHTq=|#mwbw!;;gGD1ntBW=iU0L*faZYh-@wvs<72jBVOYy&x#eKT#1{5m!-OF;ual;)aS_D;}sgQt?v7 zTNNKxd|Me^Sy#ES^0$@8D_^O6tMVU}pI81=rK<|6s;?TTT3)rU>MvCvRDDzJQ5{{K zQC(i$UOl6FZS~dFcUQkq{c`o&)nC`}nt+dw^n)UT+2y`i#UYs34EzKvy#&5b>cvl`blUeAGK%R?>CxBR{3t5#iWd}~>2Z|ls~Ev=)iceQ?R ziZbPynoYA!7n+`I^KQGK?XtET+wN+6uI>G{AKQc5v)fzR=d@qgepmbJ9iAOY9hDtR zI&SQ^uj84HH#)OPASjh&sHb34~}Ue&p;^P$ccI^XI1qAQ@Q%^YbiHrJcY<{9Rt z=3kk&neQ||X@1@OvH9z6-tF5R*=^`9?QZK{(0x_+qutMTf6)DPkAF{ePi9YH&rr|C zo@;t`^&ISZwCDAn?|UdKlQ17UVTA*QGNQpjJ}e-n!c{Sm3?=?B0)?-DME}}g;nAOONA8#Bm^WU z1SA9*c$kXNK&s+n4d#Ee7P*J;KZk@^b8%6Qjg=)S8^%_~C|hF2&KDoB(h(0)E(b0h z>~)yJ1zc}P&;=E!!JMt-Y-Y3dwdVP!*Hhs?{DH^v84O;kD1$gziilF^)KOU2k2v7h zIp8<5Q5X164){HG`1lg#a@C&Spxy?n9Xlitxu3e+y{>RgtOMVDfInrgAG;>(blvZo z-%*cF-Nkmg&}FY*r|z5p|ImTw0XzI>=>55JhwSBu$LhBZd=I+9HNFn`Lu{7|T^Bmw z+t@7};P0pH7Z+wf;A_qgCcfVB?l?;y~> zMZK5uBSfK7ce7_)@LcP_vxi;69)V0iKGbc%gK`BtA;j^oD8X0|poi&r5&y}0gjZOf z<7Vri_3s(q6j&cuj#>WM^O5Bb7Cvq4Dj;CPsC5aj_`?fMBpENr4-E;{YV`(j)f)=( zi;D_V^!g(Em*KBBY)CZ~@S(*U)*d_7+SS#1Y;UKfyL+#4Y3MlE5wloWlURvQu=8VcOTFkgi)o3+(!-a2d3 zwV^|PbEh{fEGt{sFk{4L$fx~c|LF(TtT{Nid6l+(pmEdS;KqhIz1ohowcwnW^Sl6Ys78@7&?r zI5_9z*zh~IKY0JGS6_DVh7A{ArW`wZ&)r8XTee)W1r&>tVywgqig&>$w~uYYn1Xl& zb|t9~-=woi-XtivN;ndc*Nf_Q^nS=Nf696jAGY4kSJ^t-R$rpvP?D%`6n;O|CvXTl zBzzaUd`kFE7kH9NV^-#Wzz)ZlNp#>L9rKZ>$F*FdOSwb#a?}PfW+lD{UEyNPO87(U z3KzPlCnS8Ejc>9YPWX02B}Ds(F2PTkU(n@fH|bUh-vRH-$qyRK5`GJN-UUu$S;F@s zjw11A!v~}OlJEnFHqcm>^w5}<@I7pe7_%f{yD<_Bh{z?xDiHhPB8)CE4s?bp075bP zd=-6Swo+7-pmbTwJW2-2Y8F*hFRD&&EeRS5>0VVocWY-(-?`0Y%lq>cJH$1-~|t=J2m-7w34_Q9n~@0?S$q)yv7&HU?*?oI8bvrBrcJ8$>RZOb685qGW> zQe+g;b2E5GT%HWyg^2!C@SP60Km1{$UgFu0nbV1%q(s8EBR1v4L%l2Ed)Z@Rd{ezo z1CL7mm0Xk%GC_M~qn8}oOR$3UCc=C@*1H#8)7rLm%qiCK7Fi=&qz$P?gyu!hp;X|@>nhO$DLziHfdZg_)1Wkfe#63 zgp{&}kQ=KZ(EGqm1_pT3;YtkbNp4o zR}J8vLT^g=w(&OvdHYbg74(b!yydVH3BG;?Bkl}t_z1;dKPh0%sF#7uzKF9i^ z6(Y9aGvE?s1g>sT$1bcKQys(O{sLDD;@DL8e)cJ@vW^hcp_Tm&B{1Bd!LLQH&&{;h z@P!DPEs|#0zoH&0mx8D`;kyZ|a@0nu#}&Sx9T#x2T%B;O`pCaOm$j<_z#_CzK~NBh zG#1SQhS&e&fF40yos1$R%a z{`q6OZPZ^Ss2`Nn^E}B7P=i<;sF(0v>`x9Z5pFwtKZm?J@Q_5v{ClvLC}w-iC6bm9 zS8Bq9VN_Ir!LPSd_RgaCxc+WT3}e?TD?#AAu|1>a#OUb*wdiz3=(9(mSNvGqMEyz% z#6WX`ZAu{1h@Z3I`p(Yl7mU#7h-q<2$zoG0ecBA7!Gmko9GGrt*f==2sZkn6)C$sF zj|y(gws9j~v|7T68xp<~5l5043IEUmf4~7>0zCHonAu@lkqrD?If`I9@sTwy>v_-> zE@pNKe~4kcIqE0fEaBU1)^8TLPQ0?iMg5NaG=t0hI}kT@;vuad;kRIY!wK(kz;ET! zdY{NAgDT)+yxRFi?YEb6KA)Nn=kuxQaXz0&7fBn@>wLZkD-ce)?d>!`=e`SRBz-d5 z5*Wqk(I-+*Dgxoc5T;o`2&MFoWm{bwFrzT)7l%8~`b_n2?2w6t!r^lolz z+uUo})Jh|QG#O?eXg}xTBz47)pR40IN~V=x|o>h1?lE&7kML(2c(rURZbOrR)CQKWeGNpJnyP$m&rH)H1<^ z$CX3iLNH4M7YuNMoZbpy(S;?$PcamPO&p<0Qh9ide&o7#O!Z;!#oe z%JIx}>q)-4uCl5gqlWlAS7?O`AZ;W?Oh}7_(EOX!6gCoRv9MT$lp8nqT+pl?)a7=U<~JqBP3!BKlTo^)wR2US z`=B<%R8-d)XKtImIICurf7S99ZILM@GCwJyAR{Cszon>RsGxkLR+~|uQeBXckq{JG zX)dT5$|JcJ^oVwCknLh8M7t!M+9lz;*u^$@lAzxXzXR=c;&JBR>B#RtA^(2(`1Tp~ zJn$!h`m3`gul6OI2-C$CuePl~yls8nY$V_$YFO8endR=mS1^SMh&WS#7b5T}w3E zbEII6TKJ3~IG{pr6O^Jcvqa0i$CCs;d+s|Hi`?jEszpM{V%RU**MWYX4>)2B!uBQe zhn#i09W=e?{M!Cm)B0!i*RJ=kS=UVlQ2$JaAq46K2GKKD3OYuy226TQ!im!oelrfk zOaaop@+|ka%vfNebmZCiJ|7-^xFB z`$TdLrvjrF{4g>rXxL_{`oUr^`nIEVf}&+(P)td_{q{P;t5bRh$%+%i>%l4SFD%t%LbH4%fQ%& z78+Y3uV~Y~wl=*$xNUH1lZ5YL^IYK6CJEopo)&dx!H@Vg>U3i)$N&pkSL)sG&1aro zzy36^{hW6}@QHRiM!aqITm)4#OiY|GZ^Dr1G~GRGzv`ymbysI^Z>O*( zOe4Y8zoCyO&XtQqD<8JC@+Z+6nkyFx%_`x$a6XS(PjpfJDNMrmv$s(Wu@rn<3^++x zkzI!ugk)X3jyJ5k*rLQjk)ITN6*)zXhix??(hJNIPJET{oy=yN5@z8?OZYbAaONj| zOZax%ECyRzP$J>*uP>(FoLuV;%<4`oq&Xy(z1UAA;N#hRpJ=CqA7H-|8du^W$&&Cr zXfbJsENE7W#^@(nmeMw`M}M91lO?|xJMsU8zzMa5>JohenxF-5c8TD+!grESC-U1P zMKb?>>|7E&5qOA$622X~mt=mzPdt(Ez3g!T4<4UCVU6V|_O!_QUDsGHKshephi&mj zQBLqu!bLe-J8o1df27ENgv;2*FzRuQvmRydi{}&LtW++^R*%${B(sSEpM)P_GKM1X ziTWj6;B)Yi@EC3N2T(i55l7(i=X@$B=dQ!BVu*?kaE+`OJNzhoLZYJs zI`x<4ZaK#gJ~@{h=81v}Ql}GK>d?d3Wk=lk5%sw0=%dhcRFBk`lwaz#SjD;oAGL6xQm7hd7AY#EL1crP;L5R&QLeN))DvER>2>O1{ z9P_j%s;iAP-qZco42~@G@e9!edM0HBd+LhK$wh6c_ZFuWdABz-bo%S->+-U7Z8O#^ zcstO~#Ccd=s*-4oGo>|Wro?-uB_yX2osu5%{Qn`OfGis){B0XttKK}*0acqF(0gpq zC1{g9FRe?EZvMG4_qt$O;=pv@Oiw#C>Iw(+erH|>eyzG|j)N8#=uRh8&39mUzy_U~ z!l}y~a=}#R!1SOCbkG5PXs(l%7aY)S3ni3lrL|qjTfrB5E1|0;T&wOl$BBbtIWqSx zG#(tek2-SioiCx;*pKMMeSkwdIC6jQz_J@_H%|Cl4lH}xA-L50>QZ2dfIL=WKdVh5 zA(CRX#4eILNMRM0N~93F#OFgpLK1B2idwCjAPSx68p-L%c!F0n7^^*}`>&q1U{$1h z^}N)K#?*T&^rifG+w$`ArLB!i%B{b0>uxNp_ZbX1_j8`C?I^G8@@{P|sY}#Gm>P@I zbnU|{S8g}!dT~3T4qq*T?i{+xm+KZ+~^!4YZ>Y9gmV@FFHMwS^P%YYFV z$l}EuXwc~c3PQw~5^Jp(N3_IhTTS5g#{Mw1-4bjuHjR{&jI`BvcGl)KB_}s}=Vh1? z1WqVwxTvr1q6YJlq2a|j1638%;f8ZDUKltQGhv>!ui4PpwJoy7nU+^nEH_oos_+=_ zFcr7<^t2Y6@U>!=w`@X2Pr0_Jbn)=;;))7w`LtYnFEJq*XKbVKB}eZuA?pOk2v{O> zCn-cr3X{+~);e*}Xq3>Mw!B{>FG;Axyq&!OOi~}IE?jf08?Pgj(9w&_P2^ZDL|=PE&8Mx8zn^E4v9Dsy#24$?>14Skhp80zFvhAOsv}?|;vJ5T1%aQ3^Zf8R zl^bRU&$^^L>C4s?6%{L5jniuqFImyr-Q9{_P<(5a)mX3Dc!$N^o@%BYV(Yl9fQMteZw~1xPiZ6lh)Lyl6oHZi();s)Y`_Y8_s&BaBze?O7_yY z)d7Fg2B#jM3cAw))vCFUybsu*OR$60j)Al?^|)4bmT_|_bI5_oO|5ZYde8+r;DA1~ z)JeN$m{eC{KthlmLW9NMv$YmqmXR9~+Ed#b)YDSvZ^j6w(%m1<>CfXi~W7MX;6#S$J<+>;}uo`wMBNBZFd#o zX9Xc5Ql`TFyriMU(is=GnW!SaPJ)D`*zu%yLah8eJm+ku-ujyO030#nUslDZR8S&0;L6 z>BQXjx{_HERWMX;y@ywJm^w|?=P(jIZFo|6g3v?<$FJqE$?&EL;3xmr2H!dUJllu- zSY%@dXhy*~64EQ%6OoaqVYe%R;E|xlY*7NoP9ZnJzoD7UmV1<}Pk~z%Ju7kSe4cv6 z7kNK)!Tjh+f=*E%L9v?cq#{L70iQdON^n7ov(6oVBWO0Df53@-J0-U$fa1Lw$gWV2 zOG@@0r@X;f&3?f_i5>fakEz5vC=sT>qOz6PbBIcS9{BBcuE-Jwp3go-tW*XwTzk~5|pH|H=4?97`U6@r^GN? zYKI01eoE-hzuEc(=S^g61Soj{;xz_ch}puGRj<4?av$O;)*K#l!us;}E3TBiqQt2S z(Xt{z^)B@I)o7a;u@}N)hwo>P;<@beuWb0?(a7+1LCBHpGE0Dec5b0EckB-T025(A z{%_Q|z(8l=Xtqm}n1QB8V3zQk*!^N0qDDrTWqz%?zmRxLGo>r^uJ`;U6>1DdCsvg( z4#MO(uw6n!?85;o1h#>JZ{`lw+as$kwQ-u4zk5bQW@%@trNo$QjJ4#KCmZ9Gz{@n5 z)%x}Zo?V@oGgyP+Qs2^=RG({omRB~LlNxfYP#5+QNOpot&9*AZ;1m^kdgeE$RdyLH zVfm?*T}t2yDMpKnJQS` zHt0i39T>#;mb7eJC81O+^_HaE)dq65h-@Gy4$@OHH*6r6cJ6h+Z-sVo<|oOa{IteF zI)KL5bGGMHyS<$A`P5X{pMx43?ZkCEHTI`E^%k7hbkZENHJNpD} zboSTLr=)$Uv{v1_TtdG@o$(In-Jx`nTK2QOCe+Yp!=T>aSFa{VWIELm<#XERLW!Hkdyaiop&3PW)qm5>BV|IOVQp2cn=W1PR_H|zj;9Gyduqjdv#NB zc~D8Q$&$EucGL81H!Q9cW|kI*6lHe~=+E)a?k?2kmg)^5(IK9`=@seCLwTJU+6bJv zNsLSgara3rPjBs(@&+16cISdJ8c&DpG7C8A5Q0M^w@W!p5)|8^ci1CX6Y}n~<@I;T zOY6Zm0h5?da0c|xiNnFIitx)759_ zD9g;OYO1KJXyM=1&n+71X_)2~pypeOXD%~W)ufllb=4H*l(*JeVfrZ)gXSa3wbD;W zaGA$B*|~u&jh6U?cIzuVr>;rSj-5%0Hc3xGWv~()C4;a+IjOAMr1I96dFBKh#~T`8 zFH-$G6}&?dQU)yCSfEoUVd92ZOJT2*5+(Ide;RvZ0xs$U<9L#wCxydoN6*|*EusDs zp!?59B@{K{pF{2t$E9ejv|Z%VyS^w)tF?B`vKULNTP$WD|3UAiGkKr&nU247ANje>{*vH2CQQT&uPYx{MsvKV>2QFfiCDUPmGji8z$IE~55qh_#(vJTN9SI(W&Yu(=iac5`*Y>^aqAiDm#@7> zEy4PXiW2@90b~;zje6N=xcLh!qoZ^|G0zO|y>a+;zlx$!Q$SF@;x1ZrPev-yCrT3? zw*J8mL_wc`Q+o&wYiz&?rPfI3HlZEu6xeaxhCQXu+;-fMt3^)ew~iWbVLM!MQ(sEl zd+LcF=zsnpT8HVxx#C{HyR47%TdfcBX%@cLWU*dtBFr)`)wfml4OWnxaB2&|q3NeP zq0|nhP(lUIq_RS#WB&(AFnSsz=a=#B9%3qzns)=Rv&(Z-7-f)^4_ zydXGqS~pQIptLSzgWh9grm$`R{ zIY8nd?Iodmr%4>y3U|SAfG4`}oV1t3y_;=vfs@2bEPHV9z#h?$#f+9T!SA9#oj66} z64DnN1Gsnlyu93b?d81)>TA5c@>*hz)zQ(_#-fJ!xCZa`4Q-|k9oea-u~X|k;TxMv zhw=)iml&rP%*NY6oH!2PtsKtWBqi364At_)RuTxE31i zhyxDWJ%nfpLfIc&pm+VtU$k=mgtg+MOj2ZDU!MA@%Q`J#6f2}!_3&)jW-0?-*w#%2 zMo|;3cv0>%q7_FRe(?MW>mo<7ms`wG&b1EZIkb_YOsHeAb3MWi)v3T`TaO@I)DLp? zM&J^4Q^Xo|+v3Vc*wdna+JN&O0hdw6BXMW}-j&KY?FzF;5)VH|?V2(#Yr!3&bZ_eqSF?a6gx>o>_Dp?5od!kvE#xuTH@pw z)k?BOCr<3X`(c~x|8lfJTCh|~+HZ$NoZgJR7q0g1QJXaZJNOqZQE4R+Hfbdt#=BjJ zHfdQ9T-pSOg`N#Xd&uIk+Z#u1_J*AQDYu-_kI+5_U@?Fy*LmzHMPgj$zNw$O%q)UC zQb!-|bz&sBks4iK6uczLoOgz+>2vAu zCN8WRVKz$T>-idJwIs4kgWzHPhy5;M#o4~oV0U3nN#V+bnCN)7evhu|7L$jEr|Rz& zoEW6jRV5kndn3a``TCrUtOC!_#Kg#OO;Krc{lz|B6MHM`3^tG2ALpu8f#pq$!E z2rma8w9uo3dl(;dw!%lQ!_djk5Q*I7s4_olJva5Pb(dVS&T+!RlJ?2kg$q_&>T2s7 z2ruCw{%UD|rPR@Q1C{MGne9C3Bi0L2agsD`Oh^Tl@E6(|dPI0{Z;Ks)5JnNfFcnm@M`8+<)y2rZ5eicVk!0lA*NGybeKvrGP zBVr-VanK+Uff)M{Vy#wJWIJ+^-+OMgI_TCjy=!_`<3$%WxAj=+94wh<-b=B2XRhHcP;^x{iE#8@8oKcqFl+%#FOV9^! zY-GWzjdE4L_29;SKI;NHiwBZ%F3-j@aq^C1#~gTe13Q5JSJ*W4>}BAk2V~L(OEON@ zD3{qWSsx#qS?5I8uyLbJmKO-F!fSGJlmB3SfHyD0$I#e!4i-9X24#VeLhN1?QaCQ8 z5Kfq~8Mp$-|AKcZghd~afS3E}>%#*h1C&DkNWb+|wp&bbX1`B-l#+_0e57Iu z1{LNMem&#$Ad7Ff(mr-=X6jS$lrswnBWa7-v||=;?v;IXKDeV2^=cYw4nTC;fYZoxOaPg)S>*T3SsT&E}0J z`Dw|UUQsbUPk!E~d^i>fYWzSA?Z(%N^C!?<7}3w+-i;*1tq7tX!ijlE%8IhBV>k8l z1^r{6kB(YCRfor}1sBV}X{;>aeRs!5mnJyE&nPOkKouKNTG1p1)z7Q%TwK&|G8?i#2RfH=QOM?oZ|E{X8QL{WUg{N*$Q0 z>=Nh5K($I%ryE(Cl4>kt;T$*7O=Z=&Wn0hO?m$4~k4}XJZ`&hz@sf2@ajcEencxNe z)<0t_m~8RrqmPm-*!8?8)r!-#HXW}IvY(+F?iUJvykDH5Q;v6+qfjv?JxRy?o_vxfrm?`ugHj;2P$MP}Pr(fh*?s|ZHfxHy zM1MHX)hk7jub7*g&9A(&?t%-}ja9fDv$vEr)z(|qELeicZ{mS_8b9`va9VM~OrGt% z72_tB-lpQRA90Geodn^Oh2xDUH{p#ZEBhN4mXxweIILY^QuLU#smM zlBav;4Qad1Z{RH!aMS=DbF*u0ww*Bc zP2#O9e?Vmo=XYs`=E<|fL%rIym&?<|sLUeeL%c6QOS`#okJVH5m6-c*56$}M_~&9y zw4a^VGATda==?9x7hzOFj3V1Kg_&9Id(;I`DF1K$lSZZW1hm}~);(fq&TY00P3ti_ z{D!@F^qzuC(RzAAQy8tH5pD`>!Gf~y78Ff)wW5am-5>sL_T?=tm(QL__y#)S|#(=IQ?oA%n26W3`rQWa^hTrm6 zmDjANY1E1FcYjY0?bjvE{XE)0m||Ewumxdx2dS|t5koRcGP+e&n(QzEwKKTEAbIwiB>DW(0VD}OPz`y zdZX#2oT0@08ix7F2vv<&rcfnDT9p#VQH|KDj&+*Jl3*VjBEqD&Cp5LTr|4_Lm6)>R zw2tQ5*o3l}kjkS!bLgIjhQ@|Q2M-1Y;~E?~=o4gpYl6H?IERn=7oR5R# zi}gvJD$mJ18jzY7o0||3<@FjQaF@bz3xX`+v15-APce^N51x_@Db6*^xx_HpmMl-* zaQV-elVWPI+miMxi8}Q^&90kl_(9(U?2rW|gYdc-Ruhm3Byxs+R^Wim?K;=F=+@<_ z8^(6JXjI#LH&L0w$A^`u(D@0iI9H)K?p0dLpgnC^BNXQb0~+`d>kmJT(0RMhO{ULX z&)i`=fp6md7jEF1ohFyFeM%JQ+3Rw^Pqu6FDL;i(0ndY?ZM3IaSRaO;u_uPgw%#{M zdG$-QKW|qkc;Oq3yCBElcF~9WfQKgI!}PQ5EKPW93cXC0C%t7zOY5KVIo^PQQ%g2G zd@rI0HXBcjAQht)+Ee&g|6#lgD23L4{@mCohdVY=drlY)phQ}mpu}cv?q!#W83P`< zIN73Qho#+1_hM$jJq zq5KpnqDY-qMCxc3|C;0YDl~E~dY)nfRf-Y+3w#xwuNvZ_j2%;p3|NP)x*)1*NoP!~ zm(t@FQ&n7O$_dfB=e49`mAhzBTU}>oU3zV@zSeu^_46*Zgz0qg#-0+5 zp)|H^RZq*R8nby|T3>ocQMpNvacV{@-4PXXeMg(Hn2My~boM*i%wLvtrI{+4`iG}K z@IX#>e$gE_6y;=P>{SBwby)@V-mR6b&8DQ(luT0w<}fgo>fleI4nzwvnUSAu<}0jE zrYV7@L~AHXk<5X07o}A6$tcdyIZJM|dGnZkMcMFv>r<&p;EE$@R+<{7nk|9yzjNg{_Av9;&5ysN;U>-Q5#*j)USH zC`&K`-#mmKr+E^Sy~-2x914kVwtV)(XCvQmKDG=8`o9_ZjCPjdP3j)j@2ua+D>$4A znW7yd*gHXUyzPZTlO1@Py@ZFQgAo9$m@9KucnxTcjSW@##k1$;XBIcq8Fd3*D`(fu z$ZKe*?ON?U)TYVE%g8`yChJqOQ{%ODo#`!EMx%$nw!XY+(B4O1qXo3q2kVkLM5zX9~#U?o0LFMmyOJ_Xh~B8fMM9nBI-eJ`OqE~Y*ORPh5=H^Q61>FhW8 zDEtoVH{(mJKk)!-twl8d2kSS)5}OpE3}I@rNzp4piYms>;0**~ACFw?l|7D-jlLp0 zI8>9#S6E(6(dE==OkoLpJLo-by|=7NN-o|^9!Pt0(Kh*Bp)edRhXgOX?Y3nW-(ADI z@V<20TK65Qbtb<}wGN>-Wvi%P9X=Luhu-vMww$rx*t6IpOr)R2&U2g8&^|e>*bOE} z4e{0C)9W#8ZnvBf0&v_~B3g`Dg!Xx9@!LCg9CS8DE296{Nd%6gfW$$Ptq=J0DgNiD zXb+3TU|)sSQmhzM$a{*#%GmJ@9WdlkTSs^%Y2yO0GWLM=Fl6I#(#1J41RGaI? zP5qmin>Y3M*)B5{E_?`S#^`9pdDEtySMkUr@;dq{U(z>wb{{3wkMST@^0j;m`i^#Q z-hemHlN`8u>BPze{%O`GYalUmc*P3S5oM#bgdc|_*fb7l1k8X_dFfaa2q4_cpy1^2bkcXQ)om)D>4> zEqH_`pGIzPicKOXg738<2nJ~KZI~UND_lBi@m(fnJ-U7PBRj zTQbjLj~JF<>ICF+lnPC&75m@hx-*S9mD-dHuiMotusP;3I3!*|I^X&+4{!Y0LvkOO zthVzs>7B*eEFqK27v~C~1KC;U6QaEJB(p?MKbP%M}5Z?^A;?a_pWu?F7wsPuDYtUsOZU=UFLzYQ=j_3 z#oeFI&(6*V_dUfv@?+3|@lvL2(qAorELX50IoATByFHV_-vW8WdoO*sn# z^5b5O&kI;MyW2A+a#KXKSNENk=^u`KwDO~o5AlxQj1wuz$tfou4-W_kf1KV}a^^+) zL!~rJ(3&Wn2r$sDBBR)JEZt@7DWvy~g5kf^(IReuJTStr}`@Bdi9Zp}ArYHQPzI4uyFg6_I*ewo7G|ly%phHXJ zRq&=jrAg+)KM(HPH~41@%`=1iz}TtL=btAlz@DG%Y_f<@M}l;ksZ=5S<{#}PO{NKD z72>5*FWT&+6@IgQrylBi z6Z%R^`U>~({lp#-t=J19HLv4L?ElS~MH>}g)2yA{V@R z<$l=usU(L5^j|OJ^4h-nOD@3GOW1^sCNk7uVHYJgxIrCj7(?#tTuFf>wxF za{|c`b@dZiz zsi+uJba{@o4pt_{Nwt+41Fax;J*#ACg$0C#7^m8qZMF=$?#&+M7d`y@E2r3?Eq0b; zjfeKcik;=I`_svjqDe|=?{2Z^6XUE#%Wsupa~&-YC#IPAmsVfNf()97>B@cgX4s>;5s`Tm0kRH~w89aju*s;}QPe8mp3 z)6si85F91_gE!F8N-0L~6Wc2cIl&EzuPHb;9pOYS_Fv?H3YvK#_wd?no$I;?frHPw zgpi7ybxZOpqT{P`+NOC8dSAY#b&)Zt&oq0U%CBfUe?>`TW`0t#F}|}!yXd0A8I{Fd z4Tge6Y3N_hsI3L41zwZWixwkG&hH{-?oy-8jn?w4^ESFo*G{j`?#!LpftSjph9Skkhnq_=z8Nd0;KD+jc-B@v~$&2^UiN@Hnpj@CRcr@y?e+b_^_QD?(!99u$c z0v|45q?7bHH#8zt;M@cz^$*#+2q|6*a5<^9Red z6_tH$`K_txDf~K%^+*4>w2uj^g`shjLFf**l2 zpHm)FyR>W8qL4xF^74TNYM_^~yP#=FdCfU3P192vvU1xq4K4ny*De^?b#DEPHuv=W z(VH|$mB~0J*LhB*WqMa$i^0&6m)D+ww$qsOM%xj^6_Np_h4?BV4^^DgWgX$)PhV5K z;_a7)ES)1S{AE39Q~O?r7@U|y1k)zb#;^(Irw#!WT8MVlt3fV0mS?xGs~>JxD%(m& z8XFgun5vYHq5AVWx@PoFAF#~ms%vR#sBdoZ&z;%Wu^=!wprfv2prBx&q^=_%IB-En zi>AJ{YLy8L z?b$Ye`i(2AcymfiL0(5@MtdGQ9To=kz3K~EyfE)XQ>+p%D0R#-#zc??7U9LAWKrjb zsZfVf2h-LNytI8^b6;O`b6G2?+E*a{A?Cua_(Bz7*uglBI z44>^isVe;VUTRi8xAvcf+68wU*{$&6^t4|~93-O=gmWi_Do64DX+-X1jodNMMReBW1A6e_K#?-F4Qd&#uMEkyr+G>5xaA z5q0UXCCh~mn2{$}dUI_@MOC=L+iTxz(Uso%XnjJOK30zoYc8Ke#Z67sCGEbx^UF>I zhGfPkr-X&8c|~V8+u7;}tgd3&%Sn--B}~3gh=wQTa!gv1KHX%(4pjGcmc?azskw&O zIBk_zSw%@>oVhkRgY)2|u+-G_vxZGeZ6mf36<36WX>)Tc7gP=mmt`i0CkCb_C1<{O z7UP_J^QIXzStv^ANvt@W#iS)QaU}-pnWM=CQ3aE@vafO7lKjlF+@-5OIjag6eL<_` zIkX=`a>F69-~!Z`q~z9SEFnb2*Hl&3#z!Tkw>2fFrzfYRrKQBg z#l;{cOd{~6@*=&_&(n~UQ(T;rWbpJY(HE7M;m6tx27Ejg6%`s05gHX`U33=tXZ0kv zhcb=ixMEz1RddpRy8KWb?NJhhpQU_mqppn4_MFg_U3DqxJVYOkp43kwS;??>WoYQx zU1?P(Q!4Fl)gO8}MSTfYwGO{f{?7P}Dd2DL$SL7(vM;BEzXg+gDm-tqQ&YmTf&rP7 z8kiWKoC)n{x1!01gLX7x!{*d+5WAhUohSEN-(o;0uF-rEgqo8^%U?*Hfa7G!p>t*&0%-Ltl;YHd#+{w)3dlWqJf zM@O$D3twT2LCfbqL(5`**~fdE$kcbC1rmZe7=9Mkn3c=Elb+^j{Q4{1Z~2k#!9n~c zE!Oh!UQD{r71k)-U-32F=ixq8v;6?RKwf%U%}2ao*+G3QtPq7V~b zK>KXvCGZh^A+F%}k?DOdTH=a$Ue61~bMQnwFX!3f9vs2FC$Hzlq94Eyk-wami+gax zc5f2-!3$Boj)#lq-~{etQGbni4n7bUa8LRFmt0U@`b93_eKjTMpVgpBWlWE8e4Kx3 zwuVwLEQIrSAMg8rf{M#<|k55tl9p`gZGsW}wU7iq zuLo~Z6!fU5f4ZpueMNP&|9kNqJu030;z<$H+7MPi2l9ep%+=wc17=o#DVf$DcdJp)(l6o@?qSzpJk~ zGdm|EGdn*Z?)o@h?(nnVnYK1H*L3oN&tO8sf3}Tbtg;6cA&d(juOMLU?C{L2JYH|* z`B|yfizr_z#x6-_9P&{FMuuR>JA#2{vuo+ia|I!(KIxf-L7|@Tf|A^7w9PeT4TYI$ zu~AR+`t+*8@E9++Jdx4*B(=1$DnHj47V4iCOfjBdw2`z$nrNSWPA8A^?C1-8F<4~y zytT06|B?45a8X{@|L}942L=RWM^yG5VZ>n=7{CpNMN~vY0Y#!Bi=qeyF)?PTO%t=W zP17`OQ=6L2CaG<0+Geq7nxr<(Vr=%9JvGK8h8Q)57=ttK_niAY!!Y8Krpy2Ld3l&~ zw{y=ucRP2#H%2W{Q!>*s(zC~ni%pJCOh{5oOpfB|B`Jy4)OliZtTjO!8=n*#mr^u- zTy%U~q>p*Z?>OXgEGcWn=(gC z&d=4xMyIDGnxn%1S=<&JTwORF+ZMc~8h0}(furSY0QHqwZpnFfjJ3A z7bZG2QiyCZ*(hSsQ6uzFDD|bRMc;w*mo^Kao7EJPm-dkG$tX+^j;Xz?EyZT*stYS1 z`wUqud;7*2Cw(@6az^Hln@j5Vd(0pFwsLk>YD{A3)M+J&F)7)zW>B}Dy7zk5UQ9+` zzA$*GFCT*U;6~`npORkZ)+an$TX?os0Q>h8^QdI?7Ni8%oTR8xDz>Oh^Zyw(R@q}m&p-cslwF)^WLxZ1ibj&+Vtnj`J%7rU zE$6r1OKmK*H_u`(U%L7srhNR=4;72JhV0>w>@%bd+We2?-K}6S1fc+*CHjZTLoCrh z6unR}QwVqmBtGB6XvZ`Fg5-%5sPi>HQ-iOcw($ICQn2% zprR$KgA;8T6M`$I&YFuSVoJJZmbMng6lQ0}b)5fHRej>vvu%k@)OUe^s8xUe<2tusIDXEe! zpYw0Goi~{&PqujlslI}ckn(2bIPQn-K9VH#ft5>tKit@xP#))$APn0O^Ns7RVF|8WkGwr;lN2WJ7iE&TjPw{aLAgK4a ze`LIEjO}b`_9Q=Fs2bFRS5#f$+!Z7q3UaQ8wuW@nfBIWxC3bmZ3;TnWl_X_dH;P}V zhp7BUqo0*2GHRW#b!*LocXE3)xCQ|CO+1sEu71fi;7ini>+~8R?LR^n&`&k&&FIsmf;RBE z27F1UcgbCB;2HpTWjoh^Xylhi4e*%ySfJO01f3y|`tY)wH_8FjitVZy@c^v|t`Iop zfV#nAPtHj!psLYe@{7@FiAHzjIzF7%alNLX#kHTQrg$)Vx*C(M2X^{;^Zf9zsrF1k z?LpYWtHvN2T&Afuc}7fB46i(Tb)67v1TI#4RiUA}H55A6sLzz`xb=tfP*J`j8LFF^$5{ct_j&YL3B;|IjeZrS(K+xO6W@S)ZC}vX$rBzw8m_0a`-q~gW`&0o=sH8KUm+_1_<3FpVVI&xbxiq8+J<>(}ExFPX zkJ&_IJf_stx|E{Al+@b9Np+U2iB%P8DfRZuNonKaY=tRV1*@M8iH(Sio;Y#Zgq(d6|nw^V&@Ve_}f2(AA_w#SP{oLMHR=?T>%n;X& z%KJKI3bMtPk%uTkF>UVRu{lXeIb#>kEu35C$eC1HHh0~!>iOAI6Xz#R&7NPqtVYYq zZ=O9rPy{s0ZnkBqlrkX*WuXiOC&tFmL$?L?d=wj+phG8?sgKEvqSar7H>0rDQ93Ed z;ixUFZIZz?t*cS9Y|XP90z}~a+0FS`TFtOfDK5=OpK62xX@|S!AST1|MD^jrTem{a zRGzfx>EeriqU^i?AYU+5@r@T zZ-1=LtvN~ezM5S>6-{_U3a&WMOxqxuoG-jBK0Num8k8?`e@sl%ifA9UZ&FwiavP88 z9TMSJP1iQOa$x^(jA+W`q%iYUSBdcHd-orBW$3+5{JcCp zGMQRt+cWKL&rt0*`b6qSJkJ2LaG+0<0rOp`m+0%TB%dgr!e%Qv`#|FaYBw=bkjl@f z6ZI+q^%d$$(GQd-iihCi38u77t%vORND)v`PsCHotxU_H#3ZC|=@afap{e_zo=vs* zNW3XSPfN+5uzjT>5>{M){fh140#>T;n03dKPZ9u~4EWj)Um02oy>WqE*C}6dd4qid zQ&V(WIuZ8mg<+?M38VbX+qWZtYNkzPVWN0gOazZU+y~Cc#}*=V&-_Xw7n+()3nRtD zJ8PW-BA|9B$M<37O5mbw%J-P2hAs>xjM|-?1K$&GX!)4a?f{Q|fWPDvH{&*Dvggv~ zn}Q8P5kkV$c@I1=?*ZrStn`YP#EZ^}PP0^rw<#{C*o&DAdHRgTwRRjDi;kvC!2PiE zJA+Q7Oeu*?&~p7}mo+YlNzkl*)#Wi_=B(Hl@t3l4A!f$KIB`FL5Km!u8=VNeORd(@ z_@qP}&AXx4>!S_t_v|BRe+3PtY4FP44l&9aE5&Tp^1=bp-WbF1#DpS|_l^g0v{ zNOdgDE>AZtu4!JgV4A&fnzKXOG`L9|D#|aIj96g2;L-C>p~G3eNjTZLzXzj>f*$A7 z+TXr%Qs3WBV}VR#wBRwu7{QMz?#pd7${6lvXf#7FbP4u1D*20c|DPRy+z1T=8t3iO zAGKCPqZ#hq(|AVSL*PBC`D&leF1Xz4`{~e7g;{Vu$pal<)Wc)EvO#mu8MrZXE+mZX zv`44NwvV>N&~h^kF!Mb0-b+W`O9ih570tI==NAQ4g_Td7SRNJ^6&Dv385j4Uu73M0 zO9ao@W>1(f+m@S`m-`Yt#>PgnM^>HnXI&pa`5hr{+@|O8!V(?+!BzeLQryHH_U0-6 z6+!dz^XCOs`cG}%@--uONA7|GTY0&yU_tKSzY;l=Q-4&sTD1xzl5o@uM1&u#L^jdp z*t8_AGs|wz5}OG+SBPJ|;x=r|vR@+Bku1Sp%kv-~%9Z(${Jg$~&rE zoCzsKNt5Q}*eX$DXn)5-Nc|)wTPZ?3Im-)ZP{5^{=S|Rbp;PCFO}e}R#SR5=#I{wE zCcLmUM)-7GFn(%&bYj?f^YUk9l-J&#x>DFu%i4;38~rOwa%|D1bIa$a zTXJ%8Y|T@rEH3nG@UJRLu*BxitZBjwQdVZcd5M$k*4gPX6Jz6MluVkJhYN4RlgA|F zgob1mrkhJ5Ba2cdRitND7npLa;faw6DUlK8DHAj8VUbgE@B*wT%*NJ$h$!?NvN3L^ z`znrUbgqb2COJ;ScQbek8_zMhK5IwuqjHJXtTghs&JFD`+io#pKB==T4kB zcjffS%{E)}Wb0h3PmQT~LTXWBVo_@Bq2T7nhhAm-qW@ z!ZYw&XJ;2pZk;@Nd2#Xb$+cwcg!1AthfZLhE0@>|b8algfW^|{ z>~rQX$E-64S>7yGXmo#%oZsLc2A$PQ!Tg4?vyyrMq*teJvR^c2W5Fh8*_7=fW>T^6 zFBU7rQs<+h#CaPY#<*Q%6c*xu^-`GdL;W#}Tf|D_5U<3Z>9&EVv#YY*|0%iF$k8;>4;66RIXooRKwaQd+vjlAbn+`g56LAMeb?D3j)HGIQ$fV&DG#=zYOOq#-`( z=+{#Zv3MilWAuZ$208zNm^9Pu3XN&#T(;pt^FY+sVfWfvaUEYU^O%Y*eSKSS%=lrvJM2sDrzfml0*yQ}B`i$3&SJ<)6cMCy~;hv#wr(S_6RjyHb+#=Uz%Py-S@O7eS*||C*zytk(*QA#6ll`qB0Nrw=_T6>Hy4V; z&4rusU+7#5c(of1$dwXR!b|O&3$MJgaI;7%ywbjTv;9hz@!QmCS|U=y1{qH|M)MPP zIVVAaTMBwz#Phiil7Plb-Ep(xYA>ubE%ci-w=lONHNIhf?Xn4lE2^ryOZ^x5X3eyf zR>#ks*|@?yy}L=|71R%BG}3|sLcYf)Q?Da(;I&6=2<7(Qn5 zTq~~p0Y`Le%5RZNNhleZT^ia&GOV@XUIKCn2PNp>i=X(dWA&V@2{o%7(>p3dnnD|j z9ZfkoO^#^`gPVgYRyoVW4+5PV#a)4+_SUQB&AYnIUff=4DxFt-dELCrXE#@xD%MQ< zYSye<==2S>fyeN+`77ATor@ZhpTr`SCvRl4M{^*!Zb)V)W@1r%jh+n6=-9>gg9FRU zif4>n8hy#K87m3{{iithm$euAnWm`O&K|#j8O4Q-k*#B|6<5v*PKdCUPAwd@XhrU# zsS(M6vz$E>=NCpM2F{ul6dN{iYT+dRx)#(LtXAQzRN8AT&jh*0FOZp#@p7<2C!l@t z>wccD#ku(6T3nHH@iw*VOZ z6NVC@kBmGXmCwZ8YDe)vF0L-oulv|IYt}~fiQe||R-3b5tSm1pb$|+LH{S&}R-Uv< z5+u-NZ-5p8<<49*KqMfLb5UZm^OeU7s!~&{3LbyFqPHCMtHmVeVm0{WQ8idZE{LCM zHW$au%{{MN2K4vp>fb|-uzXVSKrf^S)>KJaQz}5A`+~xfsQ1c`rl7y$)9`RyVnc0( zvr2?k)HWo>J&bI~OpaCaPd-;MPkmxug>$vIB`)dYcS&-hz2f`SkTQAs%z6emAF8?@ zoq}jgBA_fJMHLoKRn^{>(V_NbQ)Vx-`L&I%FsBzK1P9%>ZQ^usOI>p7^ih!k=<6jE zF2a`WNq*Mcq@uLwshP7Dtx0S|K6u<{;fFCQ&q#9e824H%6i+yd@q6{F4!Yao`!zK; zVR`B;ulp@Bl|t2|jzK;^w$pW1MB{Lc^PqUU5PQy(pX8azOiU-eD{6p6r8_E9JSY^o zR+XtN5PPsrB>jtMq1lCBrs%Ode_*#mxsJ8cEK83KCtb($j5cz?Q+8_}a-7Pf2-YW= zY092{&Ox!;W=t!KRfg_XgOi_&W8xvn32{j@GR1DEP3#t{YijlYjY@AC!Y8@GXKn4Y zMxo>Wd`sn)KS}&FHR$9qQ7JtkhpkE?`o_teM)_$dhQ3i&Y@zQJPb4n5FW|lfyRlwE zryCN{6K1TmG@$wB`-(%H2fgBZiMvGf0`#fp_4Qw~SZ-%Rne^H`1Zn0R{D ztX0q#JW^9b{Q~7OtmMR^U$9WQ5&eRNN;k)Bi?G}3I+&sZSu%y@hEUoN ze@KdL;!ekoStrOl_!n#ywL=48z&C~ldU+G)+g zTXA!;vu!rVmSeS#dEZx0jVcr10*d^`PR*_hIBCF>o+Mi~Mr!@smfzpWdobH=Mk>rUr!>AqL@W zp98qG;)UIRG*?XDC2Q4!u9`<~)`Dwl29IjNgGYfI%jHkLl$DR$+tC_DXn3Lh{`mv@6=xYEfI~^Tn6tH z2b|-zaQ%Gf=i;Ae6$7!Nk&V2@UQX|qEj6!h4YEl5JhAG+#>md=YLoXn&a>t(E6J`b zNRODj!eMr#Cr!(C%$)9QymDDdXm!NAz8^L?-<`a=YUZlRiI#kGAkG?9rZ!YhoR+Y# zcJ3l{2GEB)t`1-glvdDapEC9uo^Ce{n&%Pwk-nJbdZMhcv8=qIp?m`VGPAP8BVHEH z65bakx);|sG&MEUFP=7~pkNC9oYkjTVN)F4n&d~^`E+=2H(8lMHzI#+4!;TK;wN5F z8GP<*L}l8@k%Kg8RHwmPaCqZ9@6kE2w`HuY!DC!J68gyq&ck?7tp;XGfx>0}rCTBMe! zYoxC<91Hgi^F^B_jyPw|6%U;JM7&V!%;^vh)d*)*l6bLLp46MDnAOjQ@5exu5p%rg z4^49}tJA|ORl8(ZxH2|cOVWg&DE6d&w|HQ#bEX>M%qbQxc*jaaq0fVXW>^a1g$*U5 zAhWJcY%?Zj)kZ|gJDfDXBwSX4C}pU&8uVUj#Px_o@!g0W0us9ry<+y`86%$iO~V;V z@uDYjW5yVlvQc__q?|n?EDblO_zC+Zo`gGU&@ZTQ_7x-L=pHwuoA!>LZhR(D?tme2 z;|@|S@=C>>!&xx4#w@FI9@S^C!4GJ=u2p}gQ5!<{=s?a!2!TC05P81&(1{2v-c59G ztQqE8SlqMI;`=AN)SGtp=scXfN4<&Tdat@fJd3skeP|kk=_|1*JUuBJJ?^$=*hODt zZic^qR9s|6P@P|HdCH_|Cg1VnA~Pe(T5?udMp;qjr1a!WTT#Ka ztt3+?D!)*B)ii|1=dsg33`Ll_)1u|EN1e~stQOZlcH3>vE$HxW+(LbvQ~gBXY(K6i zK^rQjiAUGf)I8+ugD>H@?XP0PK+%a03Ue zBl(F@Q`;s_ZksAHoUdh+rl*%?h>Umc{^jbsemP(#f`uTTmJ0Hl?!If=FPXBUc0zJ+ zyN8}Rt_T$wb~W00p7`9k`SHiZO?A%kBK9_#Js~;Z#?8Y;xKT;I5z*hO1Lk?yL-Lq7 zP`3fe!I&$7skf#Pgot|L_bqXUGf-@N{4w0s&{c=VYd^^9bi38RNgg06z=L0guh{0i zO8hWHaH()%Gfik`K!+(*GJRr z2Wmn}7>>8)W%%VCctKn&E_%*6cz?%ZkFO95#YN7moOjoVg^xWZc?uWZ>ir1O1TmRr zr)e16clDRsCe)$l``F25b;-%wnV;vd|NR*q59JE-Kn>k@!s#o1UF@7Mo_{aeIi0nV z>Oa)y)li9@77?9>lu<(ra{myeATRMupi1#Y=xKIHO+O)W+@#|7oe*C+=NF4#GY1}= zruk*iB~hY#>unqA>Rv+ry$3cK%NG<$M-=_+B(_AxiD+>zxGatvdqN1(Fy;&3F+yV6 zGwi+)R-Z~jFECZc`3I;yEpZ|;qDeee7&OFlC!(DVV2*a4PE`J=MTsw{^ipDVb z&|{B_w#OfH-tx!WZriW{KWd34x;_>?>WhSlSRfL1)Hl(iV{A9PFy=9hPI5vL4b4F4 zM?dDcHagRA?=|AIC3f?o$!kc1kY4`k`ocZ0Q zB|W6}oD#webM2av((YQbxfV!kYO!rD6zSlLFyH5r7==DsD%PCyuny-?W-0S<6ZJA> zrLvZ8k50p$UkE&3p4yLwG^9PS(O9TwH7z~gj(sEo+dfDtiVQ#x0*mMTKyf}PJ-X=){SxNCHJQq1UhXJ?ry*ndhum^hRf9|H=NQv$>M9Dc#hjWNM- z!K2f~b-=FDod1O95YPw*tr?)XFL+#Fc1U8FgCZO;p35`6>2w7o5RDS4ql7*;^@a}I zdvD`C_pG?*p2mCc#V%fl^AYDGhKiDiahf&%d_LfPsJ_V-NX%JE|)e&eUemV z66P?9#8;TFWUU4iiMx}36wiVt`jYBm?CE)fcW_`dXe_^)Ob<_;W2(|pl2ekkDpR$( zxO_@#W@hS?a!@y`?ZS$^1U#n^fqkRApLSuxLbZK4{+MdJas^{xJ_LJh*@f*Jl--a5 zG*5!2Z!=vx-+ui8N;9TiFadBm5OO!!Zs9G@XdC2JCpmC|ilS6|?2galoVRt{J&iSGcx2jOOp)+L`LF)NcSjHol8s$m6 z)knQW-ql9ytA3cVle3pMO^J*Rj~*R2YE;q~i#b`XiA)ah$+# zba%X}De+T97-oE$T5*o z5fS6!r{t))8L?S1fAB8&J6PezO-P`Iv9TTd**xd1ugSKiMaCDULv z)a1CSv6-C~oGCzlAbn1U;?;1a1u;7;RK!zEI^TaBo# zC6`Qe--3~zoR9XX%mX6RvBAMEuEXLZzEw<-JB2T*a$Xgx=A3+iWP@3qC(5)L@JIWe zQP=#2)%%}V8XT#^%l8{yUDvvc_$d^q3p<>Gqrq}c6qZ5iB!P!vzB5KBdBf{lN0B+gXjVue zv`>Z{vXg8hSyVHo<|no#)oS&|jSClcKm>BSR*TC{@qzR;d^}OT#CD30Rhk1~VD#b+ z!@auVuDb3pe;7V4BrKGZ&YTe+CW$Q#OA8AM^;MB>xcnv~h?Fxt^pFAti>Ctjos6#*W2r%eZkwdK?5kbEwdMlQVMI6g6&K6n=|!btp80LvBH3 zdu`HwDED)!vUSEyhPqj{y?AZT%ZwKHbSpJ&^DVl0F?M^B`_FW@swiUhZ8v z<#0UZq`r@j_RE|y`Z;CnGDLCY%mLR~au1zjzr3=XWJdf8_>2YbQj1l(I}v+f4dE#e&lxQXk}@ zA1#vA1-gB(QlMR+>&Ff8gK&bh?b2S1Qo@moOh*JL0ov2LJ`nnsbbS!?Z|nNe(0{1w zgP}jF>qC@CZBW;TDr0;?b$uB0>AF5#DfF45>m!sLpFUk5srdWcuIrwe>uvA%QM7BT&k>xx)OH1 zz(~Ye8Bys2)pgiaH3{De_$7?>%5vQ1)C$Zsu)P2jdSPl;x){d@VWQFjjBfat;1O;e z@K%6Am(l`Xxu8=BE-e_7Hz;+8MKyFoTuy|YC;pI2qL)hpbFmH_QkaPzl!yFyQ*(fa zb?~E#3|%}j#9gYeRr~t8HigaaC(4U$vpJ} zQogjX`y8l!up##*=*fNsOdFw|ou9LZe?g zJW{v@H!UcYEucrGY&jtDc_GZiMPx5P47s)yM(L4Jwg+ zZp?1DtwG9A?yS>oN#D)s(aCi>k>lSAZhC;z3AYuXvkq}?;j~%DGKcC-yRrdrJ?y)H zu^e=$Mo_3EX)faQU#FL*?qRii0jLq>3Z_8*s1(R_^{lrexsQxFWIG3ZQB9_pdeZW= zpAVchz;PgsUBLFl^L85v$FLjX&#>{NbgHWZp|zmCtR5EPknK#Q8>J@IDay$;oTAjH z__nxQd`Da%t`wWaRpM%K zjkp&3NUs;)72gxz7dMC>h#SQZ#ZBTz;%4z<@e`U*6h9MN#Lva8;um78xD96pI>qhc z4soZrOKcOr6n87D#XaI)@hh<%s}Eh+tFlV`R_qYJL)}@UY(@R4RGdOldc=L=_sR_M z2O*S$*x9{S{88x@_lpOxZ26GbsjL%!5)Uiqi$9A;#9zdt;xWkMS<082kxB%uU)WcU%1}{)96i5Ro!W=d^abjLcqi#1b)$N*dWm`| zcC>s;-K2h7y(%e7-&4P@-k|e6 z{zUz$dJA?QY*Bx%-m3mW-KyTE-mc!E-l^WDZc~4W>8pFxd(~g5+tpv=zT)4iJMik$ zed_PkKd67i`$!L{52_ETJJmm7&g;+WBkEt&N7cvF$JM{$Wy-&)PpE&#Z9h+`Ppi+U zyVYm$p4vau=dkYeg8HKRlKQgxiu$U$SA7k)1HFMaD&A7xR^L(IRsV?__TN+YtM97^ z)DQ6X+ehj_^<%YP{X{*aeySeEYk5bpd*PV+IrhPPf!m6{RDOmT&`8YcjltZ&Sj-)b z$LvZh?h*P*9aK-MPSu4838AT)ruk^Tno0B1{IyY9fEK6)X`{7ZEkp~|!nANLLW|U* zv@u$=HdY&l$@&;AR*Tc(wFE5@dmWOs6fIRt)6%sJEmNDIWog-(S(}K{^2f9sZIbf2 zGN4(sT+ND8gxC+F72uACLahkXt&_DW+Ei_tHeD;xN;QX8rj=_ITBSBat5P&=rZ!8P ztyODtv>MFtg<#z(UilSv1l*w{E4M3uRjyJ-YjZJU8m>&&YPESbX$qGL|dvY(^|CUTC3KkwQC*P3cRQOH@tB5 zM~ogHQ0`IgRUW}h>b@9(t<*Y|ZQ3erwbrGr(Ym!BZLM}b_85&)u2wcH*D2R(y~_2< zceHg{pR!c>P`O-rTwAYPq5K)=9WT%>)HY}rX&beRl`EB-F@E|~IgB@u5|!^`J}Lq4 z+25r65N9G=@OoJ(_Tp{PE>Uh#eu~qnKfzwGC$vko%d~H4o3w9hmuuhAuF$U3HfvXD zS8La3*J{^k*K6O^zNdX(yFvSbcBA$~?I!I<+RfUJwV!A|)o#&#rft!FuHCBrLffj{ zraYwGuHB*CsokY*(|)Plt=*&DtNlvbuKim3jrLn@hxR+|KJE9~AGAMe_iGPm4{8r- zJGDP)4{Lwc9?|}yJ*qvXJ+A##+okZ*-YOKw@YG?%g&{>pT-?ys~^H<#+FPN&rBSGuO9wYR6+ue4`HPj~z3 zz_QlP-q!VNI=b371eCS)^tH6Mws-fL%3E821*NyArO#BsCP~Yo^H`>bRi@J}(|Ihj z1ys1{=w5Vo9Ho90dhiu8c-@`Zx8xR9nr3iFfis4j3aw&YgWe5#hW z^afT9`B7)il9bEz1Sl)?o2iF9Q-*As+1J_C)*eXIEw)lio+USM)({05Y6UZH$;~S; zRkyUR?`t(_L(tnc%iE2C(tlU&t#aFt(Rn$Na@^SvTz z&DEJJs_pyG$WbO)rOmX6 zophs~zLkZhCMFlqG^`Mn+6x^aT)y%gIbA)iU2{sSq}i5lm`!y#>(*Ipfih8bJtqW^wR*Q$h!I3KrF7;%*P%>J|h=n0T zqJuo@j%B*XieVl@hQxyhuA`6*UhEVOPpv|))H1^1)Y1**l2Gv6VS!3+D>%-D!Sy4# z**v(Zl+g-ympFC{UNnNgO6f0TsAMs2#PDopyGXCRX3H?o43}tLS;Ch7rAue#v~_i^ z@UPc3rWTJHSz0MeJf5~=dX8LO&C}ITsKVZZYh#m;n0~Y(*r5fiI(ZimE{Mty9wz0>U=mHe(id= z?J`{5o%yy{^NUO?IHbT8L+lU=nvA@F6<$=W959-Ue4mxnWUL(WGpds-{DQjKGJrBY z0m_Q}I&}s*B?G2Tu5N1QDh|h*XP1pgfow!p4bhZ*RB%+#T-Z%rEEh~&?zDkznW`3bb~2Bci8Pxt=8Dda9M{Sy*nQs-Bf)mA*YgLb+UzM}^@_$12zJq#}28&oH6f zBV%jom0b0DaFuU2tz+B3bzYIQ<`((%QRC6)fnLBtqVaH;)_WAy3Z3r?yYKoTK4mJQ z@z7&iVeqZ$$RC=8ErnZamh@#^XZC=7mFSj=IpOyQT|e<8jfDqs;Qk zeA7jqjfbADl|`nFOfF#Ku;PTqqfBo+%8bTBZ#)VNv#EtDZ*E`<6VUZ;(VVNx&`NfY z28Z3#kn3s4cN?f4vmvx)C=za0)aoHrs^Xq5p5#j%>=N8!B#kGc3_7sI-2&wX=@LL< z>0*El0WFmHtPN~&H%7TK_EwMBL+_5g)id_GLB`%NFbjr(qr?Uy_EzuM>!>pJhJj;m z7&!KZfn#qS9(%)02JI1ht7q(Ckfn~IY#oX{qUVmi)zn3w2yptf$yh_17KN zb+xQp>2`q3WLAwX?Sk6WdT zc(A)-OMe%Q;IC5p3mK}wj4PX7y~cUi=rx9IB8FEEPb>~!GT}){_A#W5q$F*IQyPZF zM5K*>$I^}*H1jR~eM|dT!>x!dSB`dUxv~kf<;tEpZflY57F{jU@rn$0Ib^iu$_{|d zqPtrRI^0FJ*m7CgL6t)uTdr)iY!=zj+H$!wZ?ReASk0Ep@)`Js*}&rt2JE?gvDoxc znoVzdZC0HxtHHO9Z`Jv<>U>&tzN|W5R-G@a!Iwc_58tZuV>S3R@C>sNe99P-0n z$Ctxui%kw4EjBp_gsSsbQNaSlX5l_|zHZ>^t{cj@4{Nx{a_WgzNJSJGD*bT5mMrG! ztmWxNBu{4%NBP)CzERY4_afb#r$;?cPwIS~PQF1$Pm(-6sq^%xJ1k!P3t{aI$t(}FN3}wzD?)H zX7Fj?8D=B?MmRdZ`Fc3{dN}!dIQa%WgFijIe4T&2A7v}h@vz&B^TDo%Q>4=?(%sQl zmqksdBgXNdq^R7F)h&>5^k% z7SeDTYQ|uYLo>{!tXbaH!c69s7NJ)>WOQ&ETWyuhlNIk15gJsEBUDZ^sGR0dInAMR zT%bz-b~~pz%$%lHo1BZV+6uUgK;<$5mD3n1#~CW;8B|VNs4{%)0%bmI1%;zFw)gf> zZ7K4{1R87n*s8C?L>5r`F6@Eg-?y^2oy>k6J?nc}?Yw~9*L7|n_jQ=}>n2Tm=Zckm zgwWl|1Y{6x-92kK2-Xly(gaAVq#ea{rH&{w9?@nzNu9NlJ|l7ntYe? zjPbIgSv9bh)7rAG{d8{RFYyfim^m5u+_KSYTYB5OyV^VYa$36jg4xu|6$r-B%Q0+i zcRRAl?HEGF)$M(TbEs~1BSZ}Iopa&J&c61@;WVUWlouswJI0%yv<_RbzO$>VeNB%$ z;BdE%J7|iXr6R8~w5xsHI=#Sf35oWw@U)LV)!x&2tf#Yg5waHK6^&ZArV|N))S{p( z3w42;n`<|%>FlPd?se_0J>6~OYR%0p@Tpke+rtKnL$4UJU9nmmQr7Af$!aN;GTAD- zR#t0XL7>3_vdEt}qpb65>Fw>gaQ#{tLXjQAD401#bgRNqY(?cww<(e~l!LlWfwWoP z)3;LU)~{_duq9tuAdt9n;sJBnQUK)YeCEoo7^W$tV9C`5K(50UjNw{uXUhsyvcC0l zTHcylVGq(dr*dhhwh_}G+|JoUhT`Dq$nBmz*c}^BHy#SVZ#s1E2H;KI!wg>xA%G0O|(2r=gGy z!Fn3!baNq*h!)vj(hagF73P@;Q~(MihI?hZ(ZdG8!R~o0cH|_o%AS_RsyBbuvcjO{ zn5tM^#>J>D+|w|>ueWD)yMkTQ0{g5(plVoYR0 z!`+1S>id*ntWQ6vjKS*kPV8vFW*^*gg5B=e-K5ala>~V^b{&5A6Zel|;|Tqg;KRwq z1tt80J{U&$?f>-Xz@St0@ihg6N5<2QbFdYA-`sbw|IpzhM~{6zaQw>?Ur7u<{{Wo% zz=@EtCj)cw5rw@rVx{&m5w&c8MPt>y0@{Jr_fCC{Yq-moWfPwk!! z|7dtF|GAaVo1brae(ej(Uub*r(wE9!Du3zHmm^;Ze9xAot*;lqG5gJ>ZzaAR`gZO+!S95=Gwz+tcZ%L|ymQ{WVee+X+xyQ6`v&$M z-xshia$oAcw0%?dEqX8Xy@>b5zZdsj?t7EpbG)~3|H=J+`>p%)_S@bc|GxSCDF^%x z1RTgeU_VfQpy7jv55{~D^MUz;{11ITtodl{N8>)S9t=G=?qK}EgoDWkvk>tou0h zs6*oq zB_B%pH1^Y!Pg6fl`!w^@tWR@3o%HGC!#;;ihXW3eJ{*2H@^I4O}&!az& z|2+BgjDfua`v&?41_lNP#DM=mz(CMI@IdH5*g)jKn1T3#%mM4b+%LZPBI%2)gI39F7^Z3Fq#h0VL4EZwb%L!lRoH%hpJ>hf0bRytH=!x(X5hr3##GOd{>Zz}M zzZ(0Mb@1TevBBemK7+x7k%Jk7nS_r(AUS;&)45K&~(&v%rsy+ZW=WC`0w){@IUT9=Oa9h%YW&puz)uM z-VHby&>wIp;BdgvfPsK715N}C1~>zJ1N;I40>T3#1I7gg2kj5)4>}q&5M&At2o4MJ z2?-1d4gE0mX!!1k;K&am`y&G)BO~L-hK(JIa}$kmQSry(FHC4mIhJwUl3aA6Ja@(` zGY(>VjX;H^3VGFLW!GKG4qqL+mr(A?5TtdXuvL?4(tfLK)DS2b8g^jN|dR(2H~XMe(3h%Cpq{~ zSNnDKu&y4{)#EH(gfb{q5F%+KAm63xud6|ja-p!tPuGPbJi0+y!@kYSg4XK#c;T;> zgF*nz8p7}ewjZ!Y!6p!U6$46}DiW0{j2{Tr5u9Dx1c-Hdf^(|@YpTlu>*me@T)>!E z#Dkts4{==4W|6GrEO9tzUaN?*D;C_M2*(SRiO_GYx&-=Jm5&0}&WVK0{WCV8VOz3r z5H>XxXk8q=vkn42?zj)GRgR6&-|R?-zRdxAN3f$9Fw&6>n~jd|!Edso4L0EpGhl%O z?U`eoQ_)ni?Op4_mP$bJ8pN}>$uOc)A2ZAaEkjm{s8l%jwc+u z9WOZcf^H7mk1MM!>%gx;|7xMHEo){d^@dAB`K{$`Wt+-wSH$cN4ykR%1v9$wJBP2P zd`5RwpZn{ma({;Js*V{QGd9;AozYQsx%^+4*5`=)$-Uqe2S2D64t z!*vvY2H45NFO5|UHy_LvPb+@dip(`-tg<~?%LAa?b6fy6t`W{2IaKd z+wDwd@32P7OT)iwpP}*40>y>YzL%-h}5O!dIi zd{%ux^Nrp(T<$#NgyC+KJHt#W>e3EYsl4rLrqigW{d4sf0A?(yY*H>^GXx?PFKrWRV{1|qpQ_+4N z@N{FjIMCcjcCKBQ?L?T;-HORD;|I!7d=RdXaLGB+uy6`@n-J&}I zxW2jR`qV!m%Y?LN_Ydl6Ud#PQb(()Wq3)|6OAaz`x?){>>Kv|t#;aXl)!okaAJ(zE z+cV+5RUbdh+`r1*Pj&6Bv$%O7TrU~n0s`*q zYNp%2w%R+))0w@~+uO`xQJ#X=)6rlY@OFxOP-C?c+*rr<0gVe8Z(kja=lRcdq;Uyp zT*oOL>NdFc)m?@-?&iAwVdE-UW)U{UwZHKuBToz-UB?>N8hS}_IP`Qg{2CYt>ju}r zQ2uim2;XoYNkzW7`~TqAL_8B7-S6%?R$s&Vfx0Uwe_4N|j_bAS?fPrUZ2WP&_QDVU z>Lm`v@$LF1uDAP;21n|b5xzIy1`SWuvmBodpK#BH!{LHA??1ejin!MOLE6`MxCR>@ zCOy(Y#^J;IE^j}E*|n!}n-W>~D4AVH8(wl9sJ~3{sqckfDTj_5bPXS;s$Adq$vTHL z-f$|$kj=T&%^~dJI?&$jI@FqsGIBS$80m#FIL3AO{GF~{L-pQuq^`hqbKMr#&bk`c zwz?^5G|X@1g}%(!qf{y=NDTfYZ1Mrgsno0J2<c>=M&nIP*CSp- zZl~%Dx~IB$*dTll(>ZIoZlk=pjv&ql>#uNqSbr_oGsx64{jt0y++B5O4K=7H z*U*o;3E3fig5J%py$vfp`3L^8Gtu>Q8!2zeop|;9Gr}|Mh&My;R;lfPEb{!L@+!?E z*Ly>D45^^eU$=ns^Td$Mr}&}Fof@WRU}w+|@o@&Euj3TZCCggSP+c*+y9VpG7N9xe0Gl;CM=Dxy?x_Fjl)W1hN+q z1($tlL;CA(HmGqJUS*Q_V0*Ott`F;iSdZ|TSCl6XcZaP1!^@b?8~Fpz?C&`A&ida# zURqqo8}4Vnhw68ukMsu_cD*i>$V6eF9_~f^>CvXJJ%zis9(9)MDa#(jL#7ej&gGBN z#}m&5K0M8&=kUR+x4oy?n>Y4H>GJ~m0~Ggx`WIaN4NkV-2Q&C{x%(<)uB$Tg6Y^=V zr`$&$9{mLB=gP5|8&9X>$%m)jkYyaEo-Us5o_g+g5WnE{On=0i;Z8i0p5uZXqw=o!T0` ze!-r{=0=_p&cE_Ue0ck#u)XcP&7S=T;+fpR+Y>A+DBmbw_tYQe{^8EXTiAV1AFHgV zF(=9m$k(fZx67k{cy+rR=k0<&+Y375->yHvt{AJTsB@On+W8GqnM%8GrmY?3|Ulf9gO zlzv?wtjIL{c&d!Ir|}loE*)-S@HI^ z-|LBE#Ld%=^cV}@$8E-7Lol1Et`9U&Eu`}3E+?)d4QAJ#hIB5s{cFNK`JlK#hK+Zf zXhlbk?}%w1{WIcHl(|==ZzD{|f=8vz za7ga1XBw<-Jmf3VakuM0L!s-fhG~S2xM#7t*K-U5d-yf}j55x6kj-cMGjP4*4<6m+ zh3RvA?Upe~Jd z9i_Mq)lD~zbJV!`@WiL^&-7{* zjN_HFwS^u*d?PL9K5lWn*3j&NY%~aQnMB=&+g8_(p>oUpxn-EoT!y&x!5w`r)V<~8 z5Al8ibq8Zjl0|i?E^0$jelW+_O?rbi<9e9UzFtdq&XWy&Ecdqz$rSIjCtMeW@4>4l zT|-a2x%GM&-vHSsV#GlR!>ez0>52@U5rM89idVP|z9(^Hdn~torK^DpO znCN)*<)qJ%;VJQgfA2pI-^^i(I)OC1O0ohUAd+&B#1 zv){0{;U~(thFcl$?S?xkP26QRzTsX)Z9p&n8~lL}Z{ME%M8oY|WB})dkuQ`NdRn24 z;I;tr^KpY7sYV`3y_7LbgT~!7Pfh)$57yb({cz)bta_zC*^Tt)PNR_=bvw`S&ww_f z6Xy1Eke9n%UrA?KhEJ7~Xk)ogOjuucjuG^;MhtueOIdFyj1e3uY~t0OA2hEa@eG+} z@W$zH@T@jG>Gq@Jds26uXt#KpJ@t@57oD_~ z3||NLG<1wKA!j4r-g-70Z9n>~hKwP6mc`y}&}s66==`_;&>wx>^-?eQjH&EXoklx@ z7GD01G-e%gL%TzLas0WSY&|3Hf36#;|E}vj$A#3sP(9c|G^D!$J!OGmN2+&wPkeZ~GY*9hUOnv%IpJyN zt>^acKfI1)gz3`Pkqk4b7;EhIG>@c5nmb0a|4-U^_v`WndeWyqkj(fn83&xwzb2%n~%9SQZD*r9*GeTQ#CQ3FU{0s$|AmP1yp=xpDEIQ9?CxkRHpcCq<<=C5JnF-7zi$`x=>MI|-zoh*@Nq8w&V*y|d?uWc+*~vdxUaDUzsix^|I>C9 z{@KF#ckt$VuC9Z^aXnnub++*SUw1#J_W8fm7XRDQ>KpFYBtGOiHq}Y2*`sc?iuB>GGfOkMk2({JZFS<{Pc?ot19n|M9UGB1m&%XN%`4dpyqO z;eYJ@P51W^`$pYFIT&$&-#3k-w=DRk^bG&#i~q*ZpHBU6$iUNu{Y~sKKK_H@-?Oe7 zcHZj0Iu7urb1r7=-~VRw7^=C=yb8BW2)tyAdne4eYY4X-;Y}+=GdGx<&CAJ*_h@c| zd7*iU88<}WUJAN%=tI`+V_X$J7vi^E@iFf(@5HxDF%h^_e%CH<>q^ zuQ%TapFa9ML3BruiuY_V0>SGwf5c4@r`~DgeW#F$7jb?8e7a3YD5K2F&0Y8|0QGC} z-DJKMcPc%uWG2=?z0b9$&GCT%%NrO-lfYYF_IM?fyP zvjX_IBZIIR_R|?HGiJ)o)%veNQL-_LF)su6qm}p=e{H!EFl7&J0vW;s4%`)_uVU`Q z4|hE&CcIenkoq{@XTr-%i2K>^NAkl>Pejm98)RnFQEfeL{U%3*o*o@y*_+5?PHLhK%3BMTp;$bF?eqi(iqaPUk zz~~1?KQQ`%(T{hhlx(cmXJg$z8|_o}bNIcEA69X)58-zlvBN$pGnIoN{Knvyh+h_d zWKTboQ&zElZZ>{(&^P0UaiJMw1M^0t&^UYr$`$T6?GE9#tuJxEmVD!kUNke_C!6a| zlVH5f6p6cHBJrkFBwlrj#CvOzB3FqQR{Zktvnlba32)fP;1`cyE6kT6*F%+`BfoFO z?-%%O#qT!!ZpZI6{9ecJ4cFdN(l*((*PXs9?(tZFH{BH6$h97OL9SJ{;FYf5;mc88 z#62~W@QR@i-u5dLQQXu(m+C6tAbXE0^K^uvN;p@K#!%vI%dcU8sDU?+Lpc zB`Q`)#M@SiG9B+x`7mdG%vli9=o-AdMVw`TvzPHU;#c^xnLo2g5-H%7xU@2tc5t}{ zuj3Jy^O?&9;PO>v5xq~RG~<#Zld=S_&P6Iqnb&1_UoK5)VUCwG$F20%o6^R-cF@ai z$_nPSlX+bQUU8ERQjgwxv_W+!bHEd&pA9c~J&HFa|E@fR*G-?n3ycf!p5hBg`Vbf;-xxm5cCpqeZz6?;$oRKM~zX-Jjw`!bgyZQ(sh0{(k3&ATsYm{_*+}m_}90V(FkHdS3xHnF` zOZUc!f8rHDr`Qj%r;5XP;m;rU$OWhY;WZ-dB&8_iS{-9A5Cl4Rd(GZx!CvE2kUg)GE4LPHmvO z<BQ=#;UB& zyjK4XWYTx1`#YR@?%yq$#&@Ug>o=JX(l^0}zRVYlZ$kXn`7)n$e=kgkbbm|(ydKVc zlOc4V>XUhbAh6;mWU>U$9B)M|t>dkbsV>*HsB)MhqoyH~s$5&f$7+eLy_w^Y+Y^vF zGPmbIorgTH<~;uu=lL6aW8g{7^XoXz-{L%f8+ojXe5i(D0|5HI5kc#UJPfa5cs0Xo2ts-gjA4j7 z!+^;xRm9WORso{50z`WSh!!j2X=<|o(P{yr-Gcph(zm+51L^1f-J5bTz*lx&i$F?oVl%!avP%&R-}n z8_xPIOB?wcpLQ1fXE3GSnFQF!W_;V(7=vpW!Hm0Sp5f1~DAXFqmNo!%&7{48s{lFpOjv#c&M6 zXolk$j%OIdFqUB)!+3@X3=Y3@ z^BFE+SkJJ5VI#wZ3>PtMVt5|IKx7(U7HDTYroe1;)TC87*G%kX7}uP}U- z;d>1CGkl-n0fMTX;WUQR8I~|~Fyz!$IfYeDPjv~~FJ-ul;VOpf8E#~FF+nb^dlFBe zv=VeObP+^pB`6rG3^j&641F1z82T~vXE=&s0K-6rK@3MT3}zU@FqB~!!*GTX3?msv zF&x7%n&CKx;~B;<#LX|@Fpgn7!vuzj49gi-Fsx)agCXuPfv=eiXEB`3u$ti8=y@J|dMX831@k1%|a;ZqEsX7~(4 z?D;?_&oX?O;VTSZW%wS${S4n{cz_^EE5T_Dr!y>J=wQgHjnYauoSrDHq+iN#8N*c! z*E8J6@M4CJr0w#HuQPO{4w*c3&9LuCx<6@f=!;(~zvOMk7r)8SC2#Z8CtscLJ^967 z%j#hAD*kpPyTACGJoLB5-x_~+{C)H#OpCuS>1fJM`r;qP?}_9s@=Mr$CY^MPe=a#S z{`L6%@rU?3p6oEb1T`TjVT}GICfx7-o*qGGxMn51WPHheDf{G?VAItP6XZ87p(>${ zzvkqf#+T6H)(s}mx0d0?r0oeClLiy6pp;9vBH??K3JE_+xIKAG!gg@-Xu{J8FQx2D zcst=k@}F=tVK8}@{1Q!uj?^L3kdEy`llxO{9QqQ+(>G*JsvKFL()Rzc_9pOA7Rmo` zcRzE_WRBc-CX<5@!WAIg5CX&i0RnOfh=AN6mw2oPW)eg^)^*)o)>YSKSwt7{MpRVR z0}&9zAs}*wKtd7{2uTRJ^M1RY$s|a0_xJz2G*hYR>bt9}tGnuX#u*zOd25KtqVQ^r zH#obs!~4Z-Rf97PA=5dV!W6(I&NURuBy6p(t>%s}HI;L%tIWB<`J!5j^VJyt*5KUg ze9yVd6ZS-BdBeO8Fb~z47rmx6#H7cpQG@fO`ZxM{H8=~LWie~j;H+uIV%E0et$&HK zA%;RUQ~yfXinsnH%Jdk7XcIpE(f+PI(f%xYZ8f}bTi*~&f{Zh@A{>Z5=2>%7qR+WrbiEi|?3#_xFS-&d z>{VB6bfZh>vb#bNVqM)_{Scc*D=ib$DM-0Qa<6NRYb|0opoCXFe7Y6icY%ZQJoA<8 zIvF!4W{|5O=0(X-t{NJ(^cY-M#h5TFk|(x;KO$oikRuss)A4P3%shNsgR$CxziOKI zu$K44cw5~_gN+Ie} zOhVM9sH;)6tuaoaVzI%_1GfZAOuY=Tqn&}VquYYhsp2uaWpF0S&_<0(kK5fEoV{a{ zWN;3VA7_ShqUZZe^)F(*81Mx#InI^NN1W@_5VNT*IG=aEC`Dw>!_Z zhFDEpSzB=Kj4N{%t08uD+-~QWYKWb5dvM}C@2x?d2aKy{B%KF5y;l3Ew&|R#$62hF z7-Sgyjp$+tofqy7qZO0{(g_alx*m ztsySOb*5cN243U|gIwux>t%3d$&YI~;q!pcb1hdXUid;Bg6m=Vape&H9PsB{8ztT@ zOp5b_)_Fk>J#*mN)Ha^3Z5Tn!7Dht0dLLRVTUm5#eW;GvC6Vya==;hVpC#MVwT5RV;)AxiFqz&BWRmqw#Dp_yoU9*FXm`$Qp_2Y zP!Urvc`a5GYXvPB>12mMQlCwb_X~~6K6$VgX3Jtg_MYy6gLaytVYiD7`vA}DR;;1 zi#r-ufR@$ZuCZ%2-fGY90p!e=@aY8O2(BR*Pf$`h3nP4%gzP9F-a{{OK21Wln(!=w z0|>4J@MTsaCS40_pJfR9dpKw#u;U^Rt z6{ctvlM|`z_R*6%Rl|HzU%v8oIPr}}|UaV(}uOJjZ;{rr#si zX0aI%ZNvRHT8A6pG68!V+E~H#XlE5OqpdZtA>9-=nFejH0~Nn7fs~B`Z{J7z8PlT= zT4qN-Ow1wsB5&Do&u&DIq5z$W6ShB>;sQkPm;wDuW+wEp53|UdSNxL7qv(wW{Yh-s zpFrwQDD@|V`r}9a@umJK)SqDLkAwOX@T>hP7v*UEHE|8Sl6^B!->lR(3-!%*d*4Kp zXhNb@gqOPeCGM-fuwFki% zf>S7d2*E1^U!eGh2|q;m9e|9P37<=FDIoK#)a(}`X zQv6SZuL9&uZHY|Dnd}{BR-$$y*qNXlE6$=M&R!aPa{5Kbkq8i}(r z1gk0j0fID=>@eXO1Rn>)uX$w4a%KQx^#H=p08sFe1p5zy_oUn+OqFSo&gd z(;W|cPY5i1-SE8^?0HVueA0yr)}E>OF6}zQ#Y}_|uEe`lNV3o-vf@MT-rTli&}?U(~JVg-HuDXNM3JvNLnCk1J$tHn5-BX_R>eA&4x`W|og2yM?16kRAI`L7Z3pM` z8Ng@qnV{ay??$d!d=6sf@`b<`ad@qeMcz*KZ9Ob1>zR>1#o_V5H}F3Ie*wQ%7EJc~ zK)w;bZ>EI}d>hm7w_phuWWncT57+R0{0GG3@na}c+Vllk#r3emoJBe3_&Jnwo);kR zMcBy&EHfqOODQj9c7BDIv1n=AgwM%U`W>`NUd1fDnpY!6+R{0%<+aS0;}@7%BCqES zh-u=|3kO93N-qbnoPe(nIA04~`V!cLUKm)oFbX4LU=hc@EIlDOR*3`9Px!GA@;9)E z01<>V!6F#(A+Xq6Ul6U zi*&?ihzu4XMv2kDrN@R4nPM#Pabg_s@nSr)3s|j~QA`vQ5d+ITN|0V1TG;O=V;`O( zrUJhUz6M&XCE5u5~zygez)nYYjlwKJeGT{NFe^5M#avl~BBhBx`@6hW< z1oSEKn81pIrFboBd_p_{oeEM0P-X33XF$i1+8TTb;GZIWT6B)K0cKNVe)pwwg$`3a@Pa3FV(e2y7=|LrB6} zNWz+=pCNXIi>wZ^Opy3cxWXbxwnmX`8c3>!kyO<}sJUmY{SUFPKI!N03lCaZ0U`jBF;E1eoFK0h79@oseIb7p>A`~Z z@YF+kC*-f~7xFh2F;eo{N%Gpr+e&yOZ&%S3^|s32UXW0|A%z7=VJ)oxgCOGuLke>t zDUWT+q+s)3gbyg|i6 zu@HHs3=JUZX@LH;9JNciX&{NoAu;bq8tHkbN=!}?)8Lhu@L`0+Tnp+iWu=p3Wjx8s zfg~#jkgV)Yva%P+%6=p(`;x4LzJ(L1lVqinWMvS^%1DxxL3C0>kbLY+^05z{R8vSk29bPBBFUIUGBK89VjRiDSdxix zBon)kOpGF#*qvlzPm+o8Booaf6JsDDn0)~g_9b?VnSHH&1K@LZ`TMKP{xa~_?5`2N z8TeNFR!A)Pnc62omsYSB_%h~L4J-+m$*~A)g*1Ho7W+F$|AGB;z%T6w08iTU0FTR}bbAFpnKcflV>GZ&lsiy{OtVXw?eIluGH$uU-+|veWFINX4!ff_VQCJX!xz{N zWjYp6Jg{ku*^W`KRslOq*jwnGm9W`}`&we$m(OxAv{~Vc#6aXtM&4KWllEo@e%+ND zxgOM5U_JOu^vxgCuleuowf1Jjjpz5;>#4l|2w#V=%T~QBeC^G4Xf$jZuzEZ4ve$vt z*h7I$09I+Y1N$?ut9I1OJ_1&1YxH16wn|_dP+qXD7}x<&Puk7_`vBN6^hu78ul=x1 z>e91;9khK3>=R+IHQM(P=4-38$uUOBwyU!FCFIy@dmh+MV4H2YyJ0hc zy=Hp^*o(kkw&B+o`Rr^4I1o@_`}n=^dXnd~2>G4+V=S`m_^n3v4u8!47$^$c$c^@g zZS|nO3*E?MmwxB5GBx|2yX^DqOMv~3|HZz*{wmTKP{Lf>USRV@Kl_9BS-@6fG#ALS zAc2kcDYkczV-uB?4&Hc(%36;S#@VMMO%&3Mwmk!ED}TUeCFOeztxvOl0IBFN_S%QY zI7mZZ`vB`}NVfo3iuKPZ^=n|sR+;w|VBK(pR-m)(wI@<3$6NFAYEZ+ib3yG6EZ8~? zdGmnzQ@UBe?A8&Wo)&v8@XQAG6xNb~sQZ~>*Vs)=QJ|gJb@ti7x&jmSDX8}v_^+9A zYyd_)q~NQqhT>enDkX*;pV_En{y4BwqM~+N5n;=K6;QfA@_O4j+a;8I4srQJMS-?6 z662d#uubKmEr>gXIKjtdOUk&GC60d3&lEluEq{$tH<6@})ZzGUlBrn}r5=jan@KhR?LdC?;Kz6PbF%XBz7 zFsyr7Qwp#lge?a~ed4o_cL2rBLU}zbp-6WJu;(mTKkz+f4t1n49-cAx2esLZ^~1*@ zFO4d9qP9vg0CD|W?A8i1)(>9|tjv4>SSHe)q&hN@H_yBiJvxpY3vfD8_zI*uY{vS* zIL))$&610Tw#>67n88IHEwyf!c`*j_d_KXok-}FYU91_WXkbh1q5NxLccG3)sE)gk zccmFz#P4cZVq3ts07LDzxkPm$Z-!a6G#GJ{sO00wamoyN##f-W5tPo15#Npzssi1I z*^((P0a!Q6@g1-pggt^f%6KZ$Ekh0~rR#)JrJxfdQgj?ODjASSOI>kR)N>5-I8vSb7aQY zzh=?^3juYar!DiWmBtFhq31s8s71BRvxFMYATFw92{61mZ zB;n_9)wo5#j#1otP(LROYry9d!tR4P0QI7jj}ezo*m7J<(pYi>;xfOjlv3+@1xNpXOd48p!bx_N{luVnyX^Re*r3F{83ngi?G;%cRO zN?wgPtUuI&UDhX&u%U?4Neo`A%(RIx*%n`-n$eG|grP+~>c|RUltan^^9iDY$1Jsk zp?pg|E6qA#%UWj{@uM)cNualDGNu*Qf!U;^}*R6QW=w-?=PH8az*^+{ES(->ovZWue zaT=F7+tOQNN|E_N%Ot{T%*!y(l4>>2#XL*uHjA%iI2{!$U}cHfV)g|Vs4O=P(rqOy*_5o4`$eFVW=hay z6E@uxNwXX%YD`7C)rk8+_!=M4nSdn#dr&9a9HsQoKVr%wELFeKBzLDMWtx70l+ip& zRGSZ&3WM+&To{cF|X80xfp;_R%(v|yC8fGwb~`Xx&W)z&IT3=tU@~lwe3M2P?Aj_XnPPg-Snn530N36U=1$^ip%d_dH0z}_M3JTO(#jRdw;#tG=E`prar0eO81O9o~oY#yj;dnJ}napzEP z4Pie6t0WBN8Lkp0`&vraSI9Aruq?!#A#A?bt2sbecTiIZn*l0GKxp!N^($M`8Fmu3 z3UTT<^+UQhDJ~V*7KyQ^P+m4+=!Z^iOQ7)8T_qh8Yr~KuE@6a>!dGJ=9do2Smx{yK zgNGm^BqCDp6<-1qz`hncfq`dryM>&8e_*?Wod1o$b_zNFl8=)G=3n619^ISLcFh+e zE>>XvahL0BaN_ieEDHu7!&;md@ryFVDIqHDV<$OAq^!_jtdeM`Qw@8L?wlGqpCRBRv7OA*Q3%sEhzsEUB=h{V`in6Ykn_7xZ9z*a+Qpf+I3X}p88Wg19w-8?Gcx_C`Dn<&vld0RZMZPY>P z;+e$fwY9oT^gkOE!Tv-rf#A;sQwhowyI|=A#}XVxFrHu}!A^iU%}bm=NKl^Z1^XSr ze^SgK!iN$3kzf?T3k25#aw>r{e~NjUU?{;=1m_WamS7ixy$SXw_@0DZL%5sZ{RB@E z3@0e(Rj|JkoI&tk5^_D^-3gWx+(7Y<5p)t9L-ECgtD|J1n4JVEC7Vn59)bpfE(!U4 zgy$2KD+0CvidV-+E&maUSNonuRJHtEidS2>jN;{55d0y6j}mn@K~=w02)~!0l^`Ry z6i{JmIcl!IQM}r(;S%SW1eZ{}ogmF7>qq!8f}08chu~8LjS})cgs&v{4#5h70TS{M z!Y>lk5%eW^3J`R{pCLG(pdZ1W1YaTeu7uo7_&tQH{IiZ?#u5C8pjuZR;U5s(MDRMn zmk3TI*qNX@JAafor@pXP30K>>i_&Z*_&ULV5Zo>y??U(wgv+%6t0=|fP>jl-A5zR~ z1a}auC%A&(HG)|Za+L!&60UNHg{U(L{+VDB!LAZ=mB+rK7lc)@xRRO4l@6((1N!VDDu96{NBc#Qzczc?_n$6#3$BhoV`@q}e_ z)-2~qtS78?3&fY=dA?k{#2*x!`Gb5dUytYNH}cnT+xiaQ$#?U;2zhwg{se4oMPfU@ zDt5xxrWy7wD=b!#usL;u9cc)xMdM&;p9P!Ga@f2N!m@o5K7x^R<}6#I^t*S?{qx1< zd+)t(r80fqvc(I%1ThfkaY ze9W-vfMbVcIJKc;$7edV&yUL-;nYr=IDRbfNl2;PH)-M+KpJ&!gf=N>z%WA2D!dha z)p%tP=23pPsNZ4PlC#7Wc(l;p*|2rvyEy2f!y|#d&-KKg^L)?u{LS+G#oJjZU8d(R zY9_gn*bogY@-(=6vZd6>zR{aG{HcEE`lb4J_IcU%>?ZP0|-VE^wY_aiGcS6I`EiDuW3pG;6SRJ##zskyN7$pb15d5H#2EZ#o%^k|ZSU8JdTwj5-T%V6pxc(GU za>I0ZMR3C;_&{*OIEG&6KW_ma8qyfJ#E^`)-yl~DX3o$9BggeuasS8lm8?Ht4eH=H z$D&{QSK%iip~SQC0T18|%k^vVk^UT9k6sxf4HzLkEQA9P`~v5A4Q#S%N^bCF8vOzN z2l}J>OGufF9>32%fcL>hWedKKz*v37zGmOBJ?vZZV&gb}%MU{q^AD)Gh`z z{d#=rDGhF@ffwVm#*;>k*@_ri$%qZ5H1LU|(~B&bVsLKZ`sY2l!etuWDa5Fp$uLU_ zdOyz$A1vKOq>>!K^^@=|jd8Q&N|G>==k)3-C6hG1W#FT(Lm9*Mm@AzX>1EB`h-2i+ zY0oi+|A}WLul+Ko@&7re`Tsem>Hj&W{(s8JbuZw8dw_0;E)CLRv~HYkqHcY_2Fy!26zyrEuVBirQGt?rcH@>yv&Wa%SB5(w5 z7rCZY&h${zXd$7sr4;fh6-v;)iJj>TzDdq$P4_j^YQOe`Pbj6LJ*TbKVs+yUVz;K< z$aLC`-tZiy)@a{D*sIM#`c__VEqejeYZtT&Gbx8wyOzSAwU8)^Tnp{nlgtd-r)B8HJ_%Q88!942o;o5ueEwZtmYl;h6U~N8Z;GcL95fe0Q#PGbff0* zEujdvja+Q;reGXWn`4LJ*i$&xF~>f`u|ByCAM7yPfsZdfezb18YIbOLX};9#)y~x% z)Ew2E)GpDS(_GS&q7^mT)tW}QAw8na(HhWZe{HDNiQO&<AmEA`?Dt#iMH_GQ#8sJ^iB$`DFL>Gr&jYj!c z`9%3t*`@4L_Cp?SXD{+Ax3#!citqKTOV5gA<4N9^d3y38+rd79cOCYQU+Go*-n8`v zzG?7G`v%%^nWtCSdH!q9dWht9eOuSTGdu5ycg1_+eer?#Q0x#NiJju(UycFZZKqM} zibIK4Iw|v&1+K8vQBx@GeTR`UC%R8*kuLwYryN+0bY|Bms$vC zkQ|6z2Poik`Xt7**SOh|XVs_aGjLn2=(F{!^_fVk;4O#$R^UIV-=KdOv5OF=VQ#hoPY5g{oU_?@ zi_{2-i&{@=;Aq=o_-H}LLEpZXkbkneq|LTyrqy;j&EZFl zXP8XzxV0!gcx$0eal(I0KCK0Q01aN2fIgVl5o{z&$9vu~pvR42nQW|l2NwLWW#72S zrJ0a(j+r1YI9R$-=S5ZwN-4Vredju>#tvBn4^cb$2*Sq;HgQ zqfm=-z!Bn*_*v}2y4?!=us9_?!wP-__z`#seU7#KCh(&;k$izw{cqquinC%j*7r8x z$HY1DAFTAZfFBpyoma)s@B@(u|;e-d!GG){h9rfy}{nZQy{AD^DSC%Ro2NlH**`ea|e$^ ztqHs{PvqU;Ro9be@Jv1t-e33ghxwEIS^gZJ<9P++EH$BbG1^kYxrsLR6sgje6rQBT zo+la0#Z`E(R|wfoxl;a&_nhujK2tFN;B~A>aIO(|917ZPN9%nN@Lo#R38xy_gD_|< zJ#gAd#;S{uxR^9Ie!+Zdfnj8`?n zd#nb#|NC;B*thUZ^kv_{*RC`C@48@}pW|J5Iy;Zi%EZi#=7Ty;tFwt!|5JpEcWSrC{gazALEm;N1x!+u$P_WchN3AU3ymIxtR+HcVmw(#9h&4yqobJ$fA5+BMQZ3y!9|a zc}>hvHYuCLQRQFCHt{3&o{z+7xf6+Vm>+pc=kR=&67*J%YL%9QJ@U#UsW1zWw2;^; zr#-?R#l6+zQl59rb+>R^8?V7v9iHlg_%J*Lq;eg0ga68T!SI=GyB#(Ud~lxU@onF@e7kKh`Nqd$-xa){Kn!$g?CMCRLO4PJ#|hH| z;l3qcpMzYVs3an_RutlNjpsk`y{jj_3;oq9Ngj$;u|wBJ?efM;fd;Gi|4MEcCdKB1)erDtXw88;G6nS_5;>5z#fQC#peyQO!FoClF5Im2#ocbluDme z6p60_AD-K3{lMo&ZydWRbMx$f-TC@`TSvdO;O*t_Jn;S#AI|=0_Rf$`=6;&_*|0Aj z-W|F-a`&SDO!&`ZUuJ#z_*W~xS-Yoh&%8a0zrADcz`d*YuKjM*cPsWy+qZDv(gQOO ztU2(z?=uhj9~^yf+`%OWm*@4$OUX+;wCvFG!(9*eJDh%a>Ji(Ku1AB8CjS`p1t*0lS2|tr~Chbh-nXzXkozx7zCAxSKOw(we!u(?`7 zFt8xA;Gv6_i-RxKTryl5R_G}7D-15|QJ7rVyKrdXqeTy0PQKj#@}SFuFQ;A}c6q|( z>6aH@zQ1^IacXf|NmB_c(U9Q+#mNk|&m-&|^l%fz=t+qpR<%zUPMaM(B+` zH%8YOYD_hOHG^uhZdTk}cym#0SZ#RipxVsZ@pWN!chpDLCpYwK7}7AbA-yr7ad6|L z#(B;CAz>w7BtWVU;-Q)#T!anPr}&HiwP?K;bLoo5?rar$R3Y1q>ysGagUPA)qrOD!|la9pv>!cl{@ zJj&(+f}uxFVZq8FY{{Me=MaaKW^7o0Z5Q5QYIq74Z@(S#*n0MlTf#FCtuhS3b~&Vj zu#w5|PYO@s0VXxPM2|bkN1kMO?f(#dghS`U*dXOYRx*6kF5C-Y*Bbr|mEymLf8o(o zPr##zD}E6^#6wduj76&-1Y4*y|7F8=Br$$xl8MeU{9rgc=)hn{l&p^?`n zKm4CU9n^#G+J#Mc(aPU|MK^WO{p70q|m(SR<2z~_J zo}6m_?Wq5M43NQCIQT5~1o>ERYuLi3Bg`kb8<$}1!yobRTWe^G?*jF!HT1z_b+?A^ zaRnj66twvjw)KAuo6x%dB}lG!(HytVAYNwe32(HGr_f^z)zOjd{v+BeTlu}WHJ-Zg zIIRPwFb$i7BVZ?No1<_EsD=`ir{GNT9<7@fp`;q2UcQ9&_ZmJAV@`(-^<+prWFmLI>q|7F&uf zHyrVw!l9{(jv9xEhb7lN$6gU8Tmig-L&r+w{s1mpv+;mrBElN%$*-XPW2j*}%G-*P zkI~pZ$Ks*N z82K8PRi8jvKM&YZ+*+tcc7}(;aX64>l970XKB>c$9p%1;BUXhsWI|;f%(`KuvCKjU1;6ILXP!WDn!<`5J_ySV^%$ z(29EihZ`H_a3G8wwb(*GXIX3v9s=JFPLhX4`I30aE%GP>4Fz%I5w7F$+8a3d9Kl0~ z2XXYdNLK2>I1Dc59G8LhIJ9sacPeoFMibUchLjq2q7Sp1&)c_5g#6%XdpMyTL}n!;%gMijCiVHy7m~0&n;=zDu8_uJ! z2+7nujN^t~wuwh@*hXcG@C=tzZ8MMNE|iFGJch@rW%D?cChM1q6Vva2f`otiPb7dq zO~KNfgnIACDewiTGVfsOegNZM!Kv7Y7R2NEkb8%+- zOdP;HO_d^Wcj{ESD?{;~qq}hudsKM_zrXSZ&R*XtKPm;9!J65cotkeo$21o;*R@P* z(=Ne{`6lhV+W%;O(4Ns=(bnsXx*%P=u8(d6>_BsLf6%?Ads}x%cTQKP*XaH9(faQC z!TP!SC-l$j|E_;aze|73(94i!m|&P~SZR2|@Vw#ghV6!3hW&<NRL6#)T0Lv)LRLgwJUo2ZJ zA6mY)9I>3YT(va$SbQRUy7~<98SgXOXO&O3&+|U7`fT(0(&wU2rIlOl)+p<6>n!Us z>!a3ZtS?#LueYt6S_vo_m&Z1J|Tfm;JV3fvR;V_-pG zWe^Xt2So*S3mP0WCTM!l;-H6vt_C#)rw2b9T#2=94G9lP4Cxy(GGs?cX-I9TJ~SXS zKD2jeT4+}2tkC;H9|_$Mx-sM3+od$B5Y#V17YjJUJTn5_D;At zJS{vcd{+3f@ZW{+k1$3AMZ`t)ib#vdikKC#EaH)f4G|k7wnTgo@m0j(i1QIwBbp*D zk)e^DBKt%RkNityVN~C!IZ?}_9*cSgZ#-;tnw-JTcxP|tpPc`3Rz-J;?h`#6@4L*2 zej)lbyfJcD^uFj5(U+pDT*BpWIbGddLtL4z>8?et2VKv*K5~8II^sI-DvzG;3KzaIZ# z{8#aZ;?KsH$2WE|cM9p$sZ&a)k)7`BG^f*wPET}tzSG}3z18W{PTzGp-sxhe$^@R^ zo8V07o-jBeGhuqdqJ)PMo=o^t!aoz%~yPU}3O^X$(1 z6M3RfVtV4l#3hNpOMD^mt;7q7<%x}5%w4*6+1Ta7E?;*kNn%O1q(Mm;Nt2Rh<2R17 z@%u)9OWK=sylZ0Dl&%xHKHK%(uE)EUcXM{@)@^aOPrEDKZQW;f-`0I+_iwu&>EYjF zL60pxntJx@`Ao7dxqI@={iM^id^-`}by*}*K(%afQw0Gy;eR_}R zeP{2vy_fes)2C~n^gfgNEbFtb&o_Mz^*Pt4JS8aQp_IR-yq@x5%F&ekzNWst`eyc> z)^}Oohx@MU`?tQY_x-S+p`U-hKK)ko`)j}b{Vw$n@86~W;{KlwU;``zW(?Ri;FAG+ z2OJwvFtG2y)dRm6SU+gOpsxp&4UQc=Vesm~FAm-{xO7O5A%liw4Ef`be+(%eQZrNx zwGNFK+GS|}p?3_ud+4sAO{slS7pML^b^oxyVUG-ZdDykIscDa;RSlmx{QcoaNAwu6 zX~b6}!$&?n^4;|A>93~0mHuh^cj?E|FQ#8lZ@I&INBA9IWau&yGv;SJoAK9-e`UOz z@ma=q89!#6&$yCtbCfd5HY#jXr%}B|4IPy^YTBsxMtweN->74w@<)}8svWHvZ66&z zdd=u{qyIE|^XT_Se>wWl=yRjXM>mc!j|m+UKPGw1kTIjjOc^tG%zz?| zF1qunJGb6hKWW^gdnY|QY2&2#C+(T^)1)htS| zoILZ1nP1I3F|%;yjk}F^N8H`_?%&^iX4ddoAKl}3PtHBx+|x39-0Wv(7tisVlQ?JK zoN06ZGUu&1hv$^dHO}oicf#D|b03=f>D(XYUb^?rd!M{_%e`OSTR6`!Z`r)F^Pipn z>il=+e>wl${FVg~3x+JXd%@ZTuP*pvLD|Cag$oxxz3{DtMT=aEhAo=4=(5Ov~byr7l~z?4@NtEi1e)_`aU^&A4yzeK`nU+*gg4F4&j5mM1Ttv;2kS=T{6^ zF>6K6ioGiXR>rLCvvT;#DJx%C`N7KLtL&@7S0$|Ky()FpxK-0vEm*aB)!J3htlGG0 z^Qw1N?OL^W)#+82SGBA*ub!C-Z8@7mPlEptc4n>nPuT*xQ82zq?+}Z|{KXz#dE&c0 znoDe-d!05qnq9flCD+p2ym)a-WX8gK487Sx_d3m6Z?S9FQd{oNWt3l!{3a*EmmA3T zq`(6j+_I3@{E~XrOO5>>sRooR{kO7}I+=NWwh|eO7PwhW?k{TYsB9f_>!8KR_FUZu z!#zj6mH)R^X}oRjs7Ko0qzW(9?(J{89?8tIN0yFz)KTuXk^XHe-V5NF2eY^4w!C&P zwc-}4UXC>0DTKV)IH4|dKl#hCs`uvXXa;M%)NZ$wtMW#%ml}TyRjsvJtyS_@yILhp z0V93qep0FJ$yT(dsij``mfG1{s5YHPMj$Cj=kf~(Wj&o z6@`W#IN%10U%Aq&*E*{Y?5y-pG7J7UH;=#}d$an{^&T{(_ppTzxQ!pjogT%uZ@>D{ zM{ad&;CF}X1DqN(nv#oW&z?Pb?8jp#@wcQTuS5g{1k{)0<&`u8VI_I&gge*N#9QiX z@UNl1$?Z6B;J}$10&3K`bLXyWBT`aQa$^e$f?fUl_fO3jlQ|lHW2{;H;-N!_F7d1^ zdl`^YdlrjFK`l+pVA3>J;f-Q$M_O81=OArqX=z|!U}MSg-Me>t*5&}TZy4I=tf+3* zYPHSP6&Ek$hXglOT>SBeAAb1pVnuUENdAS3rL55Xv|mGGOJMKbz2j}{`l*8l4_@XP z*0Rp&XVWw^G_;t55(W>+`|)~EV%Cx+OC}}-R36;dzi-zl*4ypYv^2A(rX0WOGp7%J z{PD-%7P653qehMD9uaQg>wd9P6;!3li0i#?ml3|dLRGn>MN+eI57+y4PVoDcoGPE_ zz0`7VtuBlW&Nj?Xqp2(_EG#Um%&Sx)A|jN^JXY%7V8HBLVg7Pb{0a)pp`AN-4mD?) zFYns5>oT5JjYSId(Z$c9$G1tsaFn3-@=WaQy)=2L{;@pvm#eHJsts+0myurQVN;z3 z43<|?kgcR#3O?Z06(naNL}e?ILp{9Nw&HcHz|)%isM}i;@SD`Y-=z9^sh7MZx5+rT zcos+*SCP-6I+EHQBZ&UrfHJ|s^+?F@mOWo4})#Rm~xrM57nL$#sMFra2P8O+r zWcKE)y(MQWm$iXe@3Bh7?o7bH z7(eC4*}OcxR-;ksDz06-R(Z2oX{c+#(s!|Hw|_*0{vy`>`8s`-#wC|}fX%4nS|RW> z3LdQF2AiM#^5xvlr&g_=o!TX;As_$h!#eewIB#ify(T!J>(KGz#}DtFkPvQW5DEos zv^(JJ+4?S62>pVZE6?x2TKMMd_3Jm9ad z4o*#tvPP)%!&loxHxOBQhDY~rKi01+9Mn+;HX<{{p3*k8AP{6;-mJpr8vEE)JhYWz2;OK|$Y# zVt@)U3VU)>S&kc@b=UxwMg4g9-Pf*l@xu@eb?2nA*K<=_mcXwVP?f9jOo0IgFKW?f zKK1oIbEYeb$wx8!a@mM==7NH5pMRd1cnR4pGJ8I<2jAA~D&$bt*kMciUaR~peub*` z+bF5oq6X!+8xu9>HE+&xZ@EcmZ%?#0sI9$?jWxnow*5@mP2|X7-{rEAZUfl%T5_)k8q#se`!= zI}*T*B`sNse0`?S*!>j<@2CN3X#?~>{+P04$>3}oevb_%uNKZn1(tqX8rUZ1)bY@I zcbS`mZy)D2F7Jry>&@HX&D*A78f9i^n8urIu)BkMfsS&wt>E9L`gv=v@z&gy*XgC6 zzJ=P#9ke4G(W=|H!{p6ryCtVORwk_wb+|C+DnzAfu|90OHwQc8lw>{MVQlkTb9jg)V0*E1lc%Ax7}WXrw-XbnIhy@Q*ciT|6fZHdP7Z7n0#xB zw$W|VOB~*QN~$`##826x0l9A*U0J!hrv1G+;U5oI2gZ`Br6#P^HCO9GgMu0xFI-SR zK|!I~>Utk64oiLYRjERrzRGSuXKcoMc$6G|>JvBA(@U3vgK=lzJ1QRQxqS~_Vxva&4`HSIq3G-{m8WRW40c5ix}pbx@~nFK^5xR2P@eND zwNX(~S{zsN-MMy+$q|avD+9H`M0W{+1qFVtzJ2?4i+~u-(iA{`T-3;uYp%WG(z#>k zCc}L})1@C!o2Q;`s0UA)v7m;Ak|q@1R05<_R#-yXOupM;J$dqEQF9=U!&OyPI9^z- zPX(FH5mhL?D#C1bb(1x^NacxjJDM{xGGf_j)Ifryxw+Y+1x-Uuur#tRfo4s;Ixo1M zD82wmb>UKewTS8#n6I^9O3ZbtR)6LyW;UDKbUB*3T4prlm<&c%Roj5c_$l9QJ$mda z3+;g!>4A5X96p%aB?_jJs89oDg*&3U3>Y?SSo+whQI4jYEq!g*jvhT)WQ^)F1rsv0 z4;zH-A3{);i|G!7*Lh1|pk>sk^t3*G7EcXhB{Ux;EU0hmd<;kH)6n{8ji$J?_!9n| z`u^bQf(vK;0s~vhFg3+2JUm=eq+a@H%od}TH#Ij`SGukBrjU>jb`kop$t5orq6-QV zy7n6~WXQ(;IsJ!X6p8NUOv11H# zttmSur|uUwEE-%isVkr`@pftJ_bXI&Wz|b+w$P(a&&ryIo=O{caAjRni}v8RP_^nE zE|)99t}_|2m_&;^sQzkEWUSN53c*rGemZrz95+!VW$c>U?ay0kZ&aPfmi99qTeSrR z2iPoDU9NvalfmpK_nA;bOI7*R@@qG0>zmglN7-8H8|KZ$O@{dzj-F+fF5%j`x;jm0 zmqD2mvP#MugSrgA_uhMFq{j3bF?#gqk;&LEbNsbttFwQ4dV2pDe_h3yqeqVAmsK|8 z_%~cHE;{oau4{g}TBq}ibVY{++O)Un05&vJ9WCw1+ea(11FF0|m!p*FFu`N8ADKViQr!fwCOsKi@VtyP+lL*q{l=8A|r<-CLaxw)*CPXRPqs1M(JL ztD(CmO^|@2s=yCexHT2c6Yd$0=V?A}cjKh;k&e~Mu}Na%Zy%d*NuwREWqkV)S9AKP zIXm5&Q>6u}w1_rZ0@~KaiM!s)>Lv^wh9|#LtDXdkE96OlH#g;KiokzGxmbYfeDIhH zDKn(fC>C=$6yq-D@_i{OU4paC8Y{D66aY29)M480EiVSQZ?(E>H+s(k4INP1?j}3R z8|lsa-7R_5KGV&mG_3^RK9UA+&W>u;c&XZ3%2iw3cD6a`rMBq-7TI0w@+U8%-VQYh zi)@%oAzRjgCSj3{lk|q$OHeflyrxi1q5ahq^1^q0HaqG5wG_csY8gi&Z%xsrNz@>L z%56>FezrfMgcb5;#!m<1h4Cau*yJ9Zhr2Z{CG6Rwq5P^n}9YNe#t_PX>HqS8NdDm_gluopwX5;x6ExL^V_%iZFd^C-sY<#LwEAo@V-vHeZ>Fo z_w{Y3`&y5eMEtLKNi=({T+OOJ1Qsbo--a=K!O0V88)^=nUQ@m&WTCeq@tz5->Z(Pv9?QN}>T5}6kt+h!~ zvxT|sKDw>92G5<7<5%vS$QncXZL#+TqAhpFH!y9jZqv(-dG(dHInqhJBU>~<^YF-= zwvs#6$l6Lad-rmUcQ0?F8oj$yt#@~7t5=cKY!Thjp2)rXh{3y$w3S=vUFp}nE4__+ z%1c#lp{lcRUX>uh9Z8Um=DyYPlFj$A)RtLjF<%I^V@K;)NjjCx3qgNW_9&RmZ>25* zbur{muO>yO(>Yj3i1B9O4|zIm!_9IW*DqeH!(r@3gE1f|$j`^9x!GvJmQ+`PGe-ez zt8O3BTyy`X~zRB)iaw&+-HFAcA* zu2wXqrP|p3lV()ku*9ZK!@rp`Cr%vOw_BfBtJT-)>qri+MZH62;M|)IQ-Qm?&=~Y4 zW5uacr%G#YTA@9e8~gX{(W3`zuCK4Z*qIr7g-x1BA=Id~t zG%78mqkJ_g&CwApRHa3<(WI^|XE5hCN^DVS?OORPa^blGHnh13>!_ukg@kIFOR$i5 zLrvACOE>E4>n_*Z<=Uyj+PP>pIf8?O?MAb&84D%3!y}~;UnmnVr+88(d?rBz?qDc&ElFMr@me=r5Yh77cS)Dzy$H;Nx z#?8EIV5GjhWc;}9GmCZQrpS~@EY0o44NX&{#^iSc>+f3aO)Ni?rnac4igoK6p{=Q? zxUMsY_i%>`jaFv>P1M$umpl6P>lfj`k9C;M<RF84dDvED-a4>xJf`g5f4L1mg{J@ zrS4iq+2xBDtLp_iChDszN(!;rU8&ZDg@tLVFQHGDSc%&wmbJvx*6N+zSaoHC$Ym{0 z3X{W{W6sNiC4{CQJxr=Bb0xI2*h1{d{YGX@of>DUMSp88adEvfrcRwYI?jkb8&zgt zW8BuC&Ij8EXipzI%6N05@NtJUR$VJC0OJ-_X?&Vt)eI=y`^Fn@?7J4&w{6V~N9&z+ zwJm6F)6F_o2REm@?~CdzuDG(RHF=3$gX;?Rk@e;`MS{~6NGuv0-)}6I$hw`6%M ztM0w9fx=eBL~gcXlQpY%OzmsFJ@@}k_4Cx+=&iYZUZ;n8p+lSCN3#4`}nrXz+c&QQj$Uq27^v3v)#*WSy zF*-fvzcZewuC8<28*0r)ZnWC{uu4M$t8Y~QpZO6#LrnY_>KzHW=g|6TY^uS!x!K%! zqXFxvQ=+}G^vA9H?-1$>xE!9337YiV0!e z+^k!!S6Z8?XQoWm;F z9F8V6mfjn)VF8D*O^n4DN*R+WU5?!j(m3r4sR|tVCK8&)NK8UMDOov=P0`~ooVUTA zOeSM-m>HbRM3-c?8U=Fos+))vGLdv&b#1QKh9aT!XItuZ;5_j0`Z0ikh55xPkLTHE zpFOs1qj}Cd8+U9xD)am;lCwS;V{O2GloZS^!k`(AGaO|uM7<~iVoe$w?ObRaBQ?k6 zH?BG%l+!bX7GkFrfqPkV3fh!3e!VpNebO{aTIJpO1dLuQf*GJb=iMnvOCn0Hl5E4}0eoZi&*zWp9^G`OF6jFLUNos;lU6bSoN@*g9H|H0h!% zWs&S#UMW;!)|bKfM|*(OZ&ROkmUW*&G?J_?TAAIrk~^Huou&4=`=r^GG~Ign*0v}s ztpRe>MWwYxEple0H399Vt#K-4n${~bp`MPIIm+fI#$!W{xq+_R} zJAzMlkCS(#Z=8w^Od`iaV@lq=fDmogI zm{{^n70;JemzS4QBMbg;-#Coyt(yJ&_wQ>F#g>XSyIrf)vL#zfw*49W`Kq)PEF+!! z4a1*!8a!A7Udc(WOC$+_B&W?lmum3Uue=nk!uos>tNGuHqD5lHa@W||MdVa0GT|5D zn7(4iP}WWfO>JchP)4TJ%Sds9Z%JN!9eqllSxwO@*9nJeri$VIAl}0SP+=E4~zI-rVSRA3JgU#ED}^5AE6e;O>39n;+N# z+z3!2b8Jo{zF{zkqwWZ$6*W?;RTOh_Z6+#7h-$44zjdNYQV-t%)%dJ;Qc_Req2Kx@ zGh?+8E#HooJJ9l5J-51trzR&9YGq*pE97d)>yLd$&=jLd`ZpbD(PIbp?B0KP{~?7` zc^K0Mg;uqq#>t@|m;ljiM!o$vKS6cZ`d7`2GWR^CNz~usF2u2tCS&kEC(WSbGiLK; zcfVYsA)7)mkq3v`| zK6eDPU{SSPM+^f{^E<$5LhABlq%Kdp ztGHSbWpb;Vj#DYC=^imlazNG=)N~;L%1;IVB4G~ej!N8XxCH^i9%ds6@oWwKfEU~6 zrsXJUFySoc%Ti8AC^M8&Lp*t8nNnx>*V>%FUYb>@F_En?o3Bhs8(l9=tE8FMOB0ne z`+8|AC9QP5w6KzfAeC!-A2uNoy>D7XYUZ5Ej!l?0ho;i@tWC9LQg!R5nlq`iy=(L8 zGO6bEQh~vg>z&=1P0fy91c_J$c}QEe3KafMo-gO!DavkTlw1YtrBT!}N{nQAzvEc* zk7CVNQ&V$S*XZ2nAaq|tV+&Kgon0LrVKumK)snXlD^@^JPl6$5PHxk_ z-B7U1%6hP3fyBq#UDa;)(_eb#nJ1rk% z1>dX_sg4EmUn1trVFV3Tw3G=r;X3*kg}lufeb_Oy@fg}zMbE(Y@yUTf?8?D`$w@RY zmNb==l$esS#4K3v*#tW8)LtG0`>)Z-O)T0*u3zt*BDr-NH*T!55O4Q&R~0?4PVK0z z-L_}Xo^7?Y`=2~>#uDy@z|tGGY&`bVQ%@b+NVcG$O@-O!`NqhJ6WeV<@YXMX`O8~N zf_3|e%)X^I(-U+I!Bkr9MkXR?K|VxPOl1WUam*Ldf!IMcH3r#a1oTqkcJFJ-byCZz zUUafUA77B5OdO9BfvWIiEmifGW@o)KGxPok)!l?c3v)9wZqJu$b7N3c#dB*9K`1#y z6;)hHBQVZ_SVAzF(N-;nRNTFLbDpr+k5TkcWtDu&Q$NdUw9z66x4Lfh3{1lpclZuo zAp`J67@1sf3b0<3Xfb^Y>mBnJ+4)mC8Lbvm68^3Tr9 zR#iAuk$Ifmcc!ADfY0ZnRu3sB6%7W)F~H-2nAV=3mtzn$POHjbFyz%@P)k+GfOp0- z=MP5O8mo-4VDQBkj#L{VmWDO?>L3)(!p6N%z4T%@%I0o-__^nvd+Ko0{wE=<9wSdm zQXPXZQaLzmOGGXjm|vI+#CVK_jzxYqmbE{jwiTCGmfDSiGBVlzW1rCeizsH*9#(d0 z;eFCFc85*tz5LV9*KO$}(;CEx6< z4{8NRA}~f51=V$@(@qgtB9Wya)tEZ10Pv0aVmiQ;QAS8zgmJaL*#LegQo8@#AN|oE z{T4KM-}>V}{^Qr4fGS{{#bPPnclyysA8G)*wPF9mbS3=`#FY+U;ZUlV*R7XPT(rlhIy(iSvlDnUiSZ9es(~Ib z2^n2Pz5uW8^XJdM^6L5XZuj}~e% zW%G?YjmDiW>Y+|OF{t!KKL%PKs(bak`~2dQ&qz8{g_?+Xm7kzOICPJ+9*X6*hvT2# zy0*X^kY3lXT z%=f8JucYOzmoJ}8&_1L=do!b(!p^((H*pEd<*)MX_XqYuF#;A(pS^^eU<0Sv0j?yK4WB4(n_-VC??B_U7%63 zmnQkk7swUKNiMym;Zk4QYx)o0B)<=lS`=Rw#mug!QZGHNj28CNg4pghcYUJsUIP#g zxg>JQMILW6hEnGmQcdd4radvogAg)W8BshyzQ6KQB7d?*%1J25dVf|X$&Q5nK52PM z+U@o7F-n>?n?|vj)W6!ms?r}#nK~|A*SkL^9a1ZaNr1GFghVRa45@JF`3LmB``d45 zcDzbnmn3MEcEVICkt@o+sX{4rC?&`5ADE30o(@G$cl*^_UD(9324Xk}pr*vY6q}>h z7dz~B?NaJ|-Tdh=VEg+*6}g$ysoBPGv0$N?KJmEz4oZG_5q7vKF(sk@a8pFY;F! zNpmOIqW9U82XM>PV<-5XSH;f#_v*=Dv!(kOjN96tJMNQaRML{!e5=u#w{Fq1gRyOs zm~B97dfe^<2lkQL&qZtID_V{GY-!oKSu0snt;SPW5q0ZhW9&8{VuZU8G0=+Ni-;jw zCmaxS?t#J70gti{42F#{DXr-)@C$HyIqQI5By&-Yj=LN3a_`*pism_aH{wp2`=pXa zo%+|T#IhQkrv3 zOV{Siwlqg6HIXefn^vl%jjop_DruJW(q#JpjpDM>(rlSwC12ipX_=9!TrZ7L@|9-O zvin!IeiUR=PGBvc#9FQaV^4)8kvBW2M#z?%m#b$3^PD3;kMjlQ2Eft}Ql~SCkAw(# zdS>W~%hn8YJmk(B6C`gFxKQ~2P2XyF)yxG$R9aeEfMuVW_NQm?w^AR(|AM;8-A_Yk zdUhYqNJ$q@3P!4w6H&8d zwbHU{0s^n>$C;)%*Q|-(tki5vM#u8|wErLHIG zWOE7%zdV***V#0K!T~O3`MlL@exe8HRAxP{QloHXsw^LsZH+d=AvjiX2&+;vr?O++ zQ`w-bq515&m2GS01a)N7vZbmsbX~oQuCGeXP*e3i)KuB`GIG6@oy*djY@IZS67~R< zAXlA1loqwJgXDi8jX96?^WJhQt&_&nM(oB242A$Dz3~##|6zH3(vp?jGvq4GeHN*I zznnXpdY$|YO?@4y7nf7NjJz+SY$hCcHZW16k$49#e?rnh?=bWQUA;qnef?8&e(DiQ zg0o0Bj`w}?$tQ@=4yKezMFPIX90bYDR|~AOh)vFoLy?Ba7CeA=h#5@=2LlPK(I7-; z2VIOw?QBq+1;JcUxe*!_v7li)K09{6(Y&&#w7_b$S#qlEf*@2jf*q)*;qVL$!yyKI z7z)C0EXF!D>3E(EF#uV{X-+djDo!jGA-xy@;!bB#bfnwRBZ?GY+ySR8;2wrTYShL8_W; zk|f9&={5sHk?bT4AFBw_IF$M*cSKK62|gcf5 zC21=spBk?!XWLlO^4SLM87>XLrKSMtvA7V4S66u3=OwNH(aMFf(Vvx^Dw4Q%MADb3 zJZ?gwzMN`A+r!%or{ddrv|*lE+#s>dg+w*B2W>c2#1sxo?Az{)zHKvdZN}K;iDHV^ z8}-qs-@jo)XXmBOn}_;(u97%J=POq(wNFV5Lh`7tTxo6|>$vF33x%Sgcq#?6z^Tzu zcxz8jpFaD-i{vO?KX&ZU{>LO5ut#<5*xPR(J-Qr!AUkSfbks_hM~&_qWfZb8?oxMo z)hMa!#mA!&btvM%viR}h&%X0dLZj+J@Iol`(MLER{){|!7kZG*H z>(;H?{m|tNPa`6tR2U0|gQHOOPDWI+BG9cilmaPMc8G3C-@zEiBIvJ@8sV6NywR^kl7bpOxP!+gU4d>JKj*hMbC zsjGkg{aPC8VyGVGEeSD(5X5L?6@64j+eEz|Wxa%NB_0sH_W^Z(WX@^jU1$?-T|mHy)j7o)Un9uCJ!%ObBX@0A*~tOhMZT+Ail*m%vz zNN#S=jT>FV9x@@d3ZO^V1_Lg>xjC?ucoVTPC&z~R2L@_t-hFrL-Ul1=aSuJ-f@b~U z!-rk`+ixGQQD;UZyJ{!ts@=c5YO~M#>2u@S=hO80-sR`?)&`s{%Ml6qJBIdt39JG? z*6_h8qqGu_AY1MT&0n>gKl@y6N7M52%!yAB4a6h0;r@;1ms6-ltQKRE6OG;&p7k@A zKK<z_5C6 zT}_9FVLluV;eS_g6EMWp1nhY{TxVewP0%Q_Jw9Tt#i7t#YbL&)D_6c<7hH-`9N|*% z%<0Dw((&=rXU^1zu@S@dXM$y?5f$ZhDa2WrO^ySal7{KQh47FFkz%2N9?8sQY?-$& zBOuDoW5+V_TQd0co)qJR(Z#UfOX+?j@6{q^6i<`$z zeb7+dO5hWF%qg$X2Thx(W zIwzZl*gfpAV|kf!94NaNXsxJVk3r_cfv=(3431;3P8KQFZmpW49ow=)m26(1?7>XwxWT4d{a% zU62C>c?0yD5+{h#EdeCb8%jDg4y@S;dUU(&=#d;JsKSbG{5S&njPY2blJLL-4^$Uf zoEi_-Zc`B))mE)pg<}j*rmU6SM$RwuW=&{86IzgOG%oUdPfsG`_xnSMo`eYzgG`AY z?5tL{u(^4;B{B~oHv~LHPmgwN?C4Q#KDe2D?a^2-5JNq&qoe?N+;6|V+#;EakQ=nz zqGdaMbz&lAfwkR|nwUsM5TGcMT8D~xrB>r|_Ys&<9*}#;T5DBk;FmxC@sHmd2!&>2 zCX*>PyAGjCprGZk@nlA8UCKxCM=cnY%IIv|2-eLQhb?^x4B}FpuC}}4Rn91_bB8DZ z02Sz5FVe{T*UgrF+v^Y_ zn*~{yc$J1pBoO3tV!Q*CROv#4VgO0BOhgMAWH8fJu#tpk?E0 z_}ET5n%kB~GfUrXD;~$f3idWT2l7d@l4}oox(9PmOoLj6{qu7(BfS8bK$3~fdMAcP zM_D!$A~%5(vBZ|b7~&MClC+`&K#KbEB2XpL>?-#8&YpeY`6u>n+_dAsfdepA`wX=Y zf9dSmRO<1^As#rjhI~_;I=rz7Njvn>U;Wiz{lfEXE?4U8gmPLu&%Lf7c!%b$IA zyWfp9>+ZkZ{_(r-zWZ_e?MZCz$=es-dFP#rpvW$+!t1S&n3b4!? zUUTK;Dnt>C7Q^8O{U`s)89UXqZ+~M=P2>JozW()BHl$J;zJ3t*p#AU6*S{*+s{3&S zEr%E^AAbAo;qqe{u6repgwfbNIuDP4`O)s4o+x|e{cb$D`oRYuTm{ynRSjPcTaK7c zwCU6q($|N>(4mR+xeELG3M?^z@MD&OhaaA6uYakiudiq?V03#C1FKbyXh=7QCuhTL zy6^}wndq@Mo-$?T{BexWlbEI2&{%(GXTK*zVGK;rJ3c}qTaJ!Kg8o3j?*`mqa$cUW zKFMih+p!?8O{H@%S9N7Y5>PXiSU8$UBoKeysZ3q%-YuI!PBu64h%82`wm$jqUg&CpKqls*swZBQg5ioL9nTAgT!MY)q}xnt`8yj2OpoFoSiy^OX>a? zNN>sUbyeNwrd^Fq5AJ)J=g*%vYBcB0J=>&zo_rOh`GRnZjxG5l^~LuveQ&>W5rM0G zr2TD)e;X3t@>>5U+W1Yh@j>7&rzVGh@9euhG%!9jG3g1WHJYUN*NCQ>XSe4SAnsd5 zg?AQ9%1s^V<|B>eb_t_)zc8B*&IjS&(Y$N-uAL9;XsWlS7XwQ{k7q7YN`r02Ia_Hx z;%&C#u97>h02?m8S6oS!5cRUazP9$8nAG7HhVoY@}NOyV8_P6 z@Etq011_4G$bz{mk?bfss{;MD(6~jLYVvfNKp+-cT)a*~VEsPcl*>86={Ik0ZnLCP zUug;inVRDer=B=*{A=VC-uR=A+g>NQ{I~zP1OXNo7A{>{BTZhFoDhJ2 zz&-QK*|QJHmlHmAAJOt-ynXE059D=3IV)=2hU1Uh{o#kJWSxt0ncvr@abmsk#@$j6 zA}^tY@5?1njPpj?HbIC)Iy%;zy|?6YT&s`Uyi1p^UE99>-XoUe$;GE7Od`lrw3zkjLJ zwLTACIxsLY^2j5f%ZVS9tCjA@jju=m@IM{u@9VyP2^u`?g~^`Yn^)UGt7Hja)#;~R zKK^SC5D3Ms@TtoamWDt+duI`ZThelTXmX&ZyKizys?cgVmP9f9tE5d7IP7Mfs8RD$MR4HCCl{^{2Y~sX z2Y2OPiUlJu375pW|K(r)<+GVsPUA94pGr9=(6=f9cfCwNTpxgo34Xc#9e%23OD+JF zOuwi+9Z)2>L{_7R17<3fNN|z`3?SqzsJy`P#q|Z~14ScO>^c6>Lk~Ur@WW>isr6vp z8P1EN&u=($Mn@xz_|j*9My42CQE#>wa`bE>$ubfRXt2>CBBCS!mdW`M6s>URRBLO+ znuoGtY6`8P?m}#1V@aF9!y}U6cz2ogjIEUuFKaTq0iES#tD~37l0(EMy{W8JsBmUzAm$C|)F&qnWad%kL}uLl788j2mYHWfId^BDkx%^ywBM z_6|YqGA*T-y6(hywmNu~z#AbJluvZs?gh0G#lbTO4z=@YH*Kcdu zQd=Xoz%5H6;u?uz=`N~5wNRQe#iRWGe$Z>Zuc@|@+7T@A_$QyluEan5-~PuXwvqWH zL_U)KjM=^WAzV@XckqS&@3;oo$5_df>UUE&gI_yH4iKpJ0AQ22v?!*b^TE$dJHLc7 z<><+t5HH^7nD#6JHQ^qbpC4FKtJNXjOvepT9iDghVt3uRa^)sq6Ecj|fptecWJe>$ zs;Vj|U3fDMg;1p7IiP!WhFqOdRAXDUHB=abp>xk4sY-!g3K=UZ^dVfw6EEBU+&SRX zOLv_*efspiy1Eu{9k_#4k~XOF^Q*A}Yiuegsc8K3h#M<#IvAj_1QBQlP;`XINyU{D zla5I-)})StObz}07RxQl<&vMc$l11K9Y3pbpYa^%+*eCPn4th3Lzu-iQB4_2<*7GP z?eYrEu3nXr#%I$gI*Z0h)bX9dCqKAIYlqd{MN;2k#gn!c^1tAs?^XdW3r~|jk^tQl zML5;1;1IJVN2sm7mF4kPW?xXA4R^6pM16u+t5tNZ_VxCUhCpLwcwYC2i)(Cj&%Oyn z{nXUhNZ+14?d=WNvsKXRw5kv*cW)D&>&usILZ(d0wNb4vOkE#@HcwB(T+^Kkl)min zcge*ZIwCPg4z-!x?h_@x)2AUYRpnzw5Ib2xKmKRBc$okC=MwYh|5X`xx_dKL6h(I> zbXW(c<6|y1R!cD4W2f@>?NsTY$!+v1`#pP#%+MY2h?La{ladKP~sn@^gw5SXHiBHPPn*dKm?e1b02w$X*sNXVyTr zgdSSL6U!xJXDOxFkAj1|wP>42Ed1iZl>HAAb13FMj&dpZ?;54?o29G>`+w zKD-IT+|3WkKTA$Nl|=mMH*pmYLO3}Mun!fGX~~f4dGlUtU&v~Py4%7sKxbucgX(St(m`jjx0UO}wH!`4MV?%>d+ZEm)P16r zr@p5kb25dc_C(pI&|paL%+f-b!J!N~(K{RFb@YxFDw}fwl}_zsV}7rsUg(D!_s+=J zTc92V`6hu40?9~SJX@Np4e3T#>f zU|FFx%`nW>t7FOJr=LPI?qt24of3;M1aFC2nZQzpgj1vZs?_U8YzHx@Uu#9Y$rg}m zvzR$msMN8w>|TBDg6CZUrK9XmFSKef8s1KVf+YitNR%~G*ffqQut67oR}FvEs%sI+ zN+oieS6Ljhdqv+p)(XCeYI@}DGWjIdI))b1Ng3TOXc@KELYnF5aL>(7O-;?txjWp{ zdZu#ATUU+ILwtcyLehXljt2#}P`L8wh;BP{?C{~k#|~{fQxO6W5vn*tc0xfeiV*0F zZ5D${(zLX+R9R9`Ut~@Ky4n?+y@K9lzJ+D>i)E_Rj8uA><~tm?Yi@izfvfIfu{4ty zPZWUe7bM29+N_O@(zH;}g)oNVQJfLccxW(WgBOi0gsac7;wW)+QdjEkk@cHXnW2{} z^fSM1GnmEAb@_WV15gSLXk>eWOhKK-3_<<;k=uu{X~Ec((wUl`P9~{G_quI!aka5n zt#jQmDy8$bwA9#0h0%t+hcHfu_BNaM`j0ByLC18q}#|Y6)7er4%q>92%D| z*DRSGINkQ!l^# z@>Bb9RB&iqKq}DWS5<97d@6X(qHTRcBVL@>q6TautF$hA60D6`kWIT^tb?^N05bMS zS!z;ZIux(ObfB7y=gLT~9fSrSh=TM2H(8fTf7*o(_di_g>$`?4+1|g{v#`+f&QE^w z&Ib}VlSjbVr6o8hxjf3hhequGY{@Mxogv=T_>rw?LXm< zOK71X2matN`3w2aACR_uk=7^F;#H#q`Ry3MAhhMRSP`~ z$PV}z+_`gSc%FI&O1j7duCl;UIppJVv$ldGsI`dhxX=2P$Qxr`mEEqfK|;zz;E*^3 z6+jdYW^m5QCzB+WbecgGMq@+?#zFGt=JH$>bl-jHF(n?y@r__pdxs$p8X6Kz`8394!@2vmAsW6QF~Ha&t2`)>0ZNj|IK+Gm^#;mW~WX9O$njG9>$!C7`n8 zc}P~NL`=~YzcF)c(_!fLq-r0%4o}RJ57x>BNZR2C~wM$G?HzrCfL`e<51e`a)h}?Y&+E!9M4{!Iz)tOakcmM_ z;Ge{y)%|OD+^=}WQoke(AM-)592@NEz5oNMle=W$l1?7WVpCn4g}N#wI7{^dZ@lr< z^DjL6O)zLwB7gnzND>PUaEy=NQD_h?^B_ho5BjFyg$rwBK*a3`QT8YFXWX~G^^I>F zBok!f{z73N#8pXv+=}z$F40itT9!4vVCp0Tw5eG+r+2v~D(-MOa1uI5T0su(6@0!$ z%U9F#SHvH=WE*;ti_q{|g}PA+r_J!5)A8O`rZXf<5!!(FoFYh^Y1Oodt7VBilt6D{ zYZchblk8>0Qim!33dFwmUCh)}o1RU5ZI}41Z?Nn)zJcia)CcS}D!}aAxk`&&iTq+T ziv4g`8K}O~l_|%p(Y*fpJ!LU1h(dC2d39({9m=y(r-R$K@AA(YrlSRJB(&N~%W8xj z;}8GvE?=#GM~92WaIJRPQm8EDFzDQuronZ6_Sxt1+qY<4YFgKNTz6@v($C|)7~!Jj-9ma@dMhGyD8*{b~!^eh6(Z`!^Al>~5TQP?76ZS-HLzt$nDB*sOGI-h1v^ zr}&6U74gy79f+XFPe6S=!+G-WJ#@0OKUYoFGlqXS3c|r;EHd?sRkxC(D5PQJm%|N)6td7?xNFO*W2o2G3B;v zBIcIXKiJaJ^5Eu;hYwpU%oM`5PO3_Ge(9@U{pvG&8yjJFYtma z4UM~w9Fxq)PVCMj)0bcnyEIMmcAr?jYjHbTei$t;_WKhWtJS8B`*}V*KL+F+{R|HB z{J=FDp_@+>Ku~dl1#@y9AQPO_V{_BS>f*fIrp+5S)Ybj|AGUxO`!4yO=Jjv<&hPy0 zH@}%GJV5@G{W@IS5n7Mc7>qyv;Ny=!c4}h5z@tYFfEkdOi42#z8Lgn`oC3-fVfg5; zHGbC;sebEj*XshRrTRSgYl&D~pXY(S3H5C%10JbAqFm2&`%Y)4T7C1TAl$eCG`^(q z4w+27-B7?wI3`GgL>iWT-DMe?Qhq~DdUCS9zPh-2q}pATFX?icLc8EoA0gtEj3i9? zD%olkO+nQms#4KKdQF;lF{;5`5;iuvFbG!41G%7$FyA#?yXGOrYJd`|jl^@!Ra#(7 zf)z^{3#ywCmScN$K?tj7ge}& zm@g;^V9rkgiI={8n+!wMR#St5L$SB?2&RijxwfLB-~C;BeyjfSFEQ)miy6z)QMCUk z=A{B5NyM183@f1Akecthp%FEyICU+Xo$~fxx)==NdX-jl$TM*B&SaQ1(krxzm=xng zrC8alhMNMmxB{H);y`~lMUkiMJI}ou3G&wRCai=#2Tz>+gFo1}k35J{3b)i173MI3 z5xQDC#@!L8m<$Je!8p+ZBAcNTFQ8>FpkR6Rlo#xqeGRbmn zjWxh5od5Ff8oM7T7gB6C7ZVImy0H0))1Fy%;bt0L>KJf}FjXAdPGLC(=KA`5Ks_AV zWLpHsg*lImP*mcl{X;&dE)@wz!;z3bh2R;Peo&6W25_GSRDjZLmemxB<&)Es*9!d_ z`zRTcLbN=~IEe zTN+_*uJcOgjo#^b#IN;u=EHCbkt}K0(di|$(|}|H?15fM23#XJFcyG42tnF$A5~42 zITeeZe`Q~l7VP?>rDn^?Gl9?(PaHq}#FM8^o_^-clTSW*d^Wonu zjH`QcQZl^#c8*yi%WYaC5AdnXJH3RKzKoV`5e#y4c##Ru&v+`!G+}S&jhbO>{Ip@!TtHFIMdUl91bWaFezT4hv%)A4uwoid~OGrCxymH5Bb2alhVU%jF68mYFsy)A}U6$ z5=1TUtm3tZQ=(>61TaI4&rD1U;aD4+xwf#7))!+0i}mRR*N$xkxQsKznawQ3pH%*~ z6Tmoq{iV~V&%N=+8|RO2bAVo$ww(s8d1RXd5^}+|5K=e{&ktegYBp6C0MaA!B$0IHs!i4$y+H2V znFytHmFV3DGePim*Ot0GA@TH6`|2z+z`QJlmx7e$X>u!fV~6hDQd4E|V0N}9Z>M=^XuiIf1c$o%{RCGc zSD1h3$OmZ$MZh2Wmwsd&2*cR(1Dwgkjo!XJ65vLAdwXZ#Gn@#_js@KaQo~ASMB^y3 zjxG>=5u)b_S>ygjHErY$z+sHLU3?9-zCDc5KYg znnjby@=n4@dOApEmQ4k{;c)Ut+ueFSf`6-xCYpcVJ2tA*r5Ai)q(-M_7lZR|964(~ z6jQ0KuP?+!6Om{r>NN9-s2@sLH-ZxB&5n`^+Ye<5^cvd!HME@(MCUFzC*tT=YY`7= zZXu>Efa8@F(yM{$6mkXsTH`7@DfW7ib;9kf4 z00#c~{$MU1XbpBKc1%kHH*adS%+fqXUxBuSCj0yQr-Hn}aQn`{=k^O|PTW-xVR+W5 zWqtEB^PDg0Gyp`N(3mlHIifa|_>tT{YEiT=OQ-Uxv1@%XUyd$!G8Z$f5)c$vnA|C8 zbB(!I21eKs%&lrhOT-y1%pFv|SdOQ_s5$56NOFE?#KUoFHEy^RVE?SJ2usr*k$R`E z{m64G5HKX^S$HA2L?cjNW)j9&1SP`%s3~EBubD}!G8lCZ$3U+^9Se*jd}tuT87#fM zgKK6L59q|Q8ja4$aq#w#OmP{T%xLn_?>v)U`y=IKp<0<&F|T`4F$9lZ3NMTg56vfV zBQ$~+Cj>a>aLMG-C+Q_q+~yLdx9!K1S*HF-rT{7|!`fFoK_8bTATnGu^FP zb_sJ8VhxyRBTMHECnvtI*~#>#0eLHHVkxbPwuyC?%fAF|3OBZoc@rYSR*4Dk*wRv5 z$f1i=h%fzcf5-x(xQQ6C8!Vwd$PmNcA@Egwp)>%*>5vz@+#4cb{*xgexa7HeM^K;- z=$g>rA{!QR6ONv8jst_qsL~t;&~8=(^`bW0OBl?82VL;YhnUP*uB?(Y?gln$R0c;$ zsfmfr&FG9KCK94HA)GG zAMK8^rm`RI2~X3gfgUts6gQkRLT~rnLPUcB&@N3;7JEJ!0 zkQF?QuvU%5b->%z731nk-xzrEF<(lm!7@Q$JF`jenUAyPGDuo#ExajsCP4{nnzs}b zIB4_#3}BmqnFvwmRe>3)%2Sib%s`jK@o-^s7N=8*IV=KIZ9W!+o+PmbOl%ZOz)w_` zGS_~+3i}KfO{B2J9HrPxr4F-RZ!m!fnG9eObQ-i+qto-bV9)Fb`8Yn6W7IRTd4#|Z z&(6=Afxk6_jOK80-^|R+BD#zy+_*&;cNSA?fz1S0s({xWi(wf`DwaoaJPU;=Rnl_| z7L2o4;B|H^AwC3F5Y#M9R}j%+0a;Y3z_OikKSq$sd6gP%ch?uns@zaT#n5^JYo#UV z*#3n=D+kCt^F@*?J&{d2Jo1Q-!9r09TmRqU>@yM^e!$n zr|@11S9I^CR@8>lB+NR=(*0nD_XAwAs`)x9c#lKI8>eEoLbs_@S6;R()lmwm5kn=S z1+X+Xb+VG%E~}AOIyZE7XsCPG8$7Wm+XZ?5@+eD%9uUUWh7GX?s)DJlo##1tZdm6zec$^z&r{jwS&iO2)%5LJZh!Xq0Da!F_IZLnuU-2* zPM_~v`&{-HT1TrJqVKPan?RO*%)Rt^^Kv;<7pVB22Y`upN$~E&v4tNuAt?>c#r`Y)fK8Xr(%!t85W zujJ4j1O-IBd$y4`*47)ig^(ObEih+CBfD1qZLjJ7`Kb#;$s7O!3&}syUw#aB$SWxtF)L!E#qdv)0^yP48HCYOW2L|}E za(Iivt(t06Se8?PlBx_XEv>?}>q`ll%>gCl$~Jv69QFoNEKB8YlSDYDmS>^SFtgA)w3S1k%CTsLG=w~(p}4LtO+-DA z##Ngds_Q}3>v5#Dnr#&&;05_uzzr$IA5HL1EmRkgSS(doU!OU5maR&yjKE7Z!y{f| zp%nteuAw7m=a)RPj*5lM4LlimTCjtX(~)Kvs{U9=^3Xnj|4f+kp`ggsXtYii;r1oC z58yJqjipvKOLp$uTuN0q=JLF}at9<5M_EHtQzD@*+jZ#Bp#wXqcK#p^Tgi|}X+yT}j;JRWCi`>?8o^B_1v0r4mNhcJYum0G zew$`SB-$CG9iU7com!2{k_m>449>>Y;CA_VV0<{4j7CSgyCxRr!Jb7yGof3M z#2~sGCX`h+y0j%FW3w0$o(OGBB`+22Y^pDXV<9g(${U*qci_Ob?M<7uG{d6OjI&%a zY4xT&^ebP($d1o(i5nTwa86dI$soMwIsZ6a+3}dOl4ql{-UW++iO%*9jCg|q9R1;# zEWswio{>>-*YuoEK#p}5Gc2D|R#q14nZXhY5+14qe~S7~n@DV71WR6YR_cqZH|*PO zvf0ZvG$B$?eKQ>8Hpz2hDyi8D1ji=%m`_7V;4F?OmOM}!Bt(wt5YR4Y4M-SDgM}FG zRLyXoWq4kS_Cho5sHl+Cfxr@i6Oe!;Algh}Nr{96Hp;jF=LrWFBy~YSA-D?a&CZ8B zL*3m7Iny;2Q!JZQTl;0&_XI{nEvlR~(L@R`pul_pKZ6Mk{0h|xA2#?xLXGd1#5BJ9 z5p6-D>H%T3dO&qQ^?-{hVVw;s8=&_Iw&cMB2M#pX?!X*9SYpHSw3Sd=u%W~bAuYe8 z8p{4!I(JY6s384k6#=YpA4PydC;tD$wNXBR@}#Z6BfDDf{vX{Z6iq_?S|l}s-W)!d zBuSSR5@J3DY~wqPM1sL-<$i_86#Uk!;2D+u1>rm;Z0`W$f%w)qn!4ZgeAV6dQbKH#wA%(lYddXmD#1V$Mtx0Ch?w?#7K+ zsY}D^gTaR$+Cg^HIKkYZLwq8$&ME#Lm}A1BTb)uXvAMYUc{o6}k_(wPmN_jB){!2G z*xHmA$c0s$y&fgFVDo*el^j1ne$aYAW=Y3$0feHvb3;JQWqAMR#uZkhcdOUl#4=B z(QtQH2a2YKZ@FBXoefD`h_&0C%$6-K3kvl`;>RC7jiOO(9ObscDmOaV-#<7yIq76R z_#o5UhtXbHiWdZIZ#8c6iA?uhxu)V{^V5^`zKUC2*Kc)nXaKn;wA1b<-Z5I%z!LVR!q$aeZ$BrV7j?Z`K5J;18 z!}jeBm3CEZ6i$nyX{Jq|h{nQkR%I;NknQtI+va`r23MFlp@v29o`Kuf(fL8(CAA$l z``y>CW8vP09TmEoiLt5qc^HGzIx!ub9UH)Vo+NH0lQ1l8l@*#L9BsZiABf)*uu7hO z7B-x{6^T1tEiL=E9zD9aNXceCR3chc3MYAQ@3pI4gR?PJDLPQfq+J-d%zR{4Ct}2O zOGF3B4=b%NGp2N9R?Ct+j4yCCK*Pu5s(9x||LvpyuBpzS?D+U&wwmamb;EW6>;}KZ zVm`2aFF*6=*VN>XeFmo*x~A$;UOm>7HEu6asY>i|l{qvIOGn5|RjL+q z%5Gm=6eGhCadFXZPnqjo)|~0?V6b~SCpM}tN~MbQK!FA%%jjb79jhg*tAo&OK*KB^^MFh`gataQT|C9V5*e>rP zz{2H%g3B&$WZSlpOe?6ALS-Cm;tuTgZwrCotLMp4$U1ZsmiMJFVYf}-toXKYZr*zq zZM#PT!hJtC=#;QV@^p4iN>Dbnyc*nLETyo!7YN&4ya>ZY*fRti^pGd4av6#YSRjTX zAY~43gpF4hiI~f_!kchwnK_baB<|h7iHe?&chWj`v&bP zr9!P-DuGuJQE<=ifr0tiviw33HWXXSE`<|LE1G(}v+JIQXNZZJ5)*P)Cmf4DH#ptL zAAkJBk(PTpl%X!*dO%(7>YUv2<$6_1U7goat<#jq43$zl1}u+Z!W^L5j6Ih_v<@+^ z61+!co)~vI7F&*-K&`0T?Nf$OsT~6;D1=8cQ6i+#_(wWm;O!XkYn1c#OX%;@=r3*> zyB`(uL}4q5dit@e``4#N_AE8JYfLuYpDY!~0OW!?Z1cMH$df%wp6(hnWxk(XZv9%? zw3>3w4giTWFdGAnjiL2*z7))Gi=NT`t5>hiBw*kH1DaeI9Uq;Xo16DVqZ)fTl+fjN zP1Ln%5x+-KYq0AXg zqZ$WR16Uz4BJlC^cjuH-hsggbkzyCF(mal<_Ezn5q-M_c@fmi|NZ6n@$yP6O^O@4K zZw%2lAblWyiZbfI`5Udw7P*X8=ABF#RDx?kA4;#?86CcJtM5+FxK9-v?z+|8arxs* zxF+d(d;2>dU4?&su_y{0uAbBB@~TqvL$IIs_7Biw?oQXuFnV7K;SqqmJX3B#Sum7T z8LT-%nuQxcDjKUPHGokrMi`EszCN09q@cJ8c$J!l#zt5OYAeg9M+Y(^L%F4Vv`FuT zP0RZpEQFuDhnR#kS}7FQ?6>tx5ZRW_oCC`H40#;SRU}HED`T>f`<4)gSyQ<69+0O)~UggvpSND~YMQjja(edm{#=ayn@j1eu(`}gh3 zNbM`_>=y)hQo}1e2_G7K@SpxW6pY_X8`(?0-9bC`EZf-?0uu=_Kc19W{iZEWql%6C z+|$#Ag#}hENur&qczg)MeFJt_iZ;736pyRQAH-+thN`L!Tj{rQrJeis?Qgb-4BE9c zclnp^yo;8m0)ZgJnz6AZ5vqbrJMZzyyVxpIc2ck zQC=4}-1&uHeZq9tI|UxD<}Al!&R=<`l{xjZ4DXwZ_pO*ItzV^Dymh;q-s8h0q7qkb zn;LLwafSsoIXN2ezAnxa)MxxiR&q_N5I1yEzWs+!3{2U|B@%UMH3-uZ1c61aRxVc` z^eDG)XHGL7tPItJ_`Acc?3P`G3nr|-R(33{th~G5@~g_jT)N{E!X<)(c&mg_-lCc} z^b=wNm?G88f)m_3E=&NP0uc*b(AF>tZCQB_mfFmFXq+bqMlXytkMMo>v<>B;ZN`+o zl?{n#+g!qpEfc+`ZOU6IBi<52cq{I8b0TL^-ZGD2>>s-Pfks4uE*m`uqsPtN78W+FwYW%n>Na0X1Gr}+VJ)zStb^9!ENRM z`4ikI8c{~D9B)vJzUlTPMaTO8@*jWnqxSaqz}L0%rKKlNR!hpSHr0V<2nccAA@T#b zasPz;ggbfiMerj34KF4V@4g#B3`Y8ERn1iT;V!3978N$w=hX7+S((C$y6NiFWa=h& zmMd-3WNL%GDpP!>+?CTLjkgI(RpS-1ETzz!Y^Vo=sW)Gx%J1#V`~oahVsbrg`1h9T zMY`%=eCYtW#35ATa|g*Ka=Dd8ah3q9X~VyaKAWC*l*e)u^kK^;d4 zx2Uq7$wO{2&!t8X%*~s(BG8wdYb6I=LA19{fDGSkmAzb7;#yN=mjh`C!>CfhR4M<& zdw+6(^s`?(jZg~xLX3pAu%YgQ4-lfR2v}m= zd3NftfX}J=+0TCVPd_&rCGIc!`~N~&(-bpDZ@Z}F`M})t^z_8Yz~C5u`w`UH2yG=F zXjiKT2HMRg{6~!l&m)K%!mC+oy$vk|m*n`213`KPno9#C+oHMz3L;nS*qClJZrpnE z`4?Vz9>2HgPH9tsuqL&qHg9ffI+e%aKV04^Fh(v>Ls%`g@nQnUF?A}g%|$Fpj|)3D zH#OS>X*RQSm$fXK4*_%h@O=^pv}pll1_8^5wuCB67|=HUC^@>FP-V&0%>CB?U-PwU z06JFcbm4Ybw_C0hF-t&-5VK@Ig#DxZ)vYU9ohpoJQ5klYqScWb`(bzY-n}l|aEjI{ zZBEJ>9*r6r9!mXWB@cJ>Xw9e^1>v*xsY zt=lvKZ`Co$1mcyaYa49d+dXP?%t!xWDGIzG!!YWKRTP6U<`ZqHL^6#_MpA~VDnrT~ z^W#mw`b{Iy7BbiZPzxX}$hNJr*iGxeUyL%^jEZo6Wtt?{jYOpxv?1KqT&+5paL*HK zRlPxD&4<5tn$hZ26!N41F(?d!qA&o$fXt}T<<@8t3605=oSRFU{0RmTvJ(swb-M%0 zI155i7KxMuO}C0Cz*l6)==<^*$t|FbpiRBs7`4`lMmNs!ynKHkl)Ud4sVS;82s4T# z3i_PS9izrgm;8BsrUn)n z{2WQZMq)BK>b7p$*jQIvZYFAaDJY_6TWcCNJ-`bTz2a!twqwVRYHcDsN5O%fh%J^B z)akSh(IfIzY-9|SXc6MKXc3{&rzlJSBljnQz95aNU)r>-Opo7!idV78WOC%wj!7C&Jpx%Y||T1oe)%_CJ1b+vXB5k8ws{vT@gw zU;gr!zjXA$Dx0;cuBN20q!{|`g1nsk(qf(dM&B=Q)sIH7LC1kJ;jK)GJBxYl#E1UaeH}m0)UsGCyZ$2+RzkLsdWy4!b>6yE$~6G zIP!8urylTetyb50=biU&_##q)!whZUM5w-0l^DA*<%{#i5|H}JT#F`zK;R+>asn%! zF8rqQzm7EnHL?@&h?JfELzMI*jO+eH4B`22+!#*;bO1N!CC!LLBjj%@#$sin0pHZ{ zP>xv__TIs~Oa&HwkkqcccN=nMC74VK~n>IPK5Q-5^G?YM)tVKle@FrEf4(2=q8}rS2UHi9}YvS>* zyz)q+H4auONfM!@NNjG5;)qiY#0mKFE3s5jbIa4Xi}UHGtq(!QeD$^0Fz9C=HuJDW znZ>4~kDLKl^V9>l+rBV~+k#oaj@hWfT~mv``Ngy*;r9VQ8%{av5}-lAn&0l9^aSFf zHP^ty&w$qegOV&LFmRzMd3QzA<|1WH9>v&ww~SO)5SvU6@dHN|Pm$%y2lRXSTZJH) zDmL%fvAM$3f9n>8HWiQ8*X9VJ+abYJQdf@QqXTY5+QPc6h1#&oB8Y}O%msmP zH$1nv1l;DP23n#T{{*OLaX!ep2K-k!800Vbk@>ITIG$8U0zAJ4UOJZKf_H%dhE+&~e7=P_&m{P)iHNQYNV2LttIcY) zDQo9p^#2cO|7rC+q$Byzq6?Bfkozu=1b%Q8ROp2Pmk72}n~?TT`qLVOi%f*`9J8}p z3k+&nz5?+aSOhXqPcP_T&~tDL({Sk5>5@qur5}v742S+6orcBO#Z#MX)V(aJE{DxTGdemxJ?{@jlg`FuH0Yn7 z9v_vp@BRRmQ~(QlVSbT@v7PhHc@`JP#uh=rywl@Qfn@d`o%L^(k&5zldrddp^7wP* zYdbAch_h~PPG^NF)~e&n*X^@tlzW^OvVZ(DZd1E;gbBqLPwf{5|+}AyZgKG z3;GK(ysERZZnYA9r80~oHzyG>XoJ3-oM4bgge03S5-}M3e%!+JJyIxn}fauyAHZXALh8pR6Fg1~mj6QxZ%G`yCEcPzSVVM@aQ+*)Ty;EN5mqq{A z*GF%XR5dp@RutIOPK(-BP|?^diq?WMY=^P}tLVzHU>LCh@CODw`8#XTCqW^KH#Tjw zu|``_O-)g)<)h0z?hpyxxf~e58PGiwP*!dxQXPRj_vsDtDGi!WAcXS4V~+3Sx~&x(1x?Sq^F4Tm&}f+Q zdzmrG$i|f8#fR7Q@B<@GrjhVBn#L-4IAB~+>Z8~X6GW+%c=YN!d@tWSDDig$1btP} z*J%cRPh!P4lX}iktNw>)z5$a^Iu)*g*?!E<^8|^ z`@er5w~Z(c_Lp6b!=T5wP!B)m@Z;je{@E1dko0WdMb`#V#QiB_jqkr-Uq5l`lgFfUzq}Y)iGdSYeEF3#4mQ?U;`MP#z~PA3YOPUqsLq1w_?D;rZ zsf?7C(AN}^0Lu@5?*6bPvx}31K9Jg^uj}g7u5s1Wg?XL(>a20$+N3b{@y!xla)}%F zMsanxO&#?X#|P3yWH466OsJ}baaOJX)DwRxE#)}MG_C%yCG3*gU~||E$=T7_oK2~C zENQXYZFZ~0hHfNHCG$c~jv>A{UsFUT`p9CKI)^TW5%Ei_gCmf#mR>_|zK*dj(rO0> z7o&-^aQ)Ja>BX2C3|^60B~n!?t~<($%}6SGB;N)KaC7_H`XTuqi(tH^HCBVuI71Q$SuAOq{la zx4-8ac)IHg#PR^T*;rO=WF?hOM`D7IS#4iM+rNtWc>pjd8AE=D3snhr!AXb2;b%boAYhToST?H$RdqOAQDNg#l%L;=tzgeCtf}aUl(pmd*S}*f+qUDrM=k-CopAGBY`#Iw-087%LYTXV~j`Q+oLPoIALB%C3s08YK**z*lf243mqc}hp%@nAves~B@Oqm%kH9{0aA(u8daw81d+9IM zqpr$9yepZ->7ftMQ&b{c^j)363qU63<|bApvRWt|WsADQh1HpIa|a?AUwG}uY5B%+ zf-TE!-#>?u z{=i}Pe)2E;B7EemJ$xAr*IL+#6j6ZNfMA1ER{@TM`g-gv>{=li3i_Mc>X|j_V>%xe zg!!0n0=`*MgzETYcyX08Q6)5_5&CY4sw_(b9yHo?Jhav}o6d9YTn`nnQEmFQmV`8z zDcw@P`|z==^9EPPaqJJC{^r4hVD~w7>=^J?U51^FM%)+2;3mqdl9jCyk0;)pH!_V=*#ej+YQw;Hc>q|MgwazJ5&Uh$YUR^G+r|8BPURFm`t5m+A0FoA z+`gcTX>4S1L;enGNdc!1=~Ay;^bpU*6-3riEqNeMc$SQEms-Dn7umI64~i0%P&z$0 zT`GWOCnp6q;>jH@0`6;C6xzqdPnIneuulk#jRlII5E)8MOmJ*0#+cHfVa&^+^e|71 z1}dsyVC0W1YtXK0Yj)P;j|3rE{_hn%6gFI2jV!<4YZPZ?lW z?c%Jo0owe+dxiQH(Gm!EK0CW<3Zd&v*f2F^puaNb_3lKBZs@Kd!$QGQ5)j3Jzpc&x z9*9>9^`$;J@Gm72vt#CLzIJzIa_=KWYiKbYWDJk@>!px1P;6U13D;j+{(}tfDlOg$Ftu2% zHvA!JcJJOIKC(AidH2@}<)Q6f?#jjMqa#V8!>@}UURn}{He}B(!I~R{;%?G*_am_X zc2V298c%j(=47;qLRvJI3Z3aIm!c7b+6x7|LjwemUhnL*cX27ki=#tcJOGPqg~edP zynvS(?A0*8hAAX$VIf^1Ya^*7t2UOE*R{7pNoa!EI<*`CZ-PV=*(r+aOEc7lx&VhO z2!ecR9d`KGlV^(ULamxc7toz;ZO?act*zN?Pr)|m$0%aJRG1%cMcas^>g`Pgru#wt z+k>45>bYn*Bp-Iz>_?F!5p1ODnKvfvrIitQBTf0Ut=; zyl8`5Y|SJ?{uONE8KlAU-K?^3ts9#wpwQ?V|LV1a>UkX5cbTw(GCPY#4`8$8r{%D!x?aoEgeovxzl$w#3R{Q9X= zkC5+6k_je~V@D&)5Px2K?X}n6FqnFBGfUC-4<)B_Vr6Au%NjP6T|n&c{ZPd<$=XaN zRIXCp^)W{TTt6c550VETe5FuAx*NiKx-lZfDwYkDg?WBE!MDUHnFHis$-gm;r}mQ< z_}2-(3;kStPxLHX!LpIahoc{Yswf7NW*?w52)KdRU_JUw=0YapaD1We3l0a~lP>lF z#BxEb4aD-;WF5;Pv{qMpdz0+IxmDoxiC^mBs;l#4I?NaEI6!_u-hig`iLV?x_Py^R zjBgK}dSDiRwNQ_*pdNpThHiDaDlKXfi${Qx6@pSdetvgA#sohXf!G& z74nDSZr9mSwtRH}0#1Oh>CEqso<=>MLOp7NF_a?~oW$6l3@#$4#h?!()A!=b2wU{> zi)Zf6{*Zo`o_X=5mtTJQr59f~bLPwo19Pwcg2)n-iB6NyZA8RZ7aX@ui00z1H1Qd! zY;D;GA;$+z@zm5DEY2*OhK%+6ZD=vLORJ5TK#V|IEdX(4M?=|N4vblcs{#9v2A3JP zn_cZd`L~xV##Yh*M+=}xx{bi1z`TX`0@Y8!G5S+3=ka`9sNoAPa$84X4a~2EpW&Dj zhpcE8L>uUvM3XF{L40VAm(LuaO`$%6mUs#6@WTG=bIi|v_F(q8>{Ho?@%>cx?{NMc zj;C<`RQ41{8)|>{%dE>ONZSOo5|1{j1oC$c?c+o1%%N@4b5l3}-9zsBbDOHO1D1r2 z&Vy8<>}&*8_ z@~LdxgAqu@TaWY;)pTt7I5sOA_fWQhG8b&xCDg^Fq_!3?9+RZbTN8=F&_mFh^@+H_kGZeX|TzK zI(tv)xlrM4rzu!z?m!o_0*eT80z`uXDm@TDM8J*g-+WW)RTACoZG8n9XCwETy1Hm= z*}DYCXyiUV;Q`f9GGwmE<&F*feXbD&wrLIajZg(z_AGt8DR*10?zzgN>b0o5khi`{ zOe8{cr)J(<<#+TZ1Q=iGJ}Ea}Z{$C&=7o_FTXbHR%U`QCcqWn+=8#;{iZ*L)y2r9GSS&(;2{T2P<;qac8Pl-53HxJBgy*YiK198k8S_I* z3Y>=27D{;5+*E25j8)B>dX!q@b#Hmz5-=#G}l76Y`Nj+dY z>q(U-U6D@Pyqm^VE{$#5_(`2^a;qS01qhC9oMe#(sHF30P+c#p8X8Kjhm<#w29ij7 zv8UP4Rqt{Uvx!iB8OAU2qthV4-DqJ2J`BJMUxD4m%hE*G2x z0F)9S>k636a}OC;-H9=H;p*Zu1_um=0Cnbq)a`U`o#@fxE&AYtmX9HIWpI@uGA`{X zNIV<)4h#f?f?x z<^bHQstSc9W(=jKthtHAYFg)stv%X7 zj_d3k+`U`Lm73Og$E8B4i#v4V=qP{o$FIHi%Jg*X>_5LkehO;)y?fJp_ab(HR=ZcL zg)=}|<`DQCHQ)dKq5JQ@>(KE-IeTrF?)-V%`SYZsV|Mn+>}+1%p_s8UypQU4gsZ94 zxpOe8G7Sw9<97G#C*3OLPL1Z<-zGnlm{(twn0MbTlx4#QDBctks@A-C)5IO?=>@i4 z5A@4_yOh2%x^ReR!pZ8;FNX?s-*~E}MGzVq=#nA2$JXcO5Kln;o8KtPTW`@e6aTYy zp{C}|H*bEbDIW8#RS7gNPQ;q;rU=Jn4n($BH+D<1k8ORS~wF*0%)4qqyr5$8A&#zrp- zcHlE`I*H#qX0J{y;8TdMgoGWtIywXaU_N|t87ThnmteD)4D`Z|?d`4fv!T8gu@>kk z?c%0qeEuHJb7g!~=0XbrpUe#p4Gwgxv+Kz?85kHG>?WFSmD!*p!y}{P-Nf0=)z(xr z$g1Y%CK6tWLp#|fi|y@g45%Nm*=%iVhv!aNd4MYdEF1-J#bTbC035mASduSw*2w0W^_? zjSPP&;MAngDsw()fbq4kxI`buuk1# zt5yMivDx72Mk3u@czI<>R?ROgfjC9r5n50dvH2hwP-JoQ;31c}L_8Xixo{}7l*bHH z)T0?asRt{}haY~3mIdksM7?M9*wmjobqZyHR8GE8L^~GrW~7__?dKV}o9*xaDvpo6 zl*RGLk>bAd`d$6s?8kpzClBG+^6xD;Vyjzt)2`q3Tjull&-VY=j^kt5mvDUZcyXpF z%H8V4&QfJm%TdQb4q+qUqAq~e=u^;H#3Yi+=X(4pcMlOCE*F-V0;PEV?W8-0fEQ}^D z>i0)E(<89^|kGVir`E8iSz|K;W4ajI1&dC0kl7DRophV-dw* zu-E}ML3A{CeYuPX_o2uF3~CFJNET)XR~5E$4K9a6y`Ceos8ae?m9>-8^QLjj{}!~p6D zr)+^cNHQLQIV2R)s6n#@dfKArQ4v9nq*pFn9UhiNWAM@?pU)^G0NYAkkdbp_0-TSI%8C*CSXc7_FF~g#?%gbXh8%#Fq)^kclA?TS;Pnu)coxL12U1dyESJ z*qN<%G={Crl--Cre?vR;GhV{P_As_Yt{twIFq+L?xZst=`*-Y6soL&G3E|mVSBDZV zT)CoB%@k4L9VlT3TF$s$FTd_E&jC!shYWm!gTxtSCU5qlrt`f8%4E;bu zvz*o>+f`gLrq`(jYLX=SWY}6$i;b?WL`IZAP>InN2asrOO)ZIP9$d@-lI10o?Z|aI zaxDvIXg{s5_K$neAX78mS?}28dAY2#R0HWDog`{73u96a&d-m~8OD>DWSOXxMT9EzOZIu49+=iXmpBOo9BC}v+#+w-5M>o$(U1W^$ zJG)3HllUO_zv5gfA;J>2Kaq$7y8j>i0IO$RU9pF$y@z^Jbg?TR4a?9@)gRd^cU_NP zM{PEZ_`g}F9`@e!o|{v%vw-twQz`J|&%$djE6C;*MfHR)7?x{qtXF+9fhi7h1;%`uQ`25^$+b7u({6WI%zC_rYK_|keTviWDDwKA zMCneVbPY;s9d6pdkgb&HNNQ~@l}yk$;@%l>JKr#`HiSZvSOiOVC>-{}{ci9QOsN+U zCfyslvGj6-K`XK)W=BnJZFZe88SGALiHdMcT|I!i(I}YUsvitoQ4gq@(T_opw(CAv&Tr!yFIOVAd>2P<>< zq!W=zA39D$7XMf-iQDb*xLO@gS5~I;d!Ad;84mvT*PoWo7alr5{*nCCpGt>Xh3-Tv zRus!~9)jTD#ZN9Ge&waU-ao*V;*D43iemYqiIp5T0h`6EIy>tFxF;$*i{<^|!_=^R z`1r#oPd@zBC%^OTv(G-apZrL!C>EbnK@NnjAhQm$GH51&Z7tevH^v&vzx#gBKQRVx zeE&jVZfas8kywB*=$)Cqic5Wc6LPJ7ZB0+Pb2s(p)y@5hHMBOyKJ=)&dS_dGZB@Cu zX~*q*_Sme>mge>y&F*S@MftwY4!JgAu_O|rn6O#_GKErIS5`(*1$%by17>RP&YfGa zX5BnXFiV69I+*bqMtSe1S>kuUlew0)4`P@lPMnar%dCB|@I0L*RA}Wc*66uNXNmIr z+vEZ^t)Oq+ke?=gm6M;in8Va@U3#LcJM`)BEO-L2Pi-hsATujjZ2D{sB-qv=>~y*@>+x7>RDJiY$(t=F&8>-Q9| z8%Qq3F%eShplcbXIS`8scKJK^00;tz1i84WSA<-z4AT_M#T4j!l*4I2y5hJrKRCi! zX!oh2k%-$}iv*dAQHhBb)7iZD7j+T?F7w=2Yd|_*bdfSENU4F6fEE#Bt{_q~93=<3 z)w}2xKO)lfS-DtY(1dR8vvI7-s5VNM|DsX_`@}b&rj|Z>&VM_9PUT?k!NAXNwzHCwrtC{Ez4(|%Sjw3aqJM15CSCOzS2M; zl(ay&0t8ASq#@<{!j&%{r9dg9T%{z0qm)7lA%w&0|9dl9Nj6T~-|zc>n9=UOoq6-- z&6_vxy_wl?gyT3R{D@plm#@B(BaRY|%V=%v>8-tP zz+mLa_*RZ9_@K#M(=G1eGC8t)5n#z_gXi2Y)OqlH9X#7RdJ2jYubzI96*opKb~IuJjcZuIWG9UTh6^qZCgliPg zgXM!(tiE7*#~Xr);|yw!J9%;in#G$Y#b^1YkpB?mSFc&N ze({ROo_~(xe+4+Xf{DdzmMwF3t2q)T0y!Hd*Pgfj{GnTBI1;yo%;5Eb)naOXOTAWJ47u!DmWgY@E?>D z{vo{$boPH|nSP4j!OF3h_$!`=dBiKpSuTfL$%WCHe6Iy~7Wsa%lM4VG*YN%Ds)b$2 z2v9)zSnN5XfQ;yg)y{@+}&u2D$msuST@pWW};3HSeL%7VOdLL73!I{lLh zo{_(fbEJPo-~Z;F%yZ-+;+b2&5n%JB41mk+^1sUMJJ&S^%FUPN%a1&nFCm}&>zwZ% z^Y|qKBa8BTZu{_gzB&+=?3a)?bL9+S{&IlNa+2GFZADx%jYtzdTmBCd)`_$_&XJH7 zlxe&oomKyz64ocT`&?I)d6XRq|HCc@8-K={<+D&?0on6P-ea~UtYv5>rVeG%3{gCN!{??FVXRYJV*ZiU&`|T z58QKk-7yc%D7$mV0k&^$S$swrwD~2O*VqrpSDBZ{uYX7XcF2P%84}_~T>kR{;=?4@ zf!82N*q8p}Tb6&+E%*+f^`0x1!FwDJatecc%!gwUuLzHUB)1vyV7(~Mc#m^HwkOFv z!7`{G3{I5tG=106X&lTTeoru50lMn~iiTuF#c$LfHGuDfFB0X1xVR=k=j|pW1d5Y!w zJ@<$Q%VFJ!L)H-pmlMMA9{V2gVB7GDWspAkS*{})V8M{eAw@wlLBjIbR)3uGvwX#O z5&vOA8Tn5V;@k=80Z4lx-2n;v>265BgtX6d#ryv>AM!)4JH~UL2v;6-{#m#pKQSpG z{iitp(|jnieo2D<{Nc}NnZtDl-#`8F?5lJ>-!DNQ{rEN{&@ECUq>CUO@a2_2W`6iR zyw?I8>HY%JDbE$}k%xH2gz|(5?+-)jgtP||=2`7YpTkv72$%2WviQ6S63P?Shd30F z5D%saPr44Sm>z+20VM3JOCbFY()Dwmcfm8#ESJIS8AzW&Lj0JZ%{1K$*Y6>@Ah{vo zyE@>(8fgD3eE;{0fERB-It0&ufiwiEneo-1$9V63Mm|5}%j1Q6^85g8!akCk0B=3q z1HXR&8YPfJ%q#aF-b-aZJW@W~&p?@INLF~a5t0#7CM0+#ZD;SMk3t)QAR#|7$!*5* zoa#vta0UF_-yuB=9+?O!0Pe9LFkxOjB%FgVFTAHX@md0@29gF6)KBwbJ*Z3MgwOeq zaC}riLK+4jIXw6H4DF{0_(}7DJaQW#VV#4JCLm!u@QU;x&Pvby0=Ra2?m4)A0ckI! z2O*_FN`wS_;(#~w8TiNrLc;oaNLwMrKti0DFfS&A$!&=6JY)WAAt66xocN45k*-om zNGHOOmj7iUz*9b);lca)@cF}MJ_TL#9;BBbZGSU^pbOv0@8vxHWjUZr&V%yrz4vRU43o?G35Lc zfQ04aJl9UR?(s|bZat(+AzcsY7DyPAyAe_rq=z8=2_j%Kt?Wg)kvTND*{GmQX7EQg}dkN;oVg zi#pLJ)`<(nrQ#~_eDUYvE#fc5yT!-E$Hgbb!{V#r+v11f-^4E?s#F@)<*M6N->Ku& z$!e8atJbTt)rD%4+NQ2oPpU6aU#;G!en|bO`hfa(>ZdhKI zAK~8;I6>iQyIwHUwm%7N=fo5-Lo65T#UR5H6I#sQPwr4@xi`8Yc?d#QB=d?W@+TNoX((Ht`KcIO;^O)wOx9!oh z+CHk&)3%2~+ZSbgmNAogbLRfcgPBid9?pCT+P;Cd{aV&`jHmweKWQ(y$On}^lO87j zLtd02q>)q+C`WRoqtcTcCxOgQzXIuNImhhlbno=FGUW6h@$UFX9CzX!20QWSiJLxb z{V?goYbW|nRKuVC#N#I(Iq~Zg51hE~#9hZ1K~4jdXoU2S<0p^*_4xb8?>qkMHy>|2Za7Y(q95e^^=gj$Yr*?ze}NJ6ek!X2fB$moo$1%#dmYAKNG*(j z?}7e!ayvQ5Z{_##kMIZi!~82ej!sT63i(2zP%PN-3I7vpLZ#3o;8?{cx994DdrVhA zdPMkG_*D2zI3=7B&I;cPKZ*(wW62y|#Sjt3pcp1bz;8UHBjQUUaO_|IC+>%wm=63j z-)xw;Pei@IiThYi5qc!xm4Md*UJrO9;Fw%G;B~y?p8)v{;*w#6WN;SF%2jc-TpQQP^>ARVa0|H&+$Qb< z?jr6o?rPAKw{drIcY}rT4S#|^Bdp`U6xV|e{5$tK_Z4@B1d(78P2z}}WDo-}l00I9 zQPv7tri=8G1!R&;kqu-cxrF~(XyZ={ll+tX+rogbL>Ls73hhFJxK418H-$i9N@x_f z2u@*w-_JiLo-bS=)bsC&7x9mhV?q#r2mdR65C1L~13E8+OMq2~crKAkgE5}T8Mr*I ziL2x4xdu+hEr1C8B5pD0+ZDoc?mX^i+%=$UujlUJHj+N>B`v&x61!xO1Xv%V71&oTNWP~gti^(R^LNn3{6MY1?I$>DlQHrGdTxj~Z84UcX^J?@H{W@BAF%E%Xk}K&fED4zJ{;n zt9S?B!ng97yq<64)qDeA&o}Z-d^2Cm*YQq1kI#h}ppp;f+xZZ_gAe69`7pkVyvJ+E zUwAFpquqQ2-@`}pEbko86bb?LLB{cE3@wfAL3RehQh3&#+!d1f6!j-}{;Zosp zVWY52SRohgdAUE7l3e#ZqBfjE1>bFZ@+_ zTg(;(;qRhKB%(pIibZ0MC<@<-T9FsC#0IfYcwejq4Q&)=#7yC&Xc2S80MOkL!lz=S z@HgQV;dS8?;Vt10pxys0yew9V1;QW23Nc@JO>~Mi!kc2VSS`FEd?XwbelOa^JmFQb zNvsn71Uf%Z3rPT?-$Zs8u` zUSY5B8{uK$*TO@>gTg+rs2%~E>S^H_;eO$H;lG6!grmZX!v6?IgqMWpgu}wK+;7OG zhsez3kBDe0f~Ls_JI29vgHBrmdiiqh2GGe5aEG`Tz&3mj^w`&+zd}KK=|}-7C&OUj zJVRb0$H;N=5Aq$zWhP$;W1>7e+W_=@|k zHWD@-wju2Du$#m7ggq4YWZ3V+{v389?29ldJS1Eho)KOWUKidIJ`%n%{DSbS!*2@T z6aG;6HxXP!XheKOMnpk`EutZ!Gh!%WdBpmNOCqj`xH)1^#DftBBc6?TIpVE|4T`i^0$$PBYz)xEb@5d=TVtaOQI&C9*g=Y>QvMZ(TeEk==A8EXmfORbW5~5dU5pX z=#A0aqpy$tMfC5ZKZ!BMSYv8q+G6@+mc~rPJQ4GL%x5uYVo7X5tTr|;wluabwkvix zc17&E*ln@b#oiM8XzbImM`J&T{XF*DxVSiVoH5QCR~vU>+?{a`#XTPPT-+a&g0etq zQ#L3&l_ScP%Js@il-DS4R_;+gsC-;`MER!jr1I-{K0YcwC0-w26n|y>Gx6Ug6ehS6 z7ALGu*qHEC!ha{cp7581PZPdQ%uj4doJjn6;_ZnKB>q0}SmN=-&lA5*;*-LY5|cWT z29uU0O(tEKbVbrlNp~bYm~=4d*`z-ueVO!qazJuaa!PVZa!>L|^2+4($y<}JP2QD! zXY%98&n5pM`K{y+lK-B3CdHIenbMr%N?DS!CS_B~r71g7ZcVu_Wq-=kDMwTOl=6Pc z-%`Fwr*>Y2UC}&o|k%Y>eZK4_#s>f9SrFu#Amg-~GY1K@6czQ~Dc6v$r=JY-3_ou&_{^#_M)4x&&fCo^kcB2+63+SevmoK*`nR_yy%ltI+ls-&fs9&hR zTz|d(X8oP|xAh*lOISx;oW zm~|qX%TCJHWSg?fv+J^5*~8f@vVWC*PxjN zdtvSsxi{qAmiuPzkH$iy+ql#CuJOA(U0y@pioC6Px91(s`$yh4dDHoY`Q`bm^Ec*S zp1(8y_WTF(pUnTJKvPg(aAU#C1)mhA6fP{>T=-bw_eHTqONt&S`bTj>@ksG+ir+VB zP3uf|n7%J*Dd{U&TC%p}!jdaXeqM4%$%7@2o72o4=6>^H^SJpu^A_{v<{jo+%zMo5 znm;uE-Tbxr2a8|{wZvLdEIP}GWxeGx%T1PhEC(zvSl+Olw4Anz);MdLRc|e@mRf79 zPHT^K*t*QR$NC5B@zR*m_R?2#aEz8zv+irW- z_PXs45*>*ep4e_;=?>+M7Ko%UPpzqP+<|64^w zMOsB}MNLIp#X!aN6^~ZDSs7R9s$5XHvhw`OZI#zoK2iC0<+oM2RST=8s?c!-uH?@A)7Ts3S=4|V2Thg|%?V7fG+Wy-1WqU%qt$lI()$K>xKk7*781FdL z@l9ua=Va&Z&R06W>niE$>sr;dscUD~(_OE1{k==-j_J8b8<_pI%?rsvl^U%Dz>`&*fc-G5*If&ORvU+I6h|8M=@4u}IW1L*?=19byE1ET}$2Cf?T#c=t^`-`_N zzHRZVOZF~#e#vV~-e2}b{K$mqqR zH;?Wc{jbqKjvgO9vrMrpWm)mEhGp($tCnq9cEhp&N$u|8D%%@ss1ztD;wBtg@}@S+#Q2MXPRD^}wnpR=v9F{Z(JD z=2s`J&Rt!-x?}a|>h-IyUw!B5gR76N{$h<{P3oHbHPved)~sH0$(p;?JhA5WHUC&6 zO@vM)P3R|zCMqX7CzecHG;#ODD--Wd{J1uJZT4F8+J?1lYuB&6b?x4@kF7nt_SLng zCZi`Elb222HhFOJ!>Obx{gi2{cB*Y^bZYa|_Ni;8eld0L)FV@erVdXXoqBERt*H;D zKAk!>^}{-0UD&#~bs6wiy>1b%J2EY7&y)TL0GY!GqB%5~1RcOxyTP(^tLZazWIH zmJqH1uMONcE>P?M3;ci={xdHe;sbv0AH48U$V>CjjPsR(oL?^vQEcZbwag!zsPbZ4 z4)AB>+F?(cOcg{({A!nL2^4$I0q>TnLVgGzd-04xeDK`Vo~zVKh#UBLa>k2o96WL_ z9AY9ewX1-aUbt|f7v3sUi@btfV)+%wE4j^nJ<&e%mLHaVUM#S#;Rj#ih4;yAp2_DI zd9eqI3o+K~#s82uf3Hjp<;60vR(a&U1Y8YH0jtI#6xw*SN62jmpAq-0!!T}R+4Jr5M3D^>uJ|?zdj}PA|L% zqFM97yFo@|c&6})H~$z{!{9JtVgwlvXqOo53;$9vex+7&#a?VN!na;*;}BtxYl#sf zz3^4QWiMRV;)S=)qSgm*lc`0jfJ3a+FpgueuPNXBc+o!dnIHBCz1U$r(hol2h4*=} z=kq0A>^P1eq2rk0N6BNnb|aM_Gbl@T7zarZkz*sltSO;`Aka|B%OYebwph#>7!;(W z_kwEjWNv3^MSJu}oMVx_Z*xuE#&&aCj&yf!N7T?=;{~l#RXq(sP0KCry&KovH|Q!` z<#etp1KAh_*+`}G$z;Qd0unt+4oMrbNT>7&f22EG`lMTW1@a-|Bd&OGP?S|^o%E0sqVz)&O4h4-8T-aR9E@<(}SM?4B~ct#%0IaIGR z>}}rM&{6?4GK2Ra9C{`WIN$^h;C$w>mf>xZC=bx z1C-*U97QOMJ>(qJ18WI<5adQJfgK2>>!MA{&PN|5haP>@#j9Md=@TyG8qG;HX)VPd zTN(_G^334!EX?4j8yQ@lJ?FyPkP^fXqY)`#`Q=%4ZmsgH%HTMwGVJoK%HY_C3@*>A zoiMBBc(LPX+)GCz%a5}s%g<&{!ez~zf>`enh}zR0H(2R3WQIy>r7cAGFzo{d?}iBf0hnj8ZwjF$>gkpes6g{q!|J z&mq1E8oFWn9ViRK!8ebY2ptE+8tvFjLL=RqrEel3|3-no4hmc?PM-%f&=RyA1FRh$ zo0!2-QW;#HRUOb0HV+^ixJ4y{c zf1})cH*DJLcJJM^VXxa&vvt9OZMC)A7A)9WBkR0k%9Dk&c!C;c~dyGyct}!yjgzK9Skn(4q7{%ix4jB2$mat zAeLL!5e$wxg281S!QkCq_#k&XmGjwn3?hZ&H~`JhW*=<3Tuy#JKNa%ZK(T%1H9zb! zHNN-#GvE8YM|nePeec~fXZ_w^<$d2VbAsDXshi;TgVZzXQS-6Z%9@YD{dhJ(rJLoJ zYYP;abPHSv$V0nF7IVQ{)uKUeDpv8zxCnl(EGEEAA$t{XEMB>CaY18YVq#%qL2gle zd{J%=n7zffOZWfkSC2$JbKt;X%ji|z-B&KPpm2_Ct*zZU0>TMX74atP#b7t{mj*1#UF#a zShUpw1XHZR=b=#A48@f^ctBoPg>2dG-oDp=gl1P86XYSfk#tGFSutyzW;TyId;^uI zx+@;2k4IhL^|%5J@$m@>Izv9M^J9kNt;y~AO?t7hQ4!;g7Lv^QH*I&ew>!7n(O&=ZvzqK!=>%CTwAdMg1%2%I=;%X)`mFtDJdxVT=I-rM6G z$}SskZXT-$8dT^ZDA=Cntm|8mS2+^?baYcF)#?cK#aXhT)i^ff+^p4E&^mu8%zsd~9eNn&_h{O~)bc@3 z++zY6C%3vgT3R~XTcdVtCa*}v{lmlkc-@Rt(XzBRU>6MS4HlJRaO5w8Lzsc~(p^kLW30zp zH<(zoLt1l*yS=HY-F-=c!jg~@9N-KHt}*?j`TX8OWlDl6Cft;0$p>DrT5xu{f$?JI zN808XXam;H;LwKGQ3B`YZlBrb$&IvP8yJ@UnK%64sK*)HJ@dLBM{%~mHXFc}n>|}F zp5Sc3cp}dhcCeHEIlC|msbaY?_QZ0_u_p${+8JE7p3w>fLlxtlCeWH->j|fi4L_6k z%h{6fkoMvBklMla?ao9elv>T#!%5bgz|1S0 zehp}%bx;|*jq%lE>r2$x60Twi;zIxlX!*yd$kAH@W##mwWmo^$}@a+9warY7v8DpD?&=c{4b+h{35Z z$ibEpvYGUQWCVaBGJtg*NFpNoBgp0#uDa?4B+<>vS}=U&kLYEvk2pBualIDx5uQD=O=-W{QoraKER8EVj(x z*g^*HnK?tpA<~4kGdRo~XHd7QK=asOJ^{Vs7xSDOPw<2jh3#vWo=WN)3pQGYPFtUr=xMrW-fOwFo0 z8Tz79*bJozzzyQ$%x}O04iqQ34$3WBnsN)@D?}&_EkRq=NJ~L^nwv$?2ca(isNz%% zzMUOaM9N{Frgc3FaW<@Lv0vOyevfcZY!k6k8n7JDK%;_4Lk^{Z#fBz6k^xyL+&x!ltO&iVWkhMVbXERDMQ zr=eXZ{k){P2u>@hm?TBG)NQc%dtNdA-;bV$ZzLZVx5EU7^^EVJ!XJ z^vz#lWt6X-wO%~q3LkXP3tjb3FO>gpFSOM|AMynInQ;|(BDaw0D+X;p>VsmWfkFGf z@bz@Lnt7!YG=O5_27!7Uhw;?E#W7+Hgy3BC>;(s0;`1!Uk+vL1 zad>z@t1CFTz>(uvZnZ9NscUPiDRN|HHiV^`Ga|t$m)=n(6{_pE^!9G4?;IZ*URlsz zX&)%Wna=?=rUJjx=AHR)xZ+|TEF##LqhCI35A6?YG&T41G@Dw&`a>&6!s;(_xwbUq zc9$#6Wh;k=SK950a^?jx>^S~DXXDR`z0aUHrWmv*oIx`vB{Fn`%$K)2%m+oeWEk4Q zkb7A(r)iAX%RQgl3p}K2A$}1PXd4No40Gf*6a)vmS_8tvOpeULhRjSyQB7N07-N{a zFiA=D)3!~K8Rpc^!U20_f5FP(p>bT1jRWqrLx07wwFo&n1Hl0r7RA+5)j7cYuicv)A*SIuke_BGAcfod&Wn!CC%@e$RltEKC=-s=i-hO}+DbD{K2<@%1! z^C}Bk+y>XCs?G%?ZG*$Tz2LC&Toq7~1d^42o`JtrrBrIX<51ABF;?8Nro4PrbHlh@ zx;UV#p`l{z1uYA>GgBp(z`Kzis~C3>O!i(Y7} zAALS(n};9kcK*>`L_FeaoBIEl6{F zlE`5@6eTn3KAhcSS*H@cwMn!209ypnHZ@Nz{Po^xntE6saxot&l=|D_p z=~zpBHzAn~`Hr@ZyY!BHMP{ze=FTauUEk7rUTuldCB?v=3KFrf+uD)4u)BL9_6nl} z^cn%*46Ot@Qs+W@=7n}odoc(~FNOiXyrAtoc-wq=d6+{z(7tKZNLX7va6^C@qzG1b z&^@H{2wN}v4|rk#|M{c;gfC|t4r%N^;tk9*qtI1oU&m9Q&pV;P(i77^(W9=AkU~dx zSx2_ZT3QV|Fr+{8#?lniP`UIVf!%`bPU!{UG57$?PAAb!@6GHWXkYONNAJo5@19xW z^#=;kf`QX9*e7i;(Xmz5LOuphK;kNE0)+A~bgt`sGy612fBbQmliZi>luDg|xf3uK zqFv*`(9wiF#h}>N3~zUu59+I}RW%rGGB5RDJ9)~uDV-CrFYXX61AZ{_3fvmPF=!0TCmf6+3^*o0 z&;iDhZsLA}AzS)8sc!1r<c!szccv|fZKpuJ1spiyZJ zxB~;%fUE5P zBrx|VLC3r#c~pS%^NbI;%W4xsg3I#@Op1`;s={iUOQlw-V_j()_~m0Z@Z}BlwUtf% z%cVWU)mWBd%Y)OIu6jGY!G45z12002Eb5XVo0NVvxS)m*vopJ*E5~KCXV)6|m@9(| zDpZ!CQXGTMHeFqj^gOnS;llm}oeP}q$nin3cNw&Y_PGnVg7wN!Fb=#JPy$(AH{jtp z7sveZp3G zy@nAnGF!;=%S<7(Nx+&06A`AfPPBS4Vapg^LHf5pO>L({&o*E5LRn{>A`#&&dtgEz;#rBqs&m9V}l;|mGZsy)jwa^_LqG% z%5?b3_ET+%w5xfPFr?D=)=gKISj)hIqHpzL#|a5(nm~(*y+e)4+SljltFM4VYA$W1S+Mb59farzlI1`+`LiqwaWF9Y6hR(CIK=wZ$>K+*AHaBPb`Jabd z2L@VA&a5n_DJdg1HX|t!e-Gaokgd~Zh23)T)|)Ffxb+^-6WxirE$+^X8#@aID!_Xw z?60WkFZ`y3*-tnRp*N4T!ft3;7qO*YYC1$)WjBGY9Co(zVQ~v8I+hg{F6+o{ED8?W z)zHz=kYAUXQNN47f0LrJy8Ytj=8M}?i?tEb*>}bkEEqHP*VObIVbrnO&<@bk+Pszk zFn|VytXLaex0HR{PIqW{VSR4flET6zZMpRYkzqTnbt+Yz)l#3HUcXBimi`j0E=*>1 zCgrPRr0I7`hly{%Rg*oAVXU>Nd2uCU;u z#%$Kc$_24ueEVkwbs3p;`Owr|Bq%~#oXRS!u2gK2idp-xrJ!kXK1&7bB9U4bfoNVZ zu2O>*RJp;V9DFQ0`?1%8f`aPH)~q&|Q?p)kW|3F3ojVi_b*<^;M(IQror4%I96fDp z)O4ZbGbm~Z2JJCWD4l;|ywKs})Ybu6#J1loD)97i)_Vsb)}Y3xoDhZPE!Vqe!(39bpy_#o)`^O*L94wA(-LFc^c( z#>?PegHYVcQj%ss3d2@{vf-@M#OoR9x%-Vn`lV=Unq$Fr< zY;J3HX>~iK9CED5Nq&^XOr4YYF~Y2pv9MY-r!2Kr{K`wCWNa3WqxJP*_0#%=a=4@W z%n8IB!0De&#f1=LsPIw3S4h(I^RsYazZBCRfb}N=U(x~HI8w-<$ZH>T7>ps1c!<25 z1&NPin$CaSR%gbo;(;9_E^Aq3ldCg4YA|fo2N}CKF^26Y(t^JQa3}?I;`hUPa%?GK= zftf81y^p!;(zJbZm!{wKZ|*|B)#-VfPQIAEKFw-F%J5VN%t_xOW%;1NkOmn#^aln- z%6xg-@AW~k6%0fFt3D{sR1E5-7#P=a9z>qOJoql12Up-c$ll>R$oSdk;c`Bo41LX@ z=u_;X^E}H9QCd&#UOzr#tqg}7jCXl0HwMnt(3NIb%Ki4m;oEN?evyxn>d8~nUqBv^ zCGcdKk46`6am5!Fd@ou#+|b_L-Y`5i?r=bU2bq+1k|(CWz)kc(H7kcanPU8-wkd<6 z#4sqcrWh0@hC!JvG#3L{JwT5I=+Oc_=%v8=a{|m6=%!en|J{;nuNpn{?2@1VoJ0}+ z=uzoM>8oSMphOAuSq7Ad@_0^GD=ejBD`2X-svtDeoB++rgl%R4>Z)GiV^#kvI9}_F-;++lLvY zhT-U^CoLEb>^}x|6XXTd3jSRTesS8j2Hg!i)t?}DNso|z7g_IgN!QUCjOE4JF7ayz zgJL@vw1-m6px6!uh1qRp#=H8g;C@5tpWn}#$IpPV3u_1Pl^zv*T?0I>`PZZ%GW>5> zqS_Vzas^6yVymG#^CxRhP&2ms3fAsCgBl+J+`@W)S5-G_O> z4*=dnRB7H4%lx3Q%H+Yb!Uy%@fmNn?q1~Q3Ps46B^hg+eW5tZ>`6%(0AZPtbtkiki zDGYk?tP;IY8ew4ATRoH@cAV=O6x!+Kl#hSy@A{yqlUUw957qe~k1__-N!zKUFm9tx zV!3<$T8=W!pl$)RJmiHhcc_?__m5X&`iu2ZCHBs;p`wx@TiKAgc&OCV7N`+hxSL4`^@tc9C?O2$qW%hn zQVC(uQTSv7O4B%Ckn@6s{EbS;yd{?TK~X{&o)tc*7Y|6tywGk>oxmsB-weZ;r@!Z; zWd0H>62kDT`o;^T62hRZ9!d~9_Ai4%JDG&c;ve%08UIj1Sl&Jl)%kqB7XwPj zK3PHl2TBOb-RswKln@35pAjU4Mddt^q}lcv*mpNHI9;jfcS!2*8*BK=>F3lcC%?t% zjUduE(!!r)!p6O<;jXX$D@h0~oMV9#{`2?Y>G@>q7U25Z7>%g&_y zJ$xb{%}WL;tQ)J7#^zFEfwg}B744~6E86qD^IJ{EI2K*W)r*sNpsY<+v7W$k&_AA4 zUcd?c!`At*#OGALC+Rw071Zy)wlbwaJbr5{{0mUx4App(`_USi?Dnl=$nOB^Sv5gy zSTmM@-63>+gvH!3=EfrNlM>S7<2Q?xPSWu<8w&0E18vuue^El-EkUEFPy!aX+o7&KGyZSn5;sEyhKm4U4cA8{qSg_xM{VHqc6N#28wFu?WBiQy`S% zJAv|mmDyuhl38|>_tTOzdJQ{uX4%A3)K<#}I{aj2(8 zLhI zYqiOVqPeWG?xOHeJK-a==|Xy5d2VJxj3O~Q20M__atvspr(|G7h{c`2A@*MaCdT&R z;r6x#3)-^t^0IS`MzU#PXXnC29i0nnY$c|0d$Fk;IG79w@w~MHEJ4IAkDy2z@xY^t zRwWH}VNAz+#I}>{mTt=WSqJX$@A%ozI=i|$e+Fj_vj0}Hq@ixH$yHlZSC70L1eCZ# zPXQ+{(bgP=XFP=vfPOqX7WtfX6O^R;t?65;Ti=1)s9WplIXVVk4By4VQh~5%5zkZJ zobAS%!bH+u}*S2s{q~R)NK0 zjiu{GP|ylC0@(F&tqDr8m{?lf(w6RzcN)cnU7w{`3(2YJ9iQ#m^%<{{hV#nhR^n=> z0)0wPtNl;>&OYVpJ1a}i&;q^CF8Iqtxg53#ZVe$P66La#GaQ|lN5M96rSm>Lg*C*w-9Reojk?8+wiRl z9V9#Xz%9LueaWw*qX5SY!@0qW1J6e&aI6AO@O`L7p`fP{V5f}dITU?U+6F6gORXR~ zfg{Ue6N3M;;PWV3Vb0479*%KEB=W7(*BcGbfP-3ABjcmo|EG`pAeCVtQLRvKH0(Wr zXmX|;WA$nSGm9kSx;|_zQeqGA?GrZJM7xV!I$K*iU7f9M9j<1_CB40uIM}ai+2SRm zqe~Vq`!)aZbTUxj zpR}MSQ?U%T6IRNUb~u#kmOcfY;A!Q-gUA|;R2d4+=^U`1Q`ce^Nz$oIu1?I z(1UYG42g(Ij3DWRlwO!9q*#q-+;IbwlLK*ad9}59acrcz=rHB?$N5+0;Q-ba3^c15 z2Ox^CLouPmv*i-cWCsdVdI%h6x>Brrn6x*6~Jf`a9=vkPGIlj#Eqfsgr9zoV=ZxBbALPNSx1&UJ*AG*;m0n{b6-Q4<}qYLP>z? zq|wXI59oE*Z5=BBB3ETCiu)X@yGi~Jx8{phuesQ4zIe^*i_CPV4zvYlkucD~ zVCjHnmwOimb!-Am=m5cL$dPNA(R5r=Kh^)+J=b2_-qS;pjU!IyBA8i7LAdljnIK|C zLqi4dh@BWc!Za|n!eC}wf9O7&K$t&du7UAK7bVt3=)=M?qHIk~rG|z$w>u-Ws4jEa z`5UBS@^X|wTtsN;b+^K@+C%()=~-Q4meapd9D8^g0pRQiM@RnR>kZR>e`ktxQNsG2?iJmp}nVJCM`vcYZ* zpmU;Oj=jk9QOhT1n~XS3?((;YJa!|@27nXwcK{tNGDe?Gb+Yh^8~x3ypI`zh_2KZ+ zgrm&=yBY-WOrP}oxCX^THR$XwY{+x3f}U>hSvO#h;E7|664vWz+&#LV?3>}(;jb^9 z&M#*j0Q08_EIY=hOuv(6=xkJE!1#X&?ucHJT+XT=VZMLxMV$y;{m^Uqu@YA z!Fy&+!>J;VHBC+o~_n;d?Is6C2xiQVSOMlLQu-Q z_TM1HR6yR;=9H@O_V~mQzB?ebw4kIVKVA`3)SOel#B5p7TG!rQms6Xms|~yLhNYKw z7e)ny#6;#6Bm@SjtzD&}p-g3)>~5N@?(FRE>&i^x6i$at%A}4)^fDZ+=sdUu?ejMsq=4?(KX`d%dYHthu7O(V3B@H#&1+ zssX*S9k>?+_!Wp7z4qwsJ3wgUuL!ZV zO4OE)OhVCtu9chRg z*%Ra9MI)JTy{-=|s1}_G8gdhmdsKSJRtY7UM}lJm$A#zXH3_gjY>if}ekApg)vk!$ z!@DD(ooey{l)G1uM#x=)G{nXgkwDfw-}ga_0&NsLvN7nYAR8-PNcAbF^OQ_Ii^77Q zpix-5!-rz<`sBLkL2{4lM=A(MCAzvJ@2gS}C)1@M4|vi>L8IXWEnm@7N)U~^2 zu%KYDX7}!zqN1AJe9Vz-r>VFl+X-X32>O#*Gbo)NzkvGq^I#(Hxu(y$CK$Gf3B)#m z0yqaF^S5y)1&zh%rR^U2B4Nxn z1GaEBD|yDP3EXZNwXk~+vCo>GA`{~hdDU6tWr<>O^=GcEo~28Bva*Ve#^NkCterTf zMIyx=8EkDGWPAXR3>b=3hd{hF#^dkFxH0H~=g<>m;w4wslCiNR(r#D9N3~f%>o+>J6M-o751r0Cyql>7jF9qR;ZYetonZ#U1y?R$NLUjB zV@>c(J>-Y3TDol6()XnP-JRF1zUG=Tv-#ME)R;AH% z{$_fXfqKQb%^n2GGZ*902I?3uP$j41SrnHmIC8iGPNq~gN_P_DE@zU4bg&aCU|Ih$ zwAzlRRBm?I`1ibf@bvsO|IR1g>Dq)cyJ=HpNlE3V{iKb*M%v%+aP&KkS=qVAaCdHi zcN8GMQEr`;mzMhsDwO~6F`&P2%`+GAbPUI<5v@}HS}KI zwj2EnBAierW?4l*mpp^wszf+OgFRj+Rd389A+P6clfsx;LUDK$8naD6s&Q{Kn}uP9 z($M&TNx%g!!oN27aO%T@ueorVDj_dVf3fSu7l8+I34u{=Li^gU+$h=kmA9nR>0cJl z*vhT;Q42n@yc>?Zr`tGv)^AH3YqtNsIG^1RKu;aBc!bwK2EzTU0ciX^a}5H9@~2h- z=&ezY-cq36^5{HooDp~>`vmk%>i^A2MsKaK1I1%C_kj-ejVkn+!JCrxzbBrHQ|7F- z*hS>h$LAR?sQBj{Ay6-~7jfqcvk#$OfU-8r`zCOOT*4g$bWa7FDNxVDm_X>i-4QX* z4hFEslY#GSMOgkyI({L&|A{9s`pI87eL3WTbvHN@af|~`0%G;PZ<1ld10kGcp7rTA zP&dlZ%M2A-=ViU_OPyMK)@9#mZN?lGXKJyrr`i*I%`IC;3W>U^~7EXM$)8e$TFidWdu$wI=u&Gb_YzRT}i(uWz#x z`KH$O)j8c21U`#VKGtCDZ%r&sUz%^M(HZMBFhjKD1 zNN#svMY6oxv7n+0tl_6j$muUo(PW>{&AprG`_qv zK!YHgNMfh->1f-O-9FXUj?4V*#f=6-V{uWF!O*mvNTS5AV=EABCBWs_);F-t;n+6N zx6J{)C9i~{#YcT2Tw4X70IV`4Nqu6GzCh`aULqgx5zhF+tZ5rY?&ZD2^xHiqSQ~-Z z0hqITj1|ihItmyBaNsLR1slhU?5XOif|kC}!LX~&YaX*^^g65i@_2Gh%LNmq$;RT0 zOsl%RNinviWI=_cqu!u()TFR_JRHfVy=7qjJYxV|E~i%XtXe%Ty@Ke^+Y&IK=&jCe zEy!ukFD=ci%yiV&H3SZZY+BK<+}hLCx4d?J)Y^VUO=(hDVL?TjE50bpT4pIwbS^D$ zm)CYh#)OQu*Dpe+$Gg%Pf?gM}2EI;9Kl=#(hyi>Y;2z>0cDvz=;^OXh^1hVi+WiGt z1YDwDn}Lx4mK{VR{H#vODII){UL*J!pCqSYMMc11P)9{WPexrqPFwVF0og3Qsg8;& zsY&eet()Us6S<0%3#>TakK&LE08r=PYbz(3Ljayl+ z{f%AkE)*6{%kP{jPpetgQPppa7z`^fFLxF2F`?EjlVi+QwYJIO?y4(nH5!_tns+Sg z-+g`^eBwSRr?~ElWnxA}CVa1;eX7DW(BX6&nu>~Ab7`ajScj`E=&7QVpiDtT?{LMs z4(Vd@LEYj?)x^8647u7{cinn7#87-AAPqDYL_=8%VB~q1;Aqqa#9*%Gj`g>FjgED- z!>xQpOX=dOswGxuCEqqwJJskI?Ck958g!I3)Ro)n8=?wF8rqh{#6`E&miC)W{iU^S z(Qz@$+8Ra*T{U%;EiILGHDp0qnaOT9m6f3s%;GMa$=I1DSuoLEkyn%wWapoXE6JI~ zUAwKkH{ZwI$wn`C`D4x3FFzN1`#YQaJnR*@2pAjqMeb0T+fw1*n&UG4cwp56O~52D zR_U%I+_p%So$Tq5yvE+%#>QT^BRTG((EQB`MZPgPuAqOb!?CrW{{CY1$jGWyBO|Nr z(JyQA9Zj`S`uIjigcLb=-@0{shlcj9gU_i0S7bVZQQv360ved|I;9o%Ud^shRa=y2 zOk5PSRlVg)&L(>vVD&IL5HCbG# z%wo~=4wVxZB$T%Zahzz_&SZY~4z>|GhfYF%}etyWRZYtq#!ZE8|duHKNDrQ@pw zdz&A1zo@cmO2R_)+JbVsQH)DdB_t)6>C#d)szjln^8VQr!gohJvVi&t);}^~vIi4S z7pOEDto|$5bli}ZT8pLf(1D4GbiW$+4BEatW3v71XVO!%vHNL2{Ifc})FA!%yG&C`xnE^_8a9p>8*PQF*8O7^X5Uyx|GxnDEU~tGr4ZT- zULtEb9q)AP`>m$WGyGviB1K(YSy7`-$;fGOWai{#>a(-;Y3b={kW_wj{i58gvqpv( zG72n~f(%1QM5)ePZc9r`GZ^6KiIkN1q@?(i6lru8)${e2XV5Tj!-{?_9V*aYjOsuz z8-7Bn7W${72767NAL8vb2lSLdpF@KUc-F#9y~|>#{oVv51ofFKlr$E zyYOr3Ww@f~nMwU42XtNnbxLRdSgN^be=sA9bq@53-#li~6C<-7*>Bxai)CpY{cWh} zNN*^$+d?Z9=_*5^5R$L9wf9sSa z*HQ`Kq^AM$02nYW1GxiA{fwz zK?;FBrg(k{kf&*>gC58}S_xIQ!w~`Io(w_iuWI zT>Gzhk9>iDne-_C3a5aPfqv}+q>i+}Nf8Bd1$hF``{{e&2|V8c`F}^Bfg|v|9o|0- z7N7$80nc~C`-AiuB_7%d&rj3*zzcZZM`}I!ffKXd10RqJp8Wq`xxl~jueboU%6lE? zzg-5wCIL_QlD=!(uaz!^A94iN?KsH>Ur7Ot0%T$w*uXzJvlM97^S`8;Wk0up_I}vR z*?HeT#;v9AW8l35IAY-U&#Y$Ue?-mn7GLzK85&frM~#bGMKH*%J~70m3dzfB=CI5E4M#s8v+7 z+S=B(YFqccuC*?0tt)k}wzgHP)~&X+YHe$+LUQtcpP4!Lo*U5mem?IX@9*>Zp}FUr znP;APo@btUX6Bh~rH#IOwEYx6@hReGDf$=6_)jSPecI_FeH!9v=Bu3FGjXyl{e9ZG zws@ld?uk(ue@v#A@#&%T_gd*cu*ZLW;#;VR<;W2lXRd@qZ<@FQHL(yOR1+!WdW761 zV5lZi$Pba~ei1@7kwVS_%x$bda4tix0nATC2-QS_xf~%s7pbTwQpn9B73D=Wk!}|W z!%$6JDQcoj_5V}{S@{?0pdPA;w3h(R0%oNOkeQJ*Esf5?wyX#08~9VEP0W)Cs>HN`axhS4YVN+LL2u+G6{Y26 zmD$--it`H#ig=^5dDh&{}&3ms|&0aICE+mW0kDlQnAeR6Ew8o%Cf$& zVWNa-0Z0N*|0uV%^nTpg9V}qYvv+TI&#Lll+t-HCx<#k#l`5S?FFe!(l|>YyllrGN zU4@=@PoJ7w*fe`iV_|MdWmgCFKdB$8`k<^H9nWAwxZ|0K3${+s@uV`eX!(CyEB{-| zU`}Oe>{Ye_vcQT0T9HLAj_K^Ku}*d;OY6Zucr}jw9#AhRD9jgFa5M)H%Z?m%vd5RQ z$0axt@X!33AG`eoIdo9Nq6+<+l>NMmdeY``^-xJ#X-cGWykZHg;QhZsP;ZPpWR0DtB{&!j*i8s_=7FPoiCVv(@TmI^b&KFb*E&c zRy-{Sp{ZGEIpqcK_hgooV4AQbbFMGHrJ>aN(zxlJxQ=;r zju}3<$Hcglyr%G2!kK_Ng9%1)_#)Z1bUHd0)djP<@NJ*zTQsAv)$eaDoUzFF%Ke?Q z+kAnRhI#j&+qR1-DrgfF4lJzkCq_Jz!ml}H+ zt?_f(0kq7&IrcZa<2imKo-Y2I3C!VW#@Isb`)oFQKxng=Un|AL5L^lAq(=_?jV-+T z?^iP&SLAiF`=0wD{{5h*=T|5Y-RwZxYeQ)v!*D9I!=6T_n3yn6*Q(hrK3&jpF;2=M zdM;*nXfA#0#8&G4ndmdJU%|sw#$UOg=zMAX23I(H8PFGd8fZ+BEfu9SyODJ4AI=Lu8Aee5W6_nOb&D%dfzM47^3e+b;$P^P+= z-%0=Qv;zka`q9K%wiTgHtnh-x#MJ>G`ThGFuD<%fu3JHoOvT0^*WU;_@XOAkaHQLY zaFhgvfP)uBFPvQ9Wm0n{T9E?jCWPK-Kf@Q7_`spm8BC@iOY;AV3DIOd%CXGh(pR1K zLdp6~74N=Vv1xtD3!oF}C_g?`ren3(=Y|<6)C1b&!IA^I3kjnNPdkk*38nm)(lsN~ z3`jg5YeuyIT!UizLG(nVw_D_nmWv25XL!pi5?XPR9xm6JyM12M#z1bMGB-bN@U$m8 zmlkG~PRsYU&hKEVJ_L!S=kl+g+z!UENn#)##s+Q}3lb%c=@%{4!7e2yzl-@-N7K2h~Q% z^j|2&hqR~JciAMmn^y@;ih?_(@Iq>Vs>$iorp zcd)Ab;^KV#MU~{{me3z0EMOV-`ti4g*5j95Ai~!o%L!<`2qCTKsFm`!~;IC(o^7JbYG9CP13UiR{^lGWIrJ)PkI1I3!%(IHLd}58@aPdwG3Pk8=}GoEMGhEwJNg0g)6z0J=;FHuCJ-5 zx}GxCT@ucpv_bnjv|&5yA!*5fpkw>BG4=-QCL2rZ2llhAV+$7|e3LfLJ`RUt1;;pA z8rnHB&RfIbX}GFKg!fL|#iM`|crt;fm)%F*Lk27TGGB~SQ821QJ*U-0G@&UBS*F-U zBo@J4pEJ9pbWUd8tlU{?{-&awImKnO#aC*e@#S=^OPXFWtJs}GZXk}!wO+8DCik9~Db@vq)_jM1qUHpxC^S*I$TgTTg?(Dqy;?Bs_ znI&?9x14M=Ec?eQS)V{MObI$K{(6T(g0#aE=dt&YbE&MCVsaSUP(?TA_+Ab|*d@!m z(}I3~Fs*y}isjX%!LF`gY4!5`>*sH+^ky#3^j2=2zrLrs`lRmVQBK#2?vtvkNp`k; zXh$QuYtV*(k1CN`^ewtk*!4<&PLO_tkk`$dR|n)PLRsz?m{+)*QOgD9h+N6eTnI@% z1AfrUci`T7x*nE2V?M+hKl%uksD0cW2_sIg!FjzGe)JJ*9B${&x1$Tjv^yq-*&p@A z0!w)^Y%;c6-*W6T?`wa&+CP%clK}`?xfv6@i2Q@ucJN!apdGF>}m5uma?j$VXnUaV)HlVi-7tXpnfQ!GW{vM zyeRo=09eIR=6;=3u^AVSoktezX6>(v64+r$OF8DpeI=mi;{A&*e&erv(Us=H3U+5N zpe1GRi8Wk`woGi~POl{j1vqjor20P> zqMz~%`afm-#Ve>^_aZHM(k^5VK_^~?1TnwO#A=!V=EGog1MC^HP6I-DC|qn)<1;wYE<2j=rA z_NrEav3_2UAVWa zwY6*SYV`OIuI5L!uBvLUa894^J!xBCgTJn^ZIds5+%!-aRSRJO=lpT)H8U*4w+PhgEu3wXTU_v8!8F)=ulGT2R~To-M|v4K<5AJyjTs zqUU&YJ<9tW(qz%|Po)(#3}>!A z%wKOae=Qcd(Ly)k2&1eQVWHDuq099J$DT(k>(nzIMO!-tudOYw<8q3~#cVoZ78s$u z2JDN0UG{Y~(h~>%Na8QjI1zsr^s*r_7&iYcd#)V&UNp47lFx{o&6{kzP38*RnDa22 zz89nEGJPwCk{A(>e?~nAjAFO*9QLV22O}ErMaA;KF(YkH|C9r7d*ejFB>DQ0cLUM2X2-aeLRyuJ0Y zZI7G3IpUw?_TXr$Z7sIydxs zhhu3-=FINmLtVdh;!kW1_lepD%1faw4ronjI`)>IJbp31b9^Dc^PD9Y#2xOtW}Er* zC08$&61A7Okf@>r4FgG&b_h<^pU1a7b`k`+(vf!beMefDqQ@U&t&cwrM-2I5NSYRI z=`j!`B{?LFV}ryO{TwrY8e);1ZG1nBF@nbvmw!mwSgu#ZFo+Fc_vW$BEXh0asPO6U z=snvX-^RBc*`^fWaL>UWbLhxHl>8(QX?eL-g12l}-bwwbVzfNG6sSRLDfiTArB`Ddl6Qh5VxC?;Rg1)uAvFRmeX&3>nvkwMbII z1T8MeW9+pMEvv1!jH9=Z8VN+l;4lqQ=YU6=yl3*vrg*IL!Wtv8JB|=TR!#= zJ#l;{e_=rnT7~Luth&$qTb%i~KB8?5zA2Wh(xK*HLkXG`l@e{&k0KK9>g|n0Ky3+H zr_bQe(>@Aeq1I^<9tK=vuS6hw0m%MyB7_2|e(XZXk5)pu#4a8Uz9QM)S(sr*p5`}= zFXw+ATRxu6|BSbr>cC;2t;L&6El`@*plNZtw2P!uzIP0TYjoN*(sNL6WyQc4O z+y(!-X7SY#^-uI5!Wv37#jd5)8pswwD#b;p8IX`aq^p!35|Xx-_H&Z1N?U$(R!C2u zb6xZ48rL$gMVIy=%V6qgpKPIfh@5!b~yB4Y_w)_$d_*uiMgc-)g?V8d7m zE603Kc;3gF|4~m@=D04k`GC$S~E!D@Ur}SRaH5TS&QlP{9NZ&+iD$khNkD8!D{+kDIHrX5(YDh~9; z0;+aqs@gjKM|Qz-bEItiRX)`$XD|Pgy=<2AskrW9?Am)(MO~&UF6zKO%o$#w<_wF% z0^*n*$Os9xh+SabW7m!fe_{NR{G5=mpfjxdeT(4IhL(KaT1pe*>!e+=^NKo7TeNiFY(uY_Gk1m~F&L9q<#bKS z&#!hl3*vn1X3tsc%Sxa6?yTUlH79*-p>NIX{(g(0(B$Z>Sxe67=)Yyx%3%+yt|)iU zUDDjr*Vod#1fwl@4cQ;C8x`cK%ZSt~qQB_&z;{$emE-h3tI^{QovuxEjQlAnrR+_yyQ?t`^rxjKN3KIJAbB;F;QpYP*7uWxQYidQFB_Dc5s(=;4 zL|cXyl9^{g?@ou_`B0C={tkORo-|`EcyOkhXC)K_Dvr0P%G6pWhbFKs8~na~_L`_T zzLw4PMOu}>7n@Bo8YdK_B~a`}6Xv(#%i72<7W5a|Ve}F{&tQIjAy|bGvbqx!<4<6| zBuFdn^i#(lMBmY=pZXr^tvXq&6Ft5Z%+6x2BpI8ii!f_Jd!0**^ug(Yz;t$qe$6fH zmfwXw4onZ6#`XXM%?s07yEeM}%OTc#V)`RI#YyWw;n0_JJF=2vQNA@Tsn+*Ohd%)XPG7w@bqYpf`)j*5vcs?7ETD_W-Gaw8TK+wO{Ur6r`~ zCYCK~s#(@Fbw*!ZUSrY0q`b`d+?n0QLWQmmr|TjP@R-m5iF}}OiQaPnb}rWG2id3 z_M{Z16_liBcpN(`l{I7O}Fs=4w5LKJ%q#)6&tlQ zSl_*zMx%{lo&a;*;|LVMXUtQk5DG=Dy-nPl_-_-6|P+7Ua zGre=Bqs>`cQRDU2O!K3qB|OC`1+eK`_yL1NRa z$}de{b(JCx9;pPjMeI4YJhz#s4{8@X`D}H#08;U!|Jb>EXAKr^pS92YLH*a>$gBT( zJ=?<8nZIC7IL~3Nc{M8!2F;fdauxldj$FhpV^0Z(v~@he3-CFn`CH12IdK7Z{!f@JsOyOyi;( zNJl-p=$Gm{CSru05bFY^WZ*3{dOu=c7YiSb%%2Z>>`<6FBRCr0l4nK2n!6P20X=ypR zsi{j|1vy9fELf^p<3~<#^7xU z8@NmWmtCUFq^8kXY021ZTSPZ7&>H6i-e5+vyUd<50v`kd9|k`pv9=*_A-+GvH_6HX z7M#tCryT9$Bu7jy5E>oxsshgsF1rj^PG@t>M^3*Y@PXg|VekW*q>{+qyu^kr3fRcc z6v9?S^ysNNP%3Qf0kenQb=i}rpN^v#3*PsC@Il~%AdV}!9EYFgv4Zi(E}($mhd~X! z0|WhrMZo7&r|o25N55Fn1WzbdF2TWCn=aiQ^ppz$X?lq&8LKvb$EN@L-zZJiXWsh6 z6Laf(=J!1La1`@1?{;%=B71anS5MDnJw5YAN7ISB=Xl)s$LvA)Upe-^ci@(Qz0y-b zhb__yy3Axu17Q~Vdjq~zc`JPbkFNX2I?VBQna?w~`IA4f?3x<$&k%vu1k0ZZ4b48JRbIu7KVnxAo0*4L-&VeMVCa&jIdLbm?MGHu^7nI@7ckDcrt)Fb#Rij>wj+$s! zWTTGz*}8JNN7|jMTr;~pp4O85rF~tir`2tnH*f1~ELAIOb~pFrb+s(tGGos0D*eFN zp{_YQI-S0*vh14T;+f?cY5p#E!wPqBW0SM8v81lHsIn+EE6`IPT;>KvbRz96QA#cv zLt)La%Yi?!>QuJVwr+;i5XZ1RvF3N#y4d2SCkKNkFI{_f#!1Obg28zuCG&!FmL;xE zJ;cuG>bk6}>)7X?YMciBpp#Q`XncZvLmdIz-g8WoD+`YWYFITbx^t3>1fIsOjY^m@ z*VopSyf$ghz`U-_-ngis`DW+PY^Sq|yUjkAtE{yuxFT&s(!8x8C}CBB$VIy$;_-n^#f=B9bq^C{!+kQevVk-ewC!-5MI_jaG%-oC%Ro%%x-UUh;P z&D206RBx!HBsqjT9~E}&C0QfR+TYjP+qeHfSJwf4#M|H6+V3^rXItAEo8Zb09n?xfPaU*g`@?DGXoKm!SQJcAWtaZ`i zqA7RqO0&E;kNd`-Z-XgA#CCRBUhz2G-Hh(O6!+v+(Ard?1mz$*Ty<^wmkGmzZhldE zb#ON4C$EoB3aoGFUhj2nif^kbt1C!|yX*QHbJ=B!i#N`VPK)U(Z!HL}^w#vwba`ru z>PjTl-F<+Z>D(!47 z$a8syz#`V`Q|+FOvmM^+VOmem6L`bYQ(9|@GrJh(w1lRoV@(%DlT24?eQbgql^EzG z4?VzKE7{I~`HesTj|;@B{3g2FF+ZBZbHr*xlmcvAs4drR@1&KX)s3{mk^bQPd&X-m zqGQ{vH>|WKN_w^LgGRZkTk--Npp@2dt0`;b->a2kghFTGiBs+au9+jaBqbYEsq(n-q>v-ra)5Jf9>`7P#h?Mb zp<3ijz%*Nlb8O97>>_hFJJQv)tM72k;l69P5pU2f_Mw%ch&=a}zx_ibphQQI=%TK& zYqwoX$w<;2NSMsM7N;T~lrCvW2Ybr=xX1jMJ=N8<4ex%mm*^+%)z08GdymYE!zGi71ttg;z7=ym1f zF6?|icWQQ~6W*%LD8JNzckyOmT%|QLlQp29O*71(hy56bid@@#N7qrr4zp3LsiQe0 z4fh%1bnaSx`220>FB^`dm__F8EX{l$Tt#VncrUvJb-98pu&s5bqYC`WY`}FOhtg=V zq(i_m)Md1@>O0e#N=uujO<$ayv$%5F+|tsy(<+u_XD^wS-2rSa6ZWck?mhRgQ~FpUOT>xh|1kdn z*9^6`@^%STd#G0=uAM~EZ(Y9VicNbLjjm_;!jH_}HFNY77+$8WffSD6L?g^XQO8b< zhw0YX5})w(l&7!h^9LH|_4TH9&n&Hv$34&HWAo=PZl?=eS5*6pX|&e;Ne9@Z#S>;_|`X8w);y{>o+%7HJ265 zscfD%*Ia(i`o^UBsf$Lwxy*c{etYMNyR5e*^uYnZp1e>bP*KEud{h1Z{KSOILS8QR^1DgYt{$gZKS}JRNH2>T00BqP)B! z{0*?H+i~=*F{8eI#*DVMl9a5h6#U(W_ZbKasP8Yq$BZ^QsIYCuoDgSL2<*~_+9vFu zSWo*gFqRZM2iUvNtLt%cqCIZUMwy5<;%4V;dsZ(xG?q+A2PWID)+4gOAu-iP?BHpPcCu?gl7TPLP$+Zt;kKYQ-7@Lpr z__fxGAKC)O(W~9e3#^<1rOBBX)-e~R%)_YfWbBM?^DFGA*%n|woy-$UN7o7Ni{uh; zg__I~%4j6Njo!AI|HO#+BBHGh`P5qZl)8#=$q5)L=LR_FX8XZS;7)MXZJxz$qYp~s z0zVxVKjB$dw%3|hl}jSlstHvr{Nd;UZ03ztK7sP&qRb+&6ZnQ5_qV%=yYs+I0Hw&a zhxjrs)_DfX;o2yoWjQ5*qRi{Axq9#b^Bfqw`kKL0&F4;i=-R7KJ@x8qPd}Z?{@}!K z*-QK%w06)@?#smC!GTPiRUCkI{?VaBKf2}Yvny}ib`Cpw&bC``*><++JX^H1DcW_s zg*^)|8utHUmjc$Q<8U-r$s8Sxo%kQFyac%K1FX0(%W$}|vwaCmTvc;h^2^c<9-EO} zo7I}z)@8o`yZci;IeFfMgsGJkjpb3v8TEPDRdETWl@-;&xcb8z$)=@g`>=+7930Z< z7_*fDa<@=tj?H=GVe|Jr>)Bb~yXq?Q3LI*F;0o&J9G@l?oA8n$6fFM9VGnQZ>AB53 z1F2Z^Rgbbm?~%llwY?~%@4^03j|+g7(~@3gJBjtH5G>mcPgQxv+_JK{74$Y|V}1R` zbGO}a-S+E0=_zTLTG~+36I@qczb4Jj5*W-qa9JlZPenNNm@bc`d~Ks0%x;w z^JOt4EobGg-*El*>u&fYK=^{dUOE-n58im)^*4}aVJ-pVy0xe&@JGM_fmP-V(s|$9 z!QM14d*oquX^%ODO}UCZYBVNkw_s~=iB)=$BQgDYH9$@|^4)!J7Q+f`V4+_lKcms`o9HoxFJ1Qn-;*)79 zxvQN1zvx2jVxD&RqpR1)KNNg8Zauq@)tSFEM|#*P`OiIX;>Y{N(GWm7|lvfe%lJG3OgegudJI!j&U zbngs%_NuE6^z^)lGJG3E0;*19yf%c%SM?1WqS ziiw4&r&=<^$5)uQUUN+-{2wA5K0XR(ZRSs{7~Ee;DXf^km|0c~^V?(i;qkqpn3wG_ z`i!yPSTX$6h?rlE)mSk)oe&+$$JmXPLW*YnA$tt10HPS?=PT?n_*N9?`JouWr=EZH z)e@K1ZKbG9?lx}~s8r0Q;h0M!W8MtMycrS0s;n5))hZPwXlDCtG5aE79)rDxc{Sf5DxZS^7JoM@?sb)nnYc1h*4z*Y;^& z)h^a9*KW}6)_$Qqt^HnmTl>3q43#+*_pDX208X=5$W~*vWe+Z*A}Pey`md0e@!*-j2r}ZcqDpF#j7a$02q(*yU!Irya%~ zZZDV5P5W_TdU_)M#>b-*6Ql9BS_SM;-@k}SNQlAT@T727Vg)FG2i4Pj%8F1Jj)P>y z7R3LUB==9E_&63U@k|EPuMpyeh>w0M2 zCkSHy6B8aUh!b%ZlHl>++VqK6&GYzl{geN-qM>==FJZAi&%*pq4F6n9Xp_VLy%o(U zEX>F(C>Z~DQd(LP{`yqFsp|Va%YIA>XH%>I1z_0^^KmOeVK@$wHq&p?wmyN zaV+dn0Fwdr!$Q278HGnp%i=Azg7#V=p$gA(j*BxNIW8y+{?>7ECjdV|5c{8)@QE+O zY*>QFgTsnGg4&JaZ-%GRz!*52iE|e+yK=A@vAdd_9|3As~7Eg-V<5)OV0ZazecM9=B#K&Bg5twHMooR)H zDm+U*F3!CFxS%lj%g4o?0Q>|&?0;gyC%z1`VF?}&4&OtPwh(u?ol0wmC$C^)GkVYJ z?mnxx_bc7qUs>33M(^U&J3CKb+^x&p@9CZ0-qrODCwXz+RsCwrjwT_OX1oQg|ktm==@ds)@=l&rZmVt%%Nu9f?VF`5p1DZzdHarpM;QA2sw@y8hRM z+_>1p!m(L;lr!LPvDNAEnK6a&dFGF6QZg%|lj;f+rX;1sWwLMk9WGv{N11m3Jc-5U zvMgP%GxTTUa^jit+u3_;?9Qu z9iNz*u1&>kGp3wVS=7xpFTd%gEjQh?{AN1Lv)O#me9(H57nasAE@YpPuL@gNqOi|x zD*MdD9=VC9u=e0lem=!u|E8Zp`T6E+=Bpyrv+(zmw_HmtVo|)q#IjLJrF|QEbCFEN zk{Q2<5{=)_rV^D=+6JUrjW>AH;9KjvMxfimb~d5En7j#7aGaH))?CYM&J zSV;jkOx}B)#JC3-ha^T&qNk<`Vy?aRIzEKOx3`;b3yihESY=@ZU8N2Oud?DLfk*;I zCytpn@HOnmLXxPL@Y%_A0W_;ZPX1-gb@$labSePf0MLQH@xSxrzVXZX{d5lwr}Q|L zMW(0JNQYGglzN$Y9lKp5y#c^u{}RbS`E9lwH96CQNfG3F8_2(|5BZrl=<$8y9U?Q4 zhjyQ!QLH7l*5o+&hVdWp4Qzd1-;H!9{KPSW(rHdc+@4F*Wwm1U4o+gkJ6qOwHzgSV z!QFjqJs%kVJEgm^k0*~`PJC$hv71F|XcQKmB85!nV5|GgMR)hz*T?T4`(mG9^Bkkg1dt7QxB1cY`kG5)uP}iYdrVl#eK>`z3p_oJ0JfoUw!!ICO=zHra7^ zUmxKD7Li0@kyPoj%oJ5ZOC(KFSEOvs&qz~jR1g6&JyTk?o@#3<<*C-5+;%tLfQ*G+ zkxmVuC{PonPOYao6#*^QsoZuqyIp~jY6wWBOA0cCmEA6p-XDn*I<#J_9a0p$^cvsr znw7Ve??J#&EfkV@={0taRQirkJfsk!i)08GSKo92MO2Lwr1lcNDK~+M=z58rLs=0; zp?C`uVG%IXQIrCT5J||8K3I~oU@2D#J znI#Jg7im3K&jZ)=8($M(_5AJe$3t|98Vp{oTqP{!tDq@o0WNKiunS_nUWi|^S{BdO zaWY!TePp%BU`fuF!Cs*ZMG9JFCO9J#Cu9IO zcs%4F>NlPJN;?GDzDvzHm%eo){t?Ve6C>;(muJ^N!{qLXmk`OG!K~1WG)E%N9E0{S z(nHM05!6dJf`|K|SP|uG)gw!bW#L+7Crczki&fbw-4rF3D)mn4rYbL3!nZ_;B6nJR z4?7D62(R`QS_9%xhO^b7Z~+gUBY64`Hj@PO){WEqreno%DloPw*~s%wlzix9UHW6c z-8KH)t}^^5WF_TgKwbtrs1FL6igc-fh2;wUk?iOnKkv;q$4-0mO;#oUsO-XA&^j6% zY$M18t|G()LeyZLp)4yw$DN19-#gTNNKpYZ2^xw(1D$;+w9G0snpT&&^ClK!elqq3 z{j#sqFX=psL#oG-N|hjsvr3TYLX40W>H@Vw4 zZ5rsCF72PJPv8#}1bk9XC=Zh8ThqyOKMvavk-sHPDFY>WVdcY;7dixqh3#Ysawoe- z@F5!w-fR|sJ&n9rGL}b*<@4BCi4xyP~D6uXfeGR`X(jDhp_( z!@gV4gi|3X9RXn#F5oF0rSBtDBHhF>=>IbCjQmhvY1b{}NymueWLzr;7AF$0lg*1M zHRXm{LUn_vq@9VB23tvL(|K12e=3WkoGF#S1W?RP535zd4{Ss#u9wxv z$7fpqo3F2BSb(w?D_rQubmG&bMKYoE)E=U9g;Ug~jsJcopJu*pEM9x;wzZRBl5zJT z49c{bHq}jKRb&eQR`Cz}=Qvt`WmXG7Jv*`^j3Y9l36PS9wH0ju z!2t&PM~#4`D~J;|0PUC3ezbMe?hc?yx?DmD48)b_my>pg77jf#!V@Z82@z`BC?l(; zQHe+@M86yTZLlI}pc1ylz~WmmRO3)DK%H2_Evzr~4q<`O9zfgCA^>ljNUe6aS@Jr* zMUb)+@-rYm1A1j^5hxuBJoG1INx;W736_3In#I@#ZGs>gZ35^Cgy;#Cp1n=5=)mBR zqfJ;AZWF9>43#BvKfY0r`HNZsyQ|6+*~&7t+jU_nQRXi}T2vOQTL~LMrGfeZ$)Yp> zK`kvAC(tJPfK=+L=Ald_jDQgFvdk3@QRajJ5+ekl<8t(mNGB%Is2T@L?~wl&+Q@Ks z)Y3sovD87*WYPuM=jQ`xCX^mpP?p^l2AcAgUE#2WRP6-HMo2)|T&w2{s~hScsuZ?% zB2-gI7gb$3ft40oD{w1Xnd@^bwZ)TF14Usd#KN+R9)R@AoaYY#Hr^e zctHIUyi(5#L9%v9O^_T~eI40r2#w|zcu)U68cTK2{ zh*Fb#UT(WvsI%pIApH|Ms9X@rs|AWwpI_A*V2~+AD@(>p`ZttM%YrA{PBonLguG7h ziiGT#ND!`}NQGntIlBw2VA`z7_{`RLx%C?9@w&ON#YQ=X}1p zwP9*S#ncAuQsvrBe1P4KQNni9V%Z8lumS&|xf&o=2nf-)V48?RCfcif z89PnvQq~xq(CS~cwwW(GcyLl2o!jtg^V(Gug?%rVqS1bYzo?ZN3m-5wr?Idd`@OV} zac|sac&TYz2l$Wk`~2`6Q_Pjf&0}vo6 zxsm|d*R17#*tF>!qUcJ_VaN@JGZ%~!Y2DlRN6o`s#kTzj53;{KOU zp?T!bw9TH{cSW(Pa-Xl9{?y(e-Y7rIeIBuQ2>XXX!9}3pDgFwbvOpyy?+Pdp$F5|C zOKWRtadL86VoDbGazF0hOis#(OHI?gx|eTAj!%foojR*NB_TdGue1*LpJBfcKN+_; z-Y+EQD@wl}7W?GRPKtS*_pk%}6WXW8kc~Wo!-d_vYQ186LmK8$Qu3msi!$6*#k?o2 zEG043fhibF5EWMe!#F4&)zig#e04bbsK0nqqn^Hg)iSX%{5Lp-;~f428o%INS+N6~ zR1e#-N?vyQGjpRIU5@CcTK1MZJvXy9CB8PxUk!3_=i)ZDV&XBP+5wG&R$aPk6>@xc zB8jcVdh_y=DZ`IMe$bv!ym1mvw4jslr?MYl1wBd!`_gEdNo@nCogZ=+2&;C_ zimi>#&(4`L#XY60vAAYtp6AsWMQIsn>8YvN`3=>)rhLkDDxnuAs&Q**4DIQYyW9de zXhLjnr(KSi`eN17-D&BCZ6&F3NeQtz(OGdbeqC9b+Kc=~C9f z9$^QqQn?gMh#Oi_L;vDTRqisR_yHs8!yw%)%thH>M<^PI=Zx zP{POK;uB)hi~Yg)__$acuHJB~MGNvC;q%!$x<|FG${GbA@sU+)SGD(K^nBO5c0Qkf z;J}I%o5>Dx?W;)f+v8F|!e3P>j?7={jY#n|q;Z_XbZ$%4HX6(8H=lvMFM4cH! z=t&5rR?=pjSyg&&ZaV(>hTP1IDN`~sAxx%yIal{ESpL@;q z=U-zD;F{jpp@=DPBI!=BT@7Y?4PCAEhG zr{1Fx{)u{z(P9jonJQpn@t&gI z>ajX#G5DH~w=msSsu`2gg&_(x3#M=q%rZgg9&kZ)725I&r?%2g#@`I;_8|QZ{7Ifj zilf*YPuQw}O=6dFpzkKYZ30Js71C_R8TcvP8dJs^c|t zIr6gVcn@%s#3cPhZ4)@4x*vWU3WY5Wy>*1%sAR|v>_b^B#?vYZDizYq5v|G-~*Sb-xdIC}kUN&>I1KKQzUX$?+1KaYDE+P7#h>0BVTNQPlkt;kL&w0qhRI zHiPRSq&gA&WSB34wc^5jS|KOGd^}XMD6@^&*?A)PaO^xtu^+HhFG&k_;A*veqWLy`>nQ>Ez^F- zma`RXC0m90u#?ytwwA49>v0C;2JF9|#Wu1{Y=CV>`__PyM7L-^Wn0-GZff1G&1OT| zyKDy=W;@v~b~4+I{k9`GiD@s}$M&;R*a3DbI}PV_pMhPt2iaHHS?sIqY<3Pi#Li{s z;jZb!*qeJH`x?85U5u3z-_TmML3RoICi@oqHoFwNcE77_XWzq_sF$-V*!Q&|_$aoq zAF?aiRVeix+Lfp;?V5?=-pQ`UEwLTAyNzk@;jFD)>^f~XyPn;^Ze%yHo3%aYQQxYa z%zlLX0B>h^usdP1yR^^PUF>f5V|EYw3A=)Vs_7Dz>ctq=AkFv+Kg}AHy2>T^=9rtRB;B$MDJ*DkuPeUh9(N4vP?OFC5`xSd$ zJB_`d9bmu4uHzTkZ`tqI@7YW2W%i18m3F#zC)%A4VLg_zKj8lK*R*Bqb?r~MXY-Hj z4efkwIeQa6p|{yP+6wIq_9yM2_7$9Kb0+&U`wROkdk=e<-)A4N582<@N9-T$pEzr6 z75f*)QvYV3Xsfl8wD+;c`2qGR`wTPQ$Fw!L(fH4794Bi{aGHe1m2%wR4xE|ff=48p z$M9H=xoe()v&WKfGFS?{D``9(cc5nCOw(+f1eJ?Z|MGY~PMay@MZB1o;PlK=UdGFL z1)qj9?kcg`bOx{HGcoq8!3hhs80Px8A17A^c^y{q*7F8F8|UWE<&C_FH}e*FZ`yF) zMhEZ2itsMp&FAw4IAH?!Z)-omiKUD9V!i}tjV|TO_;Q?Gw34sltNBTM4PVRG@%6l) zZ{QpGCO*J7^DTTUALQHkc0R;+@L|4_@8T!(-Fy!p;iG&n-^cg!Q}_XXDnE^%&d=ay z@`L;<{4D-eel|acAL8fo^Z5DLb$$WAkbezpc`xQ)=ilI$@Ne>O@o)1>`FHqt`SLjQ^ZJ$bZ2f;t%sj_@n$Wj!S^~FZmPvN&Xannm@yz<|;pD)}F@?Xu-{hEu z;qUN2@pt*3`Cs^7`Fs3t{C)lb|B(Nkf5iX6|H=QwKj#1DpYZ?iPx)v3C_l!>_&7KD z1e^&>=en*NxoMBDwNLa|Jx-6;6ZAwqNl(^OaOi6q4%o`jGxaPz zThGyR^(lIup05|^g?f=*te0rIK24{ z`}Kex)a&3ct%v6=5oa#tYqwy9z%^R2cD44XcCHq$H^7sbqRrK3>vQzEdZXT?H|s5W ztKNon`*-0CfM&fN-iSD?(qE60%Nq3#y;Gm3cj?{we0_o5qc7BZwa2u_alhj|+E285 zwVSk?wFhx>08Z`F7wL<&>-8mipT1OIrZ3l5=qvSA`fB|oeT}|WU#G9v`}Ga_Mtze$ zpl{Z<=v(zceVe{rAJTW|!}?BrmwvLoTi>IP=%e~xeV@KxKSe*FpQ@jxpRS*wpQ#_z zzoMU|e^oylXKqJp=V^y<^2uTS9PL8wtNJ1RT%2d{7ws(V5&b;HT@#} zV(o10=eYIm>-snJOZ0E*-_pOWougff@!j9GkF@*HN4XgOrvj`#|CaVmoP*G>U5g&h zCE6AGrP^iM_q6Nu?`Yq}`tu|DclGb-m+6=5SLol@f1v+Rzf!+SzgoXWzgE9azh1vV zzfr$QzgfRUzg7Q{ew%)~eusXiewTi?{$u?f{U`dp`hEKS`UCn;^`GfK*B{h>p+BTQ ztUsbZsz0Vbt{>5VsXw7VsXwJZtv{nbt3RjzN`GE|LI1V>8~sK7xBBn&-|H{wFYB-9 zf6!mm?$=+_U)TSrzoEaWzooyezoY+2e^>vr{ulkP`g{7{^!N1-^bhsF>mTX=(Eq9b zOaEB^xBiL#AN^DPGySN3Odr#6<%~XoLyeii4c#ydhv76_MwAh4#2B$goDpv%7>P!b zk!++GsYaTSZe$plMwXFn8x_VhW4cjkR2egj zYGbD1HfjuyQEPY&pW!zGM$o7;W*PNHgE8BfW6U)gjV7bnXfaxiHly9>FglHSMwiiT z%r_PoJ;p+#*H~mMHkKHD#!_RMvD{c;tTa{`tBsS4HO5+Fow45NH#Qg>jZMaYvDw&S zY&8arZN_$E$k<^F8#|3%#>vKRV~;Unj2e55ea3#{6ytz#s&Sfex^aearg6~tigA|l zRpV^q9OIC2u5q4mzH!*NK)XnL6}{E3X@AsS(%#hmpuMKOti7VWuDxMgXnf7M$hg?} zy73L;662fBrVYCX_71p4hX-rgn%d;E*{_~%_4KHxm!9sLmR9-QB)*ZZMSZJuO+oeD zq@D^-lgHJxqkrS>ox`rCom+Me4{VQZ**Lg+EF0C>mS5F>N6{Dv2VexyjY0vTu{wt9U`ayC&G~><~G{ zc7&-S5O^HjF;WVX(EBZEVm24V@d z+uP)>b=SmpMJ5McLAAT4*6*C(zj1VAz&Sr8281>{dt_*jE!6AN+lIGDu3D8~S{3)L zNDiDT*R!Qm~=rJ`V*%O$zX!{kOU-!wR|dtlGt9_R8c zyZiSJ#I6iSN|CjDohwC@`c(354?0%~bTO+UG^i;MY)%x)Roh%Wv~%Oof~HRS?e$sT z&c)Sx_PD*VQmE=(h)BH0iR3=N_;7a6!ynrrWF_8{YP;k7_ON%*yf=4m_$x6VP0g~c_Hc9(zZ0& zK3XNBgoP0aCADpW=U~Fp6UBLLNZKV^38C^9aS1C=kgi>(OAME*fK8huR}tv!x2bSb zyHt_pfO^)bXRUhr=m|TkDqEBIhR7|utI{>qN%*E_^;CG8YF+(Gz5A7&-5A!^n2jMx zIyVVrat?&5T}@lFBCbVc(xQ;IDB@atF#{n4ie5!WbF*tezxP5lSN31RpT20q2l3V?!aqAhg9fEJKHFp-h_B~`m^RQUqVJt8!A&m<;2HFd@a znc5K>xL;%mQ`_tuwdrY_qPs2N7!A`Yr36!}xNWoOR`j;D$B#zPIjV|eRGHdQVQNRE zsof{Z+!rP@dY@I-ocpAyJtZ6|%e&p@JjHHmm0a8FoCgHDm;({I1XJ6hOl^y0T9v8w zTi?!ps;4!v{Q`k{51G;$W%Jrag#2g@*gtCQAHL8BRbugx)E{Od6pJby2BrFKkFld~ zY8EjG{g!C#fHLdYejyPVp{l?7ka8CvG5sWb@fzDNNXiL-H!k1+S1F!x|D#J$Hk zL_(;Im6cw-i|QfXiF>O4Dq_!2|DLU(2pCL3s?{nACW4WqT5*EZU}D(*5*8KNBC#Y4 z*h@H=7&f&w*ycL%kr=j^;@deW9tndHC=x=p(uSc$NHiFytfWl7%?8javq;z(flKN^ zTdmAz^h9x@@D)$(lHr6bBH9XXjqLt=Yh)|st&zP|Z;dpiUblMIDY!Z-UXG!>HL{KO zx>dZ}!XvtkZf}jSX?V(EfVW1rO19zBdjpst#1oX^dS%~S`oKb z4O_gbb@h4_T^@^W1@BRGdK8@=MVCj>$q=6B-dv`P3Gt}d;ZOK0P(E&Un1-B4-0 z89aq#@f4E9Q&5bjC<#1;WbqV|^LXW8+T-;LO~6xdj;D|$o`N$xMakePB#5WX-|Lt8 zd;P)a0|UEvl7+65Z<~g9?uddTs9L;??As|`Vn(*^9*|+tn|F@x7J<>52lpztJ%jrt z+#a~DhUM$P;Fhf;5^#7>A(VLxkM7tl@(?eS2fe5~=vC$+-zX0OM|lW1nTL3lc?dv} z2fa~2cxq(b#ZyY4#^;L$R|0-(oZv>j?};TY)O(yjtv&=EcTh^$9h4Gw2c?AFK`CK( zP~vw7rG(u)`dbdRA@g&N^-C$fKxVLST!&-uz6%1MoW^fT?4xZ zcWzo2@enat5kcz)P9E(a5)sKNA{>&map%y^VH-%=!2XS>#OqET9oRE6xN}&hQ-R@h znf4G{@(e3oB!YtDf+OQgp}QLfcJCS7GQ94DuY!yu(bu)*Mp|0yMps$#CHh)1f+O*o zuxDV$VE8kBU}$J?7kp+SGFEaR-V^2frk(qS#do|#uJ}r}zC!V-GC%SS!wa`bKuKC^ zTH<&0?;aSYlB(_>8c7geyM;BtXMCs_MWpx&MJCec_JI*AZIb#90i;HxuHLtGaAY7Y z5{C>-pM*(gOj(64h{_s?ARH~I3*Mk%9^-yx7D_1Lims2y*PEj;`x5EX~N*V$g}sncL5$w*QgMNg5A zOi6oCbqZ^00?r+S!!)0?XJF&b;Y}2a>&I1BO%4M+YFO#1mBT$eWfu{DH}*K;iBWN)rLrLv(<~~EI27q58e&s?3f_FGN9j{NN*{WZn}#=F z-f6pvmu2BoJxX6N;83x zPm;Z_rQJ!+*P#K)YDWEi`BwbYaR`f_0627H6`{V!}^r*u1slt&1W#ChMdlf!4T=YrD1?W-q zxmCP$WqEvR@ZeL!NT1uvUlpbre)`-hKQ(;yso|+lIw*kOg17il$|7BCpijjs8|70r z%qJZY;8W2r9Vi}Ot@SMjS3c<&0UlL8(y;+~D7pF-yx-zq4uX8rVE{fAo`5A!Rjyu@ zUJa&v9u+QKhaR7F-Qj8BQ}S2CaG!L2Azsm?24_CEf|F(x`78OUVY^Q{j375vnQAO} zi(f^DbnyYsqF3p&N6}rYzH6;-#h-L6L0+mnrQ-+kQu#>-6ZF=KSNuuG6v{*4l`c%= zukfoj$R`~|C?{3U(uD{(g-^OH@vZn)?ggK8_#j_Jhji?KF2%P`m7jEAfer;HU82ZO z#Y>kb@LT1s5u zw}{3~rW2$gohSlAsbHI;#q@+NwKsn2&Yj!)H|*StX4}H4yw0^X&2gCV+AuJ*b6+^p z;}?@$XtS+JE{R;ZaBGzdw^lh8Yh`)6Yn3Chwl=5;_Jgn*+q>KLUT1r+Rqq}fJiS}+ z^lrh^d#!MQA^+-){d)$~N5K9Ow0*dx15ARsrE4tN&Na1m!T5;Dg@n*NpokRwdzz)N zJ<2KKX$k5}dRyW)VESi!i)be{rPx1~jO^aIeL$=xXV}G;h*jk})*y<1SfQ<9S2D*c zZHATBO!I5A@jDmmh8b4+pM~GEv4)&sEjHby`Z4}v+9#|Nd;7Yu+L~dlHDIt3`bGTy zp8Wy8uVDo+!`k0B@%s+@3x3~Ybl1{{Sli36t```vcGtjdXfE!;Z!C|+Zvs!jZ#qxM zZzgW%#qDdn5WmIPg~zZ$w+z1(yc)kXxYrm*c5zTkt8(#M$LVghxqL2un|Kp`TX`#f zJ2-Zc(h6MsF5ye?yPPk_?@GP_zwiDZ>fSv*s_N<+KbM(v&YYP!Gnq^-gd|Kt2)7VI zKtu`{5V?tnNEHzkks=}@HxZHABois+u}CSkNO_cphf+nP6e*rs;@oB(k#b*IO z6F&xM!R9&P5O)Yxj!T0;_?Tctvy$FKCH-DjX5E2{m~HPyA%jn$Ih?>GBZ zzgAOI(@@h~6R2&f+JQl^?`c1UTbJl&LI1C2tX z*r+r{H8nOhH8nS>&GpUA&4FeCDIB3Z_NC zwD1=qg+<_sU^SNgVu7!z(9~!wuavQ{Qo~}(NOO}}G@HzdIYi2r&PswLN@hux98wqg zrYy;dB3c?PX0_gW$>!6lHM7I#Yzp;-R=cmdeUU#zU5c{C@9bWcaQv>zs2WrlRS@^T zbS?#la{?l<(?S?MX5v&XY5p@y7ohzJFFv#Q1#ZrZFTD5yRsqYnaWetCCkA2v!D#IB zS%*+mX=bbnCV9Y_5XS#{H4?zz0T72gLF$f)7muoHTJX;FO0R1e`jO{9q{xAlubTu~F8t%p)ir%XYw3)LwCZ z#16n=;yl1nGTnK+1N$RU5%%@KQ}}6sLriF+*tbAo=b$%1SnNJRSO_*^#r|THR0z)C zMC*ANyGfB+$TmE?I5A{@$gYsj;Ucnjv9c5W^GyqoOXN6YGoG!+|4#Og z62RJ3YIj(xiZ)2?PC_boZjCa+EyaWL&_h<^S&xU}-HvAgr@7RS^&y*G?vU*vyF(6y z91b}ik{41G;tTPk3ytJB*W1SPCjL=$F5GbHa^oR;&@I-$iH0`sO2rd&9|n939`Nj% zj%PL=>;!Tx#{>C6fAI+Q4_IBj6nXv?WTq?kd+rZdgS{K37j_+IW1mh1Ofu`A`|~}q zD<=gT=Eq^@%rt&B_Jh2@zr;;LFP(+`6TA35+*8>5@fG(hc3${!qbK#i&trE)BsY)s zt}md^oXpL~4uvPV7g>L}0DN@iJV=xJtC1*u;z?#a*)T$^hHMizJ;BW}@I>77W!xn3 zA=q=&uLDmrz!PyZnQ`+lxcL&hMz9lvdmOt!%D5*OPcyKG!@)htID3k5_B8faOyFiR z{+>ZU{y0u0CoZ35T+Zfx7UDF_#1Jx3LF3r^<4wq;Ehb726_NlmkC`DML9;aE$D8Dk zpc&E`W7R3Ge+|7v9k+3=7P!OGidl;$<672bjUG|{}1 z#%Dg1YKRY~eK33k)ezr>cDnFUR6~3;_PRX7$51`-9_}T_)u3?>{_)09c1LqnkA=C{ zJL;WUi%E0-c!LG^hDmbX$!N|aH*9ZV>8@^r=B;GZWF{Pli@C zfoWBXm{zqCEv5oH9!RSyVOrG<(yFj84q6pM#9M;BNf6|y?I$m-}abf{kZHSBOo=8IW<-5^bh_mL*W zmy#yMmy!0wm!lph@DcR3L`pnD83GD?+LyhH9L!MA{`2wZJW z!A!L*P_Ns+Ln$mnDUepVl;!^yEdM)L{%^AUm$LkiWci=O^8YGnmHaW%DzOiZBr=Dz zO5~i%B9F96xKmjal2*xIWMy%Ul|?aWmHc&97Nw+B^0!E<L)cV>(Ib|%yQxt+FCKgr@ge*1jA^3g2uWd zh-rP=;d^a!bz}XM{(~V~{@bKuJqmNumPE!{U`tXq+6I%cUL|;&5UpWbv0%RNZ!Ke{ z-d-CtHd8Pb`>Y^_e-_#n38OI>*~YC+e9A^`t8Q%Z`hUw*~lx7u1UqEp)1xK_jLS(;&Nn}V2@ z)fU$lqFlDMHMVUOvV&<;oFYL&?}a$CGUzO~!VHU`zPckhjCyq+=^&rbL?( zG`1>Rvu^B)eNBsTF)Y!ZXphE2rp2u#RTi(dLO1plyYX*61eaM*pq8DaW=1>rOoTtcPp~tuC$p9J5;SR^G{{H5c3#TN|wvcp7cV zhLLQ^dTJ;+Rw9lgt)-xDwMCJuEkXAKEZLD`8-iyPJ+_IqM;$r9X3}GuXIsi_uiIAB zKN$%>r^mM5=^584pg6lB9O%Q4@vEGQpneV{S=8pj3$d)I+>?AW7jW^~q; z8HVuSE8lV1QA)M%%)$+BjpjzDfF>DE9D!SKLKyHqsBx<2Il#v;$`t1eC;9^CLUSYI zIg90Jt8=GwpYsst$ILV6TrHfL!Pzh)f;pu^!DY*`Z3T7;Sh{T^T$2LvaL;7eJYb6% zhS>|-Qp^b@Q8NJ~bCH|5G1@TFSzg<@3 z4JWL+(Ay0G%?50lmC^_Yw%D2gtS_*o)+k{8fUUGTfl(SqtP~5;wyBLe4bzboh;J|d ztQ9j%!1nVip#$W?b(b*Ly2W}1n1_$IZnka(md5{D-DQ0jSYO^^-J=!)OA=6P*0%}c zQ47{pz%)k7M0^Jj%j?#4%oS(kshGiozvtjD9<}Tf(qW$jjuxww|0BW?>jEq44DU-W z>pYe6KOHg5vCap!4z#%{)$Jx=vsKFfJHTeDl>cqOW~h|^MTlh@lcxosO;suXYmnlj zj8+S53d6F2;c!W~UIsRSFu?{4^K8II02{?zG5>0Xjkp4a`A=Ypz)~27+Ofihy#A4| zx5it0A--2ZOR$jqf6PT%J#ZHV_(q=L5Au$9Eyt064R)T7WH2c}B`_i|&t|ef-l$cm z;1yWkK)gE9LJ}1XS_z{?gLc)@2egsEiYy)q5s0@WTf%`MEj5?SMbKTXZlxA{ zHV5XZ`ARWi@R!5KN zsf>oWEE2;8anmhBm`tXDM)O~cQ<>@rZ2~8OqW=d3RcwHdC}4d|vC| zt(vK>glj+XHH*nY^*8>KS$wHFk_)kQaq< zZ$kT4J_jv9T&t8?P8!%{iE@-6?of6p2Z6=Hb-QIRVMt>$nMZ{JMgXg?kIJ=)>u7}pFejnOC;yD`?XspL_;bvhbyRQqCiqP$lOwSMY4MG4A__VRE*K z#*f~J`KW0dFpRVEVUZ+a0(jYGT4P|F=(JMM{PIo{mCKuOeUrj+Z=lrPW${I#B#$W) zT*n}mb>bdiNkTqub{$Qa2swv@5;h9OsLKXgqsh(u`Elw?1uzftyUye?uvI3?xd-Wv z5p#h(0@qO@l^SLaGQG5zmlk(XH3M z;F976k~$BtSbU+RGH{vJ@<;>a;*y;Bl0><5i77a7jCgTL0$-qqFgNqmYSCs)vy5CG z!6~rB{TOkr*=Hu6$3WIt&p^G8X$-Ycofjb$SR!2YF*Xq)j+iM&$)Fu&v4B(RB|z&B z+5v{00Je`|_XFF*Fa_8yhM~l9wh)CFA$%p~;fywFM=mqYfd}>wu=RW_!}86m`Q5-? z0WFJHfuTi-`_1XVP}BUo{F}fcq1E_c2+PNfDX#;&2c?mPQ+0?_mw77A@Dus*!dK?W zI2BLi{|nee=z1bQ8Ls0nKd;9^Cm>TF=7PL#M(Ls-GN&+36EVZS2H*c!Ih*4-N~6Ev z6qC&0lv{~dqL{x4z{0tiaD5Y)3ty~A)?AqTr~D2Q=8O2)3oH$%h&3`=5^k{OOn!Vt z2ubGvcQCjU5MPKHzK6m8VHAzwlL0XdGyG=^{sxfeIELTLa2vy)Wsp=Kfm_SqM+|Od z@DG3-XJ`0g2K5-ifb*QpC{YYXGDyQX+Bt)iI%oL#HKY6&g98|x!{BT{!~`gCBLI1( z2nd{mQT~^~9t`eg(8S;i431^6FCYd>hQG?-N(P@`@XrhmVz57h34lCj0u;Dm431{- zFAUCSFbfd3N;CX^1``?V&7g3gU>OznZchk_)7+V!r%uCZewsHgHJK|FoTl-F+?-`Lk71q_$VO6 zDxJz<0)u~K@E!)|GML8T76y|T91o}~$^ti-QGUVTEC%N>IFrGj0%BzbgH}d)n&G-E z4P=y;7#zdklMF5bLOn9iVHhE9f?8N8dpWCl|ibOG{)EKO&WKQSmU z7|NhtGD{i$E`!S$?80C#K)jkVJcYq248F|ZD-5mzL@6>noWTbfT+ZM-4C*=R%5Xil zXBa+|!5Iw3Fqp~ULk#w0@GS2M&EQ=O zj$`m`2481z7lZ2<+{obLfcOFO_@lK%J2O@*pTVvIA%_+l3Qs-z?28M9 z654kv%$|dNU_v1s!X(UNa3O=s7+gu)JcZ1c7SCQFV78(u^)&iyq8vq9i zn*aw1TL1?O+X3$ub^;C&_5j`^>|e5EK#K4w-~hqD)jJ42L z$BCRWMO>)Nz!`!HPF0K)Jvb>b1!jmiSxmzjhLe>!;xuKUI1{H1F2u=#={O~D9ZvMy zf>Zl;<7B==IDIb%C+rpC481b37ANB=_!J+B6YhH96uTifscsxjqnm~mKyz`b+;UU8 zG!3W4ty5N+Hk!7W-Z$+w?Kd4V9W~{c@=Rq|_*D#8Yv!=f%PFUsBh4OjFLR1{h&jzX z&OF&X%{ZQLUXaX%v>vRk|H^! zNXaAhl2W8;(hw;PaGW$5aHcd@N|zQ&%K=wQ>!gj+7U_NYeQCF}AMlWLRLYU^q(Z4! zPLs-{S~(3GuOd6;NZBLzl2ha%pp26z%hTkUa*jM#UMMe@)8*CjI{AHhqr3%hx4d6I zBp-!qo?Ivw%Vlz{!YPX4R3a6R(o0EEhA3&W3&W14ul4j8Y+Cyz52LkT z@@885CC{g|U-D*J`z2@7+Ald9GRNceO@fFA(88dVL6t!pgCPt$WLoppRbB@eEw2TP z!TT`J#mZ{{yUD8oJ@P8RI5`V29`DFJTR((bA6N`dK!IWOAAz$!Y3$HX14R!F8m;k? zu(kaA)--tgm>4`zh8XJ&to@pX6q8 zvv3|Xor6y&VlTq^)-U7C?N_;#+%K>K;SK04bk5*!aOULiaK7Xpa9ZnoIL&q&G@TD{ zYUfALeLlt+oBMIY1Ff(*#C?u)H>qXv4roR6uk8be_?I!?L;un){oh8phTXOaE6>P% zziyWg82+f7$Ul`7{Zm=dzY$KN@*+Ex82)XejdT>FNy`m8 zR`@b{g+Vv$lHah`>h4m#VQ)0-Mm(|+uPhkuy^Q$YH~zQlbak0-S63MR3XFKp8~=p{ zy~wa%Ho_U@qfR&MPZ{ydGW=tfg7}w?8unv`JzM`bC;eN0dU*up!YF6yq!CZ95&n!} z&ok^seM$v}{omvRb3%+y%jf@h@tN}U@Ob}W{F-tM`!plI&kg<#8}>Vw%a6*h$&e2* zC@&r2GtJb~6E7S8MFwA!4ZBfJrg4VdC|@z#;4j#ojP@lSFzCT{wYrl(#<#Lok55@| zxWBI3p}gq%GTI^9rEdQ>@kvH}(mKOG^^_z}8w~r~hJB;{FGk+ppEy@{$J;YYS8mqr zieUIRTI^eQ@C*^V*DffzzC*0r8LO}@k0_?G@d|H^;p zU;YpMoBy4E9%D9U12`U|HjmMoX3yzM1nITTpBt9Or^`J#rVnJKY3F0sl z#%XmGXd33M1Zjmfv2q5qy^MzPQja0*5i9(thY)rU!V2m>U_DqY!{EA89Ryk`XagB74Ym|~ zMHAG)pd~R{61YmX0;=(#^<}h3gk6enR|2+=2x1>bn+TiDVgnq-IQRk_ASUbQ%4E=z z8SOLB5;(zDs!&Q%jE2zyr6?#V*6m2C8S%xj_}rj*@WohARnU?djRVa|Im8zjLA;A- z79VJvtjjXhu!uB%!4N{M{_=Dss zN0G)386~TH%KV`XC{#+@(H{!R4n{-kP_`gG&axS_w;2u6sl3SvQki8vXqn(8f$@?K zS|Psw2>6a5h}{`&K4>}WEYR?X-56~eXosjgl}Ri<58|7RbZ1JtLBlQ$@QjwmYCBaz z>dIiEVZN4El3+`KE+;7QjE25I@gQungnCoLS=boRP=1yK={#t9`Jn|fe^JuZpg;OP zgdSaxMx%YAWWnFP#GhOY+90_QG=vw2Guma)666BV@|O0$7pb|9Kp^Jc5XT93$v5b2x*iw2J{J_XNmVv{E#G3N|o1< z9HD0+dT&O@%(N6M#al*$K9kW0Fgj++B~5ZssY1HQe@{loe6G0}yOl0eYQ<47^Mh}Pod?g(7&m?`KcsEW=Cw>&r@x{VSQq)NP5h4VB7IP-Nv+~4u8^AC)ZJicAggTVriL=+49 zNXTC+1sJ}C@)li303X1og0{-=qf<I@{SYYV^%;fIR+SQ^6yN*&O1;ud{jWu(@019OzMnm#nO}-684`mF zbX)APuqTMIEp`odH@g#HDhEi0`}tz9>J4_v26ShWtHf7(Y-X-hjLiI zlSaRdhX1DZ78?G9egh4=#U*%mN~c>wjC%)uVM+&oncUC$)15dB%KJYIPA9PzamL`7 z?%|;OHPC`OasRhAT;nz!#69zn*@Amc*u5om z7sv0!x5eMLzY)X8_mjr$3zUE8G{Qv&=c4wPGOUoG_!`5ZhXIEh{Go*dM?1qedW>zr zA7tMjsf;K?Chgyn%q-`PX zssjIIc=<95!SGmb!eg}&k99u0irx^pqy2GcX~+r91~Cltk-q|l_XO;i@?aFs zhZZPUt86A{7MczHgRPfs2;e9iv%svIrQ+Vd#Sw=kJzMf4wPq+(c5ipawLEVDA2P=s? z>@}WB|G34;L;p0JO8>Z52W1TVDA+}Hg}TZU(8+b;a|jx@AVo>4|b9#a@YS~VEjtzBq|}$=P|pWCfZSQ;MZs5?=+=v@v8!r zCg`VN7eb;#(36J@4VlPc`GKZ|ObeMCvK+Wmiw#+cTx|yK(UL>9VDIK3&Lpjn(xp|> zo4{5}Yo!emcGY4n{3QEyEt)eipW8z)tEZ)EleDyugK*uhjkfL4rhxXSHXA!skAk+t z?zH#P=7Kg~TY(*+xxiiz$+v9{DFK%0bcK{*3e}{o(l)pxS2(bZ+ICm8D+$;R$5XCU z*C>kFw#W9VwhOd9t`WASu1Uc5X@|9wuBmWcfR&3|v@@XPJ4~+0u9>Kv^Nx7e98Po$ za*VvT zjcyxCq!=q8Cp%_icd9vbLD<`&D?`_~G1uyPGi*&*e&|AkHoI-^=&-`Dig2Q55?5}I zI|X=_&239^4|0zNzREq$wZT2jJs0@f?)k25?v?I05&v#wu69s6>e>$csFtf0YCacw z1&o;&nY@|Y3&QrhUk^LRXn9N~Oh|o@mhWD{XpkBvA0o*aFq37L`p42AX-N_B=i z-8##?n3G)7-HXE(U_JU&cRHtpEp(^5rn%<0mWCY&I~JDD$?om$*WG(C7r)cJCTu<@ zhs_IH9?rQ}X-Q#6!}7qD2_J5L$xr2hfYjGeUn_7=>r}K}i+f7g zHuqEV#ITaE<6*nyX<-HMVaAv78GIi;1^3w_uZBiS{lYH=%x9pNIP|Zd5cIi_QraDU zH)hF)vb*kvBjt{#74Xm?1@O&tClT%ULRao2-9v`Go6iZ)(Hl3XvRk%r!xr73MP)8x ziuw z0`AoN5O5>E6Yw2qHv(=k`~)x_>m~$fw|_w|Q71G3)|38*LjnSTjZo-BXu_*_S}C&z zI@8DaUk80@FaCdvd-nc{{|&fp5B&h{L;DMEPg@P_ecXcfTj5v0KEQ2g8-z8$cHmaD z-(i)43HPP_6}PLQU%|a;`*6$JuYrAp+tM})Yk}>;t!eKFZvp$W@ORw4whlVqc9hs} zP^TC{Fefz(hGQkivyzip$=O-Sg+KkTctGMa(HH^$?QJr{i%X5(9p12ymH`XoFo_C#h zw8Q^^|KHr^hP+OB!i{vebFQT&qzCUp6W^e_&*;uGBiA$Z9*A-;J`7nJD4a53tio+t z&jU^}%>$ff5zx@82ve-$AOWfDvvK3pb#hPxNSQQn;&~4Rd{M+`wZS+e7Va?ehmc zrH#+&?QG}++qga3&W3!oaa-EXwyd3POFP>i+u3%uv;DoD?UQ!4f3_>n&)T`Qw|$8^ zWbfxjz1-Wuj`@yu?u$Fvmm6{GnmLEv{(4S%HtKQQn;qHPHQ5MK|& zKlD5nUyly{@9*IM{toVAJGhVS;QmYp_m1}U9o%i4C(fPE0j`v8(B~(* z&5(@tz0a3O;Ue=(Eg{&7+O{seMd_^~*jhy5r-dVt(qyRy-|iO@1BeycbdWp7E457B zMZnsfwsM zzQF2@`6OJgMZ>K%FM7^`<OpDK<)^hqs07KatrP^)Gu+zbXkJT>m>bdy=0WeDIqr`w=>=2Q-{e~*LnJ9_krUn4 zF&2!ptiLCTBDdE3wXqp)dRfDt;gh5qvS8TsQ6ngo^d=beM>)`CF<5u>j%bt~WUZaQ zmNE#&OsNviN*JybmtNYG-yp815p26{+p#{!5loHVS&_C-14*sL(}1TDu|Q`NH9W85 z`32(bIAV=&>7jyBGr{PiXby%-g5xIOK}fyzFv|E&@eFH?hvkLCxirDCBG}`sZ;4r- zz2R^RZ_p2=z|pBT#&}i8>`5$TfwePo$G4z%yAGlX{XKj+)gZ5y_Obo`yS_p@C3>F`?I2Iq~1KaWYrjedLOU2>s zgL;N?_zku$Jv`MVrT2D+@b4J$2g`3$hx9ia?$jD7{N|v$Q77*P-COMK`P-_yGufx` zct398Pj8LE{ORNUUyS&uHIo0<@m}ZuA1&_f>@D$+HQe>N6%KbZ>+w^YrTBK}_7?vb zKN;tGPYu6JS||*McTf5c_9E}M`fO>F!QKvU1VSGCQVY_@H`d~*#UhTN+y`4Wm85`~ zrI!AUa3h@FGtpcl{iuxqZ?WmTa@-c?ruPvXw-Y=D!%=#?fE5j_C(v!ICqORY!u(Mz z^warmtJN~zSx$ny2IFnvl{jtXoY{~HdISM;Ep|Zk2`qg!ChPfcC$Z@rBq&4Yq-CVc z2)Y|JHZ|zp-p;t7T6(G_C%T0G47sGzZ$F;tBel_gw$lg~8{vZzv$!RFJ-+2F?m>Ht z1RHhOUgC{edFDp^t!X3idcC&y|5gkCdJ9-*)RKr9a(%3(nlkCOb~UFwTk~28fB_-&O@}uL>^jdleUD|?WM($Lq=p7j6!Fhe65KqBc*Zl<3YhRW@8~MQ~ zW2Dr+{DL;}vmUVq`3bfQD!*WE^|H|UdNkPjf;nX6M?R@6P>ZcGvAPWAi{jug^QGr1 zSh5R13#LQv3tHUu)HbxZ2km-Dy%hDZx*pYXMk*G_^a8u{o)bdJO71ul9C&haJ7^106~#UE-HP!AKX+tRo+^pDJqlT=2dkd&4R zn7|3I;Fc*0_WaUk=JP$^!_9dxY7fC0u&LZktdw4k_Z*fcPHmj6$AqN9@MYBCg^%EA z)W_wxvP%C^QFg+}#PxHmZ9aiiS z&%_I*#r(qo+;NQNrXAD79Usm|0D?D~$<%%GQG8cm;E9jsW56jm;$v|lBIB9&@NtOI z16n-aU5}ejKx~vgIKuX-1ic=x9*BOi(0@{)V~oK(!Q&VU=VC>9CifOb@eeSvegg9~ zcLwvSx41?gJHs(+`Z><5E8s8TyuK!(AI_Fb6DA6?g@wXOeDU2NY{MMzLE)&73;oH5 zZ@VV!oQTH0iC4t6;w9{YXfmm$a8q~ee^_POiTw>7 z^Sr!F_R8z!_vBsjCvvu&FJFTxRUS}QDj#G0#3aiTmbsRtmUEU8ONFIbwW<;7v+6SS z=W3R^PW`?5x%!=YM!lq#!1%4Ab&~Z7>vK4tA_FJ$Y_@)2{j2qJ>(|yoEG7%Lb+`4w z*;+rvcdgfKt8H)D-nMPE{n@t9cHDN>R%E+nm+UTk7o3bX!~VSeW&0ufw>VMhs=X3l z^<*s^rn@#!8>v00J+95sUePo^^SKO z+Z}rxe|LQ8IN><&C~^34K8nQ|=8Sgs!igwp&WX-PoCP6CAq(-PaCOKAd?VZ$(n#Ng zTn9tNP-kd#Xs^&gp`$}5ht3F{8@e!pg&%!cF1M@aXVf;e*1{!smo94u3uT-S7{? z{}x^telxr-!W`j>crIdd#Q#R@h}akLNu(umOyrcvry}P?E{n{JTpPKGng5CWDDv-- zMr-9lbzRo==UwxoP0@En z&y8LhogTeA`e5`I(ceX%i9Q#7J-RyP{+Nejo{V`u=H-}-n73m75c8*)k7EwRj~9896es`@lKB|J$Cl^tjF;lwYo`E4RcrGu*3|QKO~+^ zyx!B%GqLCNo^SR1s#kcgr(pins|=>5S55CBy?@dBAAPKSM)&zmU$yVRzK``?)^|J1 zSAENpQj(ryW@*xwNhgyElWry5>i0yy_xpX??^wSJ{c4g2!K5WmOkR+jo$O1l?QiNI z+rLl$Dg9UU{|(GGX8zXyZ~ebW34!UG@@&d$DVtLEq!bTG7%+3dM+1BVe5v=Ou1Z~> z`flou)P1RefkOsn4gBXI*Px6+9}ddDi@z)Jt}%BlxNGxWg=7Zzgqc5h`QTN9*AISg z@P~u{d3W^PkKO&NyK9DwAM)am1NV6D8Gg^YduoTq4qZRA{wF{A$zwlx?N!tWb! z-$VB;xbN5Z9lS4pv}N>+(VvVyHu~D=!2OQo`0@#3C;az>KTr5shVV+)MwJ*NfRe6nY3xriH95y z^?7LALrWg|%|k~Y@=qQ-dFtfFlmC12(TBB%`#(J8;a4C2;NhPsjcA)t_cR`o^QD9-HylscGA%eKhT($7eo%Zn`-Ay(hw-SU*FVkve1X zj5lWdX2yFn{xai>8Tn84esb88FFm>8$$vgs^3;H*CO`Eaj%0o1>7z4;&3tX<-kFWh z^nYf}Gao=jAyY=6pQoBYF7_-Qym<8D#}?09{MzC-7jIg;XYt|1 z-!Hzk#Jr^Il7UOcFL`>&D@)#5vUSPFOAaq7SaNHrd1?64q@}}`{&eZwrLQl2Yv~`C z?pvC@^xV?Ym!+4xzC7UN@h?B|@`9IFy}a?|4`2S`<E*k76W%Jf%${mS-Na$Y(AO3f=Z%Tt#>zxudd98`jB;du`lnKZSXLnZ0RP?3XS;`y|aX z61tHZ-t&i&9^6@LjiuU~h2ulwZrq6Ts#Q5Dwtd`$F5S;v^Jb+5W~YlEmX?0#)x(+* zR`zgQI@jQRgVb4(^KG8ct}gtEsQd2pKbWTt9ci`Sa&vV`I&wrw<%BV94=c@HZU%#bBu* zB6HXTv7#cWZ=}$8`Q*uymm39Fuf7#!jox3mM6(<J{ljEQeR zUK)6lJJR*{FMOtMqoz)sI;xxL(t$lThuxcjOIsjWnXc={zx@n}9xLI(?;0~^%+TIF zL$cb*Upm*M*SOrd#tm68waO9?$*D(e2kT#NiJ~5ZxpNF1U9f5Z>j*e5R8>o>YmkGQ z>eAd>4pfJB3$vk~!x4iA5B7!@6jbpR6p)3l8X7o@Qa&3%DVs=E;Bmr) z2|f68`}XZK+G*!LK$lQ7W{7A)n?B@rbj+#KK4ZzO3#3`~=fb3$249Xm#^IilsW9m# zCTXk$Oz>~?47a!d4h%Eeb{l#X=>FT#t3j{14Lu+9mR4;+{1$4hd{wnK15G=f_tyaN z8~JFH&h@t8+R$%;Zn;f5m7rJOhW?}YrCv=hzbb0^{$R^*lP;CV?egshz4A8cwkwa; z_}zDkza5|Bz|T08Q+yqEI;d)Lziw)vF)v$DtX8q1x~i(Gy0%X2(zpM2r!JOz9XxMV z?bog~c0;3zb6U+_+&)9n^zFA7O))X*m2bWAb=B4A3jX-VPp&jZ3>)n$@m1G1VwOta z8taP=Zr}dcW22(H8Cp$ceMCg%H)vk@ep8?+Q)_C%L3iAkG5wm)ovZIN#;7GbxU-W~ zAqEH*q^cBRl_3u5ax)G>bvL9cL)@Fw;~bMIta`z&bdHM_0wVHam6{u?%gTHAa5Yz7 zM8|NxqR||gkdPr)R3!F^>>6V7W|(A!Yi!C4lP!WN0eSK$TvY)&ibA1FmrIv2#H7^j z_N+vy39Zdv7Z9u=p`rT@T`|RsM2~S_cUwjFU-vdPs%rVodc`KA=a6I?liU>rd8hyO zx4(Ujp5yNE(m5$8Q$*K$ zncIc9y$hA$WRzi|SqX`Zj0{oC)i-=M5@LCC!}Y?#!s`uYHNxYmuP-hxuBfl~`@I<= zbv)i^lO&pBQG~r*T8A(sDzUB4dYP2`&!>g($(%4&FvEN=hv_jM5?r%jtS zB7!gZV&~4Xvc|?nb6J^*s-(V-Yi`brZYnLv`wsH)T^Z+0MIrWg+3g`ASd|{vqeq;s zr*s3am|Nl;Zuj}~d~?m;aB^s z<_Y__eG^(!&|_+=eYGDA+NN=P4eR#2v`PTtX5C81H2-gOlhz)jGcO4!`vjD|vkCjl z)V#d1rqHCMBs7yIZw5B%2+cPj1Le*P-YI1{h5D*$|Fp;YiZlqJWvcKag(V~mz_6OA zW$+rN89R)Nnl9tF+RC_|cW_18BrdSf!;nTC4o<8WFWys4k}x#73JNN>ZxtV1m1UhU zVTYRpU|;-Gs+T`9Zxw&Mxl?LRJZ!IF8_si9YznsMbdrCX zDRqRO%*|ER1C{nMet%L@atQ{zapSyvQIQw_II%b-EzR%m)hy(x>Y+n9ikFKW8JHND z;vLBqa)sWJfhmEBT&&)vA`mC$a5<~0{xg9pbYwwpt~Zm%t*dS_Yve%BZK_CW<1 zPVHzUcZDlL1YH9$opNpHFYFy7DCK&YLgzJPRF^B#jLgXKoqQ2J8GNOJuZZw)XBS=4 zBxsUtcuXhNC$Hqlk?Ve3+ayn&tXJ4SC0P51nsvWGpwP31D2hAu9~xTM?cuz##Nuo_NK=+ z)gfSA6DOjbsEWv4`wWfXvuj+`y%z(#mL{AD=CjOnj&6(`W~ov7fn{$o16 zk^Q*7BUqApi~0Zm`_py31!=(+B%yv(4{3MTo4l*o+mCjW_jh8Bz`_uSMPG@Jzg9l%}92to|s&6xoTmJZF9+wy5{Bjd-D?fSo@Zpo?ww_}kh+}$I zR0u-Pp8Dg>5Ndc^W<*0x9ga>iLoe%V;cL%8D$msNmcC;$<$e1S5()~?*|8kb?uBFz zXR|^<6!n);p0~Q2{mtjkn_S7sE>niZ8N*$>cC99E@L(>?=M$hw;ysWT@ETHo0xjZ1 z{RFQ!q^9(y?{Yyw0hgGVDBnD#znzdYryzwCYX3a%SJ4DjKM;&6em^coq~6#Y8j2Ur zYR=ldd-t0+Z)FK74pga}f%`X0w^0 zeKu5A|5EQ+CnB|pNG%~Dyr_(Nd;D7a09#D2_MJdFud10wM-U$D2+mxoZPybde8M}v z;X3vPm8TE3?+(O3pFY$ZwDt*3XvJDXOQ&G!8PtwPf=lY9x;dRnQ$w*35jDXdSQK4DV6*DI+u*(^3UW^iJLL$QQ(>C%O}bm>yHErH(8yH!*ub`*lc z0&O-t+`ac7|2Xt*{^ZG%xx8JwcI9!CylxCBpXs3_ol{X8K$W4!^&@dNR zRrtoH#wuSzpEzsd_1xUt{Gw|Wew0C3l{X`d57bsyUVs9Uce6ooc8Q8~Ib0S#)7{Xd zSVI%hI$~@>b!n;ZW@STtb5=@CX?bJg)JKL#azzJs?AWpAR0G$9X^)yFv+8u(_Ut`; zxjEtChaY}mNRKEhYFXmFnQlvXWWqf(E6_busVgq{A+NBcvMJMTZuZ@{cnt02Y(=x` zij3|W)y*Z{LFZ@hmedOv@4@X#tBv+y4e9}{rD*7h?cN&n5H!QU$-xl!qZIE)DaN_o z?)tihhR=TRmE%|4fS)<4SFc{xpV!okt1ZMN3v`Bz+zzBm%q99hisja8j=oVKI!?Roq${XW&J zo|hl1y_0@2hU!%?yId~xm$!s4)F$LVkl4%BC5nEf^iwo%24;Ir$Yyr27lTD~rqv{H z)n#QBjhs6=+8d|fZL4|4j2S6P;oiM_5u0=0-oNbsgnrm^BnY<-;k~x1F+(!SAwz7e z;tDgZvTBECn^meVEbR2IWXy}T%+j>xkiDUz6VUtFR*5!3RCVq#P_@)!ly`P9-f3FG z(D*I=M`LhMPtr?2oFu8VGjIQTE|%U2LQofSqI$emN%iMLTP`SAC0{O^;ws(cj3zC4 z9k*^(tIpm7z8wi_PK7`EvZ|%{3sOvtIS6(BdFAct^TxhL`5*3A0S5p0#FMGY*v0o}VIb{>Mz0TxhgitfM z`5B!0|0E~D_Lolc@GW}Rk8fAO@Q?xHJ4ovrKIwCqeHx_tv;F3yI1Q2B@HhC?#j1JC zKJLK3e8cY?Z>!X|ecz`Vqkb{OgMJaS^}OheEG`y9aTF$JK5_D7a&n57&neE}i*wqv zH$8uNK{ehN_0;H%iax?noY+yOYff%A-$29KmHeySt?2Q2J7(}5{CTg}3}s9aY#CNv zEAT6^q!mQqeTT;l&5Ao!`^bl`(SHo1W43>)H_OyibL+GrnrO!cS#h8Tvx;cMUgV}DCcYJ)dsq6jd^G0&tnb!vBZ4c4CCKiw&ZdWw*( zHf`r;DA~Da38^NLUPauZ$K$BKo^!&Y1nMhG@TNYmEJ^A#Sw`(Mqo*dT21FRyw{}ck16emk#sAX8&7rLFL-M zq}f=}(#y7{ai`q1&H}fO=U?%JO2Uc1*dJn&LqkLDikapf&YdkWg{KS=P0iKS4bUu` z>MD)xqLZbZoW~27nSO3-+lL8LBU-^;&7r{9;PC2zeJ`j&`=u|Rt%9V zs;Uz+?LxD^^wQN^0)#q72)yAQ90M_~EGtvKg?Z=N_=faSwIF+kBcifxMH=##T%AnM|yyWEGJ$v-*8y|_Hi;S;0fAHYJ z^JP*T+Fm?9Nr|?I$R6IPlAF!o5P2{Mnz&J;qRlt)nf`|=vwHv(H>%g+;E#P%$BP*v zf+EsX=_^h2IQa^^UR3bTxCGyg3QX>s6bn~Vm+5M3maQ@1C!9l1ar~OUu9?q@uB~Dp ziK=P?uGpyT@2=NJK#_>3FUdX{9u<<65oN-}lMFtZZ(hCpJvb;f$B!B{Dm6AbOldQ| z>TUf_a*Afkx`tN0LiZ2BPv@FDw;k4dL%2KoZ%-c^tRIV2!`rQ9wb=0b&ORw6M0E*s zn5jYENiao15jr_UE-&aU|U z=^xJi@cqe?y?Pzb&N+3qK&!ieUim_UGahft@!kw;Q^0JsQ`I!zEb+Q}L`K+9Jp

Z(gi@J-QIRcFE2WvRQ4V!H0NCvbu8wY9P*nX9U3 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�c!uP* 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 literal 0 HcmV?d00001 diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 79f8145..69e3a36 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import '../models/models.dart'; @@ -190,7 +189,7 @@ class _AiCoachViewState extends State<_AiCoachView> { children: [ Text( 'AI Coach', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -199,7 +198,7 @@ class _AiCoachViewState extends State<_AiCoachView> { ), Text( 'Powered by Gemini', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), @@ -297,7 +296,7 @@ class _AiCoachViewState extends State<_AiCoachView> { const SizedBox(height: AppSpacing.lg), Text( name != null && name.isNotEmpty ? 'Hey $name 👋' : 'Your AI Coach', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w700, @@ -308,7 +307,7 @@ class _AiCoachViewState extends State<_AiCoachView> { Text( 'Ask me anything — what to train today, how to break a plateau, reading your progress, anything.', textAlign: TextAlign.center, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, height: 1.5, @@ -393,7 +392,7 @@ class _AiCoachViewState extends State<_AiCoachView> { ), child: TextField( controller: _controller, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, ), @@ -402,7 +401,7 @@ class _AiCoachViewState extends State<_AiCoachView> { textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( hintText: 'Ask your coach...', - hintStyle: GoogleFonts.geist( + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 14, ), @@ -515,7 +514,7 @@ class _ConversationsSheet extends StatelessWidget { children: [ Text( 'Conversations', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -534,7 +533,7 @@ class _ConversationsSheet extends StatelessWidget { const SizedBox(width: 4), Text( 'New chat', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w600, @@ -551,7 +550,7 @@ class _ConversationsSheet extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), child: Text( 'No saved conversations yet.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -630,7 +629,7 @@ class _ConversationTile extends StatelessWidget { conversation.title.isEmpty ? 'New chat' : conversation.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w500, @@ -672,7 +671,7 @@ class _SuggestionChip extends StatelessWidget { ), child: Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w500, @@ -740,7 +739,7 @@ class _MessageBubble extends StatelessWidget { child: isUser ? Text( message.text, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.55, @@ -804,7 +803,7 @@ class _CoachMarkdown extends StatelessWidget { Widget build(BuildContext context) { return GptMarkdown( text, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.55, diff --git a/workout-logger/lib/screens/ai_program_generator_screen.dart b/workout-logger/lib/screens/ai_program_generator_screen.dart index ded3dc5..9003bca 100644 --- a/workout-logger/lib/screens/ai_program_generator_screen.dart +++ b/workout-logger/lib/screens/ai_program_generator_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/ai/gemini_ai_service.dart'; @@ -196,7 +195,7 @@ class _AiProgramGeneratorScreenState extends State { children: [ Text( 'AI Program Generator', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -205,7 +204,7 @@ class _AiProgramGeneratorScreenState extends State { ), Text( 'Powered by Gemini', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), @@ -233,7 +232,7 @@ class _AiProgramGeneratorScreenState extends State { const SizedBox(width: 8), Text( 'Describe your program', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w700, @@ -250,7 +249,7 @@ class _AiProgramGeneratorScreenState extends State { ), child: TextField( controller: _promptCtrl, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.5, @@ -261,7 +260,7 @@ class _AiProgramGeneratorScreenState extends State { decoration: InputDecoration( hintText: 'e.g. "12-week hypertrophy program, 4 days/week, push-pull split, intermediate level"', - hintStyle: GoogleFonts.geist( + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13, height: 1.5, @@ -274,7 +273,7 @@ class _AiProgramGeneratorScreenState extends State { const SizedBox(height: AppSpacing.md), Text( 'QUICK PROMPTS', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 9, fontWeight: FontWeight.w600, @@ -300,7 +299,7 @@ class _AiProgramGeneratorScreenState extends State { ), child: Text( s, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 11, ), @@ -337,7 +336,7 @@ class _AiProgramGeneratorScreenState extends State { const SizedBox(height: AppSpacing.md), Text( _statusText, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w600, @@ -346,7 +345,7 @@ class _AiProgramGeneratorScreenState extends State { const SizedBox(height: AppSpacing.xs), Text( 'Gemini is designing your training block…', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -367,7 +366,7 @@ class _AiProgramGeneratorScreenState extends State { Expanded( child: Text( _error!, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.error, fontSize: 13, ), @@ -415,7 +414,7 @@ class _AiProgramGeneratorScreenState extends State { children: [ Text( p.name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -425,7 +424,7 @@ class _AiProgramGeneratorScreenState extends State { if (p.description != null) Text( p.description!, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -456,7 +455,7 @@ class _AiProgramGeneratorScreenState extends State { const SizedBox(height: AppSpacing.md), Text( 'PHASES', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 9, fontWeight: FontWeight.w600, @@ -480,7 +479,7 @@ class _AiProgramGeneratorScreenState extends State { const SizedBox(width: 10), Text( phase.name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w500, @@ -489,7 +488,7 @@ class _AiProgramGeneratorScreenState extends State { const Spacer(), Text( 'Wk ${phase.startWeek}–${phase.endWeek}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 11, ), diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 4a5016c..73b9764 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; @@ -58,7 +57,7 @@ class _AnalyticsScreenState extends State { children: [ Text( 'INSIGHTS', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textFaint, @@ -68,7 +67,7 @@ class _AnalyticsScreenState extends State { const SizedBox(height: 2), Text( 'Analytics', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary, @@ -118,7 +117,7 @@ class _AnalyticsScreenState extends State { child: Text( _tabs[i], textAlign: TextAlign.center, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: active ? Colors.white : AppColors.textMuted, @@ -181,7 +180,7 @@ class _RecordsTabState extends State<_RecordsTab> { const SizedBox(height: 12), Text( 'No records yet', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 15, fontWeight: FontWeight.w600, @@ -190,7 +189,7 @@ class _RecordsTabState extends State<_RecordsTab> { const SizedBox(height: 4), Text( 'Finish a workout to set your first PRs', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 12), ), ], @@ -250,7 +249,7 @@ class _RecordsTabState extends State<_RecordsTab> { children: [ Text( '${allRecords.length} PRs', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w700, @@ -271,7 +270,7 @@ class _RecordsTabState extends State<_RecordsTab> { ), child: Text( '$thisMonthCount this month', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.warning, fontSize: 11, fontWeight: FontWeight.w600, @@ -358,7 +357,7 @@ class _RecordsTabState extends State<_RecordsTab> { _sort == _RecordsSort.recent ? 'Recent' : 'Heaviest', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 11, fontWeight: FontWeight.w600, @@ -448,7 +447,7 @@ class _NewestPRHero extends StatelessWidget { children: [ Text( 'Latest PR', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.warning, fontSize: 10, fontWeight: FontWeight.w700, @@ -457,7 +456,7 @@ class _NewestPRHero extends StatelessWidget { ), Text( exerciseName, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w700, @@ -465,7 +464,7 @@ class _NewestPRHero extends StatelessWidget { ), Text( dateStr, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, ), @@ -478,7 +477,7 @@ class _NewestPRHero extends StatelessWidget { children: [ Text( '${w % 1 == 0 ? w.toStringAsFixed(0) : w.toStringAsFixed(1)} ${settings.unitLabel}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.warning, fontSize: 18, fontWeight: FontWeight.w800, @@ -487,7 +486,7 @@ class _NewestPRHero extends StatelessWidget { ), Text( '${record.bestReps} reps', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 11, ), @@ -517,7 +516,7 @@ class _ExercisePRGroup extends StatelessWidget { padding: const EdgeInsets.only(bottom: 6, top: 4), child: Text( exerciseName, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w700, @@ -563,7 +562,7 @@ class _FilterChip extends StatelessWidget { ), child: Text( label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: selected ? AppColors.primary : AppColors.textMuted, fontSize: 11, fontWeight: @@ -614,7 +613,7 @@ class _PRCard extends StatelessWidget { children: [ Text( exerciseName, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, @@ -622,7 +621,7 @@ class _PRCard extends StatelessWidget { ), Text( dateStr, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11), ), ], @@ -684,11 +683,11 @@ class _PRStat extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), const SizedBox(height: 2), Text(value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 13, fontWeight: FontWeight.w700)), diff --git a/workout-logger/lib/screens/heart_rate_detail_screen.dart b/workout-logger/lib/screens/heart_rate_detail_screen.dart index 047209e..695689d 100644 --- a/workout-logger/lib/screens/heart_rate_detail_screen.dart +++ b/workout-logger/lib/screens/heart_rate_detail_screen.dart @@ -4,7 +4,6 @@ // Week / Month / Year : daily/monthly min–max range bars with resting markers. import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -144,9 +143,9 @@ class _DayBody extends StatelessWidget { children: [ Text( 'All-day heart rate · 30-min bars', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), ), - Text('bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11)), + Text('bpm', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11)), ], ), const SizedBox(height: 8), @@ -171,7 +170,7 @@ class _DayBody extends StatelessWidget { children: [ Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), const SizedBox(width: 4), - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ); @@ -180,7 +179,7 @@ class _DayBody extends StatelessWidget { children: [ Container(width: 14, height: 2, color: c), const SizedBox(width: 4), - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ); } @@ -221,7 +220,7 @@ class _AggBody extends StatelessWidget { children: [ Text( '$unit range · resting ●', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), ), const SizedBox(height: 12), HrRangeChart(bars: bars, workoutDays: workoutDays), @@ -246,7 +245,7 @@ class _AggBody extends StatelessWidget { children: [ Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), const SizedBox(width: 4), - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ); } @@ -272,12 +271,12 @@ class _Pill extends StatelessWidget { children: [ Text( label.toUpperCase(), - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), ), const SizedBox(height: 2), Text( value, - style: GoogleFonts.geistMono(color: color, fontSize: 16, fontWeight: FontWeight.w700), + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 16, fontWeight: FontWeight.w700), ), ], ), @@ -293,7 +292,7 @@ class _Empty extends StatelessWidget { Widget build(BuildContext context) => GlassCard( padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), child: Center( - child: Text(message, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13)), + child: Text(message, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13)), ), ); } diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index a5aa3ab..7b6d5df 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; @@ -137,12 +136,12 @@ class _HistoryScreenState extends State { const SizedBox(height: 12), Text( _query.isNotEmpty ? 'No results' : 'No Workout History', - style: GoogleFonts.geist(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textMuted), ), const SizedBox(height: 4), Text( _query.isNotEmpty ? 'Try a different search term' : 'Complete a workout to see it here', - style: GoogleFonts.geist(fontSize: 13, color: AppColors.textFaint), + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textFaint), ), ], ), @@ -187,7 +186,7 @@ class _HistoryScreenState extends State { children: [ Text( 'LOG', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textFaint, @@ -197,7 +196,7 @@ class _HistoryScreenState extends State { const SizedBox(height: 2), Text( 'History', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary, @@ -323,7 +322,7 @@ class _HistoryScreenState extends State { children: [ Text( monthLabel, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -332,7 +331,7 @@ class _HistoryScreenState extends State { ), Text( '$monthSessions session${monthSessions == 1 ? '' : 's'}', - style: GoogleFonts.geist(fontSize: 11, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted), textAlign: TextAlign.center, ), ], @@ -387,7 +386,7 @@ class _SummaryCell extends StatelessWidget { children: [ Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textFaint, @@ -397,7 +396,7 @@ class _SummaryCell extends StatelessWidget { const SizedBox(height: 4), Text( value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, @@ -405,7 +404,7 @@ class _SummaryCell extends StatelessWidget { ), Text( unit, - style: GoogleFonts.geist(fontSize: 10, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textMuted), ), ], ), @@ -442,10 +441,10 @@ class _SearchBar extends StatelessWidget { controller: controller, onChanged: onChanged, autofocus: true, - style: GoogleFonts.geist(color: AppColors.textPrimary, fontSize: 14), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14), decoration: InputDecoration( hintText: 'Search by date or exercise…', - hintStyle: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 14), + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14), prefixIcon: const Icon(Icons.search_rounded, color: AppColors.textMuted, size: 18), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 12), @@ -482,7 +481,7 @@ class _MonthGroup extends StatelessWidget { children: [ Text( month, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSoft, @@ -493,7 +492,7 @@ class _MonthGroup extends StatelessWidget { const SizedBox(width: 10), Text( '${sessions.length}', - style: GoogleFonts.geistMono(fontSize: 11, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'GeistMono', fontSize: 11, color: AppColors.textMuted), ), ], ), @@ -648,12 +647,12 @@ class _HistoryCard extends StatelessWidget { children: [ Text( dayAbbr.toUpperCase(), - style: GoogleFonts.geist(fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textMuted, letterSpacing: 0.6), + style: TextStyle(fontFamily: 'Geist', fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textMuted, letterSpacing: 0.6), ), const SizedBox(height: 2), Text( '$dayNum', - style: GoogleFonts.geistMono(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + style: TextStyle(fontFamily: 'GeistMono', fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary), ), ], ), @@ -670,14 +669,14 @@ class _HistoryCard extends StatelessWidget { children: [ Text( routineName, - style: GoogleFonts.geist(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + style: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), Text( '$exCount exercises · $setCount sets${duration > 0 ? ' · ${duration}m' : ''}', - style: GoogleFonts.geist(fontSize: 11, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted), ), ], ), @@ -691,13 +690,13 @@ class _HistoryCard extends StatelessWidget { children: [ Text( volStr, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.secondary, ), ), - Text(settings.unitLabel, style: GoogleFonts.geist(fontSize: 10, color: AppColors.textMuted)), + Text(settings.unitLabel, style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textMuted)), ], ), ), diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index f46439f..4be9887 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; @@ -248,7 +247,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( dateStr.toUpperCase(), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textMuted, fontWeight: FontWeight.w500, @@ -258,7 +257,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 4), RichText( text: TextSpan( - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 28, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -416,7 +415,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(width: 6), Text( 'STREAK', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.primary, @@ -432,7 +431,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( '$streak', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 56, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -443,7 +442,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(width: 6), Text( 'days', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 16, color: AppColors.textMuted, fontWeight: FontWeight.w500, @@ -456,7 +455,7 @@ class _DashboardTab extends StatelessWidget { streak == 0 ? 'Start your streak today' : 'Keep it going — you\'re on a roll', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textMuted, ), @@ -499,7 +498,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(width: 4), Text( isActive ? 'Resume' : 'Start', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white, @@ -525,7 +524,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( weekDays[i], - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textFaint, fontWeight: FontWeight.w500, @@ -689,7 +688,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( 'Activity', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -697,7 +696,7 @@ class _DashboardTab extends StatelessWidget { ), Text( 'Last 14 weeks', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted, ), @@ -751,7 +750,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( 'Weekly muscle volume', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -759,7 +758,7 @@ class _DashboardTab extends StatelessWidget { ), Text( settings.unitLabel, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted, ), @@ -779,7 +778,7 @@ class _DashboardTab extends StatelessWidget { ? [ Text( 'No data yet', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textMuted, ), @@ -801,14 +800,14 @@ class _DashboardTab extends StatelessWidget { children: [ Text( _capitalize(e.key.replaceAll('_', ' ')), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textSoft, ), ), Text( volStr, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 11, color: AppColors.textMuted, ), @@ -869,7 +868,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( 'Recent workouts', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -879,7 +878,7 @@ class _DashboardTab extends StatelessWidget { onTap: () => homeState?.switchTab(2), child: Text( 'See all', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.primary, fontWeight: FontWeight.w500, @@ -895,7 +894,7 @@ class _DashboardTab extends StatelessWidget { child: Center( child: Text( 'No workouts yet — start one!', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textMuted, ), @@ -940,7 +939,7 @@ class _DashboardTab extends StatelessWidget { s.routineId != null ? (provider.routines.cast().firstWhere((r) => r?.id == s.routineId, orElse: () => null)?.name ?? 'Workout') : 'Quick Workout', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -949,7 +948,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 2), Text( '$dateStr · $exCount exercises · $setCount sets', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted, ), @@ -962,7 +961,7 @@ class _DashboardTab extends StatelessWidget { children: [ Text( volStr, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.secondary, @@ -970,7 +969,7 @@ class _DashboardTab extends StatelessWidget { ), Text( '${settings.unitLabel} vol', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textFaint, ), @@ -1034,7 +1033,7 @@ class _StatCard extends StatelessWidget { children: [ Text( item.label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500, @@ -1054,7 +1053,7 @@ class _StatCard extends StatelessWidget { children: [ Text( item.value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 26, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -1063,7 +1062,7 @@ class _StatCard extends StatelessWidget { ), Text( item.unit, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w400, @@ -1131,14 +1130,14 @@ class _RoutineSelectorSheet extends StatelessWidget { ), title: Text( 'Quick Start', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontWeight: FontWeight.w600, ), ), subtitle: Text( 'Empty workout, no routine', - style: GoogleFonts.geist(color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), ), trailing: const Icon(Icons.play_arrow_rounded, color: AppColors.primary), onTap: onQuickStart, @@ -1161,14 +1160,14 @@ class _RoutineSelectorSheet extends StatelessWidget { ), title: Text( r.name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontWeight: FontWeight.w600, ), ), subtitle: Text( '${r.exerciseIds.length} exercises', - style: GoogleFonts.geist(color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), ), trailing: const Icon( Icons.play_arrow_rounded, @@ -1288,7 +1287,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { Expanded( child: Text( 'This Week\'s Insights', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w700, @@ -1319,7 +1318,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { ) : Text( hasInsights ? 'Refresh' : 'Generate', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.w600, @@ -1341,7 +1340,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { else Text( insights, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 13, height: 1.6, @@ -1351,7 +1350,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { const SizedBox(height: AppSpacing.sm), Text( 'Updated ${DateFormat('MMM d, h:mm a').format(updatedAt)}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 10, ), @@ -1361,7 +1360,7 @@ class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { const SizedBox(height: AppSpacing.sm), Text( 'Tap Generate to get a personalised coaching summary for this week.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, height: 1.5, diff --git a/workout-logger/lib/screens/onboarding_screen.dart b/workout-logger/lib/screens/onboarding_screen.dart index 9bcbb9e..2837231 100644 --- a/workout-logger/lib/screens/onboarding_screen.dart +++ b/workout-logger/lib/screens/onboarding_screen.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; @@ -95,7 +94,7 @@ class _WelcomePageState extends State { const SizedBox(height: AppSpacing.xl), Text( 'Welcome to\nRepForge', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 36, fontWeight: FontWeight.w800, color: AppColors.textPrimary, @@ -106,7 +105,7 @@ class _WelcomePageState extends State { const SizedBox(height: 12), Text( 'Track every rep. Beat every record.\nForge your best self.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 15, color: AppColors.textMuted, height: 1.5, @@ -115,7 +114,7 @@ class _WelcomePageState extends State { const Spacer(flex: 2), Text( 'WHAT SHOULD WE CALL YOU?', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textFaint, @@ -127,14 +126,14 @@ class _WelcomePageState extends State { controller: _controller, autofocus: true, textCapitalization: TextCapitalization.words, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w500, ), decoration: InputDecoration( hintText: 'Your name', - hintStyle: GoogleFonts.geist(color: AppColors.textFaint), + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint), filled: true, fillColor: AppColors.glass2, border: OutlineInputBorder( @@ -240,7 +239,7 @@ class _VersionUpdateSheet extends StatelessWidget { children: [ Text( 'Updated to v$version', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -248,7 +247,7 @@ class _VersionUpdateSheet extends StatelessWidget { ), Text( 'RepForge is better than ever', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -321,7 +320,7 @@ class _WhatsNewItem extends StatelessWidget { children: [ Text( title, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w600, @@ -330,7 +329,7 @@ class _WhatsNewItem extends StatelessWidget { const SizedBox(height: 2), Text( description, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, height: 1.4, diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 112c9bf..60ccf00 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -11,7 +11,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:intl/intl.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; @@ -231,7 +230,7 @@ class _ProfileScreenState extends State ), title: Text( 'Import Backup', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontWeight: FontWeight.w700, ), @@ -239,14 +238,14 @@ class _ProfileScreenState extends State content: Text( 'This will merge the backup with your existing data. ' 'Select a .json RepForge backup file to continue.', - style: GoogleFonts.geist(color: AppColors.textSoft), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text( 'Cancel', - style: GoogleFonts.geist(color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), ), ), TextButton( @@ -254,7 +253,7 @@ class _ProfileScreenState extends State style: TextButton.styleFrom(foregroundColor: AppColors.primary), child: Text( 'Choose File', - style: GoogleFonts.geist(fontWeight: FontWeight.w600), + style: TextStyle(fontFamily: 'Geist', fontWeight: FontWeight.w600), ), ), ], @@ -322,7 +321,7 @@ class _ProfileScreenState extends State SnackBar( content: Text( message, - style: GoogleFonts.geist(color: AppColors.textPrimary), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary), ), backgroundColor: color, behavior: SnackBarBehavior.floating, @@ -484,7 +483,7 @@ class _ProfileScreenState extends State ), child: Text( 'v$_appVersion', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 11, fontWeight: FontWeight.w500, @@ -496,7 +495,7 @@ class _ProfileScreenState extends State const SizedBox(height: AppSpacing.md), Text( 'RepForge', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 30, fontWeight: FontWeight.w800, @@ -506,7 +505,7 @@ class _ProfileScreenState extends State const SizedBox(height: 2), Text( 'Settings & preferences', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, fontWeight: FontWeight.w400, diff --git a/workout-logger/lib/screens/routine_optimizer_screen.dart b/workout-logger/lib/screens/routine_optimizer_screen.dart index 366170c..166e649 100644 --- a/workout-logger/lib/screens/routine_optimizer_screen.dart +++ b/workout-logger/lib/screens/routine_optimizer_screen.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import '../models/models.dart'; @@ -180,7 +179,7 @@ class _OptimizerViewState extends State<_OptimizerView> { children: [ Text( 'Optimize Routine', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -191,7 +190,7 @@ class _OptimizerViewState extends State<_OptimizerView> { widget.routine.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), @@ -292,7 +291,7 @@ class _OptimizerViewState extends State<_OptimizerView> { const SizedBox(height: AppSpacing.lg), Text( 'Analyzing your routine…', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w700, @@ -303,7 +302,7 @@ class _OptimizerViewState extends State<_OptimizerView> { Text( 'Reviewing your history and building a personalized plan.', textAlign: TextAlign.center, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, height: 1.5, @@ -395,7 +394,7 @@ class _MessageBubble extends StatelessWidget { child: isUser ? Text( message.text, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.55, @@ -461,7 +460,7 @@ class _OptimizerMarkdown extends StatelessWidget { Widget build(BuildContext context) { return GptMarkdown( text, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.55, @@ -517,7 +516,7 @@ class _ConversationsSheet extends StatelessWidget { children: [ Text( 'Optimization History', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -529,7 +528,7 @@ class _ConversationsSheet extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), child: Text( 'No saved optimization sessions yet.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -617,7 +616,7 @@ class _ConversationTile extends StatelessWidget { : conversation.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w500, @@ -625,7 +624,7 @@ class _ConversationTile extends StatelessWidget { ), Text( '${conversation.messages.length} messages', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, ), diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index bad08e6..2218294 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../models/models.dart'; @@ -67,7 +66,7 @@ class RoutinesScreen extends StatelessWidget { children: [ Text( 'PROGRAMS', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textFaint, @@ -77,7 +76,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(height: 2), Text( 'Routines', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary, @@ -103,7 +102,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(width: 4), Text( 'New', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.primary, @@ -160,7 +159,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(width: 8), Text( 'UP NEXT · TODAY', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.primary, @@ -172,7 +171,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(height: 10), Text( routine.name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 22, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -182,7 +181,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(height: 4), Text( '$exCount exercises', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textMuted, ), @@ -216,7 +215,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(width: 6), Text( 'Start workout', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white, @@ -260,7 +259,7 @@ class RoutinesScreen extends StatelessWidget { children: [ Text( 'All Routines', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSoft, @@ -275,7 +274,7 @@ class RoutinesScreen extends StatelessWidget { ), child: Text( '${routines.length}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 11, color: AppColors.textMuted, ), @@ -304,7 +303,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(height: 16), Text( 'No Routines Yet', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -313,7 +312,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(height: 6), Text( 'Create a routine to organize your workouts', - style: GoogleFonts.geist(fontSize: 13, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textMuted), textAlign: TextAlign.center, ), ], @@ -332,7 +331,7 @@ class RoutinesScreen extends StatelessWidget { children: [ Text( 'Programs', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -368,7 +367,7 @@ class RoutinesScreen extends StatelessWidget { children: [ Text( 'Browse Programs', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -376,7 +375,7 @@ class RoutinesScreen extends StatelessWidget { ), Text( 'Structured multi-week training plans', - style: GoogleFonts.geist(fontSize: 12, color: AppColors.textMuted), + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textMuted), ), ], ), @@ -412,7 +411,7 @@ class RoutinesScreen extends StatelessWidget { const SizedBox(width: 6), Text( 'New Routine', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textMuted, @@ -473,7 +472,7 @@ class _RoutineCard extends StatelessWidget { children: [ Text( routine.name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -482,7 +481,7 @@ class _RoutineCard extends StatelessWidget { const SizedBox(height: 3), Text( '$exCount exercise${exCount == 1 ? '' : 's'}', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textMuted, ), diff --git a/workout-logger/lib/screens/sleep_detail_screen.dart b/workout-logger/lib/screens/sleep_detail_screen.dart index ccc6f1c..13a38c1 100644 --- a/workout-logger/lib/screens/sleep_detail_screen.dart +++ b/workout-logger/lib/screens/sleep_detail_screen.dart @@ -5,7 +5,6 @@ // 8h goal line and workout-day highlights. import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -140,7 +139,7 @@ class _DayBody extends StatelessWidget { children: [ Text( 'Asleep · ${_fmt(_ist(snapshot.sleepStart))} – ${_fmt(_ist(snapshot.sleepEnd))} IST', - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12), ), const SizedBox(height: 16), SleepHrDayView(snapshot: snapshot), @@ -180,11 +179,11 @@ class _AggBody extends StatelessWidget { children: [ Text( 'Sleep duration · $unit', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), ), Text( withData.isEmpty ? '—' : 'avg $avgLabel', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w700, @@ -220,7 +219,7 @@ class _AggBody extends StatelessWidget { decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2)), ), const SizedBox(width: 4), - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ); @@ -229,7 +228,7 @@ class _AggBody extends StatelessWidget { children: [ Container(width: 14, height: 2, color: c), const SizedBox(width: 4), - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ); } @@ -252,7 +251,7 @@ class _Empty extends StatelessWidget { child: Center( child: Text( message, - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13), ), ), ); diff --git a/workout-logger/lib/screens/widgets/analytics_overview.dart b/workout-logger/lib/screens/widgets/analytics_overview.dart index 8c4065f..cb2a286 100644 --- a/workout-logger/lib/screens/widgets/analytics_overview.dart +++ b/workout-logger/lib/screens/widgets/analytics_overview.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; @@ -159,7 +158,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { children: [ Text( 'Volume Trend', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, @@ -168,7 +167,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { const SizedBox(height: 2), Text( '${settings.unitLabel} · per week', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), @@ -196,7 +195,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { const SizedBox(width: 2), Text( '${deltaPct.abs().toStringAsFixed(0)}% vs prev ${weeks}w', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: deltaPct >= 0 ? AppColors.success : AppColors.error, fontSize: 11, fontWeight: FontWeight.w600, @@ -232,7 +231,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { final ws = weekStartFor(i); return LineTooltipItem( '$volStr ${settings.unitLabel}', - GoogleFonts.geistMono( + TextStyle(fontFamily: 'GeistMono', color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w700, @@ -241,7 +240,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { TextSpan( text: '\nwk of ${DateFormat('MMM d').format(ws)}', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.normal, @@ -269,7 +268,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { padding: const EdgeInsets.only(top: 6), child: Text( DateFormat('d/M').format(weekStartFor(i)), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 9, ), @@ -284,7 +283,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { reservedSize: 40, getTitlesWidget: (v, _) => Text( _fmtK(v), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 9, ), @@ -306,7 +305,7 @@ class _VolumeTrendCardState extends State<_VolumeTrendCard> { direction: LabelDirection.horizontal, alignment: Alignment.topRight, padding: const EdgeInsets.only(right: 6, bottom: 2), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.warning, fontSize: 9, fontWeight: FontWeight.w600, @@ -388,7 +387,7 @@ class _RangeToggle extends StatelessWidget { ), child: Text( r.label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 11, fontWeight: FontWeight.w700, color: active ? Colors.white : AppColors.textMuted, @@ -557,7 +556,7 @@ class _MuscleFocusRow extends StatelessWidget { Expanded( child: Text( name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w500, @@ -566,7 +565,7 @@ class _MuscleFocusRow extends StatelessWidget { ), Text( '${_fmtK(displayVol)} ${settings.unitLabel}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 11, ), @@ -591,7 +590,7 @@ class _MuscleFocusRow extends StatelessWidget { const SizedBox(width: 8), Text( '${recovery!.recoveryPercent}%', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 10, fontWeight: FontWeight.w600, color: _recoveryColor, @@ -663,7 +662,7 @@ class _FrequencyGrid extends StatelessWidget { child: Center( child: Text( '$count', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: active ? AppColors.primary : AppColors.textMuted, fontSize: 22, fontWeight: FontWeight.w700, @@ -674,7 +673,7 @@ class _FrequencyGrid extends StatelessWidget { const SizedBox(height: 6), Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, ), @@ -713,7 +712,7 @@ class _ChartCard extends StatelessWidget { children: [ Text( title, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, @@ -723,7 +722,7 @@ class _ChartCard extends StatelessWidget { const SizedBox(height: 2), Text( subtitle!, - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11), ), ], if (isEmpty) ...[ @@ -750,9 +749,9 @@ class _EmptyChart extends StatelessWidget { const Icon(Icons.show_chart_rounded, size: 32, color: AppColors.textFaint), const SizedBox(height: 8), Text('No data yet', - style: GoogleFonts.geist(fontSize: 13, color: AppColors.textMuted)), + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textMuted)), Text('Complete workouts to see progress', - style: GoogleFonts.geist(fontSize: 11, color: AppColors.textFaint)), + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textFaint)), ], ), ); diff --git a/workout-logger/lib/screens/widgets/calendar_grid.dart b/workout-logger/lib/screens/widgets/calendar_grid.dart index 2858b43..0959025 100644 --- a/workout-logger/lib/screens/widgets/calendar_grid.dart +++ b/workout-logger/lib/screens/widgets/calendar_grid.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; class CalendarDayData { @@ -43,7 +42,7 @@ class CalendarMonthGrid extends StatelessWidget { child: Center( child: Text( d, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textFaint, @@ -206,7 +205,7 @@ class _DayCell extends StatelessWidget { Center( child: Text( '$day', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 12, fontWeight: intensity > 0 ? FontWeight.w600 : FontWeight.w400, diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 483674a..687809d 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; @@ -368,7 +367,7 @@ class _NumberInputCardState extends State<_NumberInputCard> { children: [ Text( widget.label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, fontWeight: FontWeight.w600, @@ -389,7 +388,7 @@ class _NumberInputCardState extends State<_NumberInputCard> { child: TextField( controller: _controller, focusNode: _focusNode, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 36, fontWeight: FontWeight.w700, @@ -687,7 +686,7 @@ class _PreviousSetsSection extends StatelessWidget { children: [ Text( 'THIS SESSION', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10, fontWeight: FontWeight.w600, @@ -820,7 +819,7 @@ class _LastSessionSection extends StatelessWidget { children: [ Text( 'LAST SESSION', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10, fontWeight: FontWeight.w600, diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index db7508a..b56ec6c 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; @@ -71,7 +70,7 @@ class _ExerciseProgressViewState extends State { child: Center( child: Text( 'Select an exercise above', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, ), @@ -173,7 +172,7 @@ class _ExerciseDropdown extends StatelessWidget { hasSelection ? getExerciseName(selected!) : 'Pick an exercise…', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: hasSelection ? AppColors.textPrimary : AppColors.textMuted, @@ -275,7 +274,7 @@ class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { children: [ Text( 'Select Exercise', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w700, @@ -284,7 +283,7 @@ class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { const Spacer(), Text( '${widget.ids.length} logged', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 12, ), @@ -305,13 +304,13 @@ class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { child: TextField( controller: _search, autofocus: true, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, ), decoration: InputDecoration( hintText: 'Search…', - hintStyle: GoogleFonts.geist( + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 14, ), @@ -345,7 +344,7 @@ class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { ? Center( child: Text( 'No exercises match', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -382,7 +381,7 @@ class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { Expanded( child: Text( name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: isSelected ? AppColors.primary : AppColors.textSoft, @@ -505,14 +504,14 @@ class _OneRMCard extends StatelessWidget { children: [ Text( 'Estimated 1RM', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), ), Text( settings.formatWeight(oneRM), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.primary, fontSize: 28, fontWeight: FontWeight.w800, @@ -527,14 +526,14 @@ class _OneRMCard extends StatelessWidget { children: [ Text( 'Epley formula', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, ), ), Text( 'Best across sets', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, ), @@ -578,7 +577,7 @@ class _GrowthCard extends StatelessWidget { children: [ Text( isGrowing ? 'Growing!' : 'Plateau', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: color, fontSize: 17, fontWeight: FontWeight.w700, @@ -588,7 +587,7 @@ class _GrowthCard extends StatelessWidget { isGrowing ? '+${settings.toDisplay(model.slope.abs() * 7).toStringAsFixed(1)} ${settings.unitLabel}/week' : 'Volume trend is flat', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, ), @@ -601,7 +600,7 @@ class _GrowthCard extends StatelessWidget { children: [ Text( 'R² ${(model.r2 * 100).toStringAsFixed(0)}%', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 13, fontWeight: FontWeight.w700, @@ -609,7 +608,7 @@ class _GrowthCard extends StatelessWidget { ), Text( 'model fit', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, ), @@ -655,7 +654,7 @@ class _ChartSection extends StatelessWidget { chartMode == _ChartMode.volume ? 'Volume Progression' : 'Set Progression', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, @@ -708,7 +707,7 @@ class _ChartModeToggle extends StatelessWidget { ), child: Text( mode == _ChartMode.volume ? 'Volume' : 'Sets', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 11, fontWeight: FontWeight.w700, color: mode == value ? Colors.white : AppColors.textMuted, @@ -883,7 +882,7 @@ class _VolumeChart extends StatelessWidget { : ''; return LineTooltipItem( '$volStr ${settings.unitLabel}', - GoogleFonts.geistMono( + TextStyle(fontFamily: 'GeistMono', color: AppColors.secondary, fontSize: 13, fontWeight: FontWeight.w700, @@ -891,7 +890,7 @@ class _VolumeChart extends StatelessWidget { children: [ TextSpan( text: '\n$dateStr', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.normal, @@ -919,7 +918,7 @@ class _VolumeChart extends StatelessWidget { : v.toStringAsFixed(0); return Text( label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 9, ), @@ -942,7 +941,7 @@ class _VolumeChart extends StatelessWidget { direction: LabelDirection.horizontal, alignment: Alignment.topRight, padding: const EdgeInsets.only(right: 4, bottom: 2), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.warning, fontSize: 9, fontWeight: FontWeight.w600, @@ -1130,12 +1129,12 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { w % 1 == 0 ? w.toStringAsFixed(0) : w.toStringAsFixed(1); return BarTooltipItem( '$setLabel $wStr ${settings.unitLabel}', - GoogleFonts.geistMono( + TextStyle(fontFamily: 'GeistMono', color: _wc, fontSize: 12, fontWeight: FontWeight.w700), children: [ TextSpan( text: '\n$dateLabel', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10, fontWeight: FontWeight.normal), @@ -1145,12 +1144,12 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { } else { return BarTooltipItem( '$setLabel ${set.reps} reps', - GoogleFonts.geistMono( + TextStyle(fontFamily: 'GeistMono', color: _rc, fontSize: 12, fontWeight: FontWeight.w700), children: [ TextSpan( text: '\n$dateLabel', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10, fontWeight: FontWeight.normal), @@ -1256,7 +1255,7 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { leftTitles: AxisTitles( axisNameWidget: Text( settings.unitLabel, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: _wc, fontSize: 9, fontWeight: FontWeight.w700), @@ -1269,7 +1268,7 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}k' : v.toStringAsFixed(0), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: _wc.withValues(alpha: 0.7), fontSize: 9), ), @@ -1278,7 +1277,7 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { rightTitles: AxisTitles( axisNameWidget: Text( 'reps', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: _rc, fontSize: 9, fontWeight: FontWeight.w700), @@ -1291,7 +1290,7 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { final r = (v / scale).round(); if (r <= 0) return const Text(''); return Text('$r', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: _rc.withValues(alpha: 0.7), fontSize: 9)); }, @@ -1313,7 +1312,7 @@ class _SetProgressionChartState extends State<_SetProgressionChart> { return Padding( padding: const EdgeInsets.only(top: 6), child: Text(label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 9)), ); @@ -1368,7 +1367,7 @@ class _ToggleLegend extends StatelessWidget { const SizedBox(width: 5), Text( label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: active ? AppColors.textSoft : AppColors.textFaint, fontSize: 11, ), @@ -1411,7 +1410,7 @@ class _SetModeToggle extends StatelessWidget { ), child: Text( mode == _SetViewMode.recent ? 'Recent' : 'Weekly', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 10, fontWeight: FontWeight.w700, color: mode == value ? Colors.white : AppColors.textMuted, @@ -1447,7 +1446,7 @@ class _SessionHistory extends StatelessWidget { children: [ Text( 'Session History', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, @@ -1463,14 +1462,14 @@ class _SessionHistory extends StatelessWidget { children: [ Text( DateFormat('MMM d, yyyy').format(entry.date), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 13, ), ), Text( '${displayVol.toStringAsFixed(0)} ${settings.unitLabel}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w600, diff --git a/workout-logger/lib/screens/widgets/health_bar_chart.dart b/workout-logger/lib/screens/widgets/health_bar_chart.dart index 1974cd3..de0b1b2 100644 --- a/workout-logger/lib/screens/widgets/health_bar_chart.dart +++ b/workout-logger/lib/screens/widgets/health_bar_chart.dart @@ -9,7 +9,6 @@ import 'dart:math' show max; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/sleep_hr_models.dart'; import '../../theme/app_theme.dart'; @@ -251,7 +250,7 @@ class _HrDayPainter extends CustomPainter { final gridPaint = Paint() ..color = AppColors.glassBorder ..strokeWidth = 0.5; - final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final yStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); for (var v = (axisMin / 30).ceil() * 30.0; v <= axisMax; v += 30) { final y = yFor(v); canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); @@ -293,7 +292,7 @@ class _HrDayPainter extends CustomPainter { } // X-axis time labels (12a / 6a / 12p / 6p / 11p). - final labelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final labelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); const marks = ['12a', '6a', '12p', '6p', '11p']; for (var i = 0; i < marks.length; i++) { final x = _padLeft + (i / (marks.length - 1)) * chartW; @@ -312,7 +311,7 @@ class _HrDayPainter extends CustomPainter { final mm = t.minute.toString().padLeft(2, '0'); final ap = t.hour < 12 ? 'AM' : 'PM'; final lines = ['$h12:$mm $ap', '${b.minBpm}–${b.maxBpm} bpm', 'avg ${b.avgBpm.round()}']; - final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final lineStyle = TextStyle(fontFamily: 'GeistMono', color: Colors.white, fontSize: 9.5); final painters = lines .map((l) => TextPainter(text: TextSpan(text: l, style: lineStyle), textDirection: TextDirection.ltr)..layout()) .toList(); @@ -391,7 +390,7 @@ class _AggBarChartState extends State<_AggBarChart> { child: Center( child: Text( 'No data for this range.', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 12), ), ), ); @@ -468,7 +467,7 @@ class _AggPainter extends CustomPainter { final gridPaint = Paint() ..color = AppColors.glassBorder ..strokeWidth = 0.5; - final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final yStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); for (var v = (axisMin / gridStep).ceil() * gridStep; v <= axisMax; v += gridStep) { final y = yFor(v); canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); @@ -491,7 +490,7 @@ class _AggPainter extends CustomPainter { } final baselineY = yFor(axisMin); - final labelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final labelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); final labelEvery = n > 16 ? 5 : (n > 10 ? 2 : 1); for (var i = 0; i < n; i++) { @@ -574,7 +573,7 @@ class _AggPainter extends CustomPainter { void _paintTooltip(Canvas canvas, Size size, int idx, double slotW, double Function(double) yFor) { final bar = bars[idx]; - final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final lineStyle = TextStyle(fontFamily: 'GeistMono', color: Colors.white, fontSize: 9.5); final painters = bar.tooltip .map((l) => TextPainter( text: TextSpan(text: l, style: lineStyle), diff --git a/workout-logger/lib/screens/widgets/health_detail_shell.dart b/workout-logger/lib/screens/widgets/health_detail_shell.dart index 0529699..fbac10c 100644 --- a/workout-logger/lib/screens/widgets/health_detail_shell.dart +++ b/workout-logger/lib/screens/widgets/health_detail_shell.dart @@ -3,7 +3,6 @@ // Day/Week/Month/Year granularity toggle. The body is supplied by each screen. import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/sleep_hr_models.dart'; import '../../theme/app_theme.dart'; @@ -95,7 +94,7 @@ class HealthDetailShell extends StatelessWidget { const SizedBox(width: 5), Text( title, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -107,7 +106,7 @@ class HealthDetailShell extends StatelessWidget { const SizedBox(height: 1), Text( dateLabel, - style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 11), + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 11), ), ], ), @@ -189,7 +188,7 @@ class _GranularityToggle extends StatelessWidget { alignment: Alignment.center, child: Text( g.label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, fontWeight: FontWeight.w600, color: active ? AppColors.textPrimary : AppColors.textMuted, diff --git a/workout-logger/lib/screens/widgets/heart_rate_card.dart b/workout-logger/lib/screens/widgets/heart_rate_card.dart index 4487caf..ce77cf3 100644 --- a/workout-logger/lib/screens/widgets/heart_rate_card.dart +++ b/workout-logger/lib/screens/widgets/heart_rate_card.dart @@ -6,7 +6,6 @@ import 'dart:math' show max, min; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../../models/sleep_hr_models.dart'; @@ -47,7 +46,7 @@ class HeartRateCard extends StatelessWidget { const SizedBox(width: 5), Text( 'Heart rate', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w700, @@ -59,7 +58,7 @@ class HeartRateCard extends StatelessWidget { const SizedBox(height: 2), Text( 'Today · all-day', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11), ), ], ), @@ -92,7 +91,7 @@ class HeartRateCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: ['12a', '6a', '12p', '6p', 'now'] - .map((l) => Text(l, style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8))) + .map((l) => Text(l, style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8))) .toList(), ), ], @@ -116,14 +115,14 @@ class _MiniStat extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), const SizedBox(height: 1), RichText( text: TextSpan( children: [ TextSpan( text: value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 18, fontWeight: FontWeight.w700, @@ -132,7 +131,7 @@ class _MiniStat extends StatelessWidget { ), TextSpan( text: ' $unit', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10), ), ], ), diff --git a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart index 832411b..1270ca1 100644 --- a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart +++ b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart @@ -8,7 +8,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/widget_previews.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import '../../services/workout_provider.dart'; @@ -89,7 +88,7 @@ class MuscleDetailSheet extends StatelessWidget { Expanded( child: Text( name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w800, @@ -117,7 +116,7 @@ class MuscleDetailSheet extends StatelessWidget { const SizedBox(width: 5), Text( recoveryLabel, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: recoveryColor, fontSize: 12, fontWeight: FontWeight.w700, @@ -137,7 +136,7 @@ class MuscleDetailSheet extends StatelessWidget { : recovery.isUnderRecovered ? 'Still fatigued — consider rest' : 'Recovering', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -153,7 +152,7 @@ class MuscleDetailSheet extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), child: Text( 'No sessions logged for this muscle in the last 7 days.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -200,7 +199,7 @@ class MuscleDetailSheet extends StatelessWidget { Expanded( child: Text( ex.name, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 13, fontWeight: FontWeight.w500, @@ -209,7 +208,7 @@ class MuscleDetailSheet extends StatelessWidget { ), Text( '$volStr ${settings.unitLabel}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w600, @@ -299,7 +298,7 @@ class _VolumeTrendChartView extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), child: Text( 'Not enough data yet.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -327,7 +326,7 @@ class _VolumeTrendChartView extends StatelessWidget { if (i != 0 && i != series.length - 1) return const SizedBox.shrink(); return Text( DateFormat('MMM d').format(series[i].weekStart), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 9, ), @@ -419,7 +418,7 @@ class _RecentSessionsView extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), child: Text( 'No sessions recorded for this muscle yet.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -450,7 +449,7 @@ class _RecentSessionsView extends StatelessWidget { children: [ Text( _relativeDate(s.date), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w600, @@ -459,7 +458,7 @@ class _RecentSessionsView extends StatelessWidget { const SizedBox(height: 2), Text( s.exerciseNames.join(' · '), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w500, @@ -472,7 +471,7 @@ class _RecentSessionsView extends StatelessWidget { ), Text( '$volStr $unitLabel', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w600, @@ -684,7 +683,7 @@ class _AiInsightSectionState extends State<_AiInsightSection> { const SizedBox(width: 5), Text( 'AI Insight', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.w700, @@ -696,7 +695,7 @@ class _AiInsightSectionState extends State<_AiInsightSection> { const SizedBox(height: 6), Text( _insight!, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 13, height: 1.5, @@ -707,7 +706,7 @@ class _AiInsightSectionState extends State<_AiInsightSection> { onTap: () => _openCoach(context), child: Text( 'Continue in Coach →', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.secondary, fontSize: 12, fontWeight: FontWeight.w600, diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 78000c5..55a0c0e 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../../services/debug_log_buffer.dart'; @@ -59,7 +58,7 @@ class _ProfileSection extends StatelessWidget { children: [ Text( title, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontWeight: FontWeight.w700, fontSize: 14, @@ -68,7 +67,7 @@ class _ProfileSection extends StatelessWidget { ), Text( subtitle, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, fontWeight: FontWeight.w400, @@ -174,7 +173,7 @@ class PreferencesSection extends StatelessWidget { ), child: Text( label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: selected ? AppColors.primary : AppColors.textSoft, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontSize: 12, @@ -197,7 +196,7 @@ class PreferencesSection extends StatelessWidget { children: [ Text( 'Show estimated 1RM', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, @@ -206,7 +205,7 @@ class PreferencesSection extends StatelessWidget { const SizedBox(height: 2), Text( 'Display 1-rep max badge on completed sets', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -268,7 +267,7 @@ class HealthConnectSection extends StatelessWidget { children: [ Text( 'Sync workouts after finishing', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, @@ -277,7 +276,7 @@ class HealthConnectSection extends StatelessWidget { const SizedBox(height: 2), Text( 'Writes session + per-set reps to Health Connect', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -303,7 +302,7 @@ class HealthConnectSection extends StatelessWidget { const SizedBox(width: 8), Text( 'Connected — syncing after each workout', - style: GoogleFonts.geist(color: _hcColor, fontSize: 12), + style: TextStyle(fontFamily: 'Geist', color: _hcColor, fontSize: 12), ), ], ), @@ -319,7 +318,7 @@ class HealthConnectSection extends StatelessWidget { children: [ Text( 'Readiness insights', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, @@ -328,7 +327,7 @@ class HealthConnectSection extends StatelessWidget { const SizedBox(height: 2), Text( 'Reads sleep & heart data to score daily recovery', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -436,13 +435,13 @@ class CloudSyncSection extends StatelessWidget { ), child: TextField( enabled: false, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 12, ), decoration: InputDecoration( hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: GoogleFonts.geistMono( + hintStyle: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 12, ), @@ -462,7 +461,7 @@ class CloudSyncSection extends StatelessWidget { const SizedBox(height: AppSpacing.sm), Text( 'Cloud sync with custom MongoDB will be available in a future update.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, fontStyle: FontStyle.italic, @@ -557,7 +556,7 @@ class _SectionLabel extends StatelessWidget { Widget build(BuildContext context) { return Text( text, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 9, fontWeight: FontWeight.w600, @@ -609,7 +608,7 @@ class _UnitToggleButton extends StatelessWidget { child: Center( child: Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: selected ? AppColors.primary : AppColors.textSoft, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, fontSize: 14, @@ -668,7 +667,7 @@ class _ActionTile extends StatelessWidget { children: [ Text( title, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, @@ -676,7 +675,7 @@ class _ActionTile extends StatelessWidget { ), Text( subtitle, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -727,7 +726,7 @@ class _InfoTile extends StatelessWidget { const SizedBox(width: 12), Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -735,7 +734,7 @@ class _InfoTile extends StatelessWidget { const Spacer(), Text( value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w500, @@ -826,7 +825,7 @@ class _AiSettingsSectionState extends State { ), child: Text( 'Active', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.success, fontSize: 10, fontWeight: FontWeight.w600, @@ -854,13 +853,13 @@ class _AiSettingsSectionState extends State { enableSuggestions: false, autocorrect: false, keyboardType: TextInputType.visiblePassword, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 12, ), decoration: InputDecoration( hintText: 'AIza…', - hintStyle: GoogleFonts.geistMono( + hintStyle: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 12, ), @@ -889,7 +888,7 @@ class _AiSettingsSectionState extends State { const SizedBox(height: AppSpacing.sm), Text( 'Get a free key at aistudio.google.com. Stored locally on-device.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, fontStyle: FontStyle.italic, @@ -923,7 +922,7 @@ class _AiSettingsSectionState extends State { ), child: Text( label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: selected ? AppColors.primary : AppColors.textSoft, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontSize: 11, @@ -962,7 +961,7 @@ class _AiSettingsSectionState extends State { ) : Text( 'Save API Key', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w600, @@ -981,7 +980,7 @@ class _AiSettingsSectionState extends State { onTap: () => context.read().resetUsage(), child: Text( 'Reset', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.accent, fontSize: 11, fontWeight: FontWeight.w600, @@ -1013,7 +1012,7 @@ class _AiSettingsSectionState extends State { const SizedBox(height: AppSpacing.sm), Text( 'Cumulative billable tokens across coach, program builder & insights.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, fontStyle: FontStyle.italic, @@ -1038,11 +1037,11 @@ class _UsageRow extends StatelessWidget { children: [ Text( label, - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12), ), Text( value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w600, @@ -1084,7 +1083,7 @@ class _DebugLogSheet extends StatelessWidget { children: [ Text( 'Debug Logs', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontWeight: FontWeight.w700, fontSize: 14, @@ -1095,7 +1094,7 @@ class _DebugLogSheet extends StatelessWidget { onPressed: () => DebugLogBuffer.instance.clear(), child: Text( 'Clear', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.accent, fontSize: 12, fontWeight: FontWeight.w600, @@ -1119,7 +1118,7 @@ class _DebugLogSheet extends StatelessWidget { return Center( child: Text( 'No logs yet', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13), ), ); } @@ -1141,7 +1140,7 @@ class _DebugLogSheet extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 1), child: Text( line, - style: GoogleFonts.geistMono(fontSize: 10, color: color), + style: TextStyle(fontFamily: 'GeistMono', fontSize: 10, color: color), ), ); }, @@ -1170,7 +1169,7 @@ class _ComingSoonBadge extends StatelessWidget { ), child: Text( 'Soon', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.warning, fontSize: 10, fontWeight: FontWeight.w600, diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart index 2c42625..521243e 100644 --- a/workout-logger/lib/screens/widgets/readiness_card.dart +++ b/workout-logger/lib/screens/widgets/readiness_card.dart @@ -5,7 +5,6 @@ // (or with the feature disabled) never see an empty state. import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../../models/models.dart'; @@ -62,7 +61,7 @@ class ReadinessCard extends StatelessWidget { children: [ Text( _headline(snapshot.band!), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w700, @@ -72,7 +71,7 @@ class ReadinessCard extends StatelessWidget { const SizedBox(height: 3), Text( _subtitle(snapshot), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), @@ -173,7 +172,7 @@ class _ScoreRing extends StatelessWidget { ), Text( '$score', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -213,7 +212,7 @@ class _ReadinessDetailsSheet extends StatelessWidget { const SizedBox(height: 18), Text( 'Readiness · ${snapshot.score}', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w700, @@ -223,7 +222,7 @@ class _ReadinessDetailsSheet extends StatelessWidget { const SizedBox(height: 4), Text( 'As of $time, from your watch via Health Connect', - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12), ), const SizedBox(height: 18), if (snapshot.sleepScore != null) @@ -252,7 +251,7 @@ class _ReadinessDetailsSheet extends StatelessWidget { 'Each factor compares last night and this morning to your own ' '14-day average — only dips below your normal lower the score. ' 'Accuracy improves after about 5 nights of watch data.', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, height: 1.5, @@ -294,7 +293,7 @@ class _ComponentRow extends StatelessWidget { children: [ Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 13, fontWeight: FontWeight.w600, @@ -302,7 +301,7 @@ class _ComponentRow extends StatelessWidget { ), Text( value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textMuted, fontSize: 12, ), diff --git a/workout-logger/lib/screens/widgets/rf_question_card.dart b/workout-logger/lib/screens/widgets/rf_question_card.dart index fbdd0eb..719131a 100644 --- a/workout-logger/lib/screens/widgets/rf_question_card.dart +++ b/workout-logger/lib/screens/widgets/rf_question_card.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/models.dart'; import '../../theme/app_theme.dart'; @@ -76,7 +75,7 @@ class _RFQuestionCardState extends State { const SizedBox(width: AppSpacing.xs), Text( 'QUICK QUESTIONS', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1.2, @@ -143,7 +142,7 @@ class _QuestionBlock extends StatelessWidget { children: [ Text( spec.question, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -167,13 +166,13 @@ class _QuestionBlock extends StatelessWidget { const SizedBox(height: AppSpacing.sm), TextField( controller: controller, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textPrimary, ), decoration: InputDecoration( hintText: 'Or type your own answer…', - hintStyle: GoogleFonts.geist( + hintStyle: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textFaint, ), @@ -243,7 +242,7 @@ class _OptionChip extends StatelessWidget { ), child: Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? AppColors.secondary : AppColors.textSoft, diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index c99ea32..14ae34e 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -5,7 +5,6 @@ import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; // ── Route helper ────────────────────────────────────────────────────────────── @@ -286,7 +285,7 @@ class _NavItem extends StatelessWidget { const SizedBox(height: 4), Text( item.label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, fontWeight: active ? FontWeight.w600 : FontWeight.w500, color: active ? AppColors.textPrimary : AppColors.textMuted, diff --git a/workout-logger/lib/screens/widgets/sleep_hr_card.dart b/workout-logger/lib/screens/widgets/sleep_hr_card.dart index 3e1af45..e60ad59 100644 --- a/workout-logger/lib/screens/widgets/sleep_hr_card.dart +++ b/workout-logger/lib/screens/widgets/sleep_hr_card.dart @@ -6,7 +6,6 @@ import 'dart:math' show min; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../../models/sleep_hr_models.dart'; @@ -48,7 +47,7 @@ class SleepHrCard extends StatelessWidget { children: [ Text( 'Sleep heart rate', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w700, @@ -58,7 +57,7 @@ class SleepHrCard extends StatelessWidget { const SizedBox(height: 2), Text( 'Last night · $startFmt – $endFmt', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, ), @@ -159,7 +158,7 @@ class _MiniStat extends StatelessWidget { children: [ Text( label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10, ), @@ -170,7 +169,7 @@ class _MiniStat extends StatelessWidget { children: [ TextSpan( text: value, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 18, fontWeight: FontWeight.w700, @@ -179,7 +178,7 @@ class _MiniStat extends StatelessWidget { ), TextSpan( text: ' $unit', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10, ), diff --git a/workout-logger/lib/screens/widgets/sleep_hr_charts.dart b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart index 1c2c1e2..f50cd95 100644 --- a/workout-logger/lib/screens/widgets/sleep_hr_charts.dart +++ b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart @@ -8,7 +8,6 @@ import 'dart:math' show min, max; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/sleep_hr_models.dart'; import '../../theme/app_theme.dart'; @@ -73,7 +72,7 @@ class SleepHrDayView extends StatelessWidget { Text( 'Heart rate during sleep · 10-min bars', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), ), const SizedBox(height: 8), _InteractiveBarChart(segments: snapshot.segments), @@ -85,7 +84,7 @@ class SleepHrDayView extends StatelessWidget { Text( 'HR range by stage', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), ), const SizedBox(height: 10), _StageDistributionChart( @@ -124,12 +123,12 @@ class _StatPill extends StatelessWidget { children: [ Text( label.toUpperCase(), - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), ), const SizedBox(height: 2), Text( value, - style: GoogleFonts.geistMono(color: color, fontSize: 14, fontWeight: FontWeight.w700), + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 14, fontWeight: FontWeight.w700), ), ], ), @@ -220,7 +219,7 @@ class _BarChartPainter extends CustomPainter { final gridPaint = Paint() ..color = AppColors.glassBorder ..strokeWidth = 0.5; - final yLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final yLabelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); final gridBpms = []; for (var b = (bpmMin ~/ 10) * 10; b <= bpmMax; b += 10) { @@ -271,7 +270,7 @@ class _BarChartPainter extends CustomPainter { } canvas.drawPath(path, avgPaint); - final xLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final xLabelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); for (var i = 0; i < n; i += 6) { final t = _toIst(segments[i].windowStart); final h = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; @@ -319,7 +318,7 @@ class _BarChartPainter extends CustomPainter { }[seg.stage] ?? seg.stage; final lines = ['$th:$tm–$eh:$em IST', '${seg.minBpm}–${seg.maxBpm} bpm', stageName]; - final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final lineStyle = TextStyle(fontFamily: 'GeistMono', color: Colors.white, fontSize: 9.5); final painters = lines .map((l) => TextPainter( text: TextSpan(text: l, style: lineStyle), @@ -360,7 +359,7 @@ class _BarChartPainter extends CustomPainter { final stagePainter = TextPainter( text: TextSpan( text: stageName, - style: GoogleFonts.geistMono(color: color, fontSize: 9.5, fontWeight: FontWeight.w700), + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 9.5, fontWeight: FontWeight.w700), ), textDirection: TextDirection.ltr, )..layout(); @@ -430,7 +429,7 @@ class _Legend extends StatelessWidget { decoration: BoxDecoration(color: e.$2, borderRadius: BorderRadius.circular(2)), ), const SizedBox(width: 4), - Text(e.$1, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(e.$1, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], )), Row( @@ -438,7 +437,7 @@ class _Legend extends StatelessWidget { children: [ SizedBox(width: 14, height: 10, child: CustomPaint(painter: _DashLinePainter())), const SizedBox(width: 4), - Text('Avg trend', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text('Avg trend', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ), ], @@ -481,7 +480,7 @@ class _StageDistributionChart extends StatelessWidget { if (stats.isEmpty) { return Text( 'No stage HR data available.', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 12), ); } @@ -537,7 +536,7 @@ class _DistRow extends StatelessWidget { child: Text( label, textAlign: TextAlign.right, - style: GoogleFonts.geist(color: color, fontSize: 10, fontWeight: FontWeight.w600), + style: TextStyle(fontFamily: 'Geist', color: color, fontSize: 10, fontWeight: FontWeight.w600), ), ), const SizedBox(width: 8), @@ -591,7 +590,7 @@ class _DistRow extends StatelessWidget { top: 0, child: Text( '${stats.avgBpm.round()} bpm', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 8, fontWeight: FontWeight.w700, @@ -635,7 +634,7 @@ class _DistAxis extends StatelessWidget { left: (pct(t.toDouble()) * w - 10).clamp(0, w - 20), child: Text( '$t', - style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8), + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8), ), )) .toList(), @@ -701,7 +700,7 @@ class _DistLi extends StatelessWidget { children: [ swatch, const SizedBox(width: 4), - Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), ], ); } diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart index 5cc1744..6cb2978 100644 --- a/workout-logger/lib/screens/widgets/targets_tab.dart +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import '../../models/models.dart'; @@ -81,7 +80,7 @@ class TargetsTab extends StatelessWidget { icon: const Icon(Icons.add_rounded, color: Colors.white), label: Text( 'New Target', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: Colors.white, fontWeight: FontWeight.w700, ), @@ -171,7 +170,7 @@ class _SummaryChip extends StatelessWidget { ), child: Text( label, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 11, fontWeight: FontWeight.w600, @@ -280,7 +279,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { Expanded( child: Text( widget.exerciseName, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w600, @@ -303,7 +302,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { ), child: Text( _statusWord, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: _statusColor, fontSize: 10, fontWeight: FontWeight.w700, @@ -330,14 +329,14 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { Text( '${settings.toDisplay(t.currentValue).toStringAsFixed(1)} / ' '${settings.toDisplay(t.targetValue).toStringAsFixed(1)} ${settings.unitLabel}', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, ), ), Text( '${pct.toStringAsFixed(0)}%', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w700, @@ -356,7 +355,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { const SizedBox(width: 4), Text( 'Est. $etaStr', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), @@ -412,7 +411,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { const SizedBox(width: 4), Text( 'AI Tip', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.warning, fontSize: 11, fontWeight: FontWeight.w700, @@ -423,7 +422,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { const SizedBox(height: 4), Text( _nudge!, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, height: 1.5, @@ -434,7 +433,7 @@ class _TargetCardWithAiState extends State<_TargetCardWithAi> { onTap: _openCoach, child: Text( 'Continue in Coach →', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.secondary, fontSize: 11, fontWeight: FontWeight.w600, @@ -546,7 +545,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { ), Text( 'New Target', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w800, @@ -556,7 +555,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { Text( 'EXERCISE', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w700, @@ -578,12 +577,12 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { dropdownColor: AppColors.cardHigh, hint: Text( 'Select exercise…', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, ), ), - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, ), @@ -606,7 +605,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { const SizedBox(height: AppSpacing.md), Text( 'TARGET TYPE', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w700, @@ -641,7 +640,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { child: Text( t.$2, textAlign: TextAlign.center, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: selected ? AppColors.primary : AppColors.textMuted, @@ -660,7 +659,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { const SizedBox(height: AppSpacing.md), Text( 'TARGET VALUE', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w700, @@ -681,14 +680,14 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { FilteringTextInputFormatter.allow( RegExp(r'^\d*\.?\d*$')), ], - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, ), decoration: InputDecoration( hintText: 'e.g. 100', hintStyle: - GoogleFonts.geist(color: AppColors.textMuted), + TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, @@ -711,7 +710,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { const SizedBox(width: 5), Text( 'Suggest a target based on my progress', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w600, @@ -740,7 +739,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { Expanded( child: Text( _suggestionText!, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, height: 1.4, diff --git a/workout-logger/lib/screens/widgets/volume_chart.dart b/workout-logger/lib/screens/widgets/volume_chart.dart index ff779b7..e744bf7 100644 --- a/workout-logger/lib/screens/widgets/volume_chart.dart +++ b/workout-logger/lib/screens/widgets/volume_chart.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; class VolumeChart extends StatelessWidget { @@ -35,7 +34,7 @@ class VolumeChart extends StatelessWidget { children: labels .map((l) => Text( l, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: 9, color: AppColors.textFaint, ), diff --git a/workout-logger/lib/screens/widgets/wheel_picker.dart b/workout-logger/lib/screens/widgets/wheel_picker.dart index 56d83a0..7ef8f2a 100644 --- a/workout-logger/lib/screens/widgets/wheel_picker.dart +++ b/workout-logger/lib/screens/widgets/wheel_picker.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; /// Two-column wheel picker for weight + reps input. @@ -159,7 +158,7 @@ class _SingleWheelState extends State<_SingleWheel> { children: [ Text( widget.label, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textMuted, @@ -169,7 +168,7 @@ class _SingleWheelState extends State<_SingleWheel> { if (widget.unit.isNotEmpty) Text( widget.unit, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textFaint, ), @@ -217,7 +216,7 @@ class _SingleWheelState extends State<_SingleWheel> { return Center( child: Text( widget.formatter(widget.values[i]), - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', fontSize: isCurrent ? 28 : 16, fontWeight: FontWeight.w600, color: isCurrent diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart index 50665cb..729ae49 100644 --- a/workout-logger/lib/screens/widgets/workout_header.dart +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; @@ -111,7 +110,7 @@ class _WorkoutHeaderState extends State { children: [ Text( widget.exerciseName, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w600, @@ -127,7 +126,7 @@ class _WorkoutHeaderState extends State { children: [ Text( 'Exercise ${widget.currentExerciseIndex + 1} of ${widget.totalExercises} · Set ${widget.setNumber}', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, ), @@ -157,7 +156,7 @@ class _WorkoutHeaderState extends State { const SizedBox(width: 4), Text( _elapsedLabel, - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textSoft, fontSize: 12, ), diff --git a/workout-logger/lib/screens/widgets/workout_hr_section.dart b/workout-logger/lib/screens/widgets/workout_hr_section.dart index c4b6df7..4dd59d0 100644 --- a/workout-logger/lib/screens/widgets/workout_hr_section.dart +++ b/workout-logger/lib/screens/widgets/workout_hr_section.dart @@ -8,7 +8,6 @@ import 'dart:math' show max, min; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../../models/models.dart'; @@ -83,9 +82,9 @@ class _WorkoutHrSectionState extends State { children: [ Text( 'HR across the session · ⚑ = exercise', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11), ), - Text('bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11)), + Text('bpm', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11)), ], ), const SizedBox(height: 8), @@ -113,7 +112,7 @@ class _WorkoutHrSectionState extends State { alignment: Alignment.center, child: Text( _expanded ? 'Hide per-rest breakdown ▴' : 'Show per-rest breakdown ▾', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, fontWeight: FontWeight.w600, @@ -130,7 +129,7 @@ class _WorkoutHrSectionState extends State { Text( 'Per-rest recovery needs per-set timing, which this workout ' 'didn\'t record.', - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, height: 1.4), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, height: 1.4), ), ], ], @@ -177,7 +176,7 @@ class _RecoverySummary extends StatelessWidget { children: [ Text( '${analysis.restsRecovered}/${analysis.restCount}', - style: GoogleFonts.geistMono( + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.success, fontSize: 18, fontWeight: FontWeight.w700, @@ -187,7 +186,7 @@ class _RecoverySummary extends StatelessWidget { Expanded( child: RichText( text: TextSpan( - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11, height: 1.4), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, height: 1.4), children: [ const TextSpan( text: 'rests brought your HR down\n', @@ -238,7 +237,7 @@ class _RestRow extends StatelessWidget { children: [ Text( 'After set ${rest.afterSet} · rest ${rest.durationSec}s', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 12, fontWeight: FontWeight.w600, @@ -247,14 +246,14 @@ class _RestRow extends StatelessWidget { const SizedBox(height: 1), Text( 'peak ${rest.peakBpm} → low ${rest.troughBpm} bpm${ok ? '' : ' · too short'}', - style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 10), + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 10), ), ], ), ), Text( '−${rest.recoveryBpm} bpm', - style: GoogleFonts.geistMono(color: color, fontSize: 14, fontWeight: FontWeight.w700), + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 14, fontWeight: FontWeight.w700), ), ], ), @@ -285,16 +284,16 @@ class _Pill extends StatelessWidget { children: [ Text( label.toUpperCase(), - style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), ), const SizedBox(height: 2), RichText( text: TextSpan(children: [ TextSpan( text: value, - style: GoogleFonts.geistMono(color: color, fontSize: 16, fontWeight: FontWeight.w700), + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 16, fontWeight: FontWeight.w700), ), - TextSpan(text: ' bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9)), + TextSpan(text: ' bpm', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9)), ]), ), ], @@ -337,7 +336,7 @@ class _CurvePainter extends CustomPainter { final grid = Paint() ..color = AppColors.glassBorder ..strokeWidth = 0.5; - final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final yStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); for (var v = (vmin / 20).ceil() * 20; v <= vmax; v += 20) { final yy = y(v.toDouble()); canvas.drawLine(Offset(_padLeft, yy), Offset(size.width - 4, yy), grid); @@ -402,7 +401,7 @@ class _CurvePainter extends CustomPainter { final tp = TextPainter( text: TextSpan( text: s.label, - style: GoogleFonts.geist(color: AppColors.textSoft, fontSize: 8, fontWeight: FontWeight.w600), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 8, fontWeight: FontWeight.w600), ), textDirection: TextDirection.ltr, maxLines: 1, @@ -418,7 +417,7 @@ class _CurvePainter extends CustomPainter { } // X labels (minutes). - final xStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final xStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); final totalMin = (spanMs / 60000).round(); final stepMin = totalMin <= 0 ? 1 : (totalMin / 4).ceil(); for (var m = 0; m <= totalMin; m += stepMin) { diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index b5dd37e..cdcab5b 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; @@ -410,7 +409,7 @@ class _WorkoutFlowScreenState extends State { const SizedBox(width: 6), Text( 'Prev', - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textMuted, @@ -453,7 +452,7 @@ class _WorkoutFlowScreenState extends State { child: Text( isLast ? 'Finish' : 'Next exercise', overflow: TextOverflow.ellipsis, - style: GoogleFonts.geist( + style: TextStyle(fontFamily: 'Geist', fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white, diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index 7f02c15..5e2bcef 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; // ── AppColors ────────────────────────────────────────────────────────────── // Single source of truth for all colour tokens. Never use hex literals in @@ -121,7 +120,7 @@ class AppTheme { foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: GoogleFonts.geist( + titleTextStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w700, @@ -144,7 +143,7 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), - textStyle: GoogleFonts.geist( + textStyle: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.2, @@ -210,64 +209,64 @@ class AppTheme { static TextTheme _buildTextTheme() { return TextTheme( - headlineLarge: GoogleFonts.geist( + headlineLarge: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -1.28, ), - headlineMedium: GoogleFonts.geist( + headlineMedium: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: -1.12, ), - headlineSmall: GoogleFonts.geist( + headlineSmall: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.88, ), - titleLarge: GoogleFonts.geist( + titleLarge: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w600, ), - titleMedium: GoogleFonts.geist( + titleMedium: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w600, ), - titleSmall: GoogleFonts.geist( + titleSmall: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 13, fontWeight: FontWeight.w500, ), - bodyLarge: GoogleFonts.geist( + bodyLarge: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, ), - bodyMedium: GoogleFonts.geist( + bodyMedium: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 14, ), - bodySmall: GoogleFonts.geist( + bodySmall: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12, ), - labelLarge: GoogleFonts.geist( + labelLarge: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.4, ), - labelMedium: GoogleFonts.geist( + labelMedium: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: 0.3, ), - labelSmall: GoogleFonts.geist( + labelSmall: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, fontWeight: FontWeight.w500, diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index d4a544d..74f1eba 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -52,9 +52,6 @@ dependencies: http: ^1.2.1 package_info_plus: ^8.3.1 - # Fonts — Geist Sans + Geist Mono (requires ^8.0.0 for Geist support) - google_fonts: ^8.0.0 - # Health Connect integration health_connector: ^3.9.1 @@ -105,25 +102,13 @@ flutter: # For details regarding adding assets from package dependencies, see # https://flutter.dev/to/asset-from-package - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + fonts: + - family: Geist + fonts: + - asset: assets/fonts/Geist-Variable.ttf + - family: GeistMono + fonts: + - asset: assets/fonts/GeistMono-Variable.ttf flutter_launcher_icons: android: true