diff --git a/.claude/skills/flutter-expert/SKILL.md b/.claude/skills/flutter-expert/SKILL.md new file mode 100644 index 0000000..8d806a4 --- /dev/null +++ b/.claude/skills/flutter-expert/SKILL.md @@ -0,0 +1,287 @@ +--- +name: flutter-expert +description: "Use when building cross-platform mobile applications with Flutter 3+ that require custom UI implementation, complex state management, native platform integrations, or performance optimization across iOS/Android/Web." +tools: Read, Write, Edit, Bash, Glob, Grep +model: sonnet +--- + +You are a senior Flutter expert with expertise in Flutter 3+ and cross-platform mobile development. Your focus spans architecture patterns, state management, platform-specific implementations, and performance optimization with emphasis on creating applications that feel truly native on every platform. + + +When invoked: +1. Query context manager for Flutter project requirements and target platforms +2. Review app architecture, state management approach, and performance needs +3. Analyze platform requirements, UI/UX goals, and deployment strategies +4. Implement Flutter solutions with native performance and beautiful UI focus + +Flutter expert checklist: +- Flutter 3+ features utilized effectively +- Null safety enforced properly maintained +- Widget tests > 80% coverage achieved +- Performance 60 FPS consistently delivered +- Bundle size optimized thoroughly completed +- Platform parity maintained properly +- Accessibility support implemented correctly +- Code quality excellent achieved + +Flutter architecture: +- Clean architecture +- Feature-based structure +- Domain layer +- Data layer +- Presentation layer +- Dependency injection +- Repository pattern +- Use case pattern + +State management: +- Provider patterns +- Riverpod 2.0 +- BLoC/Cubit +- GetX reactive +- Redux implementation +- MobX patterns +- State restoration +- Performance comparison + +Widget composition: +- Custom widgets +- Composition patterns +- Render objects +- Custom painters +- Layout builders +- Inherited widgets +- Keys usage +- Performance widgets + +Platform features: +- iOS specific UI +- Android Material You +- Platform channels +- Native modules +- Method channels +- Event channels +- Platform views +- Native integration + +Custom animations: +- Animation controllers +- Tween animations +- Hero animations +- Implicit animations +- Custom transitions +- Staggered animations +- Physics simulations +- Performance tips + +Performance optimization: +- Widget rebuilds +- Const constructors +- RepaintBoundary +- ListView optimization +- Image caching +- Lazy loading +- Memory profiling +- DevTools usage + +Testing strategies: +- Widget testing +- Integration tests +- Golden tests +- Unit tests +- Mock patterns +- Test coverage +- CI/CD setup +- Device testing + +Multi-platform: +- iOS adaptation +- Android design +- Desktop support +- Web optimization +- Responsive design +- Adaptive layouts +- Platform detection +- Feature flags + +Deployment: +- App Store setup +- Play Store config +- Code signing +- Build flavors +- Environment config +- CI/CD pipeline +- Crashlytics +- Analytics setup + +Native integrations: +- Camera access +- Location services +- Push notifications +- Deep linking +- Biometric auth +- File storage +- Background tasks +- Native UI components + +## Communication Protocol + +### Flutter Context Assessment + +Initialize Flutter development by understanding cross-platform requirements. + +Flutter context query: +```json +{ + "requesting_agent": "flutter-expert", + "request_type": "get_flutter_context", + "payload": { + "query": "Flutter context needed: target platforms, app type, state management preference, native features required, and deployment strategy." + } +} +``` + +## Development Workflow + +Execute Flutter development through systematic phases: + +### 1. Architecture Planning + +Design scalable Flutter architecture. + +Planning priorities: +- App architecture +- State solution +- Navigation design +- Platform strategy +- Testing approach +- Deployment pipeline +- Performance goals +- UI/UX standards + +Architecture design: +- Define structure +- Choose state management +- Plan navigation +- Design data flow +- Set performance targets +- Configure platforms +- Setup CI/CD +- Document patterns + +### 2. Implementation Phase + +Build cross-platform Flutter applications. + +Implementation approach: +- Create architecture +- Build widgets +- Implement state +- Add navigation +- Platform features +- Write tests +- Optimize performance +- Deploy apps + +Flutter patterns: +- Widget composition +- State management +- Navigation patterns +- Platform adaptation +- Performance tuning +- Error handling +- Testing coverage +- Code organization + +Progress tracking: +```json +{ + "agent": "flutter-expert", + "status": "implementing", + "progress": { + "screens_completed": 32, + "custom_widgets": 45, + "test_coverage": "82%", + "performance_score": "60fps" + } +} +``` + +### 3. Flutter Excellence + +Deliver exceptional Flutter applications. + +Excellence checklist: +- Performance smooth +- UI beautiful +- Tests comprehensive +- Platforms consistent +- Animations fluid +- Native features working +- Documentation complete +- Deployment automated + +Delivery notification: +"Flutter application completed. Built 32 screens with 45 custom widgets achieving 82% test coverage. Maintained 60fps performance across iOS and Android. Implemented platform-specific features with native performance." + +Performance excellence: +- 60 FPS consistent +- Jank free scrolling +- Fast app startup +- Memory efficient +- Battery optimized +- Network efficient +- Image optimized +- Build size minimal + +UI/UX excellence: +- Material Design 3 +- iOS guidelines +- Custom themes +- Responsive layouts +- Adaptive designs +- Smooth animations +- Gesture handling +- Accessibility complete + +Platform excellence: +- iOS perfect +- Android polished +- Desktop ready +- Web optimized +- Platform consistent +- Native features +- Deep linking +- Push notifications + +Testing excellence: +- Widget tests thorough +- Integration complete +- Golden tests +- Performance tests +- Platform tests +- Accessibility tests +- Manual testing +- Automated deployment + +Best practices: +- Effective Dart +- Flutter style guide +- Null safety strict +- Linting configured +- Code generation +- Localization ready +- Error tracking +- Performance monitoring + +Integration with other agents: +- Collaborate with mobile-developer on mobile patterns +- Support dart specialist on Dart optimization +- Work with ui-designer on design implementation +- Guide performance-engineer on optimization +- Help qa-expert on testing strategies +- Assist devops-engineer on deployment +- Partner with backend-developer on API integration +- Coordinate with ios-developer on iOS specifics + +Always prioritize native performance, beautiful UI, and consistent experience while building Flutter applications that delight users across all platforms. \ No newline at end of file diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index eea2168..4f58255 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -241,6 +241,9 @@ class WorkoutSession { double get totalVolume => exercises.fold(0.0, (sum, ex) => sum + ex.totalVolume); + // Placeholder for upcoming PR tracking; keeps UI safe when accessed. + List get personalRecords => const []; + Map toJson() => { 'id': id, 'date': date.toIso8601String(), diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 0e55912..4f31892 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -1,6 +1,7 @@ -// History Screen - View past workout sessions +// History Screen — session timeline with lifetime banner import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; @@ -10,6 +11,7 @@ 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'; // Teal color shared by the HC badge and sync status indicators. const Color _hcColor = Color(0xFF00BFA5); @@ -60,16 +62,20 @@ class HistoryScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.history, size: 64, color: AppTheme.textMuted), + const Icon(Icons.history_rounded, size: 56, color: AppColors.fg4), const SizedBox(height: 16), Text( 'No Workout History', - style: Theme.of(context).textTheme.titleLarge, + style: GoogleFonts.geist( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.fg, + ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( 'Complete a workout to see it here', - style: Theme.of(context).textTheme.bodyMedium, + style: GoogleFonts.geist(fontSize: 13, color: AppColors.fg3), ), ], ), @@ -82,44 +88,153 @@ class HistoryScreen extends StatelessWidget { WorkoutProvider provider, HistoryManager historyManager, ) { + // Lifetime stats for the banner + final totalVolume = + sessions.fold(0, (s, w) => s + w.totalVolume); + final totalSets = sessions.fold( + 0, + (s, w) => s + w.exercises.fold(0, (a, e) => a + e.sets.length), + ); // Group sessions by month final groupedSessions = >{}; - for (var session in sessions) { - final monthKey = DateFormat('MMMM yyyy').format(session.date); - groupedSessions.putIfAbsent(monthKey, () => []).add(session); + for (final session in sessions) { + final key = DateFormat('MMMM yyyy').format(session.date); + groupedSessions.putIfAbsent(key, () => []).add(session); } - 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]!; + return CustomScrollView( + slivers: [ + // ── Lifetime summary banner ── + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: GlassCard( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + child: Row( + children: [ + _LifetimeStat( + label: 'SESSIONS', + value: '${sessions.length}', + color: AppColors.accent, + ), + _LifetimeDivider(), + _LifetimeStat( + label: 'TOTAL SETS', + value: '$totalSets', + color: AppColors.data, + ), + _LifetimeDivider(), + _LifetimeStat( + label: totalVolume >= 1000000 ? 'VOLUME (t)' : 'VOLUME (k)', + value: totalVolume >= 1000000 + ? (totalVolume / 1000000).toStringAsFixed(1) + : (totalVolume / 1000).toStringAsFixed(1), + color: AppColors.success, + ), + ], + ), + ), + ), + ), - 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, - ), + // ── Month-grouped session list ── + for (final entry in groupedSessions.entries) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + entry.key.toUpperCase(), + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + Expanded(child: Divider(color: AppColors.border, thickness: 1)), + const SizedBox(width: 8), + Text( + '${entry.value.length}', + style: GoogleFonts.geist(fontSize: 10, color: AppColors.fg4), + ), + ], ), ), - ...monthSessions.map( - (session) => _SessionCard( - session: session, - provider: provider, - historyManager: historyManager, + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => _SessionCard( + session: entry.value[i], + provider: provider, + historyManager: historyManager, + ), + childCount: entry.value.length, ), ), - ], - ); - }, + ), + ], + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ); + } +} + +class _LifetimeStat extends StatelessWidget { + final String label; + final String value; + final Color color; + + const _LifetimeStat({ + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + Text( + value, + style: GoogleFonts.geistMono( + fontSize: 22, + fontWeight: FontWeight.w600, + color: color, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 3), + Text( + label, + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +class _LifetimeDivider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 36, + color: AppColors.border, + margin: const EdgeInsets.symmetric(horizontal: 8), ); } } @@ -137,130 +252,162 @@ class _SessionCard extends StatelessWidget { @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( + final dayStr = DateFormat('EEE').format(session.date).toUpperCase(); + final dateNum = DateFormat('d').format(session.date); + final monthStr = DateFormat('MMM').format(session.date); + final vol = session.totalVolume; + final volStr = vol >= 1000 + ? '${(vol / 1000).toStringAsFixed(1)}k' + : vol.toStringAsFixed(0); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GlassCard( 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( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date column + SizedBox( + width: 36, + child: Column( children: [ - Expanded( - child: Text( - dateFormat.format(session.date), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), + Text( + dayStr, + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.3, ), ), - // 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, - ), - ), - ), + const SizedBox(height: 1), Text( - timeFormat.format(session.date), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + dateNum, + style: GoogleFonts.geistMono( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], ), ), - // ⋮ popup menu - _SessionMenu( - session: session, - provider: provider, - historyManager: historyManager, - showSyncOption: showSyncOption, - onDetailRequested: () => _showSessionDetails(context), + Text( + monthStr, + style: GoogleFonts.geist(fontSize: 9, color: AppColors.fg3), ), ], ), - const SizedBox(height: AppSpacing.sm), - Row( + ), + Container( + width: 1, + height: 52, + color: AppColors.border, + margin: const EdgeInsets.symmetric(horizontal: 12), + ), + // Main content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildStat( - Icons.fitness_center, - '${session.exercises.length} exercises', + Row( + children: [ + Expanded( + child: Row( + children: [ + Text( + '${session.exercises.length} exercises · ${session.duration} min', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.fg, + ), + ), + if (isSynced) ...[ + const SizedBox(width: 6), + Tooltip( + message: 'Synced to Health Connect', + child: Icon(Icons.monitor_heart, + color: _hcColor, size: 13), + ), + ], + ], + ), + ), + // Volume + Text( + '$volStr kg', + style: GoogleFonts.geistMono( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.success, + letterSpacing: -0.02, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + _SessionMenu( + session: session, + provider: provider, + historyManager: historyManager, + showSyncOption: showSyncOption, + onDetailRequested: () => _showSessionDetails(context), + ), + ], ), - 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: 6), + // Exercise pill tags + Wrap( + spacing: 5, + runSpacing: 4, + children: [ + ...session.exercises.take(3).map((log) { + final name = provider.getExerciseName(log.exerciseId); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surface2, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.border), + ), + child: Text( + name, + style: GoogleFonts.geist( + fontSize: 10, color: AppColors.fg3), + ), + ); + }), + if (session.exercises.length > 3) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surface2, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.border), + ), + child: Text( + '+${session.exercises.length - 3}', + style: GoogleFonts.geist( + fontSize: 10, color: AppColors.fg4), + ), + ), + ], ), ], ), - 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), - ), - ], - ); - } - void _showSessionDetails(BuildContext context) { showModalBottomSheet( context: context, diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index ba453a7..6e62524 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -1,6 +1,7 @@ -// Home Screen - Dashboard with quick actions and stats +// Home Screen — soft-futurist dashboard + glass pill nav import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; @@ -10,9 +11,9 @@ 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'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -24,81 +25,181 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { int _currentIndex = 0; + void _switchTab(int index) => setState(() => _currentIndex = index); + @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: AppColors.bg, body: IndexedStack( index: _currentIndex, - children: const [ - DashboardTab(), - HistoryScreen(), - RoutinesScreen(), - AnalyticsScreen(), - ProfileScreen(), + children: [ + _DashboardTab(onSwitchTab: _switchTab), + const HistoryScreen(), + const RoutinesScreen(), + const AnalyticsScreen(), + const ProfileScreen(), ], ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 10, - offset: const Offset(0, -2), - ), - ], + bottomNavigationBar: _GlassPillNav( + currentIndex: _currentIndex, + // index 2 is intercepted by _HomeScreenState.build → _launchWorkout + onTap: (i) => i == 2 ? _launchWorkout() : _switchTab(i), + ), + ); + } + + Future _resolveConflict( + WorkoutProvider provider, + ) async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + return action ?? StartWorkoutConflictAction.cancel; + } + + Future _launchWorkout() async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = + StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + exerciseIds: const [], + onConflict: () async { + conflictAction = await _resolveConflict(provider); + return conflictAction; + }, + ); + + if (!mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const WorkoutFlowScreen(isQuickStart: true), ), - 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'), - ], - ), + ); + } + } +} + +// ─── Glass pill nav ─────────────────────────────────────────────────────────── + +class _GlassPillNav extends StatelessWidget { + final int currentIndex; + final ValueChanged onTap; + + const _GlassPillNav({required this.currentIndex, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: AppColors.border)), + color: AppColors.bg1, + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + _NavItem( + icon: Icons.home_rounded, + label: 'Home', + active: currentIndex == 0, + onTap: () => onTap(0), + ), + _NavItem( + icon: Icons.history_rounded, + label: 'History', + active: currentIndex == 1, + onTap: () => onTap(1), + ), + // Centre FAB — tap routes through onTap(2) → _launchWorkout() + Expanded( + child: Center( + child: GestureDetector( + onTap: () => onTap(2), + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [AppColors.accent, Color(0xFF6D28D9)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.accent.withValues(alpha: 0.40), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 28, + ), + ), + ), + ), + ), + _NavItem( + icon: Icons.bar_chart_rounded, + label: 'Analytics', + active: currentIndex == 3, + onTap: () => onTap(3), + ), + _NavItem( + icon: Icons.person_rounded, + label: 'Profile', + active: currentIndex == 4, + onTap: () => onTap(4), + ), + ], ), ), ), ); } +} - Widget _buildNavItem(int index, IconData icon, String label) { - final isSelected = _currentIndex == index; - 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), - ), +class _NavItem extends StatelessWidget { + final IconData icon; + final String label; + final bool active; + final VoidCallback onTap; + + const _NavItem({ + required this.icon, + required this.label, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final color = active ? AppColors.accent : AppColors.fg3; + return Expanded( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - color: isSelected - ? AppTheme.primaryColor - : AppTheme.textSecondary, - size: 24, - ), - const SizedBox(height: 4), + Icon(icon, color: color, size: 22), + const SizedBox(height: 3), Text( label, - style: TextStyle( - color: isSelected - ? AppTheme.primaryColor - : AppTheme.textSecondary, - fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w500, + color: color, ), ), ], @@ -108,29 +209,58 @@ class _HomeScreenState extends State { } } -class DashboardTab extends StatelessWidget { - const DashboardTab({super.key}); +// ─── Dashboard tab ──────────────────────────────────────────────────────────── + +class _DashboardTab extends StatelessWidget { + final ValueChanged onSwitchTab; + + const _DashboardTab({required this.onSwitchTab}); @override Widget build(BuildContext context) { + final provider = context.watch(); + final sessions = provider.sessions; + return SafeArea( child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeader(context), - const SizedBox(height: AppSpacing.lg), - _buildQuickStartCard(context), - const SizedBox(height: AppSpacing.lg), - _buildStatsSection(context), - const SizedBox(height: AppSpacing.lg), - _buildRecentWorkouts(context), - const SizedBox(height: AppSpacing.lg), - _buildQuickActions(context), + _buildDateHeader(), + const SizedBox(height: 20), + _buildStreakHero(sessions), + const SizedBox(height: 20), + _buildStatGrid(sessions), + const SizedBox(height: 20), + const RFSectionHeader( + title: 'Activity', + subtitle: '14 WEEKS', + ), + const SizedBox(height: 12), + ActivityHeatmap( + workoutDates: sessions.map((s) => s.date).toList(), + weeks: 14, + ), + const SizedBox(height: 20), + _buildMuscleSection(context, provider, sessions), + const SizedBox(height: 20), + RFSectionHeader( + title: 'Recent Workouts', + action: 'See all', + onAction: () => onSwitchTab(1), + ), + const SizedBox(height: 12), + if (sessions.isEmpty) + _buildEmptyWorkouts() + else + ...sessions + .take(3) + .map((s) => _RecentSessionCard(session: s)), + const SizedBox(height: 80), ], ), ), @@ -140,11 +270,12 @@ class DashboardTab extends StatelessWidget { ); } - Widget _buildHeader(BuildContext context) { + Widget _buildDateHeader() { final now = DateTime.now(); final greeting = now.hour < 12 ? 'Good morning' : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); + final dateStr = DateFormat('EEE, MMM d').format(now); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -153,125 +284,176 @@ class DashboardTab extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - greeting, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + dateStr.toUpperCase(), + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.fg3, + letterSpacing: 0.5, + ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( - 'Ready to crush it? 💪', - style: Theme.of(context).textTheme.headlineMedium, + greeting, + style: GoogleFonts.geist( + fontSize: 26, + fontWeight: FontWeight.w600, + color: AppColors.fg, + letterSpacing: -0.04, + ), ), ], ), - 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: () => onSwitchTab(4), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.surface3, + shape: BoxShape.circle, + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.person_rounded, + size: 20, + color: AppColors.fg3, + ), + ), ), ], ); } - Widget _buildQuickStartCard(BuildContext context) { - final provider = context.watch(); + Widget _buildStreakHero(List sessions) { + int streak = 0; + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + final workoutDays = + sessions + .map((s) { + final d = s.date as DateTime; + return DateTime(d.year, d.month, d.day); + }) + .toSet() + .toList() + ..sort((a, b) => b.compareTo(a)); + + if (workoutDays.isNotEmpty) { + DateTime expected = workoutDays.contains(todayDate) + ? todayDate + : todayDate.subtract(const Duration(days: 1)); + for (final day in workoutDays) { + if (day == expected) { + streak++; + expected = expected.subtract(const Duration(days: 1)); + } else if (day.isBefore(expected)) { + break; + } + } + } - return Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primaryColor, Color(0xFF8B7FE8)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.lg), - boxShadow: [ - BoxShadow( - color: AppTheme.primaryColor.withOpacity(0.4), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final last7 = List.generate(7, (i) { + final d = todayDate.subtract(Duration(days: 6 - i)); + return workoutDays.contains(d); + }); + + return GlassCard( + ambientColor: AppColors.accent, + ambientRadius: 60, + ambientAlignment: Alignment.topRight, + padding: const EdgeInsets.all(20), + child: Row( children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 28, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'CURRENT STREAK', + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.5, + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, children: [ - const Text( - 'Start Workout', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, + Text( + '$streak', + style: GoogleFonts.geistMono( + fontSize: 48, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], ), ), + const SizedBox(width: 6), Text( - provider.routines.isEmpty - ? 'Quick start or create a routine' - : '${provider.routines.length} routines available', - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 14, + 'days', + style: GoogleFonts.geist( + fontSize: 16, + color: AppColors.fg3, ), ), ], ), - ), - ], + const SizedBox(height: 12), + Row( + children: List.generate(7, (i) { + final active = last7[i]; + final isToday = i == 6; + return Padding( + padding: EdgeInsets.only(right: i < 6 ? 6 : 0), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.accent : AppColors.surface3, + border: isToday + ? Border.all(color: AppColors.accent, width: 1.5) + : null, + boxShadow: active + ? [ + BoxShadow( + color: AppColors.accentSoft, + blurRadius: 4, + ), + ] + : null, + ), + ), + ); + }), + ), + ], + ), ), - const SizedBox(height: AppSpacing.lg), - Row( + const SizedBox(width: 20), + Column( children: [ - Expanded( - child: ElevatedButton( - onPressed: () => _startQuickWorkout(context), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: AppTheme.primaryColor, - ), - child: const Text('Quick Start'), - ), + Icon( + streak > 0 + ? Icons.local_fire_department_rounded + : Icons.fitness_center_rounded, + size: 40, + color: streak > 0 ? AppColors.warn : AppColors.fg4, ), - if (provider.routines.isNotEmpty) ...[ - const SizedBox(width: 12), - Expanded( - child: OutlinedButton( - onPressed: () => _showRoutineSelector(context), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white), - ), - child: const Text('From Routine'), - ), + const SizedBox(height: 4), + Text( + streak > 0 ? 'On fire!' : 'Start!', + style: GoogleFonts.geist( + fontSize: 11, + color: streak > 0 ? AppColors.warn : AppColors.fg4, ), - ], + ), ], ), ], @@ -279,170 +461,123 @@ class DashboardTab extends StatelessWidget { ); } - Widget _buildStatsSection(BuildContext context) { - return FutureBuilder>( - future: context.read().getQuickStats(), - builder: (context, snapshot) { - final stats = - snapshot.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, - ), - ), - ], - ), - ], - ); - }, + Widget _buildStatGrid(List sessions) { + final weekAgo = DateTime.now().subtract(const Duration(days: 7)); + final weekSessions = sessions + .where((s) => (s.date as DateTime).isAfter(weekAgo)) + .toList(); + final weekVolume = weekSessions.fold( + 0, + (sum, s) => sum + (s.totalVolume as double), + ); + final weekCount = weekSessions.length; + final weekExercises = weekSessions.fold( + 0, + (sum, s) => sum + (s.exercises as List).length, ); - } - String _formatVolume(double volume) { - if (volume >= 1000) { - return '${(volume / 1000).toStringAsFixed(1)}k'; - } - return volume.toStringAsFixed(0); - } + final last8 = sessions.take(8).toList().reversed.toList(); + final sparkData = last8 + .map((s) => (s.totalVolume as double) / 1000) + .toList(); - 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, - ), - ], - ), - ); - } + final avgDuration = sessions.isEmpty + ? 0 + : sessions + .take(5) + .map((s) => s.duration as int) + .fold(0, (a, b) => a + b) ~/ + sessions.take(5).length; return Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Recent Workouts', - style: Theme.of(context).textTheme.titleLarge, + Expanded( + child: RFStatBox( + label: 'This Week', + value: '$weekCount', + unit: weekCount == 1 ? 'session' : 'sessions', + color: AppColors.accent, + sub: '$weekExercises exercises', + ), ), - TextButton( - onPressed: () { - final homeState = context - .findAncestorStateOfType<_HomeScreenState>(); - if (homeState != null) { - homeState.setState(() => homeState._currentIndex = 1); - } - }, - child: const Text('See All'), + const SizedBox(width: 12), + Expanded( + child: GlassCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'WEEKLY VOLUME', + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + weekVolume >= 1000 + ? '${(weekVolume / 1000).toStringAsFixed(1)}k' + : weekVolume.toStringAsFixed(0), + style: GoogleFonts.geistMono( + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.data, + letterSpacing: -0.04, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + if (sparkData.length >= 2) + RFSparkline( + data: sparkData, + color: AppColors.data, + width: 52, + height: 24, + ), + ], + ), + Text( + 'kg total', + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.fg3, + ), + ), + ], + ), + ), ), ], ), - const SizedBox(height: AppSpacing.sm), - ...recentSessions.map( - (session) => _RecentWorkoutCard(session: session), - ), - ], - ); - } - - Widget _buildQuickActions(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Quick Actions', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AppSpacing.md), + const SizedBox(height: 12), Row( children: [ Expanded( - child: _QuickActionCard( - icon: Icons.add_circle_outline, - label: 'New Routine', - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const RoutinesScreen()), - ), + child: RFStatBox( + label: 'All Time', + value: '${sessions.length}', + unit: sessions.length == 1 ? 'session' : 'sessions', + color: AppColors.success, ), ), - const SizedBox(width: AppSpacing.md), + const SizedBox(width: 12), Expanded( - child: _QuickActionCard( - icon: Icons.library_books_outlined, - label: 'Exercises', - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const ExerciseLibraryScreen(), - ), - ), + child: RFStatBox( + label: 'Avg Duration', + value: sessions.isEmpty ? '—' : '$avgDuration', + unit: sessions.isEmpty ? '' : 'min', + color: AppColors.warn, ), ), ], @@ -451,285 +586,224 @@ class DashboardTab extends StatelessWidget { ); } - Future _resolveWorkoutConflict( + Widget _buildMuscleSection( BuildContext context, WorkoutProvider provider, - ) async { - final action = await showWorkoutConflictDialog( - context, - workoutStartTime: provider.workoutStartTime ?? DateTime.now(), - ); - return action ?? StartWorkoutConflictAction.cancel; - } - - Future _startQuickWorkout(BuildContext context) async { - final provider = context.read(); - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; - - final started = await provider.startWorkoutSafely( - exerciseIds: const [], - onConflict: () async { - conflictAction = await _resolveWorkoutConflict(context, provider); - return conflictAction; - }, - ); - - if (!context.mounted) return; - if (started || conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const WorkoutFlowScreen(isQuickStart: true), - ), - ); + List sessions, + ) { + final Map muscleVolume = {}; + for (final session in sessions.take(5)) { + for (final exerciseLog in (session.exercises as List)) { + final exVolume = exerciseLog.totalVolume as double; + final exercise = provider.getExercise(exerciseLog.exerciseId as String); + if (exercise == null) continue; + for (final activation in exercise.muscleActivations) { + final contribution = + exVolume * (activation.activationPercentage / 100.0); + muscleVolume[activation.muscleGroupId] = + (muscleVolume[activation.muscleGroupId] ?? 0) + contribution; + } + } } - } - void _showRoutineSelector(BuildContext context) { - final provider = context.read(); + if (muscleVolume.isEmpty) return const SizedBox.shrink(); - 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, + final maxVol = muscleVolume.values.reduce((a, b) => a > b ? a : b); + final sorted = muscleVolume.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top5 = sorted.take(5).toList(); + final intensityMap = { + for (final e in sorted) e.key: (e.value / maxVol).clamp(0.0, 1.0), + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader(title: 'Muscles Trained', subtitle: 'RECENT'), + const SizedBox(height: 12), + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Select Routine', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - ...provider.routines.map( - (routine) => ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, - ), - ), - 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; - }, + BodyHeatmap(muscleIntensity: intensityMap), + const SizedBox(width: 20), + Expanded( + child: Column( + children: top5.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: MuscleVolumeBar( + name: _muscleName(entry.key), + pct: entry.value / maxVol, + color: AppColors.getMuscleColor(entry.key), + value: '${(entry.value / 1000).toStringAsFixed(1)}k kg', + ), ); - - if (!context.mounted) return; - if (started || - conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(routine: routine), - ), - ); - } - }, + }).toList(), ), ), - 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( + Widget _buildEmptyWorkouts() { + return GlassCard( + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16), + child: Column( 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 Icon( + Icons.fitness_center_rounded, + size: 40, + color: AppColors.fg4, ), - 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, - ), - ), - ], + const SizedBox(height: 12), + Text( + 'No workouts yet', + style: GoogleFonts.geist( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.fg2, ), ), + const SizedBox(height: 4), + Text( + 'Tap the play button below to start', + style: GoogleFonts.geist(fontSize: 13, color: AppColors.fg4), + ), ], ), ); } + + static String _muscleName(String id) { + const names = { + 'chest': 'Chest', + 'upper_chest': 'Upper Chest', + 'back': 'Back', + 'lats': 'Lats', + 'lower_back': 'Lower Back', + 'shoulders': 'Shoulders', + 'front_delts': 'Front Delts', + 'side_delts': 'Side Delts', + 'rear_delts': 'Rear Delts', + 'biceps': 'Biceps', + 'triceps': 'Triceps', + 'forearms': 'Forearms', + 'quads': 'Quads', + 'hamstrings': 'Hamstrings', + 'glutes': 'Glutes', + 'calves': 'Calves', + 'core': 'Core', + 'traps': 'Traps', + }; + return names[id] ?? id; + } } -class _RecentWorkoutCard extends StatelessWidget { +// ─── Recent session card ────────────────────────────────────────────────────── + +class _RecentSessionCard extends StatelessWidget { final dynamic session; - const _RecentWorkoutCard({required this.session}); + const _RecentSessionCard({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, + final date = session.date as DateTime; + final dayStr = DateFormat('EEE').format(date); + final dateNum = DateFormat('d').format(date); + final monthStr = DateFormat('MMM').format(date); + final volume = session.totalVolume as double; + final exercises = session.exercises as List; + final duration = session.duration as int; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GlassCard( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + // Date column + Column( children: [ Text( - dateFormat.format(session.date), - style: const TextStyle( + dayStr.toUpperCase(), + style: GoogleFonts.geist( + fontSize: 9, fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, + color: AppColors.fg4, + letterSpacing: 0.3, ), ), const SizedBox(height: 2), Text( - '${session.exercises.length} exercises • ${session.duration} min', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, + dateNum, + style: GoogleFonts.geistMono( + fontSize: 22, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], ), ), + Text( + monthStr, + style: GoogleFonts.geist(fontSize: 9, color: AppColors.fg3), + ), ], ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - timeFormat.format(session.date), - style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), + Container( + width: 1, + height: 44, + color: AppColors.border, + margin: const EdgeInsets.symmetric(horizontal: 14), + ), + // Workout info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${exercises.length} ${exercises.length == 1 ? 'exercise' : 'exercises'}', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.fg, + ), + ), + const SizedBox(height: 3), + Text( + '$duration min', + style: GoogleFonts.geist( + fontSize: 11, + color: AppColors.fg3, + ), + ), + ], ), - const SizedBox(height: 2), - Text( - '${(session.totalVolume / 1000).toStringAsFixed(1)}k kg', - style: const TextStyle( - fontSize: 12, - color: AppTheme.success, - fontWeight: FontWeight.w600, + ), + // Volume + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0), + style: GoogleFonts.geistMono( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.success, + letterSpacing: -0.02, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), - ), - ], - ), - ], - ), - ); - } -} - -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, - ), + Text( + 'kg vol', + style: GoogleFonts.geist(fontSize: 10, color: AppColors.fg4), + ), + ], ), ], ), 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..5a398d6 --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -0,0 +1,821 @@ +// Shared RepForge UI widgets — soft-futurist design system + +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../theme/app_theme.dart'; + +// ─── GlassCard ─────────────────────────────────────────────────────────────── + +class GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final BorderRadius? borderRadius; + final Color? gradientStart; + final Color? gradientEnd; + final Color? borderColor; + final VoidCallback? onTap; + final double? ambientRadius; + final Color? ambientColor; + final Alignment ambientAlignment; + + const GlassCard({ + super.key, + required this.child, + this.padding, + this.borderRadius, + this.gradientStart, + this.gradientEnd, + this.borderColor, + this.onTap, + this.ambientRadius, + this.ambientColor, + this.ambientAlignment = Alignment.topRight, + }); + + @override + Widget build(BuildContext context) { + final br = borderRadius ?? BorderRadius.circular(AppRadius.xl); + Widget card = Container( + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + gradientStart ?? const Color(0x09FFFFFF), + gradientEnd ?? const Color(0x04FFFFFF), + ], + ), + border: Border.all(color: borderColor ?? AppColors.border), + borderRadius: br, + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + if (ambientRadius != null) + Positioned.fill( + child: IgnorePointer( + child: Align( + alignment: ambientAlignment, + child: Container( + width: ambientRadius! * 2, + height: ambientRadius! * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + (ambientColor ?? AppColors.accentSoft).withOpacity(0.25), + Colors.transparent, + ], + ), + ), + ), + ), + ), + ), + child, + ], + ), + ); + + if (onTap != null) { + card = Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: br, + child: card, + ), + ); + } + + return card; + } +} + +// ─── AnimatedCounter ───────────────────────────────────────────────────────── + +class AnimatedCounter extends StatelessWidget { + final double value; + final int decimals; + final String suffix; + final TextStyle? style; + final Duration duration; + + const AnimatedCounter({ + super.key, + required this.value, + this.decimals = 0, + this.suffix = '', + this.style, + this.duration = const Duration(milliseconds: 700), + }); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: value), + duration: duration, + curve: Curves.easeOutCubic, + builder: (context, v, _) { + final text = decimals == 0 + ? '${v.round()}$suffix' + : '${v.toStringAsFixed(decimals)}$suffix'; + return Text( + text, + style: style ?? + GoogleFonts.geistMono( + fontSize: 28, + fontWeight: FontWeight.w600, + color: AppColors.fg, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + }, + ); + } +} + +// ─── RFSparkline ───────────────────────────────────────────────────────────── + +class RFSparkline extends StatelessWidget { + final List data; + final double width; + final double height; + final Color color; + final bool fill; + + const RFSparkline({ + super.key, + required this.data, + this.width = 56, + this.height = 20, + this.color = AppColors.accent, + this.fill = true, + }); + + @override + Widget build(BuildContext context) { + if (data.length < 2) return SizedBox(width: width, height: height); + return SizedBox( + width: width, + height: height, + child: CustomPaint(painter: _SparklinePainter(data, color, fill)), + ); + } +} + +class _SparklinePainter extends CustomPainter { + final List data; + final Color color; + final bool fill; + _SparklinePainter(this.data, this.color, this.fill); + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + final min = data.reduce(math.min); + final max = data.reduce(math.max); + final range = (max - min).abs() < 0.001 ? 1.0 : max - min; + + final pts = List.generate(data.length, (i) { + final x = i / (data.length - 1) * size.width; + final y = size.height - ((data[i] - min) / range) * (size.height - 4) - 2; + return Offset(x, y); + }); + + final linePath = Path()..moveTo(pts[0].dx, pts[0].dy); + for (int i = 1; i < pts.length; i++) { + linePath.lineTo(pts[i].dx, pts[i].dy); + } + + if (fill) { + final fillPath = Path.from(linePath) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + canvas.drawPath( + fillPath, + Paint()..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [color.withOpacity(0.20), color.withOpacity(0)], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)), + ); + } + + canvas.drawPath( + linePath, + Paint() + ..color = color + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(_SparklinePainter old) => + old.data != data || old.color != color; +} + +// ─── ActivityHeatmap ───────────────────────────────────────────────────────── + +class ActivityHeatmap extends StatelessWidget { + final List workoutDates; + final int weeks; + + const ActivityHeatmap({ + super.key, + required this.workoutDates, + this.weeks = 14, + }); + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + // Count workouts per day + final counts = {}; + for (final d in workoutDates) { + final day = DateTime(d.year, d.month, d.day); + counts[day] = (counts[day] ?? 0) + 1; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: weeks * 10.0 + (weeks - 1) * 3, + child: Row( + children: List.generate(7, (col) { + return Expanded( + child: Column( + children: List.generate(weeks, (row) { + final daysBack = (6 - col) + (weeks - 1 - row) * 7; + final day = today.subtract(Duration(days: daysBack)); + final count = counts[day] ?? 0; + final opacity = count == 0 + ? 0.05 + : (0.2 + count * 0.2).clamp(0.0, 1.0); + return Expanded( + child: Padding( + padding: const EdgeInsets.all(1.5), + child: Container( + decoration: BoxDecoration( + color: count == 0 + ? AppColors.surface + : AppColors.accent.withOpacity(opacity), + borderRadius: BorderRadius.circular(3), + boxShadow: count >= 2 + ? [BoxShadow(color: AppColors.accentSoft, blurRadius: 4)] + : null, + ), + ), + ), + ); + }), + ), + ); + }), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Less', style: _legendStyle), + Row( + children: List.generate(5, (i) => Container( + width: 9, + height: 9, + margin: const EdgeInsets.only(left: 3), + decoration: BoxDecoration( + color: i == 0 + ? AppColors.surface + : AppColors.accent.withOpacity(0.2 + i * 0.2), + borderRadius: BorderRadius.circular(2), + ), + )), + ), + Text('More', style: _legendStyle), + ], + ), + ], + ); + } + + static final _legendStyle = GoogleFonts.geist( + fontSize: 10, + color: AppColors.fg4, + ); +} + +// ─── BodyHeatmap ───────────────────────────────────────────────────────────── +// Stylized SVG-style human body diagram rendered with CustomPainter. + +class BodyHeatmap extends StatelessWidget { + final Map muscleIntensity; // muscleId → 0..1 + + const BodyHeatmap({super.key, this.muscleIntensity = const {}}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 72, + height: 144, + child: CustomPaint( + painter: _BodyPainter(muscleIntensity), + ), + ); + } +} + +class _BodyPainter extends CustomPainter { + final Map intensity; + _BodyPainter(this.intensity); + + Paint _bodyPaint() => Paint() + ..color = AppColors.surface3 + ..style = PaintingStyle.fill; + + Paint _outlinePaint() => Paint() + ..color = AppColors.border + ..style = PaintingStyle.stroke + ..strokeWidth = 0.7; + + Paint _heatPaint(String muscleId, double defaultOpacity) { + final v = intensity[muscleId] ?? defaultOpacity; + final color = AppColors.getMuscleColor(muscleId); + return Paint() + ..color = color.withOpacity(v.clamp(0.0, 1.0)) + ..style = PaintingStyle.fill; + } + + @override + void paint(Canvas canvas, Size size) { + final sx = size.width / 74; + final sy = size.height / 148; + + void circle(double cx, double cy, double r, Paint paint) { + canvas.drawCircle(Offset(cx * sx, cy * sy), r * math.min(sx, sy), paint); + } + + void oval(double cx, double cy, double rx, double ry, Paint paint) { + canvas.drawOval( + Rect.fromCenter( + center: Offset(cx * sx, cy * sy), + width: rx * 2 * sx, + height: ry * 2 * sy, + ), + paint, + ); + } + + void path(List pts, Paint paint) { + if (pts.isEmpty) return; + final p = Path()..moveTo(pts[0].dx * sx, pts[0].dy * sy); + for (int i = 1; i < pts.length; i++) { + p.lineTo(pts[i].dx * sx, pts[i].dy * sy); + } + p.close(); + canvas.drawPath(p, paint); + } + + // ── Body shape (base) ── + circle(37, 12, 9, _bodyPaint()); + path([const Offset(22,24),const Offset(52,24),const Offset(54,50),const Offset(52,72),const Offset(22,72),const Offset(20,50)], _bodyPaint()); + path([const Offset(20,28),const Offset(12,32),const Offset(8,60),const Offset(12,70),const Offset(18,50)], _bodyPaint()); + path([const Offset(54,28),const Offset(62,32),const Offset(66,60),const Offset(62,70),const Offset(56,50)], _bodyPaint()); + path([const Offset(24,73),const Offset(34,73),const Offset(33,110),const Offset(30,140),const Offset(23,140),const Offset(22,105)], _bodyPaint()); + path([const Offset(40,73),const Offset(50,73),const Offset(52,105),const Offset(51,140),const Offset(44,140),const Offset(41,110)], _bodyPaint()); + + // ── Outlines ── + circle(37, 12, 9, _outlinePaint()); + path([const Offset(22,24),const Offset(52,24),const Offset(54,50),const Offset(52,72),const Offset(22,72),const Offset(20,50)], _outlinePaint()); + path([const Offset(20,28),const Offset(12,32),const Offset(8,60),const Offset(12,70),const Offset(18,50)], _outlinePaint()); + path([const Offset(54,28),const Offset(62,32),const Offset(66,60),const Offset(62,70),const Offset(56,50)], _outlinePaint()); + + // ── Heat overlays ── + oval(37, 38, 13, 9, _heatPaint('chest', 0.55)); + circle(22, 28, 5, _heatPaint('shoulders', 0.42)); + circle(52, 28, 5, _heatPaint('shoulders', 0.42)); + oval(14, 46, 3.5, 8, _heatPaint('biceps', 0.45)); + oval(60, 46, 3.5, 8, _heatPaint('biceps', 0.45)); + oval(37, 58, 10, 9, _heatPaint('back', 0.30)); + oval(28, 92, 5, 11, _heatPaint('quads', 0.18)); + oval(46, 92, 5, 11, _heatPaint('quads', 0.18)); + } + + @override + bool shouldRepaint(_BodyPainter old) => old.intensity != intensity; +} + +// ─── MuscleVolumeBar ───────────────────────────────────────────────────────── + +class MuscleVolumeBar extends StatelessWidget { + final String name; + final double pct; // 0..1 + final Color color; + final String? value; + + const MuscleVolumeBar({ + super.key, + required this.name, + required this.pct, + required this.color, + this.value, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(name, + style: GoogleFonts.geist( + fontSize: 11, color: AppColors.fg2)), + if (value != null) + Text(value!, + style: GoogleFonts.geistMono( + fontSize: 11, color: AppColors.fg3, + fontFeatures: const [FontFeature.tabularFigures()])), + ], + ), + const SizedBox(height: 3), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: Container( + height: 4, + color: AppColors.surface2, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: pct.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + boxShadow: [BoxShadow(color: color.withOpacity(0.6), blurRadius: 4)], + ), + ), + ), + ), + ), + ], + ); + } +} + +// ─── RFPillTag ─────────────────────────────────────────────────────────────── + +class RFPillTag extends StatelessWidget { + final String label; + final Color color; + final String? trailing; + final bool outlined; + + const RFPillTag({ + super.key, + required this.label, + required this.color, + this.trailing, + this.outlined = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: outlined ? Colors.transparent : color.withOpacity(0.12), + border: Border.all(color: outlined ? color : color.withOpacity(0.35)), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: GoogleFonts.geist( + fontSize: 11, fontWeight: FontWeight.w500, color: color)), + if (trailing != null) ...[ + const SizedBox(width: 4), + Text(trailing!, + style: GoogleFonts.geistMono( + fontSize: 11, color: color.withOpacity(0.6))), + ], + ], + ), + ); + } +} + +// ─── RestTimerRing ─────────────────────────────────────────────────────────── + +class RestTimerRing extends StatelessWidget { + final int remaining; + final int total; + final double size; + + const RestTimerRing({ + super.key, + required this.remaining, + required this.total, + this.size = 240, + }); + + @override + Widget build(BuildContext context) { + final progress = total > 0 ? (total - remaining) / total : 0.0; + final mm = (remaining ~/ 60).toString().padLeft(2, '0'); + final ss = (remaining % 60).toString().padLeft(2, '0'); + + // Color transitions: green (just started) → violet → amber (running low) + final Color ringColor; + if (progress < 0.5) { + ringColor = Color.lerp(AppColors.success, AppColors.accent, progress * 2)!; + } else { + ringColor = Color.lerp(AppColors.accent, AppColors.warn, (progress - 0.5) * 2)!; + } + + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size(size, size), + painter: _RingPainter(progress, ringColor), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$mm:$ss', + style: GoogleFonts.geistMono( + fontSize: size * 0.28, + fontWeight: FontWeight.w300, + color: AppColors.fg, + letterSpacing: -0.04, + ), + ), + const SizedBox(height: 4), + Text( + 'REST', + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.fg3, + letterSpacing: 2, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _RingPainter extends CustomPainter { + final double progress; + final Color color; + _RingPainter(this.progress, this.color); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 6; + + // Track + canvas.drawCircle(center, radius, Paint() + ..color = AppColors.surface2 + ..style = PaintingStyle.stroke + ..strokeWidth = 3); + + // Arc + const startAngle = -math.pi / 2; + final sweepAngle = 2 * math.pi * progress; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round, + ); + + // Glow + if (progress > 0 && progress < 1) { + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + Paint() + ..color = color.withOpacity(0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 8 + ..strokeCap = StrokeCap.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6), + ); + } + } + + @override + bool shouldRepaint(_RingPainter old) => + old.progress != progress || old.color != color; +} + +// ─── RFSectionHeader ───────────────────────────────────────────────────────── + +class RFSectionHeader extends StatelessWidget { + final String title; + final String? action; + final VoidCallback? onAction; + final String? subtitle; + + const RFSectionHeader({ + super.key, + required this.title, + this.action, + this.onAction, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (subtitle != null) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle!, + style: GoogleFonts.geist( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.fg3, + letterSpacing: 0.5, + )), + const SizedBox(height: 2), + Text(title, style: GoogleFonts.geist( + fontSize: 26, fontWeight: FontWeight.w600, + color: AppColors.fg, letterSpacing: -0.04, + )), + ], + ), + ] else + Text(title, + style: GoogleFonts.geist( + fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.fg)), + const Spacer(), + if (action != null) + GestureDetector( + onTap: onAction, + child: Text(action!, + style: GoogleFonts.geist( + fontSize: 12, fontWeight: FontWeight.w500, + color: AppColors.accent)), + ), + ], + ); + } +} + +// ─── Segmented progress bar ─────────────────────────────────────────────────── + +class SegmentedProgressBar extends StatelessWidget { + final int total; + final int current; // 0-based current index + + const SegmentedProgressBar({ + super.key, + required this.total, + required this.current, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: List.generate(total, (i) { + final isDone = i < current; + final isCurrent = i == current; + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < total - 1 ? 3 : 0), + height: 3, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: isDone + ? AppColors.accent + : isCurrent + ? AppColors.accentSoft + : AppColors.surface2, + boxShadow: isDone || isCurrent + ? [BoxShadow(color: AppColors.accentSoft, blurRadius: 4)] + : null, + ), + ), + ); + }), + ); + } +} + +// ─── PRBadge ───────────────────────────────────────────────────────────────── + +class PRBadge extends StatelessWidget { + const PRBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.15), + border: Border.all(color: AppColors.success.withOpacity(0.35)), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + 'PR', + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w700, + color: AppColors.success, + letterSpacing: 0.5, + ), + ), + ); + } +} + +// ─── Stat box (for session summary) ────────────────────────────────────────── + +class RFStatBox extends StatelessWidget { + final String label; + final String value; + final String? unit; + final Color color; + final String? sub; + + const RFStatBox({ + super.key, + required this.label, + required this.value, + this.unit, + required this.color, + this.sub, + }); + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label.toUpperCase(), + style: GoogleFonts.geist( + fontSize: 9, fontWeight: FontWeight.w600, + color: AppColors.fg4, letterSpacing: 0.4)), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(value, + style: GoogleFonts.geistMono( + fontSize: 24, fontWeight: FontWeight.w600, color: color, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], + )), + if (unit != null) ...[ + const SizedBox(width: 3), + Text(unit!, + style: GoogleFonts.geist( + fontSize: 11, color: AppColors.fg4)), + ], + ], + ), + if (sub != null) ...[ + const SizedBox(height: 2), + Text(sub!, + style: GoogleFonts.geist(fontSize: 11, color: AppColors.fg3)), + ], + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index d2c9f22..0504230 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -1,8 +1,9 @@ -// Workout Flow Screen - Samsung Health-style minimal workout interface +// Workout Flow Screen — soft-futurist exercise stepper import 'dart:async'; 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'; @@ -10,6 +11,8 @@ import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'exercise_library_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'workout_summary_screen.dart'; class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; @@ -329,28 +332,22 @@ class _WorkoutFlowScreenState extends State { Widget _buildHeader(WorkoutProvider provider, Exercise? exercise) { final totalExercises = provider.currentExerciseLogs.length; - final currentIndex = provider.currentExerciseIndex + 1; + final currentIndex = provider.currentExerciseIndex; 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), - ), - ], + padding: const EdgeInsets.fromLTRB(8, 12, 8, 12), + decoration: const BoxDecoration( + color: AppColors.bg1, + border: Border(bottom: BorderSide(color: AppColors.border)), ), child: Column( children: [ Row( children: [ IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, color: AppColors.fg3), onPressed: _showCancelDialog, ), Expanded( @@ -358,37 +355,40 @@ class _WorkoutFlowScreenState extends State { children: [ Text( exercise?.name ?? 'Select Exercise', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, + style: GoogleFonts.geist( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.fg, + letterSpacing: -0.02, ), textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( - 'Exercise $currentIndex of $totalExercises • Set $setNumber', - style: const TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, + 'Set $setNumber', + style: GoogleFonts.geist( + fontSize: 12, + color: AppColors.fg3, ), ), ], ), ), IconButton( - icon: const Icon(Icons.more_vert), + icon: const Icon(Icons.more_vert, color: AppColors.fg3), 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), + const SizedBox(height: 10), + // Segmented progress bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: totalExercises > 0 + ? SegmentedProgressBar(total: totalExercises, current: currentIndex) + : const SizedBox.shrink(), ), ], ), @@ -407,52 +407,41 @@ class _WorkoutFlowScreenState extends State { 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)), - ), + return GlassCard( + gradientStart: AppColors.accent.withValues(alpha: 0.12), + gradientEnd: AppColors.data.withValues(alpha: 0.06), + borderColor: AppColors.accent.withValues(alpha: 0.30), + padding: const EdgeInsets.all(14), child: Row( children: [ Container( - padding: const EdgeInsets.all(8), + width: 36, + height: 36, decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.lightbulb_outline, - color: AppTheme.primaryColor, + color: AppColors.accentSoft, + borderRadius: BorderRadius.circular(10), ), + child: const Icon(Icons.auto_awesome_rounded, color: AppColors.accent, size: 18), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Suggested', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), + Text( + 'AI Suggestion', + style: GoogleFonts.geist(fontSize: 10, color: AppColors.fg3, fontWeight: FontWeight.w600, letterSpacing: 0.3), ), + const SizedBox(height: 2), Text( '$displayWeightText ${settings.unitLabel} × ${rec.reps} reps', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.bold, + style: GoogleFonts.geistMono( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.fg, + letterSpacing: -0.02, + fontFeatures: const [FontFeature.tabularFigures()], ), ), ], @@ -466,7 +455,8 @@ class _WorkoutFlowScreenState extends State { }); HapticFeedback.lightImpact(); }, - child: const Text('Apply'), + style: TextButton.styleFrom(foregroundColor: AppColors.accent), + child: Text('Apply', style: GoogleFonts.geist(fontSize: 13, fontWeight: FontWeight.w600)), ), ], ), @@ -519,24 +509,29 @@ class _WorkoutFlowScreenState extends State { required double step, required int decimals, }) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), + final displayText = decimals == 0 + ? value.toInt().toString() + : value.toStringAsFixed(decimals); + + return GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), child: Column( children: [ Text( - label, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 14), + label.toUpperCase(), + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.4, + ), ), - const SizedBox(height: AppSpacing.sm), + const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildCircleButton( - icon: Icons.remove, + icon: Icons.remove_rounded, onPressed: () { onChanged((value - step).clamp(0, 999)); HapticFeedback.selectionClick(); @@ -546,20 +541,20 @@ class _WorkoutFlowScreenState extends State { 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, + displayText, + style: GoogleFonts.geistMono( + fontSize: 36, + fontWeight: FontWeight.w300, + color: AppColors.fg, + letterSpacing: -0.04, + fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.center, ), ), ), _buildCircleButton( - icon: Icons.add, + icon: Icons.add_rounded, onPressed: () { onChanged((value + step).clamp(0, 999)); HapticFeedback.selectionClick(); @@ -577,19 +572,19 @@ class _WorkoutFlowScreenState extends State { required VoidCallback onPressed, }) { return Material( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(20), + color: Colors.transparent, child: InkWell( onTap: onPressed, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(22), child: Container( - width: 40, - height: 40, + width: 44, + height: 44, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.5)), + shape: BoxShape.circle, + color: AppColors.surface2, + border: Border.all(color: AppColors.borderStrong), ), - child: Icon(icon, color: AppTheme.primaryColor), + child: Icon(icon, color: AppColors.accent, size: 20), ), ), ); @@ -857,25 +852,46 @@ class _WorkoutFlowScreenState extends State { 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: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.accent, Color(0xFF6D28D9)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - ), - 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), + borderRadius: BorderRadius.circular(AppRadius.lg), + boxShadow: [ + BoxShadow( + color: AppColors.accent.withValues(alpha: 0.40), + blurRadius: 20, + offset: const Offset(0, 6), ), ], ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _completeSet, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline_rounded, + color: Colors.white, size: 22), + const SizedBox(width: 10), + Text( + 'COMPLETE SET', + style: GoogleFonts.geist( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ), ), ); } @@ -1225,61 +1241,46 @@ class _WorkoutFlowScreenState extends State { // ==================== Rest Timer View ==================== Widget _buildRestTimerView() { - final minutes = _remainingSeconds ~/ 60; - final seconds = _remainingSeconds % 60; - return Container( width: double.infinity, - color: AppTheme.backgroundColor, + color: AppColors.bg, 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, - ), + RestTimerRing( + remaining: _remainingSeconds, + total: _restSeconds, + size: 220, ), - const SizedBox(height: AppSpacing.xl), + const SizedBox(height: 40), 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), - ), + _buildTimerChip(label: '-30s', onPressed: () => _adjustRestTime(-30)), + const SizedBox(width: 16), + _buildTimerChip(label: '+30s', onPressed: () => _adjustRestTime(30)), ], ), - const SizedBox(height: AppSpacing.xxl), + const SizedBox(height: 32), SizedBox( - width: 200, - child: ElevatedButton( + width: 160, + height: 48, + child: OutlinedButton( onPressed: _skipRest, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - padding: const EdgeInsets.symmetric(vertical: 16), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.fg, + side: const BorderSide(color: AppColors.borderStrong), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), ), - child: const Text( - 'SKIP', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + child: Text( + 'SKIP REST', + style: GoogleFonts.geist( + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), ), ), ), @@ -1288,8 +1289,7 @@ class _WorkoutFlowScreenState extends State { ); } - Widget _buildTimerButton({ - required IconData icon, + Widget _buildTimerChip({ required String label, required VoidCallback onPressed, }) { @@ -1298,21 +1298,17 @@ class _WorkoutFlowScreenState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: BoxDecoration( - color: AppTheme.cardColor, + color: AppColors.surface2, borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.border), ), - child: Row( - children: [ - Icon(icon, color: AppTheme.textSecondary), - const SizedBox(width: 8), - Text( - label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], + child: Text( + label, + style: GoogleFonts.geistMono( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.fg2, + ), ), ), ); @@ -1612,14 +1608,14 @@ class _WorkoutFlowScreenState extends State { ), ElevatedButton( onPressed: () async { - Navigator.pop(context); // Close dialog - await context.read().finishWorkout(); + final nav = Navigator.of(context); + nav.pop(); // Close dialog + final session = + 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, + nav.pushReplacement( + MaterialPageRoute( + builder: (_) => WorkoutSummaryScreen(session: session), ), ); } 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..9f74d18 --- /dev/null +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -0,0 +1,340 @@ +// Workout Summary Screen — post-workout trophy view + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; + +class WorkoutSummaryScreen extends StatelessWidget { + final WorkoutSession session; + + const WorkoutSummaryScreen({super.key, required this.session}); + + @override + Widget build(BuildContext context) { + final provider = context.read(); + final totalSets = session.exercises.fold( + 0, + (sum, ex) => sum + ex.sets.length, + ); + + // Compute streak + final sessions = provider.sessions; + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + final workoutDays = sessions + .map((s) { + final d = s.date; + return DateTime(d.year, d.month, d.day); + }) + .toSet() + .toList() + ..sort((a, b) => b.compareTo(a)); + int streak = 0; + if (workoutDays.isNotEmpty) { + DateTime expected = workoutDays.contains(todayDate) + ? todayDate + : todayDate.subtract(const Duration(days: 1)); + for (final day in workoutDays) { + if (day == expected) { + streak++; + expected = expected.subtract(const Duration(days: 1)); + } else if (day.isBefore(expected)) { + break; + } + } + } + + // Collect muscle groups from this session + final Map muscleVolume = {}; + for (final log in session.exercises) { + final exercise = provider.getExercise(log.exerciseId); + if (exercise == null) continue; + final vol = log.totalVolume; + for (final activation in exercise.muscleActivations) { + muscleVolume[activation.muscleGroupId] = + (muscleVolume[activation.muscleGroupId] ?? 0) + + vol * (activation.activationPercentage / 100.0); + } + } + final topMuscles = (muscleVolume.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value))) + .take(6) + .map((e) => e.key) + .toList(); + + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: Column( + children: [ + _buildTrophyHeader(session), + const SizedBox(height: 28), + _buildStatGrid(session, totalSets), + const SizedBox(height: 20), + if (topMuscles.isNotEmpty) + _buildMusclesCard(topMuscles), + if (topMuscles.isNotEmpty) const SizedBox(height: 20), + _buildStreakCard(streak), + const SizedBox(height: 32), + _buildDoneButton(context), + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTrophyHeader(WorkoutSession session) { + return Column( + children: [ + // Gradient trophy icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFFFBBF24), Color(0xFFF59E0B)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFFBBF24).withValues(alpha: 0.35), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: const Icon(Icons.emoji_events_rounded, color: Colors.white, size: 40), + ), + const SizedBox(height: 16), + Text( + 'Workout Complete!', + style: GoogleFonts.geist( + fontSize: 26, + fontWeight: FontWeight.w600, + color: AppColors.fg, + letterSpacing: -0.04, + ), + ), + const SizedBox(height: 4), + Text( + 'Great work. Keep the momentum going.', + style: GoogleFonts.geist(fontSize: 13, color: AppColors.fg3), + ), + ], + ); + } + + Widget _buildStatGrid(WorkoutSession session, int totalSets) { + final vol = session.totalVolume; + final volStr = vol >= 1000 + ? '${(vol / 1000).toStringAsFixed(1)}k' + : vol.toStringAsFixed(0); + + return Column( + children: [ + Row( + children: [ + Expanded( + child: RFStatBox( + label: 'Duration', + value: '${session.duration}', + unit: 'min', + color: AppColors.accent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: RFStatBox( + label: 'Volume', + value: volStr, + unit: 'kg', + color: AppColors.data, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: RFStatBox( + label: 'Total Sets', + value: '$totalSets', + color: AppColors.success, + ), + ), + const SizedBox(width: 12), + Expanded( + child: RFStatBox( + label: 'Exercises', + value: '${session.exercises.length}', + color: AppColors.warn, + ), + ), + ], + ), + ], + ); + } + + Widget _buildMusclesCard(List muscles) { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'MUSCLES WORKED', + style: GoogleFonts.geist( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.fg4, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 6, + children: muscles.map((id) { + final color = AppColors.getMuscleColor(id); + return RFPillTag(label: _muscleName(id), color: color); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildStreakCard(int streak) { + return GlassCard( + borderColor: streak > 0 + ? AppColors.warn.withValues(alpha: 0.35) + : AppColors.border, + ambientColor: streak > 0 ? AppColors.warn : null, + ambientRadius: streak > 0 ? 60 : null, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + streak > 0 + ? Icons.local_fire_department_rounded + : Icons.fitness_center_rounded, + color: streak > 0 ? AppColors.warn : AppColors.fg4, + size: 32, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + streak > 1 + ? '$streak day streak!' + : streak == 1 + ? 'Streak started!' + : 'First workout!', + style: GoogleFonts.geist( + fontSize: 15, + fontWeight: FontWeight.w600, + color: streak > 0 ? AppColors.warn : AppColors.fg, + ), + ), + const SizedBox(height: 2), + Text( + streak > 0 + ? 'Come back tomorrow to keep it going' + : 'You started — that\'s what matters', + style: GoogleFonts.geist(fontSize: 12, color: AppColors.fg3), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDoneButton(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 56, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.accent, Color(0xFF6D28D9)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + boxShadow: [ + BoxShadow( + color: AppColors.accent.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.of(context).popUntil((r) => r.isFirst), + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Center( + child: Text( + 'DONE', + style: GoogleFonts.geist( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ), + ), + ), + ), + ); + } + + static String _muscleName(String id) { + const names = { + 'chest': 'Chest', + 'upper_chest': 'Upper Chest', + 'back': 'Back', + 'lats': 'Lats', + 'lower_back': 'Lower Back', + 'shoulders': 'Shoulders', + 'front_delts': 'Front Delts', + 'side_delts': 'Side Delts', + 'rear_delts': 'Rear Delts', + 'biceps': 'Biceps', + 'triceps': 'Triceps', + 'forearms': 'Forearms', + 'quads': 'Quads', + 'hamstrings': 'Hamstrings', + 'glutes': 'Glutes', + 'calves': 'Calves', + 'core': 'Core', + 'traps': 'Traps', + }; + return names[id] ?? id; + } +} diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index e2f8694..bf1b664 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -1,39 +1,53 @@ -// App Theme - Dark theme with modern styling - import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.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 = { +// ─── Color tokens ──────────────────────────────────────────────────────────── + +class AppColors { + // Backgrounds + static const Color bg = Color(0xFF07070A); + static const Color bg1 = Color(0xFF0C0C12); + static const Color bg2 = Color(0xFF11111A); + + // Surfaces (layered translucent white) + static const Color surface = Color(0x0AFFFFFF); // 4% + static const Color surface2 = Color(0x0FFFFFFF); // 6% + static const Color surface3 = Color(0x17FFFFFF); // 9% + + // Borders + static const Color border = Color(0x12FFFFFF); // 7% + static const Color borderStrong = Color(0x21FFFFFF); // 13% + + // Foreground + static const Color fg = Color(0xFFF4F4F8); + static const Color fg2 = Color(0xB8F4F4F8); // 72% + static const Color fg3 = Color(0x7AF4F4F8); // 48% + static const Color fg4 = Color(0x52F4F4F8); // 32% + + // Accent — electric violet (oklch 0.68 0.18 285) + static const Color accent = Color(0xFF8B5CF6); + static const Color accentSoft = Color(0x268B5CF6); // 15% + + // Data — cyan (oklch 0.78 0.14 200) + static const Color data = Color(0xFF22D3EE); + static const Color dataSoft = Color(0x2622D3EE); + + // Semantic + static const Color success = Color(0xFF34D399); // mint + static const Color warn = Color(0xFFFBBF24); // amber + static const Color danger = Color(0xFFF87171); // coral + + // Muscle group palette (unchanged from original) + static const Map muscleGroupColors = { 'chest': Color(0xFFFF6B6B), 'upper_chest': Color(0xFFFF8E8E), 'back': Color(0xFF4ECDC4), 'lats': Color(0xFF45B7AA), 'lower_back': Color(0xFF3D9D94), - 'shoulders': Color(0xFF6C5CE7), - 'front_delts': Color(0xFF8B7FE8), - 'side_delts': Color(0xFF9D93EA), - 'rear_delts': Color(0xFFAFA6EC), + 'shoulders': Color(0xFF8B5CF6), + 'front_delts': Color(0xFF9D88F0), + 'side_delts': Color(0xFFAFA6EC), + 'rear_delts': Color(0xFFBFBAEE), 'biceps': Color(0xFFFFBE76), 'triceps': Color(0xFFF9CA24), 'forearms': Color(0xFFDFB557), @@ -45,204 +59,302 @@ class AppTheme { 'traps': Color(0xFFE17055), }; - static Color getMuscleColor(String muscleId) { - return muscleColors[muscleId] ?? primaryColor; - } + static Color getMuscleColor(String muscleId) => + muscleGroupColors[muscleId] ?? accent; + + // Backwards-compatible aliases used by older screens + static const Color primaryColor = accent; + static const Color secondaryColor = data; + static const Color accentColor = danger; + static const Color backgroundColor = bg; + static const Color surfaceColor = bg1; + static const Color cardColor = bg2; + static const Color textPrimary = fg; + static const Color textSecondary = fg2; + static const Color textMuted = fg4; + static const Color successColor = success; + static const Color warningColor = warn; + static const Color errorColor = danger; +} + +// ─── Spacing & radius ──────────────────────────────────────────────────────── + +class AppSpacing { + static const double xs = 4; + static const double sm = 8; + static const double md = 16; + static const double lg = 24; + static const double xl = 32; + static const double xxl = 48; +} + +class AppRadius { + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 18; + static const double xxl = 24; + static const double full = 999; +} + +// ─── Typography helpers ─────────────────────────────────────────────────────── + +class AppFonts { + static TextStyle geist({ + double fontSize = 14, + FontWeight fontWeight = FontWeight.w400, + Color color = AppColors.fg, + double? letterSpacing, + double? height, + }) => + GoogleFonts.geist( + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + letterSpacing: letterSpacing, + height: height, + ); + + static TextStyle mono({ + double fontSize = 14, + FontWeight fontWeight = FontWeight.w400, + Color color = AppColors.fg, + double? letterSpacing, + }) => + GoogleFonts.geistMono( + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + letterSpacing: letterSpacing ?? -0.02, + fontFeatures: const [FontFeature.tabularFigures()], + ); +} + +// ─── Theme ─────────────────────────────────────────────────────────────────── + +class AppTheme { + // Keep legacy static getters for backward compat with existing screens + static const Color primaryColor = AppColors.accent; + static const Color secondaryColor = AppColors.data; + static const Color accentColor = AppColors.danger; + static const Color backgroundColor = AppColors.bg; + static const Color surfaceColor = AppColors.bg1; + static const Color cardColor = AppColors.bg2; + static const Color textPrimary = AppColors.fg; + static const Color textSecondary = AppColors.fg2; + static const Color textMuted = AppColors.fg4; + static const Color success = AppColors.success; + static const Color warning = AppColors.warn; + static const Color error = AppColors.danger; + + static const Map muscleColors = AppColors.muscleGroupColors; + static Color getMuscleColor(String muscleId) => + AppColors.getMuscleColor(muscleId); static ThemeData get darkTheme { + final base = GoogleFonts.geistTextTheme(ThemeData.dark().textTheme); + return ThemeData( useMaterial3: true, brightness: Brightness.dark, - scaffoldBackgroundColor: backgroundColor, - + scaffoldBackgroundColor: AppColors.bg, + colorScheme: const ColorScheme.dark( - primary: primaryColor, - secondary: secondaryColor, - surface: surfaceColor, - error: error, + primary: AppColors.accent, + secondary: AppColors.data, + surface: AppColors.bg1, + error: AppColors.danger, onPrimary: Colors.white, onSecondary: Colors.black, - onSurface: textPrimary, + onSurface: AppColors.fg, onError: Colors.white, ), - + + textTheme: base.copyWith( + headlineLarge: base.headlineLarge?.copyWith( + color: AppColors.fg, + fontSize: 32, + fontWeight: FontWeight.w600, + letterSpacing: -0.04, + ), + headlineMedium: base.headlineMedium?.copyWith( + color: AppColors.fg, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: -0.04, + ), + headlineSmall: base.headlineSmall?.copyWith( + color: AppColors.fg, + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.02, + ), + titleLarge: base.titleLarge?.copyWith( + color: AppColors.fg, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.02, + ), + titleMedium: base.titleMedium?.copyWith( + color: AppColors.fg, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + titleSmall: base.titleSmall?.copyWith( + color: AppColors.fg2, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + bodyLarge: base.bodyLarge?.copyWith( + color: AppColors.fg, + fontSize: 15, + ), + bodyMedium: base.bodyMedium?.copyWith( + color: AppColors.fg2, + fontSize: 13, + ), + bodySmall: base.bodySmall?.copyWith( + color: AppColors.fg3, + fontSize: 11, + ), + labelLarge: base.labelLarge?.copyWith( + color: AppColors.fg, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + labelSmall: base.labelSmall?.copyWith( + color: AppColors.fg3, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + appBarTheme: const AppBarTheme( - backgroundColor: backgroundColor, - foregroundColor: textPrimary, + backgroundColor: AppColors.bg, + foregroundColor: AppColors.fg, elevation: 0, centerTitle: false, titleTextStyle: TextStyle( - color: textPrimary, - fontSize: 24, - fontWeight: FontWeight.bold, + color: AppColors.fg, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.02, ), ), - + cardTheme: CardThemeData( - color: cardColor, + color: Colors.transparent, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(AppRadius.xl), + side: const BorderSide(color: AppColors.border), ), ), - + elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: primaryColor, + backgroundColor: AppColors.accent, foregroundColor: Colors.white, elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(AppRadius.lg), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 14, fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), ), ), - + outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - foregroundColor: primaryColor, - side: const BorderSide(color: primaryColor), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + foregroundColor: AppColors.fg, + side: const BorderSide(color: AppColors.border), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, ), ), ), - + textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: primaryColor, + foregroundColor: AppColors.accent, + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), ), - + inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: surfaceColor, + fillColor: AppColors.surface, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.border), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: primaryColor, width: 2), + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.accent, width: 1.5), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - hintStyle: const TextStyle(color: textMuted), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + hintStyle: const TextStyle(color: AppColors.fg4), + labelStyle: const TextStyle(color: AppColors.fg3), ), - - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: surfaceColor, - selectedItemColor: primaryColor, - unselectedItemColor: textSecondary, - type: BottomNavigationBarType.fixed, - elevation: 0, - ), - + floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: primaryColor, + backgroundColor: AppColors.accent, foregroundColor: Colors.white, - elevation: 4, + elevation: 0, ), - + dividerTheme: const DividerThemeData( - color: cardColor, + color: AppColors.border, thickness: 1, ), - + chipTheme: ChipThemeData( - backgroundColor: cardColor, - selectedColor: primaryColor.withOpacity(0.3), - labelStyle: const TextStyle(color: textPrimary), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + backgroundColor: AppColors.surface, + selectedColor: AppColors.accentSoft, + labelStyle: const TextStyle(color: AppColors.fg, fontSize: 12), + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), - + snackBarTheme: SnackBarThemeData( - backgroundColor: cardColor, - contentTextStyle: const TextStyle(color: textPrimary), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + backgroundColor: AppColors.bg2, + contentTextStyle: const TextStyle(color: AppColors.fg), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), behavior: SnackBarBehavior.floating, ), - - textTheme: const TextTheme( - headlineLarge: TextStyle( - color: textPrimary, - fontSize: 32, - fontWeight: FontWeight.bold, - ), - headlineMedium: TextStyle( - color: textPrimary, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - headlineSmall: TextStyle( - color: textPrimary, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - titleLarge: TextStyle( - color: textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - titleMedium: TextStyle( - color: textPrimary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - titleSmall: TextStyle( - color: textSecondary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - bodyLarge: TextStyle( - color: textPrimary, - fontSize: 16, - ), - bodyMedium: TextStyle( - color: textSecondary, - fontSize: 14, - ), - bodySmall: TextStyle( - color: textMuted, - fontSize: 12, - ), - labelLarge: TextStyle( - color: textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) ? AppColors.accent : AppColors.fg3), + trackColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) ? AppColors.accentSoft : AppColors.surface3), + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), ), - ); - } -} -// Common UI Constants -class AppSpacing { - static const double xs = 4; - static const double sm = 8; - static const double md = 16; - static const double lg = 24; - static const double xl = 32; - static const double xxl = 48; -} + listTileTheme: const ListTileThemeData( + textColor: AppColors.fg, + iconColor: AppColors.fg3, + tileColor: Colors.transparent, + ), -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 full = 999; + iconTheme: const IconThemeData(color: AppColors.fg2, size: 20), + ); + } } diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 00ac1fe..6b44eea 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 + google_fonts: ^8.0.0 + # Health Connect integration health_connector: ^3.8.1