diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..b49ab01 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,22 @@ +# Codecov Configuration for RepForge (Devasy/RepForge) +codecov: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: auto + +ignore: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "workout-logger/test/**/*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 393c5ca..0d02413 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,10 @@ on: - main workflow_dispatch: +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release: name: Build and Release APK @@ -18,17 +22,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 # Fetch all history for proper versioning token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + - name: Setup Gradle Build Cache + uses: gradle/actions/setup-gradle@v6 + - name: Set up Flutter uses: subosito/flutter-action@v2 with: @@ -117,6 +124,10 @@ jobs: - name: Decode release keystore run: | + if [ -z "${{ secrets.KEYSTORE_BASE64 }}" ]; then + echo "Error: KEYSTORE_BASE64 secret is not configured in repository secrets." + exit 1 + fi echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/repforge-release.jks - name: Build APK @@ -126,7 +137,7 @@ jobs: KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - run: flutter build apk --release --split-per-abi + run: flutter build apk --release --split-per-abi --obfuscate --split-debug-info=build/app/outputs/symbols - name: Rename APKs run: | @@ -150,7 +161,7 @@ jobs: - name: Create GitHub Release if: github.event_name == 'push' && steps.commit_version.outputs.committed == 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: v${{ steps.version.outputs.value }} name: RepForge v${{ steps.version.outputs.value }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62890d7..36cdedf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,14 +8,21 @@ on: release: types: [published] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: name: Analyze & Test runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Flutter id: flutter-action @@ -28,13 +35,14 @@ jobs: pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-${{ hashFiles('workout-logger/pubspec.lock') }}" - name: Install dependencies - if: steps.flutter-action.outputs.PUB-CACHE-HIT != 'true' + if: steps.flutter-action.outputs.CACHE-HIT != 'true' working-directory: ./workout-logger run: flutter pub get - name: Analyze working-directory: ./workout-logger run: | + set -o pipefail # Only fail on errors, ignore warnings and info messages flutter analyze --no-fatal-infos --no-fatal-warnings | tee analyze_output.txt @@ -53,3 +61,5 @@ jobs: with: files: workout-logger/coverage/lcov.info token: ${{ secrets.CODECOV_TOKEN }} + slug: Devasy/RepForge + diff --git a/.gitignore b/.gitignore index 82e5c63..19d41dc 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,9 @@ repforge_backup_*.json # Claude Code project memory & session files .claude/ + +# Hive test databases and temporary directories +*.hive +tmp_hive_*/ +**/tmp_hive_*/ + diff --git a/workout-logger/.gitignore b/workout-logger/.gitignore index 3820a95..905f107 100644 --- a/workout-logger/.gitignore +++ b/workout-logger/.gitignore @@ -43,3 +43,19 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Signing Keystore & Credentials +**/android/key.properties +*.jks +*.keystore +*.p12 +.env +.env.* + +# Hive test databases and temporary directories +*.hive +tmp_hive_*/ +**/tmp_hive_*/ + + + diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index 3c71d30..d68c9d8 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -1,4 +1,6 @@ import com.android.build.gradle.internal.api.ApkVariantOutputImpl +import java.io.FileInputStream +import java.util.Properties plugins { id("com.android.application") @@ -34,11 +36,16 @@ android { signingConfigs { create("release") { - val keystorePath = System.getenv("KEYSTORE_PATH") - val storePass = System.getenv("KEY_STORE_PASSWORD") - val alias = System.getenv("KEY_ALIAS") - val keyPass = System.getenv("KEY_PASSWORD") - if (keystorePath != null && storePass != null && alias != null && keyPass != null) { + val keyProperties = Properties() + val keyPropertiesFile = rootProject.file("key.properties") + if (keyPropertiesFile.exists()) { + keyProperties.load(FileInputStream(keyPropertiesFile)) + } + val keystorePath = System.getenv("KEYSTORE_PATH") ?: keyProperties.getProperty("storeFile") + val storePass = System.getenv("KEY_STORE_PASSWORD") ?: keyProperties.getProperty("storePassword") + val alias = System.getenv("KEY_ALIAS") ?: keyProperties.getProperty("keyAlias") + val keyPass = System.getenv("KEY_PASSWORD") ?: keyProperties.getProperty("keyPassword") + if (!keystorePath.isNullOrEmpty() && !storePass.isNullOrEmpty() && !alias.isNullOrEmpty() && !keyPass.isNullOrEmpty()) { storeFile = file(keystorePath) storePassword = storePass keyAlias = alias @@ -72,6 +79,12 @@ android { manifestPlaceholders["appLabel"] = "RepForge (Debug)" } release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) // Uses the production EC P-256 keystore when KEYSTORE_PATH env var is set // (CI injects it via GitHub Secrets). Falls back to the debug key for a // local `flutter run --release` without env vars configured. diff --git a/workout-logger/android/app/proguard-rules.pro b/workout-logger/android/app/proguard-rules.pro new file mode 100644 index 0000000..3dde057 --- /dev/null +++ b/workout-logger/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Suppress missing class warnings for Play Core deferred components in Flutter engine +-dontwarn com.google.android.play.core.** + +# Flutter Wrapper Rules +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.embedding.** { *; } +-keep class io.flutter.provider.** { *; } +-keep class io.flutter.plugin.editing.** { *; } + +# Keep Native plugins and Health Connect interfaces +-dontwarn com.google.android.gms.** diff --git a/workout-logger/android/key.properties.example b/workout-logger/android/key.properties.example new file mode 100644 index 0000000..24133ee --- /dev/null +++ b/workout-logger/android/key.properties.example @@ -0,0 +1,8 @@ +# Local Android Release Keystore Configuration +# Fill in your local keystore path and passwords below. +# Note: This file should NEVER be committed to Git. + +storeFile=C:/path/to/your/upload-keystore.jks +storePassword=your_store_password +keyAlias=your_key_alias +keyPassword=your_key_password diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 5ad5b8f..caf412d 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -22,6 +22,7 @@ import 'widgets/readiness_card.dart'; import 'widgets/sleep_hr_card.dart'; import 'widgets/heart_rate_card.dart'; import 'widgets/rf_widgets.dart'; +import 'widgets/floating_nav_bar.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; import 'widgets/body_heatmap.dart'; @@ -39,19 +40,34 @@ class _HomeScreenState extends State { int _currentIndex = 0; static const _navItems = [ - RFNavItem(icon: Icons.home_rounded, label: 'Home'), - RFNavItem(icon: Icons.layers_rounded, label: 'Routines'), - RFNavItem(icon: Icons.history_rounded, label: 'History'), - RFNavItem(icon: Icons.bar_chart_rounded, label: 'Stats'), + FloatingNavItem(icon: Icons.home_rounded, label: 'Home'), + FloatingNavItem(icon: Icons.layers_rounded, label: 'Routines'), + FloatingNavItem(icon: Icons.history_rounded, label: 'History'), + FloatingNavItem(icon: Icons.bar_chart_rounded, label: 'Stats'), ]; void switchTab(int index) => setState(() => _currentIndex = index); @override Widget build(BuildContext context) { - return Scaffold( - extendBody: true, - backgroundColor: AppColors.background, + return FloatingNavBarScaffold( + scaffoldBackgroundColor: AppColors.background, + // App-specific colour overrides — all other values use the + // FloatingNavBarTheme defaults which adapt to ThemeData.colorScheme. + theme: FloatingNavBarTheme( + backgroundColor: AppColors.surface, + borderColor: AppColors.glassBorderStrong, + // Chip colours (replaces old pill API) + selectedChipColor: AppColors.glass3, + selectedChipBorderColor: AppColors.primary.withValues(alpha: 0.25), + selectedChipShadowColor: AppColors.primary.withValues(alpha: 0.15), + selectedContentColor: AppColors.textPrimary, + inactiveIconColor: AppColors.textMuted, + outerGlowColor: AppColors.primary.withValues(alpha: 0.08), + ), + items: _navItems, + currentIndex: _currentIndex, + onTabChanged: switchTab, body: IndexedStack( index: _currentIndex, children: const [ @@ -61,11 +77,6 @@ class _HomeScreenState extends State { AnalyticsScreen(), ], ), - bottomNavigationBar: RFNavBar( - currentIndex: _currentIndex, - onTap: switchTab, - items: _navItems, - ), ); } diff --git a/workout-logger/lib/screens/widgets/floating_nav_bar.dart b/workout-logger/lib/screens/widgets/floating_nav_bar.dart new file mode 100644 index 0000000..6d82d62 --- /dev/null +++ b/workout-logger/lib/screens/widgets/floating_nav_bar.dart @@ -0,0 +1,763 @@ +// floating_nav_bar.dart — Self-contained floating navigation bar for Flutter. +// +// Redesigned with the "expanding chip" pattern from EssentialsFloatingToolbar: +// • Selected tab expands horizontally (spring physics) to reveal an inline label +// • Unselected tabs show icon-only at a fixed compact size +// • Badge dot support on any nav item +// • Glassmorphic container — backdrop blur + border + outer glow +// • Scroll-aware hide/show via FloatingNavBarScaffold +// +// ─── Quick start ────────────────────────────────────────────────────────────── +// +// ```dart +// const items = [ +// FloatingNavItem(icon: Icons.home, label: 'Home'), +// FloatingNavItem(icon: Icons.search, label: 'Search'), +// FloatingNavItem(icon: Icons.person, label: 'Profile'), +// ]; +// +// FloatingNavBarScaffold( +// items: items, +// currentIndex: _index, +// onTabChanged: (i) => setState(() => _index = i), +// body: IndexedStack(index: _index, children: _pages), +// ) +// ``` +// +// ─── Drop-in dependency ─────────────────────────────────────────────────────── +// Only needs the Flutter SDK (material.dart · dart:ui · flutter/physics.dart). + +import 'dart:ui' show ImageFilter; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/services.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavItem +// ───────────────────────────────────────────────────────────────────────────── + +/// A single tab entry for [FloatingNavBar]. +/// +/// [label] is displayed as an expanding inline text when the tab is active +/// and is also used for screen-reader semantics (TalkBack / VoiceOver). +/// Supply [activeIcon] for a distinct icon when selected. +/// Set [hasBadge] to `true` to render a small red indicator dot. +@immutable +class FloatingNavItem { + const FloatingNavItem({ + required this.icon, + required this.label, + this.activeIcon, + this.hasBadge = false, + }); + + /// Icon shown when this tab is **inactive**. + final IconData icon; + + /// Optional icon shown when this tab is **active**. Falls back to [icon]. + final IconData? activeIcon; + + /// Text displayed as an expanding label when active, and used for semantics. + final String label; + + /// When `true`, a small red dot is painted at the top-right of the icon. + final bool hasBadge; + + /// Returns the correct icon for the given [active] state. + IconData iconFor(bool active) => active ? (activeIcon ?? icon) : icon; +} + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavBarTheme +// ───────────────────────────────────────────────────────────────────────────── + +/// Visual and behavioural configuration for [FloatingNavBar] and +/// [FloatingNavBarScaffold]. +/// +/// All colour fields are nullable — `null` values derive from the ambient +/// [ThemeData.colorScheme] at runtime. Override only what you need. +@immutable +class FloatingNavBarTheme { + const FloatingNavBarTheme({ + // ── Colours ────────────────────────────────────────────────────────────── + this.backgroundColor, + this.backgroundOpacity = 0.82, + this.borderColor, + this.borderWidth = 1.2, + /// Background of the selected tab chip. + this.selectedChipColor, + /// Border of the selected tab chip. + this.selectedChipBorderColor, + /// Glow shadow of the selected tab chip. + this.selectedChipShadowColor, + /// Icon + label colour inside the selected chip. + this.selectedContentColor, + this.inactiveIconColor, + this.outerShadowColor, + this.outerGlowColor, + // ── Sizes ──────────────────────────────────────────────────────────────── + this.navHeight = 60.0, + this.chipHeight = 46.0, + /// Fixed width of each icon tap cell (active and inactive). + this.iconCellSize = 48.0, + /// Extra width that slides open when a chip becomes active (label area). + this.labelWidth = 80.0, + this.iconSize = 22.0, + /// Symmetric horizontal inset inside the container. + this.horizontalPadding = 8.0, + /// Gap between adjacent chips. + this.itemSpacing = 4.0, + this.blurSigma = 18.0, + // ── Label ──────────────────────────────────────────────────────────────── + /// Set to `false` to disable label expansion (icon-only compact mode). + this.showLabels = true, + /// Override the label [TextStyle]. Colour is always resolved from theme. + this.labelStyle, + // ── Spring animation ───────────────────────────────────────────────────── + /// Spring mass (heavier = slower). + this.springMass = 1.0, + /// Spring stiffness (higher = snappier). + this.springStiffness = 500.0, + /// Damping ratio: 0.5 = bouncy, 1.0 = critically damped. + this.springDampingRatio = 0.72, + /// Duration for collapsing (non-spring ease-in). + this.collapseDuration = const Duration(milliseconds: 200), + // ── Show / hide animation ───────────────────────────────────────────────── + this.showDuration = const Duration(milliseconds: 250), + this.hideDuration = const Duration(milliseconds: 280), + this.showCurve = Curves.easeInOutCubic, + this.hideCurve = Curves.easeInOutCubic, + // ── Scroll behaviour ───────────────────────────────────────────────────── + /// Set to false to keep the nav bar permanently visible. + this.hideOnScroll = true, + this.scrollDownThreshold = 2.0, + this.scrollUpThreshold = 2.0, + // ── Misc ───────────────────────────────────────────────────────────────── + this.bottomMargin = 16.0, + this.hapticFeedback = true, + // ── Fallback colour opacities ───────────────────────────────────────────── + this.defaultBorderOpacity = 0.13, + this.defaultChipOpacity = 0.10, + this.defaultChipBorderOpacity = 0.25, + this.defaultChipShadowOpacity = 0.15, + this.defaultInactiveOpacity = 0.48, + this.defaultShadowOpacity = 0.45, + this.defaultGlowOpacity = 0.08, + // ── Shadow geometry ─────────────────────────────────────────────────────── + this.outerShadowBlurRadius = 28.0, + this.outerShadowSpread = -4.0, + this.outerShadowOffset = const Offset(0, 10), + this.outerGlowBlurRadius = 32.0, + // ── Slide animation ─────────────────────────────────────────────────────── + /// Offset applied to [AnimatedSlide] when the nav bar hides. + this.slideHideOffset = const Offset(0, 1.5), + /// Fade-out duration = hideDuration × fadeOutDurationFactor. + this.fadeOutDurationFactor = 0.75, + }); + + // ── Colours ───────────────────────────────────────────────────────────────── + final Color? backgroundColor; + final double backgroundOpacity; + final Color? borderColor; + final double borderWidth; + final Color? selectedChipColor; + final Color? selectedChipBorderColor; + final Color? selectedChipShadowColor; + final Color? selectedContentColor; + final Color? inactiveIconColor; + final Color? outerShadowColor; + final Color? outerGlowColor; + + // ── Sizes ──────────────────────────────────────────────────────────────────── + final double navHeight; + final double chipHeight; + final double iconCellSize; + final double labelWidth; + final double iconSize; + final double horizontalPadding; + final double itemSpacing; + final double blurSigma; + + // ── Label ──────────────────────────────────────────────────────────────────── + final bool showLabels; + final TextStyle? labelStyle; + + // ── Spring animation ───────────────────────────────────────────────────────── + final double springMass; + final double springStiffness; + final double springDampingRatio; + final Duration collapseDuration; + + // ── Show / hide animation ──────────────────────────────────────────────────── + final Duration showDuration; + final Duration hideDuration; + final Curve showCurve; + final Curve hideCurve; + + // ── Scroll ─────────────────────────────────────────────────────────────────── + final bool hideOnScroll; + final double scrollDownThreshold; + final double scrollUpThreshold; + + // ── Misc ───────────────────────────────────────────────────────────────────── + final double bottomMargin; + final bool hapticFeedback; + + // ── Fallback opacities ─────────────────────────────────────────────────────── + final double defaultBorderOpacity; + final double defaultChipOpacity; + final double defaultChipBorderOpacity; + final double defaultChipShadowOpacity; + final double defaultInactiveOpacity; + final double defaultShadowOpacity; + final double defaultGlowOpacity; + + // ── Shadow geometry ────────────────────────────────────────────────────────── + final double outerShadowBlurRadius; + final double outerShadowSpread; + final Offset outerShadowOffset; + final double outerGlowBlurRadius; + + // ── Slide animation ────────────────────────────────────────────────────────── + final Offset slideHideOffset; + final double fadeOutDurationFactor; +} + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavBar — pure stateless presentation widget +// ───────────────────────────────────────────────────────────────────────────── + +/// A compact, pill-shaped, glassmorphic navigation bar with expanding chip tabs. +/// +/// The selected tab chips open sideways with a spring animation to reveal the +/// tab label; inactive tabs show the icon only. Inspired by the +/// EssentialsFloatingToolbar pattern from the Compose world. +/// +/// **Purely presentational** — no internal state, no scroll listening. +/// +/// Use [FloatingNavBarScaffold] for the full scroll-aware experience. +class FloatingNavBar extends StatelessWidget { + const FloatingNavBar({ + super.key, + required this.currentIndex, + required this.onTap, + required this.items, + this.theme = const FloatingNavBarTheme(), + }) : assert(items.length >= 2, 'FloatingNavBar requires at least 2 items.'); + + /// Index of the currently selected tab. + final int currentIndex; + + /// Called with the tapped tab index. + final ValueChanged onTap; + + /// Tab definitions. Minimum 2. + final List items; + + /// Visual and layout configuration. + final FloatingNavBarTheme theme; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bottomPad = MediaQuery.of(context).padding.bottom; + + // ── Resolve colours ────────────────────────────────────────────────────── + final bg = theme.backgroundColor ?? + cs.surface.withValues(alpha: theme.backgroundOpacity); + final border = theme.borderColor ?? + cs.outline.withValues(alpha: theme.defaultBorderOpacity); + final chipBg = theme.selectedChipColor ?? + cs.primary.withValues(alpha: theme.defaultChipOpacity); + final chipContent = theme.selectedContentColor ?? cs.onSurface; + final inactiveContent = theme.inactiveIconColor ?? + cs.onSurface.withValues(alpha: theme.defaultInactiveOpacity); + final outerShadow = theme.outerShadowColor ?? + Colors.black.withValues(alpha: theme.defaultShadowOpacity); + final outerGlow = theme.outerGlowColor ?? + cs.primary.withValues(alpha: theme.defaultGlowOpacity); + + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: bottomPad > 0 ? bottomPad : theme.bottomMargin, + ), + child: _ShadowWrapper( + outerShadow: outerShadow, + outerGlow: outerGlow, + shadowBlurRadius: theme.outerShadowBlurRadius, + shadowSpread: theme.outerShadowSpread, + shadowOffset: theme.outerShadowOffset, + glowBlurRadius: theme.outerGlowBlurRadius, + child: ClipRRect( + borderRadius: BorderRadius.circular(9999), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.blurSigma, + sigmaY: theme.blurSigma, + ), + child: Container( + height: theme.navHeight, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(9999), + border: Border.all(color: border, width: theme.borderWidth), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: theme.horizontalPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (int i = 0; i < items.length; i++) ...[ + _NavCell( + item: items[i], + active: i == currentIndex, + theme: theme, + chipBg: chipBg, + chipContent: chipContent, + inactiveContent: inactiveContent, + onTap: () => onTap(i), + ), + if (i < items.length - 1) + SizedBox(width: theme.itemSpacing), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// _ShadowWrapper — outer drop-shadow + ambient glow +// ───────────────────────────────────────────────────────────────────────────── + +/// Applies a drop-shadow and ambient glow **outside** the clipped pill shape. +/// Must be a separate widget because [ClipRRect] clips its own BoxDecoration +/// shadows. +class _ShadowWrapper extends StatelessWidget { + const _ShadowWrapper({ + required this.outerShadow, + required this.outerGlow, + required this.shadowBlurRadius, + required this.shadowSpread, + required this.shadowOffset, + required this.glowBlurRadius, + required this.child, + }); + + final Color outerShadow; + final Color outerGlow; + final double shadowBlurRadius; + final double shadowSpread; + final Offset shadowOffset; + final double glowBlurRadius; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9999), + boxShadow: [ + BoxShadow( + color: outerShadow, + blurRadius: shadowBlurRadius, + spreadRadius: shadowSpread, + offset: shadowOffset, + ), + BoxShadow(color: outerGlow, blurRadius: glowBlurRadius), + ], + ), + child: child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// _NavCell — stateful, spring-animated expanding chip +// ───────────────────────────────────────────────────────────────────────────── + +/// A single tappable chip inside [FloatingNavBar]. +/// +/// When [active] becomes `true`, the chip expands rightward using a +/// [SpringSimulation] (bouncy, lively) to reveal the label text. +/// When [active] becomes `false`, the chip collapses with a quick ease-in. +class _NavCell extends StatefulWidget { + const _NavCell({ + required this.item, + required this.active, + required this.theme, + required this.chipBg, + required this.chipContent, + required this.inactiveContent, + required this.onTap, + }); + + final FloatingNavItem item; + final bool active; + final FloatingNavBarTheme theme; + final Color chipBg; + final Color chipContent; + final Color inactiveContent; + final VoidCallback onTap; + + @override + State<_NavCell> createState() => _NavCellState(); +} + +class _NavCellState extends State<_NavCell> + with SingleTickerProviderStateMixin { + /// Unbounded controller so the spring can overshoot > 1.0 naturally, + /// producing the satisfying bounce on expansion. + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController.unbounded(vsync: this) + ..value = widget.active ? 1.0 : 0.0; + } + + @override + void didUpdateWidget(_NavCell old) { + super.didUpdateWidget(old); + if (old.active == widget.active) return; + + if (widget.active) { + // Spring expand — medium bounce feel, matching DampingRatioMediumBouncy. + _ctrl.animateWith( + SpringSimulation( + SpringDescription.withDampingRatio( + mass: widget.theme.springMass, + stiffness: widget.theme.springStiffness, + ratio: widget.theme.springDampingRatio, + ), + _ctrl.value, + 1.0, + 0.0, // initial velocity + ), + ); + } else { + // Quick ease-in collapse — no spring, feels intentional / snappy. + _ctrl.animateTo( + 0.0, + duration: widget.theme.collapseDuration, + curve: Curves.easeIn, + ); + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = widget.theme; + + return Semantics( + button: true, + label: widget.item.label, + selected: widget.active, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (t.hapticFeedback) HapticFeedback.lightImpact(); + widget.onTap(); + }, + child: AnimatedBuilder( + animation: _ctrl, + builder: (context, _) { + // Raw spring value — may overshoot [0,1] during bounce. + final raw = _ctrl.value; + + // Clamped to [0,1] for colour interpolation (no weird colours). + final colorP = raw.clamp(0.0, 1.0); + + // Width can overshoot slightly for the spring bounce feel. + // Clamp at 1.2× to prevent excessively wide chips on large oscillation. + final widthP = raw.clamp(0.0, 1.2); + + // Label fades in during the second half of expansion. + final labelOpacity = ((colorP - 0.5) * 2.0).clamp(0.0, 1.0); + + // Extra width contributed by the label area. + final extraW = + t.showLabels ? widthP * t.labelWidth : 0.0; + + // Right padding inside chip (breathing room for the label). + final rightPad = t.showLabels ? colorP * 10.0 : 0.0; + + final iconColor = Color.lerp( + widget.inactiveContent, + widget.chipContent, + colorP, + )!; + + return Container( + height: t.chipHeight, + width: (t.iconCellSize + extraW).clamp( + t.iconCellSize, + t.iconCellSize + t.labelWidth * 1.2, + ), + decoration: BoxDecoration( + color: Color.lerp(Colors.transparent, widget.chipBg, colorP), + borderRadius: BorderRadius.circular(9999), + border: colorP > 0.05 + ? Border.all( + color: (t.selectedChipBorderColor ?? widget.chipContent) + .withValues(alpha: 0.28 * colorP), + width: 1.0, + ) + : null, + boxShadow: colorP > 0.05 + ? [ + BoxShadow( + color: + (t.selectedChipShadowColor ?? widget.chipContent) + .withValues(alpha: 0.18 * colorP), + blurRadius: 14, + spreadRadius: -2, + ), + ] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(9999), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // ── Icon (fixed width cell) ────────────────────────────── + SizedBox( + width: t.iconCellSize, + height: t.chipHeight, + child: Center( + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Icon( + widget.item.iconFor(widget.active), + size: t.iconSize, + color: iconColor, + ), + // Badge dot + if (widget.item.hasBadge) + Positioned( + right: -3, + top: -3, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all( + // border matches the chip bg for a + // "punched out" halo effect + color: widget.chipBg, + width: 1.5, + ), + ), + ), + ), + ], + ), + ), + ), + + // ── Expanding label area ───────────────────────────────── + if (t.showLabels && extraW > 1.0) ...[ + Opacity( + opacity: labelOpacity, + child: Text( + widget.item.label, + style: (t.labelStyle ?? + const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + )) + .copyWith(color: widget.chipContent), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.clip, + ), + ), + SizedBox(width: rightPad), + ], + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavBarScaffold — all-in-one convenience wrapper +// ───────────────────────────────────────────────────────────────────────────── + +/// A ready-to-use [Scaffold] that wires [FloatingNavBar] with automatic +/// scroll-aware hide/show logic. +/// +/// Scroll notifications propagate from **any** nested scrollable without +/// any [ScrollController] wiring in child widgets. +/// +/// ### Visibility rules +/// | Event | Result | +/// |---|---| +/// | Scroll down `> scrollDownThreshold` | Nav hides | +/// | Scroll up `> scrollUpThreshold` | Nav shows | +/// | Scroll reaches position `0` (top) | Nav always shows | +/// | Tab switch via [onTabChanged] | Nav always shows | +/// +/// ### Bottom content padding +/// The floating nav overlaps content. Add bottom padding to inner lists: +/// ```dart +/// ListView( +/// padding: EdgeInsets.only( +/// bottom: MediaQuery.of(context).padding.bottom +/// + theme.navHeight +/// + theme.bottomMargin +/// + 8, +/// ), +/// ) +/// ``` +class FloatingNavBarScaffold extends StatefulWidget { + const FloatingNavBarScaffold({ + super.key, + required this.items, + required this.body, + required this.currentIndex, + required this.onTabChanged, + this.theme = const FloatingNavBarTheme(), + this.scaffoldBackgroundColor, + }) : assert( + items.length >= 2, + 'FloatingNavBarScaffold requires at least 2 items.', + ); + + /// Tab definitions. Minimum 2. + final List items; + + /// Main content — typically an [IndexedStack] or [PageView]. + final Widget body; + + /// Currently selected index, managed by the parent. + final int currentIndex; + + /// Called when the user taps a tab. The parent must update [currentIndex]. + final ValueChanged onTabChanged; + + /// Visual and behavioural config. + final FloatingNavBarTheme theme; + + /// [Scaffold] background colour. + final Color? scaffoldBackgroundColor; + + @override + State createState() => _FloatingNavBarScaffoldState(); +} + +class _FloatingNavBarScaffoldState extends State { + bool _visible = true; + + // ── Tab change — always restore visibility ──────────────────────────────── + + void _handleTabChange(int index) { + if (!_visible) setState(() => _visible = true); + widget.onTabChanged(index); + } + + // ── Scroll detection ────────────────────────────────────────────────────── + + bool _handleScrollNotification(ScrollNotification n) { + if (!widget.theme.hideOnScroll) return false; + + if (n is ScrollUpdateNotification) { + final delta = n.scrollDelta ?? 0; + + if (delta > widget.theme.scrollDownThreshold && _visible) { + setState(() => _visible = false); + } else if (delta < -widget.theme.scrollUpThreshold && !_visible) { + setState(() => _visible = true); + } + + // At the very top → always show. + if (n.metrics.pixels <= 0 && !_visible) { + setState(() => _visible = true); + } + } + + // Never absorb — let notifications keep bubbling. + return false; + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: widget.scaffoldBackgroundColor, + body: Stack( + children: [ + // Content: scroll notifications propagate upward from here. + NotificationListener( + onNotification: _handleScrollNotification, + child: widget.body, + ), + + // Floating nav bar with slide + fade animation. + AnimatedSlide( + offset: _visible ? Offset.zero : widget.theme.slideHideOffset, + duration: _visible + ? widget.theme.showDuration + : widget.theme.hideDuration, + curve: + _visible ? widget.theme.showCurve : widget.theme.hideCurve, + child: AnimatedOpacity( + opacity: _visible ? 1.0 : 0.0, + duration: _visible + ? widget.theme.showDuration + : Duration( + milliseconds: (widget.theme.hideDuration.inMilliseconds * + widget.theme.fadeOutDurationFactor) + .round(), + ), + curve: + _visible ? widget.theme.showCurve : widget.theme.hideCurve, + // Disable hit-testing when fully hidden. + child: IgnorePointer( + ignoring: !_visible, + child: FloatingNavBar( + currentIndex: widget.currentIndex, + onTap: _handleTabChange, + items: widget.items, + theme: widget.theme, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index 261a498..b5a5c9e 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -2,7 +2,6 @@ // All widgets consume AppColors/AppSpacing/AppRadius tokens only. import 'dart:math' as math; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../theme/app_theme.dart'; @@ -135,148 +134,10 @@ class AmbientGlow extends StatelessWidget { } } -// ── RFNavBar ───────────────────────────────────────────────────────────────── -// Premium floating glassmorphic bottom navigation bar with perfect rounded blur, -// deep drop shadow, and clean transparent padding so it sits elegantly above the content. -class RFNavBar extends StatelessWidget { - const RFNavBar({ - super.key, - required this.currentIndex, - required this.onTap, - required this.items, - }); - - final int currentIndex; - final ValueChanged onTap; - final List items; - - @override - Widget build(BuildContext context) { - final bottomPadding = MediaQuery.of(context).padding.bottom; - return Container( - color: Colors.transparent, // Completely transparent outer container - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - bottomPadding > 0 ? bottomPadding + 8 : 16, - ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.xxl), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 28, - spreadRadius: -4, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.xxl), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.8), // Sleek transparent surface - borderRadius: BorderRadius.circular(AppRadius.xxl), - border: Border.all( - color: AppColors.glassBorderStrong, - width: 1.5, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(items.length, (i) { - final active = i == currentIndex; - return _NavItem( - item: items[i], - active: active, - onTap: () => onTap(i), - ); - }), - ), - ), - ), - ), - ), - ); - } -} - -class RFNavItem { - const RFNavItem({required this.icon, required this.label}); - final IconData icon; - final String label; -} - -class _NavItem extends StatelessWidget { - const _NavItem({ - required this.item, - required this.active, - required this.onTap, - }); - - final RFNavItem item; - final bool active; - final VoidCallback onTap; +// ── Nav bar ────────────────────────────────────────────────────────────────── +// Moved to floating_nav_bar.dart (zero-dependency, drop-in portable widget). +// Import and use FloatingNavBar / FloatingNavBarScaffold / FloatingNavItem. - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: item.label, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 60, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Accent indicator above icon - AnimatedContainer( - duration: AppDurations.normal, - width: active ? 18 : 0, - height: 2, - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(2), - color: AppColors.primary, - boxShadow: active - ? [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.6), - blurRadius: 6, - ), - ] - : null, - ), - ), - Icon( - item.icon, - size: 19, - color: active ? AppColors.textPrimary : AppColors.textMuted, - ), - const SizedBox(height: 4), - Text( - item.label, - style: TextStyle(fontFamily: 'Geist', - fontSize: 10, - fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? AppColors.textPrimary : AppColors.textMuted, - letterSpacing: 0.2, - ), - ), - ], - ), - ), - ), - ); - } -} // ── GlowButton ────────────────────────────────────────────────────────────── // Full-width primary action button with glow shadow + haptic feedback. diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 24bbd90..3377710 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -684,8 +684,7 @@ class _WorkoutFlowScreenState extends State { await context.read().finishWorkout(); final newPRs = await prManager.checkAndUpdatePRs(session); if (!mounted) return; - nav.pop(); - nav.push(MaterialPageRoute( + nav.pushReplacement(MaterialPageRoute( builder: (_) => WorkoutSummaryScreen( session: session, newPRs: newPRs, diff --git a/workout-logger/scripts/build_release.py b/workout-logger/scripts/build_release.py new file mode 100644 index 0000000..619b442 --- /dev/null +++ b/workout-logger/scripts/build_release.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys +from pathlib import Path + +def load_env_file(env_path: Path): + """Loads key-value pairs from a .env file into os.environ.""" + if not env_path.exists(): + return + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + # Remove whitespace and wrapping quotes if present + val = val.strip().strip("'\"") + os.environ[key.strip()] = val + +def main(): + script_dir = Path(__file__).resolve().parent + project_dir = script_dir.parent if script_dir.name == "scripts" else script_dir + os.chdir(project_dir) + + env_file = project_dir / ".env" + if env_file.exists(): + print(f"Loading environment from {env_file}") + load_env_file(env_file) + else: + print("No .env file found. Using existing environment variables.") + + cmd = "flutter build apk --release --target-platform android-arm64 --obfuscate --split-debug-info=build/app/outputs/symbols" + + print(f"Executing: {cmd}") + result = subprocess.run(cmd, env=os.environ, shell=True) + sys.exit(result.returncode) + +if __name__ == "__main__": + main() diff --git a/workout-logger/test/api_service_test.dart b/workout-logger/test/api_service_test.dart new file mode 100644 index 0000000..7ef55bd --- /dev/null +++ b/workout-logger/test/api_service_test.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:repforge/services/api_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Box settingsBox; + late ApiService service; + + setUpAll(() async { + Hive.init('./test/tmp_hive_api_service'); + if (Hive.isBoxOpen('settings')) { + settingsBox = Hive.box('settings'); + } else { + settingsBox = await Hive.openBox('settings'); + } + }); + + setUp(() { + service = ApiService(); + }); + + tearDownAll(() async { + await settingsBox.close(); + await Hive.deleteFromDisk(); + }); + + group('ApiService', () { + test('userAppId returns non-empty string and persists to box', () async { + final id = await service.userAppId; + expect(id, isNotEmpty); + expect(settingsBox.get('user_app_id'), equals(id)); + + final secondCall = await service.userAppId; + expect(secondCall, equals(id)); + }); + + test('sendHeartbeat sends POST request to /heartbeat', () async { + bool called = false; + final mockClient = MockClient((request) async { + if (request.url.path == '/heartbeat') { + called = true; + final jsonBody = jsonDecode(request.body) as Map; + expect(jsonBody.containsKey('user_app_id'), isTrue); + expect(jsonBody.containsKey('platform'), isTrue); + return http.Response('{"status": "ok"}', 200); + } + return http.Response('Not Found', 404); + }); + + ApiService.setTestClient(mockClient); + await service.sendHeartbeat(); + expect(called, isTrue); + }); + + test('trackEvent sends POST request to /event with metadata', () async { + bool called = false; + final mockClient = MockClient((request) async { + if (request.url.path == '/event') { + called = true; + final jsonBody = jsonDecode(request.body) as Map; + expect(jsonBody['event'], equals('workout_started')); + expect(jsonBody['metadata'], equals({'routine_id': 'rot_123'})); + return http.Response('{"status": "ok"}', 200); + } + return http.Response('Not Found', 404); + }); + + ApiService.setTestClient(mockClient); + await service.trackEvent('workout_started', metadata: {'routine_id': 'rot_123'}); + expect(called, isTrue); + }); + + test('reportUsage posts stats to /report', () async { + bool called = false; + final mockClient = MockClient((request) async { + if (request.url.path == '/report') { + called = true; + final jsonBody = jsonDecode(request.body) as Map; + expect(jsonBody['total_workouts'], equals(15)); + expect(jsonBody['weekly_volume'], equals(12500.0)); + return http.Response('{"status": "ok"}', 200); + } + return http.Response('Error', 500); + }); + + ApiService.setTestClient(mockClient); + await service.reportUsage({ + 'totalWorkouts': 15, + 'weeklyWorkouts': 3, + 'weeklyVolume': 12500.0, + 'exercisesThisWeek': 12, + }); + expect(called, isTrue); + }); + + test('backupData posts backup payload and returns true on 200', () async { + final mockClient = MockClient((request) async { + if (request.url.path == '/backup') { + return http.Response('{"status": "success"}', 200); + } + return http.Response('Forbidden', 403); + }); + + ApiService.setTestClient(mockClient); + final result = await service.backupData({'routines': [], 'sessions': []}); + expect(result, isTrue); + }); + + test('backupData returns false on error status', () async { + final mockClient = MockClient((request) async { + return http.Response('Error', 500); + }); + + ApiService.setTestClient(mockClient); + final result = await service.backupData({}); + expect(result, isFalse); + }); + }); +} diff --git a/workout-logger/test/debug_log_buffer_test.dart b/workout-logger/test/debug_log_buffer_test.dart new file mode 100644 index 0000000..3fbcfca --- /dev/null +++ b/workout-logger/test/debug_log_buffer_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/debug_log_buffer.dart'; + +void main() { + group('DebugLogBuffer', () { + late DebugLogBuffer buffer; + late DebugPrintCallback originalDebugPrint; + + setUp(() { + originalDebugPrint = debugPrint; + buffer = DebugLogBuffer.instance; + buffer.clear(); + }); + + tearDown(() { + debugPrint = originalDebugPrint; + buffer.clear(); + }); + + test('initial lines list is empty', () { + expect(buffer.lines, isEmpty); + }); + + test('attach intercepts debugPrint and appends timestamped message', () { + DebugLogBuffer.attach(); + + bool notified = false; + buffer.addListener(() { + notified = true; + }); + + debugPrint('Test log message'); + + expect(buffer.lines, hasLength(1)); + expect(buffer.lines.first, contains('Test log message')); + expect(buffer.lines.first, matches(RegExp(r'^\[\d{2}:\d{2}:\d{2}\] Test log message$'))); + expect(notified, isTrue); + }); + + test('clear wipes all logs and notifies listeners', () { + DebugLogBuffer.attach(); + debugPrint('Message 1'); + debugPrint('Message 2'); + expect(buffer.lines, hasLength(2)); + + bool notified = false; + buffer.addListener(() { + notified = true; + }); + + buffer.clear(); + + expect(buffer.lines, isEmpty); + expect(notified, isTrue); + }); + + test('lines is unmodifiable', () { + expect(() => buffer.lines.add('direct add'), throwsUnsupportedError); + }); + }); +} diff --git a/workout-logger/test/gemini_context_builder_test.dart b/workout-logger/test/gemini_context_builder_test.dart new file mode 100644 index 0000000..8b28ddf --- /dev/null +++ b/workout-logger/test/gemini_context_builder_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/gemini_context_builder.dart'; + +void main() { + group('GeminiContextBuilder', () { + test('buildCoachSystemPrompt includes today date, unit label, and username', () { + final now = DateTime(2026, 7, 23); + final prompt = GeminiContextBuilder.buildCoachSystemPrompt( + userName: 'Devasy', + unitLabel: 'lbs', + now: now, + ); + + expect(prompt, contains('Today is 2026-07-23')); + expect(prompt, contains('Weights are in lbs')); + expect(prompt, contains("The user's name is Devasy")); + expect(prompt, contains('RepForge')); + }); + + test('buildOptimizerSystemPrompt builds routine optimizer system prompt', () { + final now = DateTime(2026, 7, 23); + final prompt = GeminiContextBuilder.buildOptimizerSystemPrompt( + userName: 'Devasy', + unitLabel: 'kg', + now: now, + ); + + expect(prompt, contains('specialized routine optimizer')); + expect(prompt, contains('Today is 2026-07-23')); + expect(prompt, contains('Weights are in kg')); + expect(prompt, contains("The user's name is Devasy")); + }); + + test('buildWeeklyInsightsContext formats sessions and volumes for this and last week', () { + final exerciseMap = { + 'ex1': Exercise( + id: 'ex1', + name: 'Bench Press', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ), + }; + + final thisWeekSession = WorkoutSession( + id: 's1', + date: DateTime(2026, 7, 20), // Monday + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'ex1', + sets: [ + WorkoutSet(weight: 100, reps: 10), + ], + ), + ], + ); + + final lastWeekSession = WorkoutSession( + id: 's2', + date: DateTime(2026, 7, 13), // Monday + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'ex1', + sets: [ + WorkoutSet(weight: 95, reps: 10), + ], + ), + ], + ); + + final result = GeminiContextBuilder.buildWeeklyInsightsContext( + thisWeek: [thisWeekSession], + lastWeek: [lastWeekSession], + exerciseMap: exerciseMap, + unitLabel: 'kg', + ); + + expect(result, contains('THIS WEEK — 1 sessions')); + expect(result, contains('Bench Press 1×sets (1000kg vol)')); + expect(result, contains('LAST WEEK — 1 sessions')); + expect(result, contains('Mon: Bench Press')); + }); + }); +} diff --git a/workout-logger/test/settings_provider_test.dart b/workout-logger/test/settings_provider_test.dart new file mode 100644 index 0000000..83ea320 --- /dev/null +++ b/workout-logger/test/settings_provider_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('SettingsProvider', () { + late MockStorageService mockStorage; + late SettingsProvider provider; + + setUp(() { + mockStorage = MockStorageService(); + provider = SettingsProvider(mockStorage); + }); + + test('initial values and fallback defaults before init', () { + expect(provider.weightUnit, equals(WeightUnit.kg)); + expect(provider.unitLabel, equals('kg')); + expect(provider.weightIncrement, equals(2.5)); + expect(provider.healthConnectEnabled, isFalse); + expect(provider.readinessEnabled, isFalse); + expect(provider.userName, isNull); + expect(provider.geminiApiKey, isEmpty); + expect(provider.geminiModel, equals('gemini-2.5-flash')); + expect(provider.showAdvancedMetrics, isFalse); + }); + + test('init loads saved settings from storage', () async { + await mockStorage.saveSetting('weightUnit', 'lbs'); + await mockStorage.saveSetting('weightIncrement', '5.0'); + await mockStorage.saveSetting('healthConnectEnabled', 'true'); + await mockStorage.saveSetting('readinessEnabled', 'true'); + await mockStorage.saveSetting('userName', 'Devasy'); + await mockStorage.saveSetting('geminiApiKey', 'secret_key'); + await mockStorage.saveSetting('geminiModel', 'gemini-1.5-pro'); + await mockStorage.saveSetting('showAdvancedMetrics', 'true'); + + await provider.init(); + + expect(provider.weightUnit, equals(WeightUnit.lbs)); + expect(provider.unitLabel, equals('lbs')); + expect(provider.weightIncrement, equals(5.0)); + expect(provider.healthConnectEnabled, isTrue); + expect(provider.readinessEnabled, isTrue); + expect(provider.userName, equals('Devasy')); + expect(provider.geminiApiKey, equals('secret_key')); + expect(provider.geminiModel, equals('gemini-1.5-pro')); + expect(provider.showAdvancedMetrics, isTrue); + }); + + test('setUserName updates state and notifies listeners', () async { + bool notified = false; + provider.addListener(() => notified = true); + + await provider.setUserName(' John Doe '); + + expect(provider.userName, equals('John Doe')); + expect(mockStorage.settings['userName'], equals('John Doe')); + expect(notified, isTrue); + }); + + test('setWeightUnit updates weightUnit, default increment, and saves settings', () async { + await provider.setWeightUnit(WeightUnit.lbs); + + expect(provider.weightUnit, equals(WeightUnit.lbs)); + expect(provider.unitLabel, equals('lbs')); + expect(provider.weightIncrement, equals(5.0)); + expect(mockStorage.settings['weightUnit'], equals('lbs')); + expect(mockStorage.settings['weightIncrement'], equals('5.0')); + + await provider.setWeightUnit(WeightUnit.kg); + + expect(provider.weightUnit, equals(WeightUnit.kg)); + expect(provider.unitLabel, equals('kg')); + expect(provider.weightIncrement, equals(2.5)); + }); + + test('weight conversions and formatting for kg and lbs', () async { + // In kg mode + expect(provider.toDisplay(100.0), equals(100.0)); + expect(provider.toStorage(100.0), equals(100.0)); + expect(provider.formatWeight(100.0), equals('100 kg')); + expect(provider.formatWeight(102.5), equals('102.5 kg')); + + // Switch to lbs mode + await provider.setWeightUnit(WeightUnit.lbs); + + expect(provider.toDisplay(100.0), closeTo(220.462, 0.01)); + expect(provider.toStorage(220.462), closeTo(100.0, 0.01)); + expect(provider.formatWeight(100.0), equals('220.5 lbs')); + }); + + test('setters for healthConnect, readiness, gemini, and advanced metrics', () async { + await provider.setHealthConnectEnabled(true); + expect(provider.healthConnectEnabled, isTrue); + expect(mockStorage.settings['healthConnectEnabled'], equals('true')); + + await provider.setReadinessEnabled(true); + expect(provider.readinessEnabled, isTrue); + expect(mockStorage.settings['readinessEnabled'], equals('true')); + + await provider.setGeminiApiKey('key123'); + expect(provider.geminiApiKey, equals('key123')); + + await provider.setGeminiModel('custom-model'); + expect(provider.geminiModel, equals('custom-model')); + + await provider.setShowAdvancedMetrics(true); + expect(provider.showAdvancedMetrics, isTrue); + }); + + test('saveWeeklyInsights updates insights string and date', () async { + await provider.saveWeeklyInsights('Great progress this week!'); + + expect(provider.weeklyInsights, equals('Great progress this week!')); + expect(provider.weeklyInsightsDate, isNotNull); + expect(mockStorage.settings['weeklyInsights'], equals('Great progress this week!')); + }); + + test('availableIncrements returns correct values for unit', () async { + expect(provider.availableIncrements, equals([1.25, 2.5, 5.0, 10.0])); + + await provider.setWeightUnit(WeightUnit.lbs); + expect(provider.availableIncrements, equals([2.5, 5.0, 10.0, 25.0])); + }); + }); +} diff --git a/workout-logger/test/sleep_hr_builder_test.dart b/workout-logger/test/sleep_hr_builder_test.dart new file mode 100644 index 0000000..6d30012 --- /dev/null +++ b/workout-logger/test/sleep_hr_builder_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/utils/sleep_hr_builder.dart'; +import 'test_utils/stub_health_connect_service.dart'; + +void main() { + final granted = { + HealthReadType.heartRate, + HealthReadType.sleep, + HealthReadType.restingHeartRate, + }; + + group('Sleep & HR Builder Utils', () { + test('buildHrDaySnapshot returns null when no HR samples or resting HR present', () async { + const stubHc = StubHcService(); + final snapshot = await buildHrDaySnapshot(stubHc, DateTime(2026, 7, 23), granted); + + expect(snapshot, isNull); + }); + + test('buildHrDaySnapshot builds buckets and resting HR for day', () async { + final day = DateTime(2026, 7, 23); + final sample1 = HealthSample( + time: DateTime(2026, 7, 23, 10, 0), + value: 70.0, + ); + final sample2 = HealthSample( + time: DateTime(2026, 7, 23, 10, 15), + value: 120.0, + ); + final resting = HealthSample( + time: DateTime(2026, 7, 23, 8, 0), + value: 58.0, + ); + + final stubHc = StubHcService( + hrSamples: [sample1, sample2], + restingHrSamples: [resting], + ); + + final snapshot = await buildHrDaySnapshot(stubHc, day, granted); + + expect(snapshot, isNotNull); + expect(snapshot!.minBpm, equals(70)); + expect(snapshot.maxBpm, equals(120)); + expect(snapshot.restingBpm, equals(58)); + expect(snapshot.buckets, isNotEmpty); + }); + + test('buildSleepHrSnapshot calculates sleep stage stats correctly', () async { + final sleepStart = DateTime(2026, 7, 23, 1, 0); + final sleepEnd = DateTime(2026, 7, 23, 7, 0); + + final sleepPeriod = SleepPeriod( + start: sleepStart, + end: sleepEnd, + stageTimeline: [ + SleepStageInterval(start: sleepStart, end: sleepStart.add(const Duration(hours: 2)), stage: 'deep'), + SleepStageInterval(start: sleepStart.add(const Duration(hours: 2)), end: sleepEnd, stage: 'light'), + ], + ); + + final hrSample1 = HealthSample( + time: DateTime(2026, 7, 23, 2, 0), + value: 55.0, + ); + final hrSample2 = HealthSample( + time: DateTime(2026, 7, 23, 2, 3), + value: 57.0, + ); + final hrSample3 = HealthSample( + time: DateTime(2026, 7, 23, 2, 8), + value: 58.0, + ); + + final stubHc = StubHcService( + sleepPeriods: [sleepPeriod], + hrSamples: [hrSample1, hrSample2, hrSample3], + ); + + final snapshot = await buildSleepHrSnapshot(stubHc, DateTime(2026, 7, 23), granted); + + expect(snapshot, isNotNull); + expect(snapshot!.segments, isNotEmpty); + expect(snapshot.stageStats, isNotEmpty); + + // Verify representative calculated values in snapshot.segments and snapshot.stageStats + final deepStats = snapshot.statsFor('deep'); + expect(deepStats, isNotNull); + expect(deepStats!.stage, equals('deep')); + expect(deepStats.minBpm, equals(55)); + expect(deepStats.maxBpm, equals(58)); + expect(deepStats.sampleCount, equals(3)); + + final firstSegment = snapshot.segments.first; + expect(firstSegment.stage, equals('deep')); + expect(firstSegment.minBpm, equals(55)); + expect(firstSegment.maxBpm, equals(58)); + }); + }); +} diff --git a/workout-logger/test/sleep_hr_models_test.dart b/workout-logger/test/sleep_hr_models_test.dart new file mode 100644 index 0000000..13b8f87 --- /dev/null +++ b/workout-logger/test/sleep_hr_models_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; + +void main() { + group('Sleep HR Models Test', () { + test('SleepHrSegment properties', () { + final now = DateTime.now(); + final segment = SleepHrSegment( + windowStart: now, + minBpm: 50, + maxBpm: 70, + avgBpm: 60.0, + stage: 'deep', + ); + + expect(segment.windowStart, equals(now)); + expect(segment.minBpm, equals(50)); + expect(segment.maxBpm, equals(70)); + expect(segment.avgBpm, equals(60.0)); + expect(segment.stage, equals('deep')); + }); + + test('SleepStageStats properties', () { + const stats = SleepStageStats( + stage: 'rem', + minBpm: 55, + p25Bpm: 60, + avgBpm: 65.5, + p75Bpm: 70, + maxBpm: 80, + sampleCount: 20, + ); + + expect(stats.stage, equals('rem')); + expect(stats.minBpm, equals(55)); + expect(stats.p25Bpm, equals(60)); + expect(stats.avgBpm, equals(65.5)); + expect(stats.p75Bpm, equals(70)); + expect(stats.maxBpm, equals(80)); + expect(stats.sampleCount, equals(20)); + }); + + test('SleepHrSnapshot statsFor helper method', () { + final start = DateTime(2026, 1, 1, 23, 0); + final end = DateTime(2026, 1, 2, 7, 0); + + const deepStats = SleepStageStats( + stage: 'deep', + minBpm: 45, + p25Bpm: 50, + avgBpm: 52.0, + p75Bpm: 55, + maxBpm: 60, + sampleCount: 15, + ); + + final snapshot = SleepHrSnapshot( + sleepStart: start, + sleepEnd: end, + p5Bpm: 48, + p95Bpm: 72, + segments: [], + stageStats: [deepStats], + ); + + expect(snapshot.statsFor('deep'), equals(deepStats)); + expect(snapshot.statsFor('rem'), isNull); + }); + + test('HealthGranularity extensions', () { + expect(HealthGranularity.day.label, equals('Day')); + expect(HealthGranularity.week.label, equals('Week')); + expect(HealthGranularity.month.label, equals('Month')); + expect(HealthGranularity.year.label, equals('Year')); + }); + + test('HrBucket JSON roundtrip', () { + final bucket = HrBucket( + windowStart: DateTime(2026, 5, 10, 14, 30), + minBpm: 60, + maxBpm: 120, + avgBpm: 85.5, + ); + + final json = bucket.toJson(); + final restored = HrBucket.fromJson(json); + + expect(restored.windowStart, equals(bucket.windowStart)); + expect(restored.minBpm, equals(bucket.minBpm)); + expect(restored.maxBpm, equals(bucket.maxBpm)); + expect(restored.avgBpm, equals(bucket.avgBpm)); + }); + + test('HrDaySnapshot JSON roundtrip', () { + final bucket = HrBucket( + windowStart: DateTime(2026, 5, 10, 14, 30), + minBpm: 60, + maxBpm: 120, + avgBpm: 85.5, + ); + + final daySnapshot = HrDaySnapshot( + day: DateTime(2026, 5, 10), + restingBpm: 58, + minBpm: 55, + maxBpm: 145, + avgBpm: 78.2, + buckets: [bucket], + ); + + final json = daySnapshot.toJson(); + final restored = HrDaySnapshot.fromJson(json); + + expect(restored.day, equals(daySnapshot.day)); + expect(restored.restingBpm, equals(58)); + expect(restored.minBpm, equals(55)); + expect(restored.maxBpm, equals(145)); + expect(restored.avgBpm, equals(78.2)); + expect(restored.buckets.length, equals(1)); + expect(restored.buckets.first.minBpm, equals(60)); + }); + + test('SleepDayBar & HrRangeBar construction', () { + final bar = SleepDayBar( + date: DateTime(2026, 6, 1), + totalMinutes: 480, + deepMin: 90, + remMin: 110, + lightMin: 250, + awakeMin: 30, + ); + + expect(bar.totalMinutes, equals(480)); + expect(bar.deepMin, equals(90)); + + final hrRange = HrRangeBar( + date: DateTime(2026, 6, 1), + label: 'Mon', + minBpm: 50, + maxBpm: 130, + avgBpm: 72.0, + restingBpm: 54, + ); + + expect(hrRange.label, equals('Mon')); + expect(hrRange.restingBpm, equals(54)); + }); + }); +} diff --git a/workout-logger/test/storage_service_test.dart b/workout-logger/test/storage_service_test.dart new file mode 100644 index 0000000..4517648 --- /dev/null +++ b/workout-logger/test/storage_service_test.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService storage; + + setUpAll(() async { + const MethodChannel channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + if (methodCall.method == 'getApplicationDocumentsDirectory') { + return './test/tmp_hive_storage_service'; + } + return null; + }, + ); + Hive.init('./test/tmp_hive_storage_service'); + }); + + setUp(() async { + storage = StorageService(); + await storage.init(); + }); + + tearDownAll(() async { + await Hive.close(); + await Hive.deleteFromDisk(); + }); + + group('StorageService CRUD & Operations', () { + test('init initializes default muscle groups', () async { + final groups = await storage.getAllMuscleGroups(); + expect(groups, isNotEmpty); + expect(groups.any((g) => g.name == 'Chest'), isTrue); + }); + + test('WorkoutSession save, get, getAll, getSessionsInDateRange, and delete', () async { + final session1 = WorkoutSession( + id: 's_101', + date: DateTime(2026, 7, 10), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'squat_id', + sets: [WorkoutSet(weight: 100, reps: 5)], + ), + ], + ); + + final session2 = WorkoutSession( + id: 's_102', + date: DateTime(2026, 7, 15), + duration: 60, + exercises: [ + ExerciseLog( + exerciseId: 'bench_id', + sets: [WorkoutSet(weight: 80, reps: 8)], + ), + ], + ); + + await storage.saveWorkoutSession(session1); + await storage.saveWorkoutSession(session2); + + final fetched1 = await storage.getWorkoutSession('s_101'); + expect(fetched1, isNotNull); + expect(fetched1!.duration, equals(45)); + + final allSessions = await storage.getAllWorkoutSessions(); + expect(allSessions.length, greaterThanOrEqualTo(2)); + // Verify most recent session first sorting + expect(allSessions.first.date.isAfter(allSessions[1].date), isTrue); + + final forSquat = await storage.getSessionsForExercise('squat_id'); + expect(forSquat.length, equals(1)); + expect(forSquat.first.id, equals('s_101')); + + final rangeSessions = await storage.getSessionsInDateRange( + DateTime(2026, 7, 12), + DateTime(2026, 7, 20), + ); + expect(rangeSessions.length, equals(1)); + expect(rangeSessions.first.id, equals('s_102')); + + await storage.deleteWorkoutSession('s_101'); + expect(await storage.getWorkoutSession('s_101'), isNull); + }); + + test('Routine CRUD', () async { + final routine = Routine( + id: 'r_101', + name: 'Push Pull Legs - Push', + exerciseIds: ['ex_bench', 'ex_ohp'], + ); + + await storage.saveRoutine(routine); + + final fetched = await storage.getRoutine('r_101'); + expect(fetched, isNotNull); + expect(fetched!.name, equals('Push Pull Legs - Push')); + + final allRoutines = await storage.getAllRoutines(); + expect(allRoutines.any((r) => r.id == 'r_101'), isTrue); + + await storage.deleteRoutine('r_101'); + expect(await storage.getRoutine('r_101'), isNull); + }); + + test('Target CRUD and getTargetsForExercise', () async { + final target = Target( + id: 't_101', + exerciseId: 'ex_bench', + targetValue: 100.0, + targetType: 'weight', + ); + + await storage.saveTarget(target); + + final fetched = await storage.getTarget('t_101'); + expect(fetched, isNotNull); + expect(fetched!.targetValue, equals(100.0)); + + final targetsForBench = await storage.getTargetsForExercise('ex_bench'); + expect(targetsForBench.length, equals(1)); + expect(targetsForBench.first.id, equals('t_101')); + + await storage.deleteTarget('t_101'); + expect(await storage.getTarget('t_101'), isNull); + }); + + test('Custom Exercise save, getAllExercises, getExercise, delete', () async { + final customEx = Exercise( + id: 'custom_ex_999', + name: 'Bulgarian Split Squat Special', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quadriceps', activationPercentage: 100), + ], + isCustom: true, + ); + + await storage.saveCustomExercise(customEx); + + final customList = await storage.getCustomExercises(); + expect(customList.any((e) => e.id == 'custom_ex_999'), isTrue); + + final allExercises = await storage.getAllExercises(); + expect(allExercises.any((e) => e.id == 'custom_ex_999'), isTrue); + + final fetched = await storage.getExercise('custom_ex_999'); + expect(fetched, isNotNull); + expect(fetched!.name, equals('Bulgarian Split Squat Special')); + + await storage.deleteCustomExercise('custom_ex_999'); + expect(await storage.getCustomExercises().then((l) => l.any((e) => e.id == 'custom_ex_999')), isFalse); + }); + + test('Export and import data payload', () async { + await storage.saveSetting('test_setting_key', 'test_val'); + + final exportJsonStr = await storage.exportAllData(); + expect(exportJsonStr, isNotEmpty); + + final exportedMap = jsonDecode(exportJsonStr) as Map; + expect(exportedMap.containsKey('settings'), isTrue); + expect(exportedMap.containsKey('exportDate'), isTrue); + + // Re-import payload + await storage.importData(exportJsonStr); + final val = await storage.getSetting('test_setting_key'); + expect(val, equals('test_val')); + }); + }); +} diff --git a/workout-logger/test/test_utils/stub_health_connect_service.dart b/workout-logger/test/test_utils/stub_health_connect_service.dart new file mode 100644 index 0000000..c6196f6 --- /dev/null +++ b/workout-logger/test/test_utils/stub_health_connect_service.dart @@ -0,0 +1,35 @@ +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; + +class StubHcService implements IHealthConnectService { + final List sleepPeriods; + final List hrSamples; + final List restingHrSamples; + + const StubHcService({ + this.sleepPeriods = const [], + this.hrSamples = const [], + this.restingHrSamples = const [], + }); + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => List.from(sleepPeriods); + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => List.from(hrSamples); + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => List.from(restingHrSamples); + @override + Future> grantedReadTypes() async => {HealthReadType.heartRate, HealthReadType.sleep, HealthReadType.restingHeartRate}; + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => const []; + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} diff --git a/workout-logger/test/userflow_history_and_session_details_test.dart b/workout-logger/test/userflow_history_and_session_details_test.dart new file mode 100644 index 0000000..8294459 --- /dev/null +++ b/workout-logger/test/userflow_history_and_session_details_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/history_screen.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'package:repforge/services/managers/history_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/stub_health_connect_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required HistoryManager historyManager, + required HealthHistoryManager healthHistoryManager, + required Widget child, +}) { + return MultiProvider( + providers: [ + Provider.value(value: healthHistoryManager), + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ChangeNotifierProvider.value(value: historyManager), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late HistoryManager historyManager; + late HealthHistoryManager healthHistoryManager; + + setUp(() async { + mockStorage = MockStorageService(); + historyManager = HistoryManager(mockStorage); + healthHistoryManager = HealthHistoryManager(StubHcService(), mockStorage); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + historyManager: historyManager, + ); + settingsProvider = SettingsProvider(mockStorage); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow 2: History & Session Details Sheet Flow', () { + testWidgets('HistoryScreen renders title when no sessions recorded', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + historyManager: historyManager, + healthHistoryManager: healthHistoryManager, + child: const HistoryScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('History'), findsOneWidget); + }); + + testWidgets('HistoryScreen lists sessions and opens SessionDetailsSheet on tap', (tester) async { + tester.view.physicalSize = const Size(800, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final session = WorkoutSession( + id: 'hist_s1', + date: DateTime.now(), + duration: 60, + exercises: [ + ExerciseLog( + exerciseId: 'squat_id', + sets: [ + WorkoutSet(weight: 140, reps: 5), + WorkoutSet(weight: 140, reps: 5), + ], + ), + ], + ); + + await mockStorage.saveWorkoutSession(session); + await historyManager.loadSessions(); + await workoutProvider.init(); // Reload sessions from storage + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + historyManager: historyManager, + healthHistoryManager: healthHistoryManager, + child: const HistoryScreen(), + )); + await tester.pumpAndSettle(); + + // Tap session item in HistoryScreen to open SessionDetailsSheet + final sessionCard = find.text('Quick Workout'); + expect(sessionCard, findsOneWidget); + await tester.tap(sessionCard); + await tester.pumpAndSettle(); + + // Verify SessionDetailsSheet displays details + expect(find.textContaining('60 min'), findsOneWidget); + expect(find.text('Exercises'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/userflow_routine_creation_test.dart b/workout-logger/test/userflow_routine_creation_test.dart new file mode 100644 index 0000000..b60a89f --- /dev/null +++ b/workout-logger/test/userflow_routine_creation_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/routines_screen.dart'; +import 'package:repforge/screens/widgets/routine_creator.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required Widget child, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + mockStorage = MockStorageService(); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow 3: Routine Creation & Management Flow', () { + testWidgets('RoutinesScreen renders title, empty state, and new routine button', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const RoutinesScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('Routines'), findsWidgets); + }); + + testWidgets('CreateRoutineScreen renders input fields, selects exercise, and saves new routine', (tester) async { + final exercise = Exercise( + id: 'ex_bench', + name: 'Bench Press', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ); + await mockStorage.saveCustomExercise(exercise); + await workoutProvider.init(); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const CreateRoutineScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('New Routine'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + + // Enter routine name into TextField + final textField = find.byType(TextField).first; + await tester.enterText(textField, 'Upper Body Hypertrophy'); + await tester.pump(); + + // Tap 'Add Exercises' button to open exercise picker modal + final addBtn = find.text('Add Exercises'); + expect(addBtn, findsOneWidget); + await tester.tap(addBtn); + await tester.pumpAndSettle(); + + // Select 'Bench Press' from picker modal + final benchPressFinder = find.text('Bench Press'); + expect(benchPressFinder, findsWidgets); + await tester.tap(benchPressFinder.first); + await tester.pump(); + + // Tap 'Add 1' button in picker header + await tester.tap(find.text('Add 1')); + await tester.pumpAndSettle(); + + // Tap Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify routine saved in provider + expect(workoutProvider.routines.any((r) => r.name == 'Upper Body Hypertrophy'), isTrue); + }); + + testWidgets('RoutinesScreen renders saved routines list', (tester) async { + await workoutProvider.createRoutine('Legs & Core Routine', ['ex_squat']); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const RoutinesScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('Legs & Core Routine'), findsWidgets); + }); + }); +} diff --git a/workout-logger/test/userflow_settings_and_storage_test.dart b/workout-logger/test/userflow_settings_and_storage_test.dart new file mode 100644 index 0000000..518de53 --- /dev/null +++ b/workout-logger/test/userflow_settings_and_storage_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/screens/settings_screen.dart'; +import 'package:repforge/services/api_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required ApiService apiService, + required Widget child, +}) { + return MultiProvider( + providers: [ + Provider.value(value: apiService), + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late ApiService apiService; + + setUp(() async { + mockStorage = MockStorageService(); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + apiService = ApiService(); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow 4: Settings & Storage Flow', () { + testWidgets('SettingsScreen renders title, section headers, and unit options', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + apiService: apiService, + child: const SettingsScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Preferences'), findsOneWidget); + expect(find.text('kg'), findsOneWidget); + expect(find.text('lbs'), findsOneWidget); + }); + + testWidgets('Toggling weight unit in SettingsScreen persists to storage and updates display label', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + apiService: apiService, + child: const SettingsScreen(), + )); + await tester.pumpAndSettle(); + + expect(settingsProvider.weightUnit, equals(WeightUnit.kg)); + expect(settingsProvider.unitLabel, equals('kg')); + + // Tap 'lbs' unit button in SettingsScreen UI + final lbsButton = find.text('lbs'); + expect(lbsButton, findsOneWidget); + await tester.tap(lbsButton); + await tester.pumpAndSettle(); + + // Assert UI display label, provider state, and persistent storage + expect(settingsProvider.weightUnit, equals(WeightUnit.lbs)); + expect(settingsProvider.unitLabel, equals('lbs')); + expect(mockStorage.settings['weightUnit'], equals('lbs')); + + // Set increment and verify persistence + await settingsProvider.setWeightIncrement(5.0); + expect(settingsProvider.weightIncrement, equals(5.0)); + expect(mockStorage.settings['weightIncrement'], equals('5.0')); + }); + }); +} diff --git a/workout-logger/test/userflow_workout_logging_test.dart b/workout-logger/test/userflow_workout_logging_test.dart new file mode 100644 index 0000000..4c67c59 --- /dev/null +++ b/workout-logger/test/userflow_workout_logging_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/workout_flow_screen.dart'; +import 'package:repforge/screens/workout_summary_screen.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required PRManager prManager, + required Widget child, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ChangeNotifierProvider.value(value: prManager), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late PRManager prManager; + + setUp(() async { + mockStorage = MockStorageService(); + prManager = PRManager(mockStorage); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + + await workoutProvider.init(); + await settingsProvider.init(); + await prManager.load(); + }); + + group('Userflow 1: Workout Logging & Rest Timer & Summary Screen Flow', () { + testWidgets('User completes sets, interacts with RestTimerView, and views WorkoutSummaryScreen through production flow', (tester) async { + // 1. Save custom exercise and start active workout + final exercise = Exercise( + id: 'ex_bench', + name: 'Barbell Bench Press', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ); + await mockStorage.saveCustomExercise(exercise); + await workoutProvider.init(); + + workoutProvider.startWorkout(exerciseIds: ['ex_bench']); + + // Render WorkoutFlowScreen + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + prManager: prManager, + child: const WorkoutFlowScreen(), + )); + await tester.pumpAndSettle(); + + // Verify WorkoutFlowScreen renders exercise name + expect(find.text('Barbell Bench Press'), findsWidgets); + + // 2. Drive production flow: Tap 'LOG SET' to trigger RestTimerView overlay in WorkoutFlowScreen + final logSetBtn = find.text('LOG SET'); + expect(logSetBtn, findsOneWidget); + await tester.tap(logSetBtn); + await tester.pumpAndSettle(); + + // Verify RestTimerView overlay appears via WorkoutFlowScreen production state + expect(find.text('REST'), findsWidgets); + expect(find.text('SKIP REST'), findsOneWidget); + + // Tap '+30s' button during rest + final addTimeBtn = find.text('+30s'); + expect(addTimeBtn, findsOneWidget); + await tester.tap(addTimeBtn); + await tester.pump(); + + // Tap 'SKIP REST' to return to active workout view + final skipBtn = find.text('SKIP REST'); + await tester.tap(skipBtn); + await tester.pumpAndSettle(); + + // 3. Complete workout session and render WorkoutSummaryScreen + final summarySession = WorkoutSession( + id: 'completed_s1', + date: DateTime.now(), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'ex_bench', + sets: [ + WorkoutSet(weight: 100, reps: 10), + WorkoutSet(weight: 100, reps: 8), + ], + ), + ], + ); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + prManager: prManager, + child: WorkoutSummaryScreen(session: summarySession), + )); + await tester.pumpAndSettle(); + + // Verify Summary Screen metrics: trophy, stat grid, volume, sets count + expect(find.byType(WorkoutSummaryScreen), findsOneWidget); + expect(find.text('Workout Complete!'), findsOneWidget); + expect(find.text('Done'), findsOneWidget); + expect(find.text('45m'), findsOneWidget); + }); + }); +}