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..02e711d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,20 +2,31 @@ name: Test
on:
push:
- branches: [main]
+ branches:
+ - main
+ - 'r[0-9]+.[0-9]+.*'
pull_request:
- branches: [main]
+ branches:
+ - main
+ - 'r[0-9]+.[0-9]+.*'
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 +39,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 +65,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/README.md b/README.md
index 6662424..794e74a 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@
-
-
-
+
+
+
@@ -49,8 +49,8 @@ Want to take RepForge for a spin or contribute? Follow these steps to build the
1. **Clone the repository:**
```bash
- git clone https://github.com/Devasy23/Workout-logger.git
- cd Workout-logger/workout-logger
+ git clone https://github.com/Devasy/RepForge.git
+ cd RepForge/workout-logger
```
2. **Install dependencies:**
@@ -84,7 +84,7 @@ We believe in the power of open-source! Whether you want to fix a bug, add a fea
5. Open a **Pull Request** and describe your changes.
### What to work on?
-Check out the **[Issues](https://github.com/Devasy23/Workout-logger/issues)** tab! If you have a new idea, feel free to open a new issue for a feature request or bug report before starting your work. Whether it's a UI tweak, performance upgrade, or a brand new workout mode, we'd love to see it!
+Check out the **[Issues](https://github.com/Devasy/RepForge/issues)** tab! If you have a new idea, feel free to open a new issue for a feature request or bug report before starting your work. Whether it's a UI tweak, performance upgrade, or a brand new workout mode, we'd love to see it!
### Development Guidelines
- Follow standard Flutter and Dart formatting (`flutter format .`).
@@ -101,7 +101,7 @@ This project is a personal workout tracking application.
**Devasy Patel**
- Email: patel.devasy.23@gmail.com
-- GitHub: [@Devasy23](https://github.com/Devasy23)
+- GitHub: [@Devasy](https://github.com/Devasy)
---
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 32e478b..d68c9d8 100644
--- a/workout-logger/android/app/build.gradle.kts
+++ b/workout-logger/android/app/build.gradle.kts
@@ -1,25 +1,28 @@
import com.android.build.gradle.internal.api.ApkVariantOutputImpl
+import java.io.FileInputStream
+import java.util.Properties
plugins {
id("com.android.application")
- id("kotlin-android")
- // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ // kotlin-android is injected automatically by Flutter's built-in Kotlin support.
+ // (android.builtInKotlin=true in gradle.properties)
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.devasy.repforge"
- compileSdk = 36
- compileSdkExtension = 19
+ compileSdk = 37
ndkVersion = flutter.ndkVersion
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_11.toString()
+ kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
}
// Strip AGP's "Dependency metadata" signing block from the APK. It embeds a
@@ -33,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
@@ -53,7 +61,7 @@ android {
// supported. If downgrading, remove the health_connector dependency and
// all HealthConnectService usages, then restore minSdk to flutter.minSdkVersion.
minSdk = 26
- targetSdk = 36
+ targetSdk = 37
versionCode = flutter.versionCode
versionName = flutter.versionName
// App display name; overridden per build type below so debug installs
@@ -71,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/gradle.properties b/workout-logger/android/gradle.properties
index f018a61..aae5292 100644
--- a/workout-logger/android/gradle.properties
+++ b/workout-logger/android/gradle.properties
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=true
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=true
diff --git a/workout-logger/android/gradle/wrapper/gradle-wrapper.properties b/workout-logger/android/gradle/wrapper/gradle-wrapper.properties
index ac3b479..f587a47 100644
--- a/workout-logger/android/gradle/wrapper/gradle-wrapper.properties
+++ b/workout-logger/android/gradle/wrapper/gradle-wrapper.properties
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip
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/android/settings.gradle.kts b/workout-logger/android/settings.gradle.kts
index fb605bc..ca7fe06 100644
--- a/workout-logger/android/settings.gradle.kts
+++ b/workout-logger/android/settings.gradle.kts
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
- id("com.android.application") version "8.9.1" apply false
- id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
diff --git a/workout-logger/fastlane/metadata/android/en-US/full_description.txt b/workout-logger/fastlane/metadata/android/en-US/full_description.txt
index 72e44bb..97cb3aa 100644
--- a/workout-logger/fastlane/metadata/android/en-US/full_description.txt
+++ b/workout-logger/fastlane/metadata/android/en-US/full_description.txt
@@ -20,4 +20,4 @@ RepForge is fully offline by default. The optional AI Coach feature sends data t
LICENSE
-Apache-2.0. Source code: https://github.com/Devasy23/Workout-logger
\ No newline at end of file
+Apache-2.0. Source code: https://github.com/Devasy/RepForge
\ No newline at end of file
diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart
index 94da3db..90495e1 100644
--- a/workout-logger/lib/screens/edit_workout_session_screen.dart
+++ b/workout-logger/lib/screens/edit_workout_session_screen.dart
@@ -9,6 +9,7 @@ import '../models/models.dart';
import '../services/workout_provider.dart';
import '../theme/app_theme.dart';
import 'widgets/rf_widgets.dart';
+import 'widgets/rf_dialogs.dart';
import 'widgets/editable_exercise_card.dart';
class EditWorkoutSessionScreen extends StatefulWidget {
@@ -206,50 +207,20 @@ class _EditWorkoutSessionScreenState extends State
{
Future _onWillPop() async {
if (!_hasChanges) return true;
- final result = await showDialog(
- context: context,
- builder: (ctx) => AlertDialog(
- backgroundColor: AppColors.cardHigh,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(AppRadius.lg),
- ),
- title: const Text(
- 'Discard Changes?',
- style: TextStyle(color: AppColors.textPrimary),
- ),
- content: const Text(
- 'You have unsaved changes. Discard them?',
- style: TextStyle(color: AppColors.textSoft),
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(false),
- child: const Text(
- 'Cancel',
- style: TextStyle(color: AppColors.textSoft),
- ),
- ),
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(true),
- style: TextButton.styleFrom(foregroundColor: AppColors.error),
- child: const Text('Discard'),
- ),
- ],
- ),
+ final result = await showRFConfirmDialog(
+ context,
+ title: 'Discard Changes?',
+ content: 'You have unsaved changes. Discard them?',
+ confirmText: 'Discard',
+ isDanger: true,
);
return result ?? false;
}
void _snack(String msg, {bool isError = false}) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)),
- backgroundColor: isError ? AppColors.error : AppColors.cardHigh,
- behavior: SnackBarBehavior.floating,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(AppRadius.md),
- ),
- ),
+ context.showRFSnackBar(
+ msg,
+ type: isError ? RFSnackBarType.error : RFSnackBarType.info,
);
}
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/programs/import_program_screen.dart b/workout-logger/lib/screens/programs/import_program_screen.dart
index 81de167..84f2a05 100644
--- a/workout-logger/lib/screens/programs/import_program_screen.dart
+++ b/workout-logger/lib/screens/programs/import_program_screen.dart
@@ -269,7 +269,6 @@ class _ImportProgramScreenState extends State {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
- allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
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..d3e9af8
--- /dev/null
+++ b/workout-logger/lib/screens/widgets/floating_nav_bar.dart
@@ -0,0 +1,768 @@
+// 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: ClipRect(
+ child: OverflowBox(
+ maxWidth: double.infinity,
+ 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/health_detail_shell.dart b/workout-logger/lib/screens/widgets/health_detail_shell.dart
index fbac10c..25914d6 100644
--- a/workout-logger/lib/screens/widgets/health_detail_shell.dart
+++ b/workout-logger/lib/screens/widgets/health_detail_shell.dart
@@ -40,7 +40,7 @@ class HealthDetailShell extends StatelessWidget {
backgroundColor: AppColors.background,
body: Stack(
children: [
- const Positioned.fill(child: AmbientGlow()),
+ const AmbientGlow(),
SafeArea(
child: Column(
children: [
diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart
index 521243e..fc6d3cf 100644
--- a/workout-logger/lib/screens/widgets/readiness_card.dart
+++ b/workout-logger/lib/screens/widgets/readiness_card.dart
@@ -107,22 +107,23 @@ class ReadinessCard extends StatelessWidget {
/// One line of evidence from the weakest available component.
static String _subtitle(ReadinessSnapshot s) {
final parts = <(int, String)>[
- if (s.sleepScore != null)
+ if (s.sleepScore != null && s.sleepMinutes != null && s.sleepBaselineMinutes != null)
(
s.sleepScore!,
'Sleep ${_fmtSleep(s.sleepMinutes!)} vs ${_fmtSleep(s.sleepBaselineMinutes!.round())} avg'
),
- if (s.rhrScore != null)
+ if (s.rhrScore != null && s.restingHr != null && s.rhrBaseline != null)
(
s.rhrScore!,
'Resting HR ${s.restingHr!.round()} vs ${s.rhrBaseline!.round()} avg'
),
- if (s.hrvScore != null)
+ if (s.hrvScore != null && s.hrvMs != null && s.hrvBaseline != null)
(
s.hrvScore!,
'HRV ${s.hrvMs!.round()}ms vs ${s.hrvBaseline!.round()}ms avg'
),
];
+ if (parts.isEmpty) return 'Ready to train';
parts.sort((a, b) => a.$1.compareTo(b.$1));
return parts.first.$2;
}
diff --git a/workout-logger/lib/screens/widgets/rf_dialogs.dart b/workout-logger/lib/screens/widgets/rf_dialogs.dart
new file mode 100644
index 0000000..15b5e1a
--- /dev/null
+++ b/workout-logger/lib/screens/widgets/rf_dialogs.dart
@@ -0,0 +1,136 @@
+// rf_dialogs.dart — Reusable RepForge confirmation dialogs and floating toast notifications
+
+import 'package:flutter/material.dart';
+import '../../theme/app_theme.dart';
+
+/// Types of snackbar toast notifications.
+enum RFSnackBarType { info, success, warning, error }
+
+extension RFSnackBarContext on BuildContext {
+ /// Displays a standardized RepForge floating SnackBar.
+ void showRFSnackBar(
+ String message, {
+ RFSnackBarType type = RFSnackBarType.info,
+ Duration duration = const Duration(seconds: 3),
+ }) {
+ final Color bgColor;
+ final Color fgColor;
+ final IconData icon;
+
+ switch (type) {
+ case RFSnackBarType.success:
+ bgColor = AppColors.success;
+ fgColor = AppColors.textPrimary; // #F4F4F8 on #00C89B: ~4.6:1 ✓
+ icon = Icons.check_circle_outline_rounded;
+ break;
+ case RFSnackBarType.warning:
+ bgColor = AppColors.warning;
+ fgColor = const Color(0xFF1A1200); // near-black on #DBA520: >7:1 ✓
+ icon = Icons.warning_amber_rounded;
+ break;
+ case RFSnackBarType.error:
+ bgColor = AppColors.error;
+ fgColor = AppColors.textPrimary; // #F4F4F8 on #E05040: ~4.7:1 ✓
+ icon = Icons.error_outline_rounded;
+ break;
+ case RFSnackBarType.info:
+ bgColor = AppColors.cardHigh;
+ fgColor = AppColors.textPrimary; // neutral — unchanged
+ icon = Icons.info_outline_rounded;
+ break;
+ }
+
+ ScaffoldMessenger.of(this).hideCurrentSnackBar();
+ ScaffoldMessenger.of(this).showSnackBar(
+ SnackBar(
+ duration: duration,
+ behavior: SnackBarBehavior.floating,
+ backgroundColor: bgColor,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(AppRadius.md),
+ side: const BorderSide(color: AppColors.glassBorder),
+ ),
+ content: Row(
+ children: [
+ Icon(icon, color: fgColor, size: 20),
+ const SizedBox(width: AppSpacing.sm),
+ Expanded(
+ child: Text(
+ message,
+ style: TextStyle(
+ fontFamily: 'Geist',
+ color: fgColor,
+ fontSize: 14,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Displays a standardized glassmorphic confirm dialog.
+Future showRFConfirmDialog(
+ BuildContext context, {
+ required String title,
+ required String content,
+ String cancelText = 'Cancel',
+ String confirmText = 'Confirm',
+ bool isDanger = false,
+}) {
+ return showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: AppColors.cardHigh,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ side: const BorderSide(color: AppColors.glassBorder),
+ ),
+ title: Text(
+ title,
+ style: const TextStyle(
+ fontFamily: 'Geist',
+ color: AppColors.textPrimary,
+ fontWeight: FontWeight.w700,
+ fontSize: 18,
+ ),
+ ),
+ content: Text(
+ content,
+ style: const TextStyle(
+ fontFamily: 'Geist',
+ color: AppColors.textSoft,
+ fontSize: 14,
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: Text(
+ cancelText,
+ style: const TextStyle(
+ fontFamily: 'Geist',
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(true),
+ style: TextButton.styleFrom(
+ foregroundColor: isDanger ? AppColors.error : AppColors.primary,
+ ),
+ child: Text(
+ confirmText,
+ style: TextStyle(
+ fontFamily: 'Geist',
+ fontWeight: FontWeight.w600,
+ color: isDanger ? AppColors.error : AppColors.primary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart
index 261a498..a08d842 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,
- });
+// ── Nav bar ──────────────────────────────────────────────────────────────────
+// Moved to floating_nav_bar.dart (zero-dependency, drop-in portable widget).
+// Import and use FloatingNavBar / FloatingNavBarScaffold / FloatingNavItem.
- 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;
-
- @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.
@@ -1037,3 +898,110 @@ class _SkeletonBoxState extends State
);
}
}
+
+// ── RFTextField ─────────────────────────────────────────────────────────────
+/// Standardized RepForge glassmorphic text input field.
+class RFTextField extends StatefulWidget {
+ const RFTextField({
+ super.key,
+ required this.controller,
+ required this.hint,
+ this.label,
+ this.keyboardType,
+ this.inputFormatters,
+ this.maxLines = 1,
+ this.onChanged,
+ this.prefixIcon,
+ this.suffixIcon,
+ });
+
+ final TextEditingController controller;
+ final String hint;
+ final String? label;
+ final TextInputType? keyboardType;
+ final List? inputFormatters;
+ final int maxLines;
+ final ValueChanged? onChanged;
+ final IconData? prefixIcon;
+ final Widget? suffixIcon;
+
+ @override
+ State createState() => _RFTextFieldState();
+}
+
+class _RFTextFieldState extends State {
+ late final FocusNode _focusNode;
+ bool _isFocused = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _focusNode = FocusNode();
+ _focusNode.addListener(_onFocusChange);
+ }
+
+ void _onFocusChange() {
+ setState(() => _isFocused = _focusNode.hasFocus);
+ }
+
+ @override
+ void dispose() {
+ _focusNode.removeListener(_onFocusChange);
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (widget.label != null) ...[
+ Text(
+ widget.label!,
+ style: const TextStyle(
+ fontFamily: 'GeistMono',
+ color: AppColors.textSoft,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: AppSpacing.xs),
+ ],
+ Container(
+ decoration: BoxDecoration(
+ color: AppColors.surface,
+ borderRadius: BorderRadius.circular(AppRadius.md),
+ border: Border.all(
+ color: _isFocused ? AppColors.primary : AppColors.glassBorder,
+ width: _isFocused ? 1.5 : 1.0,
+ ),
+ ),
+ child: TextField(
+ controller: widget.controller,
+ focusNode: _focusNode,
+ keyboardType: widget.keyboardType,
+ inputFormatters: widget.inputFormatters,
+ maxLines: widget.maxLines,
+ onChanged: widget.onChanged,
+ style: const TextStyle(color: AppColors.textPrimary, fontSize: 14),
+ decoration: InputDecoration(
+ hintText: widget.hint,
+ hintStyle: const TextStyle(color: AppColors.textMuted, fontSize: 14),
+ prefixIcon: widget.prefixIcon != null
+ ? Icon(widget.prefixIcon, color: AppColors.textSoft, size: 20)
+ : null,
+ suffixIcon: widget.suffixIcon,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: AppSpacing.md,
+ vertical: AppSpacing.sm,
+ ),
+ border: InputBorder.none,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
diff --git a/workout-logger/lib/screens/widgets/routine_creator.dart b/workout-logger/lib/screens/widgets/routine_creator.dart
index 97fc6b5..deaa717 100644
--- a/workout-logger/lib/screens/widgets/routine_creator.dart
+++ b/workout-logger/lib/screens/widgets/routine_creator.dart
@@ -10,6 +10,7 @@ import '../../data/exercise_database.dart';
import '../workout_flow_screen.dart';
import 'rf_widgets.dart';
import 'rf_cards.dart';
+import 'rf_dialogs.dart';
import 'workout_conflict_dialog.dart';
// ── Start routine workout (shared helper) ─────────────────────────────────────
@@ -101,25 +102,10 @@ class _CreateRoutineScreenState extends State {
children: [
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
- child: Container(
- decoration: BoxDecoration(
- color: AppColors.card,
- borderRadius: BorderRadius.circular(AppRadius.md),
- border: Border.all(color: AppColors.glassBorder),
- ),
- child: TextField(
- controller: _nameController,
- style: const TextStyle(color: AppColors.textPrimary),
- decoration: const InputDecoration(
- hintText: 'Routine name (e.g. Push Day)',
- hintStyle: TextStyle(color: AppColors.textMuted),
- border: InputBorder.none,
- contentPadding: EdgeInsets.symmetric(
- horizontal: AppSpacing.md,
- vertical: AppSpacing.md,
- ),
- ),
- ),
+ child: RFTextField(
+ controller: _nameController,
+ hint: 'Routine name (e.g. Push Day)',
+ prefixIcon: Icons.fitness_center_rounded,
),
),
Padding(
@@ -159,15 +145,16 @@ class _CreateRoutineScreenState extends State {
AppSpacing.md,
),
itemCount: _selectedIds.length + 1,
- onReorder: (old, next) {
+ onReorderItem: (old, next) {
if (old >= _selectedIds.length ||
- next >= _selectedIds.length + 1) {
+ next > _selectedIds.length) {
return;
}
setState(() {
- if (next > old) next--;
final item = _selectedIds.removeAt(old);
- _selectedIds.insert(next, item);
+ final targetIndex =
+ next > _selectedIds.length ? _selectedIds.length : next;
+ _selectedIds.insert(targetIndex, item);
});
},
itemBuilder: (_, i) {
@@ -409,14 +396,16 @@ class _CreateRoutineScreenState extends State {
Future _save() async {
if (_nameController.text.trim().isEmpty) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Please enter a routine name')),
+ context.showRFSnackBar(
+ 'Please enter a routine name',
+ type: RFSnackBarType.warning,
);
return;
}
if (_selectedIds.isEmpty) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Please add at least one exercise')),
+ context.showRFSnackBar(
+ 'Please add at least one exercise',
+ type: RFSnackBarType.warning,
);
return;
}
@@ -440,8 +429,9 @@ class _CreateRoutineScreenState extends State {
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Failed to save routine: $e')),
+ context.showRFSnackBar(
+ 'Failed to save routine: $e',
+ type: RFSnackBarType.error,
);
}
}
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/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart
index b278b66..cac965d 100644
--- a/workout-logger/lib/services/health_connect_service.dart
+++ b/workout-logger/lib/services/health_connect_service.dart
@@ -58,10 +58,24 @@ class HealthConnectService implements IHealthConnectService {
'leg_raises': ExerciseSegmentType.legRaise,
};
+ static const _statusDeadline = Duration(seconds: 5);
+ static const _queryDeadline = Duration(seconds: 10);
+ static const _hrQueryDeadline = Duration(seconds: 20);
+
+ Future _getConnector() async {
+ try {
+ _connector ??= await HealthConnector.create().timeout(_statusDeadline);
+ return _connector;
+ } catch (e) {
+ debugPrint('[HC] _getConnector failed: $e');
+ return null;
+ }
+ }
+
@override
Future isAvailable() async {
try {
- final status = await HealthConnector.getHealthPlatformStatus();
+ final status = await HealthConnector.getHealthPlatformStatus().timeout(_statusDeadline);
debugPrint('[HC] isAvailable: platform status = $status');
return status == HealthPlatformStatus.available;
} catch (e) {
@@ -73,8 +87,9 @@ class HealthConnectService implements IHealthConnectService {
@override
Future requestPermissions() async {
try {
- _connector ??= await HealthConnector.create();
- final results = await _connector!.requestPermissions([
+ final connector = await _getConnector();
+ if (connector == null) return false;
+ final results = await connector.requestPermissions([
HealthDataType.exerciseSession.writePermission,
HealthDataType.exerciseSession.readPermission,
]);
@@ -88,10 +103,11 @@ class HealthConnectService implements IHealthConnectService {
@override
Future hasPermissions() async {
try {
- _connector ??= await HealthConnector.create();
- final status = await _connector!.getPermissionStatus(
+ final connector = await _getConnector();
+ if (connector == null) return false;
+ final status = await connector.getPermissionStatus(
HealthDataType.exerciseSession.writePermission,
- );
+ ).timeout(_statusDeadline);
return status == PermissionStatus.granted;
} catch (_) {
return false;
@@ -111,11 +127,12 @@ class HealthConnectService implements IHealthConnectService {
@override
Future requestReadPermissions() async {
debugPrint('[HC] requestReadPermissions: requesting ${_readPermissions.length} permissions individually');
- _connector ??= await HealthConnector.create();
+ final connector = await _getConnector();
+ if (connector == null) return false;
var anyGranted = false;
for (final entry in _readPermissions.entries) {
try {
- final results = await _connector!.requestPermissions([entry.value]);
+ final results = await connector.requestPermissions([entry.value]);
final granted = results.any((r) => r.status == PermissionStatus.granted);
debugPrint('[HC] requestReadPermissions: ${entry.key} → granted=$granted');
if (granted) anyGranted = true;
@@ -129,11 +146,12 @@ class HealthConnectService implements IHealthConnectService {
@override
Future> grantedReadTypes() async {
- _connector ??= await HealthConnector.create();
+ final connector = await _getConnector();
+ if (connector == null) return {};
final granted = {};
for (final entry in _readPermissions.entries) {
try {
- final status = await _connector!.getPermissionStatus(entry.value);
+ final status = await connector.getPermissionStatus(entry.value).timeout(_statusDeadline);
debugPrint('[HC] grantedReadTypes: ${entry.key} → $status');
if (status == PermissionStatus.granted) granted.add(entry.key);
} catch (e) {
@@ -150,13 +168,14 @@ class HealthConnectService implements IHealthConnectService {
DateTime end,
) async {
try {
- _connector ??= await HealthConnector.create();
- final response = await _connector!.readRecords(
+ final connector = await _getConnector();
+ if (connector == null) return const [];
+ final response = await connector.readRecords(
HealthDataType.sleepSession.readInTimeRange(
startTime: start,
endTime: end,
),
- );
+ ).timeout(_queryDeadline);
final result = response.records.map((r) {
// Tally stage durations from embedded SleepStageSamples and build
// an ordered stage timeline for HR segment colouring.
@@ -217,13 +236,14 @@ class HealthConnectService implements IHealthConnectService {
DateTime end,
) async {
try {
- _connector ??= await HealthConnector.create();
- final response = await _connector!.readRecords(
+ final connector = await _getConnector();
+ if (connector == null) return const [];
+ final response = await connector.readRecords(
HealthDataType.restingHeartRate.readInTimeRange(
startTime: start,
endTime: end,
),
- );
+ ).timeout(_queryDeadline);
final result = response.records
.map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute))
.toList();
@@ -238,13 +258,14 @@ class HealthConnectService implements IHealthConnectService {
@override
Future> readHrvRmssd(DateTime start, DateTime end) async {
try {
- _connector ??= await HealthConnector.create();
- final response = await _connector!.readRecords(
+ final connector = await _getConnector();
+ if (connector == null) return const [];
+ final response = await connector.readRecords(
HealthDataType.heartRateVariabilityRMSSD.readInTimeRange(
startTime: start,
endTime: end,
),
- );
+ ).timeout(_queryDeadline);
final result = response.records
.map((r) => HealthSample(time: r.time, value: r.rmssd.inMilliseconds))
.toList();
@@ -262,16 +283,17 @@ class HealthConnectService implements IHealthConnectService {
DateTime end,
) async {
try {
- _connector ??= await HealthConnector.create();
+ final connector = await _getConnector();
+ if (connector == null) return const [];
// heartRateSeries = Android HeartRateRecord (container with BPM samples).
// heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect.
- final response = await _connector!.readRecords(
+ final response = await connector.readRecords(
HealthDataType.heartRateSeries.readInTimeRange(
startTime: start,
endTime: end,
pageSize: 5000,
),
- );
+ ).timeout(_hrQueryDeadline);
final samples = response.records
.expand(
(r) => r.samples.map(
@@ -291,7 +313,8 @@ class HealthConnectService implements IHealthConnectService {
@override
Future syncWorkoutSession(WorkoutSession session, {String? title}) async {
try {
- _connector ??= await HealthConnector.create();
+ final connector = await _getConnector();
+ if (connector == null) return false;
final sessionStart = session.date;
final durationMinutes = max(session.duration, 1);
@@ -302,27 +325,13 @@ class HealthConnectService implements IHealthConnectService {
startTime: sessionStart,
endTime: sessionEnd,
exerciseType: ExerciseType.strengthTraining,
- metadata: Metadata.manualEntry(),
+ metadata: Metadata.manualEntry(clientRecordId: 'workout_${session.id}'),
title: title?.isNotEmpty == true ? title : null,
notes: session.notes?.isNotEmpty == true ? session.notes : null,
events: segments,
);
- await _connector!.writeRecords([record]);
-
- // DEBUG: read back to verify weight is stored — remove after confirming.
- final response = await _connector!.readRecords(
- HealthDataType.exerciseSession.readInTimeRange(
- startTime: sessionStart,
- endTime: sessionEnd,
- ),
- );
- for (final r in response.records.whereType()) {
- for (final e in r.events.whereType()) {
- debugPrint('[HC debug] segment=${e.segmentType} reps=${e.repetitions} weight=${e.weight}');
- }
- }
-
+ await connector.writeRecords([record]).timeout(_queryDeadline);
return true;
} catch (e) {
debugPrint('Health Connect sync failed: $e');
diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock
index a22582b..24bd7ac 100644
--- a/workout-logger/pubspec.lock
+++ b/workout-logger/pubspec.lock
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
- sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
+ sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b
url: "https://pub.dev"
source: hosted
- version: "93.0.0"
+ version: "100.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
- sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
+ sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf"
url: "https://pub.dev"
source: hosted
- version: "10.0.1"
+ version: "13.0.0"
archive:
dependency: transitive
description:
@@ -53,34 +53,34 @@ packages:
dependency: transitive
description:
name: build
- sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
+ sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
url: "https://pub.dev"
source: hosted
- version: "4.0.6"
+ version: "4.0.7"
build_config:
dependency: transitive
description:
name: build_config
- sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
+ sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae
url: "https://pub.dev"
source: hosted
- version: "1.3.0"
+ version: "1.3.1"
build_daemon:
dependency: transitive
description:
name: build_daemon
- sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
+ sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
url: "https://pub.dev"
source: hosted
- version: "4.1.1"
+ version: "4.1.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
- sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
+ sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
url: "https://pub.dev"
source: hosted
- version: "2.15.0"
+ version: "2.15.1"
built_collection:
dependency: transitive
description:
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: code_assets
- sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
+ sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
- version: "1.0.0"
+ version: "1.2.1"
code_builder:
dependency: transitive
description:
@@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: cross_file
- sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
+ sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.dev"
source: hosted
- version: "0.3.5+2"
+ version: "0.3.5+4"
crypto:
dependency: transitive
description:
@@ -189,26 +189,26 @@ packages:
dependency: transitive
description:
name: dart_style
- sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
+ sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60"
url: "https://pub.dev"
source: hosted
- version: "3.1.7"
+ version: "3.1.9"
dbus:
dependency: transitive
description:
name: dbus
- sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
+ sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
- version: "0.7.12"
+ version: "0.7.14"
equatable:
dependency: transitive
description:
name: equatable
- sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
+ sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
- version: "2.0.8"
+ version: "2.1.0"
fake_async:
dependency: transitive
description:
@@ -225,6 +225,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ ffi_leak_tracker:
+ dependency: transitive
+ description:
+ name: ffi_leak_tracker
+ sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.2"
file:
dependency: transitive
description:
@@ -237,10 +245,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
- sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
+ sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be
url: "https://pub.dev"
source: hosted
- version: "11.0.2"
+ version: "12.0.0-beta.7"
fixnum:
dependency: transitive
description:
@@ -266,10 +274,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
- sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
- version: "0.13.1"
+ version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -290,10 +298,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
- sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
+ sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
- version: "2.0.34"
+ version: "2.0.35"
flutter_svg:
dependency: transitive
description:
@@ -332,10 +340,10 @@ packages:
dependency: "direct main"
description:
name: gpt_markdown
- sha256: c14c2a4599a67df5b6a984808cbb7631b8d15c91984ffdd7998597b93a6ff136
+ sha256: ab6fe339f500104816139a034b8a23a125dadbc1988eda1641c6616562a4962b
url: "https://pub.dev"
source: hosted
- version: "1.1.7"
+ version: "1.1.8"
graphs:
dependency: transitive
description:
@@ -348,34 +356,34 @@ packages:
dependency: "direct main"
description:
name: health_connector
- sha256: "5d2d785077e1004457808ee568efc1264e5e090258fac507c00542066fe12064"
+ sha256: "3caec088ae94023117b30e804a2bc19c369121a0618a5baa0849472e17d7b6c8"
url: "https://pub.dev"
source: hosted
- version: "3.9.1"
+ version: "3.9.3"
health_connector_core:
dependency: transitive
description:
name: health_connector_core
- sha256: "452823baeb89c8e63e6bf775b1eda86cfc42e50ad777addde899c77bea710136"
+ sha256: "8a2aa99574dcd8868447af7ab16dc9a26caba48a194e8141e6f6e54f7848f82d"
url: "https://pub.dev"
source: hosted
- version: "3.9.0"
+ version: "3.9.2"
health_connector_hc_android:
dependency: transitive
description:
name: health_connector_hc_android
- sha256: "21834d80e8d0c65f5263c3da7076c2933cf98b61e28547d2603f0b7f66caf8c7"
+ sha256: "063023a7ee4ec2acb4e2d4166de68df145ddaaa58e67d251926f22eed28a306c"
url: "https://pub.dev"
source: hosted
- version: "3.6.0"
+ version: "3.6.2"
health_connector_hk_ios:
dependency: transitive
description:
name: health_connector_hk_ios
- sha256: "617bc9d52c7a57b15d4a01663a1484f86b7d7460f93fdbf24e4ea4b54c7f0974"
+ sha256: "12e958e5481c493319a1972460be369f38ac494d103359fc09ba121b46f5d6eb"
url: "https://pub.dev"
source: hosted
- version: "3.9.0"
+ version: "3.9.2"
health_connector_logger:
dependency: transitive
description:
@@ -404,10 +412,10 @@ packages:
dependency: transitive
description:
name: hooks
- sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
+ sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
- version: "1.0.3"
+ version: "2.0.2"
http:
dependency: "direct main"
description:
@@ -444,10 +452,10 @@ packages:
dependency: "direct main"
description:
name: intl
- sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
+ sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
- version: "0.19.0"
+ version: "0.20.3"
io:
dependency: transitive
description:
@@ -540,10 +548,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
- version: "1.17.0"
+ version: "1.18.0"
mime:
dependency: transitive
description:
@@ -556,18 +564,10 @@ packages:
dependency: "direct dev"
description:
name: mockito
- sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422
- url: "https://pub.dev"
- source: hosted
- version: "5.6.4"
- native_toolchain_c:
- dependency: transitive
- description:
- name: native_toolchain_c
- sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
+ sha256: c8d040d754367108fbe482dcb79dd72b8fe60ac6727abd15b4783c5560297ee6
url: "https://pub.dev"
source: hosted
- version: "0.17.6"
+ version: "5.7.0"
nested:
dependency: transitive
description:
@@ -580,10 +580,10 @@ packages:
dependency: transitive
description:
name: objective_c
- sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
+ sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
- version: "9.3.0"
+ version: "9.4.1"
package_config:
dependency: transitive
description:
@@ -596,18 +596,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
- sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
+ sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
url: "https://pub.dev"
source: hosted
- version: "8.3.1"
+ version: "10.2.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
- sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
+ sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
- version: "3.2.1"
+ version: "4.1.0"
path:
dependency: transitive
description:
@@ -628,10 +628,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
- sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+ sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
- version: "2.1.5"
+ version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -652,18 +652,18 @@ packages:
dependency: transitive
description:
name: path_provider_linux
- sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
+ sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
- version: "2.2.1"
+ version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
- sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
+ sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
- version: "2.1.2"
+ version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
@@ -708,10 +708,10 @@ packages:
dependency: transitive
description:
name: posix
- sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
+ sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
- version: "6.5.0"
+ version: "6.5.2"
provider:
dependency: "direct main"
description:
@@ -748,18 +748,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
- sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
+ sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
url: "https://pub.dev"
source: hosted
- version: "12.0.2"
+ version: "13.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
- sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
+ sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
- version: "6.1.0"
+ version: "7.2.0"
shelf:
dependency: transitive
description:
@@ -785,10 +785,10 @@ packages:
dependency: transitive
description:
name: source_gen
- sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
+ sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5
url: "https://pub.dev"
source: hosted
- version: "4.2.3"
+ version: "4.2.4"
source_span:
dependency: transitive
description:
@@ -841,10 +841,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.11"
tuple:
dependency: transitive
description:
@@ -897,10 +897,10 @@ packages:
dependency: "direct main"
description:
name: uuid
- sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+ sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
- version: "4.5.3"
+ version: "4.6.0"
vector_graphics:
dependency: transitive
description:
@@ -921,10 +921,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
- sha256: "09854c7633b215e6f7bb2a9adb607bc525bae8655c7fc29db880c33e62f72230"
+ sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3"
url: "https://pub.dev"
source: hosted
- version: "1.2.4"
+ version: "1.2.6"
vector_math:
dependency: transitive
description:
@@ -977,10 +977,10 @@ packages:
dependency: transitive
description:
name: win32
- sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+ sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
- version: "5.15.0"
+ version: "6.3.0"
xdg_directories:
dependency: transitive
description:
@@ -1007,4 +1007,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.11.4 <4.0.0"
- flutter: "3.41.6"
+ flutter: "3.44.8"
diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml
index 4ca30f0..e3e29f2 100644
--- a/workout-logger/pubspec.yaml
+++ b/workout-logger/pubspec.yaml
@@ -20,7 +20,7 @@ version: 2.0.6+27
environment:
sdk: ^3.11.4
- flutter: 3.41.6
+ flutter: 3.44.8
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@@ -48,9 +48,9 @@ dependencies:
# Utilities
uuid: ^4.5.1
- intl: ^0.19.0
+ intl: ^0.20.3
http: ^1.2.1
- package_info_plus: ^8.3.1
+ package_info_plus: ^10.2.1
# Health Connect integration
health_connector: ^3.9.1
@@ -59,9 +59,9 @@ dependencies:
google_generative_ai: ^0.4.3
# Backup export/import
- file_picker: ^11.0.2
+ file_picker: ^12.0.0-beta.7
path_provider: ^2.1.5
- share_plus: ^12.0.1
+ share_plus: ^13.2.1
gpt_markdown: ^1.1.7
dev_dependencies:
@@ -74,7 +74,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
- flutter_launcher_icons: ^0.13.1
+ flutter_launcher_icons: ^0.14.4
# Testing
mockito: ^5.4.4
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/screens/ai_coach_screen_full_test.dart b/workout-logger/test/screens/ai_coach_screen_full_test.dart
new file mode 100644
index 0000000..775e1e7
--- /dev/null
+++ b/workout-logger/test/screens/ai_coach_screen_full_test.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/ai_coach_screen.dart';
+import 'package:repforge/services/ai/gemini_ai_service.dart';
+
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/managers/pr_manager.dart';
+
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late GeminiAiService aiService;
+ late WorkoutProvider workoutProvider;
+ late PRManager prManager;
+
+ late SettingsProvider settingsProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ aiService = GeminiAiService(storage: storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ prManager = PRManager(storage);
+
+ settingsProvider = SettingsProvider(storage);
+
+ await workoutProvider.init();
+ await prManager.load();
+ await settingsProvider.init();
+ });
+
+ group('AiCoachScreen Full Suite', () {
+ testWidgets('Renders unconfigured no-key state when API key missing', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const AiCoachScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ geminiAiService: aiService,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(AiCoachScreen);
+ expect(find.text('API Key Required'), findsOneWidget);
+ });
+
+ testWidgets('Renders configured state and prompt suggestions when API key present', (tester) async {
+ final robot = TestRobot(tester);
+
+ aiService.init('valid_mock_api_key');
+
+ await robot.pumpScreen(
+ const AiCoachScreen(seedPrompt: 'How can I improve my Bench Press?'),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ geminiAiService: aiService,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(AiCoachScreen);
+ expect(find.byType(TextField), findsOneWidget);
+
+ final sendIcon = find.byIcon(Icons.arrow_upward_rounded);
+ if (sendIcon.evaluate().isNotEmpty) {
+ await tester.tap(sendIcon);
+ await tester.pump();
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/screens/ai_coach_screen_test.dart b/workout-logger/test/screens/ai_coach_screen_test.dart
new file mode 100644
index 0000000..0b30e59
--- /dev/null
+++ b/workout-logger/test/screens/ai_coach_screen_test.dart
@@ -0,0 +1,35 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/ai_coach_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders AiCoachScreen with prompt banner when API key missing', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const AiCoachScreen(),
+ storage: storage,
+ settingsProvider: settings,
+ workoutProvider: workout,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('AI Coach'), findsOneWidget);
+ // When no API key is configured, the screen renders _buildNoKeyState
+ // which contains an RFEmptyState with title 'API Key Required'.
+ expect(find.text('API Key Required'), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/ai_program_generator_screen_test.dart b/workout-logger/test/screens/ai_program_generator_screen_test.dart
new file mode 100644
index 0000000..77aaec2
--- /dev/null
+++ b/workout-logger/test/screens/ai_program_generator_screen_test.dart
@@ -0,0 +1,34 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/ai_program_generator_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders AiProgramGeneratorScreen title and suggestion chips', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const AiProgramGeneratorScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('AI Program Generator'), findsOneWidget);
+
+ final suggestionChip = find.text('12-week hypertrophy, 4 days/week, push-pull-legs-upper');
+ expect(suggestionChip, findsWidgets);
+
+ await tester.tap(suggestionChip.first);
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/workout-logger/test/screens/edit_workout_session_screen_test.dart b/workout-logger/test/screens/edit_workout_session_screen_test.dart
new file mode 100644
index 0000000..d585541
--- /dev/null
+++ b/workout-logger/test/screens/edit_workout_session_screen_test.dart
@@ -0,0 +1,135 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/edit_workout_session_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_fixtures.dart';
+import '../test_utils/test_robot.dart';
+
+Future _createProvider(MockStorageService storage, {List sessions = const []}) async {
+ for (final s in sessions) {
+ await storage.saveWorkoutSession(s);
+ }
+ final provider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ await provider.init();
+ return provider;
+}
+
+void main() {
+ testWidgets('Renders EditWorkoutSessionScreen with session details', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession(notes: 'Feeling strong today');
+ final provider = await _createProvider(storage, sessions: [session]);
+
+ await robot.pumpScreen(
+ EditWorkoutSessionScreen(session: session),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ robot.expectVisible('Edit Workout');
+ robot.expectVisible('Feeling strong today');
+ });
+
+ testWidgets('Adds a set to an existing exercise', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession();
+ final provider = await _createProvider(storage, sessions: [session]);
+
+ await robot.pumpScreen(
+ EditWorkoutSessionScreen(session: session),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ // Count set-delete icons before adding (fixture has 3 sets total = 3 close icons).
+ final initialCount = find.byIcon(Icons.close_rounded).evaluate().length;
+
+ await robot.tap(find.text('Add Set').first);
+
+ // After adding a set, there should be one more close icon.
+ final updatedCount = find.byIcon(Icons.close_rounded).evaluate().length;
+ expect(updatedCount, greaterThan(initialCount));
+ robot.expectVisible(EditWorkoutSessionScreen);
+ });
+
+ testWidgets('Deletes a set from an exercise log', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession();
+ final provider = await _createProvider(storage, sessions: [session]);
+
+ await robot.pumpScreen(
+ EditWorkoutSessionScreen(session: session),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ // Count set-delete icons before deletion (fixture has 3 sets total = 3 close icons).
+ final initialCount = find.byIcon(Icons.close_rounded).evaluate().length;
+ expect(initialCount, greaterThan(0));
+
+ await robot.tap(find.byIcon(Icons.close_rounded).first);
+
+ // After deletion, one fewer close icon should be visible.
+ final updatedCount = find.byIcon(Icons.close_rounded).evaluate().length;
+ expect(updatedCount, lessThan(initialCount));
+ });
+
+ testWidgets('Edits session notes and saves session', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession();
+ final provider = await _createProvider(storage, sessions: [session]);
+
+ await robot.pumpScreen(
+ EditWorkoutSessionScreen(session: session),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ await robot.fill('Sample session notes', 'Updated workout session note');
+ await robot.tap('Save');
+
+ // Verify in-memory provider update.
+ final updated = provider.sessions.firstWhere((s) => s.id == session.id);
+ expect(updated.notes, equals('Updated workout session note'));
+
+ // Verify persistence through storage.
+ final persisted = await storage.getWorkoutSession(session.id);
+ expect(persisted, isNotNull);
+ expect(persisted!.notes, equals('Updated workout session note'));
+ });
+
+ testWidgets('Shows discard dialog on back navigation when modified', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession();
+ final provider = await _createProvider(storage, sessions: [session]);
+
+ await robot.pumpScreen(
+ EditWorkoutSessionScreen(session: session),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ await robot.fill('45', '90');
+ await robot.handlePop();
+
+ robot.expectVisible('Discard Changes?');
+ await robot.tap('Discard');
+
+ // After confirming discard, the dialog and the edit screen should both be gone.
+ robot.expectNotVisible('Discard Changes?');
+ robot.expectNotVisible(EditWorkoutSessionScreen);
+ });
+}
diff --git a/workout-logger/test/screens/heart_rate_detail_screen_test.dart b/workout-logger/test/screens/heart_rate_detail_screen_test.dart
new file mode 100644
index 0000000..cb79353
--- /dev/null
+++ b/workout-logger/test/screens/heart_rate_detail_screen_test.dart
@@ -0,0 +1,25 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/heart_rate_detail_screen.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders HeartRateDetailScreen title and granularities', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ HeartRateDetailScreen(initialDate: DateTime(2026, 5, 10)),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(HeartRateDetailScreen), findsOneWidget);
+ // The screen renders its title and granularity tab controls via HealthDetailShell.
+ expect(find.text('Heart rate'), findsOneWidget);
+ expect(
+ find.text('Day').evaluate().isNotEmpty ||
+ find.text('Week').evaluate().isNotEmpty,
+ isTrue,
+ reason: 'HealthDetailShell should render granularity controls',
+ );
+ });
+}
diff --git a/workout-logger/test/screens/history_screen_test.dart b/workout-logger/test/screens/history_screen_test.dart
new file mode 100644
index 0000000..208dcaa
--- /dev/null
+++ b/workout-logger/test/screens/history_screen_test.dart
@@ -0,0 +1,73 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/history_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/history_manager.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_fixtures.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders HistoryScreen title and empty history state', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ final historyManager = HistoryManager(storage);
+ await historyManager.loadSessions();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const HistoryScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ historyManager: historyManager,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(HistoryScreen), findsOneWidget);
+ // With no sessions, the empty-history state is shown.
+ expect(
+ find.textContaining('No').evaluate().isNotEmpty ||
+ find.textContaining('empty').evaluate().isNotEmpty ||
+ find.textContaining('history').evaluate().isNotEmpty,
+ isTrue,
+ reason: 'Empty history state should be visible',
+ );
+ });
+
+ testWidgets('Displays session item in history list', (WidgetTester tester) async {
+ const viewportSize = Size(800, 1800);
+ tester.view.physicalSize = viewportSize;
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+
+ await TestHarness.prepareTester(tester, size: viewportSize);
+
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession(date: DateTime.now(), notes: 'Morning Leg Workout');
+ await storage.saveWorkoutSession(session);
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ final historyManager = HistoryManager(storage);
+ await historyManager.loadSessions();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const HistoryScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ historyManager: historyManager,
+ viewportSize: viewportSize,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('Quick Workout'), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/home_screen_test.dart b/workout-logger/test/screens/home_screen_test.dart
new file mode 100644
index 0000000..6aa7ace
--- /dev/null
+++ b/workout-logger/test/screens/home_screen_test.dart
@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/home_screen.dart';
+import 'package:repforge/screens/routines_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders HomeScreen with navigation bar items', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const HomeScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ settingsProvider: settings,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('Home'), findsOneWidget);
+ expect(find.byIcon(Icons.layers_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.history_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.bar_chart_rounded), findsOneWidget);
+ });
+
+ testWidgets('Switches tabs when floating nav bar item is tapped', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const HomeScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ settingsProvider: settings,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ // Tap Routines tab (Icons.layers_rounded)
+ await tester.tap(find.byIcon(Icons.layers_rounded));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ // RoutinesScreen should be displayed in IndexedStack
+ expect(find.byType(RoutinesScreen), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/onboarding_screen_test.dart b/workout-logger/test/screens/onboarding_screen_test.dart
new file mode 100644
index 0000000..aad5b44
--- /dev/null
+++ b/workout-logger/test/screens/onboarding_screen_test.dart
@@ -0,0 +1,30 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/onboarding_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_robot.dart';
+
+void main() {
+ testWidgets('Renders WelcomePage welcome page', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await robot.pumpScreen(
+ WelcomePage(onComplete: () {}),
+ storage: storage,
+ settingsProvider: settings,
+ workoutProvider: workout,
+ );
+
+ robot.expectVisible(WelcomePage);
+ expect(find.textContaining('RepForge'), findsWidgets);
+ });
+}
diff --git a/workout-logger/test/screens/profile_screen_full_test.dart b/workout-logger/test/screens/profile_screen_full_test.dart
new file mode 100644
index 0000000..0de1a63
--- /dev/null
+++ b/workout-logger/test/screens/profile_screen_full_test.dart
@@ -0,0 +1,60 @@
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/profile_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+
+import '../test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ settingsProvider = SettingsProvider(storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ });
+
+ group('ProfileScreen Full Test Suite', () {
+ testWidgets('Renders ProfileScreen, toggles weight units, and opens clear data confirmation dialog', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ProfileScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(ProfileScreen);
+
+ // Toggle weight unit chips
+ final kgBtn = find.text('kg');
+ if (kgBtn.evaluate().isNotEmpty) {
+ await tester.tap(kgBtn);
+ await tester.pumpAndSettle();
+ }
+
+ final lbsBtn = find.text('lbs');
+ if (lbsBtn.evaluate().isNotEmpty) {
+ await tester.tap(lbsBtn);
+ await tester.pumpAndSettle();
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/screens/profile_screen_test.dart b/workout-logger/test/screens/profile_screen_test.dart
new file mode 100644
index 0000000..f8569e9
--- /dev/null
+++ b/workout-logger/test/screens/profile_screen_test.dart
@@ -0,0 +1,57 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/profile_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_robot.dart';
+
+void main() {
+ testWidgets('Renders ProfileScreen with sections', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await robot.pumpScreen(
+ const ProfileScreen(),
+ storage: storage,
+ settingsProvider: settings,
+ workoutProvider: workout,
+ );
+
+ robot.expectVisible('Preferences');
+ robot.expectVisible('Data Management');
+
+ await tester.drag(find.byType(CustomScrollView), const Offset(0, -800));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ robot.expectVisible('About');
+ });
+
+ testWidgets('Toggles weight unit preference', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await robot.pumpScreen(
+ const ProfileScreen(),
+ storage: storage,
+ settingsProvider: settings,
+ workoutProvider: workout,
+ );
+
+ await robot.tap('lbs');
+ expect(settings.weightUnit, equals(WeightUnit.lbs));
+ });
+}
diff --git a/workout-logger/test/screens/programs/program_designer_screen_test.dart b/workout-logger/test/screens/programs/program_designer_screen_test.dart
new file mode 100644
index 0000000..45c44d9
--- /dev/null
+++ b/workout-logger/test/screens/programs/program_designer_screen_test.dart
@@ -0,0 +1,88 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/programs/program_designer_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/mock_ml_service.dart';
+import '../../test_utils/test_harness.dart';
+
+Future _createProvider() async {
+ final storage = MockStorageService();
+ final provider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ await provider.init();
+ return provider;
+}
+
+void main() {
+ testWidgets('Renders Step 1 metadata controls in ProgramDesignerScreen', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final provider = await _createProvider();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const ProgramDesignerScreen(),
+ workoutProvider: provider,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('New Program'), findsOneWidget);
+ expect(find.text('PROGRAM DETAILS'), findsOneWidget);
+ expect(find.text('Step 1 of 3'), findsOneWidget);
+ expect(find.text('Next'), findsOneWidget);
+ });
+
+ testWidgets('Shows validation error if program name is empty on Next', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final provider = await _createProvider();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const ProgramDesignerScreen(),
+ workoutProvider: provider,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ // Tap Next without filling program name
+ await tester.tap(find.text('Next'));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ // Step 1 stays active because name is empty
+ expect(find.text('Step 1 of 3'), findsOneWidget);
+ // The validation SnackBar is shown
+ expect(find.text('Enter a program name to continue'), findsOneWidget);
+ });
+
+ testWidgets('Enters program name and navigates to Step 2', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final provider = await _createProvider();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const ProgramDesignerScreen(),
+ workoutProvider: provider,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ // Enter Program Name
+ final nameField = find.widgetWithText(TextField, 'Program Name *');
+ await tester.enterText(nameField, 'Hypertrophy 101');
+ await tester.pump();
+
+ // Tap Next
+ await tester.tap(find.text('Next'));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('Step 2 of 3'), findsOneWidget);
+ expect(find.text('WEEKS & DAYS'), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/programs/programs_screens_test.dart b/workout-logger/test/screens/programs/programs_screens_test.dart
new file mode 100644
index 0000000..227092b
--- /dev/null
+++ b/workout-logger/test/screens/programs/programs_screens_test.dart
@@ -0,0 +1,78 @@
+import 'dart:convert';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/programs/import_program_screen.dart';
+import 'package:repforge/screens/programs/programs_screen.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/workout_provider.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/mock_ml_service.dart';
+import '../../test_utils/test_robot.dart';
+
+void main() {
+ testWidgets('Renders ProgramsScreen list and empty state', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await robot.pumpScreen(
+ const ProgramsScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ );
+
+ robot.expectVisible(ProgramsScreen);
+
+ final fab = find.byType(FloatingActionButton);
+ expect(fab, findsWidgets);
+ await robot.tap(fab.first);
+ });
+
+ testWidgets('Renders ImportProgramScreen, validates valid program JSON', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await robot.pumpScreen(
+ const ImportProgramScreen(),
+ storage: storage,
+ workoutProvider: workout,
+ );
+
+ robot.expectVisible(ImportProgramScreen);
+
+ final validJson = jsonEncode({
+ 'id': 'prog_custom_1',
+ 'name': 'Custom Powerlifting 4-Week',
+ 'description': 'Heavy compound lifting',
+ 'totalWeeks': 4,
+ 'phases': [],
+ 'daysPerWeek': 4,
+ 'weeks': [
+ {
+ 'weekNumber': 1,
+ 'days': [
+ {
+ 'dayNumber': 1,
+ 'name': 'Bench Day',
+ 'exercises': [
+ {'exerciseId': 'bench_press', 'targetSets': 4, 'targetReps': 5}
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ final textField = find.byType(TextField);
+ expect(textField, findsOneWidget);
+ await robot.fill(textField.first, validJson);
+
+ final validateBtn = find.text('Validate');
+ expect(validateBtn, findsOneWidget);
+ await robot.tap(validateBtn);
+ await tester.pumpAndSettle();
+ });
+}
diff --git a/workout-logger/test/screens/settings_screen_test.dart b/workout-logger/test/screens/settings_screen_test.dart
new file mode 100644
index 0000000..06aca71
--- /dev/null
+++ b/workout-logger/test/screens/settings_screen_test.dart
@@ -0,0 +1,33 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/settings_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders SettingsScreen title and preference options', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final settings = SettingsProvider(storage);
+ await settings.init();
+
+ final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await workout.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const SettingsScreen(),
+ storage: storage,
+ settingsProvider: settings,
+ workoutProvider: workout,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('Settings'), findsOneWidget);
+ expect(find.text('Weight Unit'), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/sleep_detail_screen_test.dart b/workout-logger/test/screens/sleep_detail_screen_test.dart
new file mode 100644
index 0000000..9126f4d
--- /dev/null
+++ b/workout-logger/test/screens/sleep_detail_screen_test.dart
@@ -0,0 +1,17 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/sleep_detail_screen.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders SleepDetailScreen title and granularities', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ SleepDetailScreen(initialDate: DateTime(2026, 5, 10)),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('Sleep'), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/widgets/editable_exercise_card_full_test.dart b/workout-logger/test/screens/widgets/editable_exercise_card_full_test.dart
new file mode 100644
index 0000000..a1e5b00
--- /dev/null
+++ b/workout-logger/test/screens/widgets/editable_exercise_card_full_test.dart
@@ -0,0 +1,73 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/widgets/editable_exercise_card.dart';
+
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/test_harness.dart';
+
+void main() {
+ late MockStorageService storage;
+
+ setUp(() {
+ storage = MockStorageService();
+ });
+
+ group('EditableExerciseCard Widget Tests', () {
+ testWidgets('Renders exercise name, set rows, dropsets, and handles actions', (tester) async {
+ final log = EditableExerciseLog(
+ exerciseId: 'bench_press',
+ sets: [
+ EditableSet(
+ weight: 100,
+ reps: 10,
+ timestamp: DateTime.now(),
+ ),
+ EditableSet(
+ weight: 90,
+ reps: 8,
+ isDropset: true,
+ drops: [DropsetEntry(weight: 70, reps: 6)],
+ timestamp: DateTime.now(),
+ ),
+ ],
+ );
+
+ bool setAdded = false;
+
+ final widget = TestHarness.wrap(
+ Scaffold(
+ body: EditableExerciseCard(
+ exerciseName: 'Bench Press',
+ editableLog: log,
+ onSetChanged: ({
+ required int setIndex,
+ required double weight,
+ required int reps,
+ required bool isDropset,
+ List? drops,
+ }) {},
+ onAddSet: () => setAdded = true,
+ onDeleteSet: (idx) {},
+ onDeleteExercise: () {},
+ ),
+ ),
+ storage: storage,
+ );
+
+ await tester.pumpWidget(widget);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Bench Press'), findsOneWidget);
+
+ // Tap + Add Set
+ final addSetBtn = find.text('+ Add Set');
+ if (addSetBtn.evaluate().isNotEmpty) {
+ await tester.tap(addSetBtn);
+ expect(setAdded, isTrue);
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/screens/widgets/health_bar_chart_test.dart b/workout-logger/test/screens/widgets/health_bar_chart_test.dart
new file mode 100644
index 0000000..9a98723
--- /dev/null
+++ b/workout-logger/test/screens/widgets/health_bar_chart_test.dart
@@ -0,0 +1,106 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/sleep_hr_models.dart';
+import 'package:repforge/screens/widgets/health_bar_chart.dart';
+import '../../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders SleepBarsChart with daily sleep stage data', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final now = DateTime(2026, 5, 10);
+ final List bars = [
+ SleepDayBar(
+ date: now.subtract(const Duration(days: 2)),
+ totalMinutes: 480,
+ deepMin: 90,
+ remMin: 120,
+ lightMin: 240,
+ awakeMin: 30,
+ ),
+ SleepDayBar(
+ date: now.subtract(const Duration(days: 1)),
+ totalMinutes: 395,
+ deepMin: 60,
+ remMin: 90,
+ lightMin: 200,
+ awakeMin: 45,
+ ),
+ SleepDayBar(
+ date: now,
+ totalMinutes: 0,
+ deepMin: 0,
+ remMin: 0,
+ lightMin: 0,
+ awakeMin: 0,
+ ),
+ ];
+
+ final workoutDays = {'2026-05-08', '2026-05-10'};
+
+ await tester.pumpWidget(TestHarness.wrap(
+ SleepBarsChart(
+ bars: bars,
+ workoutDays: workoutDays,
+ ),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(SleepBarsChart), findsOneWidget);
+
+ // Tap on a bar area to trigger tooltip interaction
+ await tester.tap(find.byType(SleepBarsChart));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('Renders HrRangeChart with heart rate min-max range data', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final now = DateTime(2026, 5, 10);
+ final List bars = [
+ HrRangeBar(
+ date: now.subtract(const Duration(days: 2)),
+ label: 'Fri',
+ minBpm: 55,
+ maxBpm: 145,
+ avgBpm: 75.0,
+ restingBpm: 58,
+ ),
+ HrRangeBar(
+ date: now.subtract(const Duration(days: 1)),
+ label: 'Sat',
+ minBpm: 60,
+ maxBpm: 165,
+ avgBpm: 82.0,
+ restingBpm: 62,
+ ),
+ HrRangeBar(
+ date: now,
+ label: 'Sun',
+ minBpm: 0,
+ maxBpm: 0,
+ avgBpm: 0.0,
+ restingBpm: null,
+ ),
+ ];
+
+ final workoutDays = {'2026-05-09'};
+
+ await tester.pumpWidget(TestHarness.wrap(
+ HrRangeChart(
+ bars: bars,
+ workoutDays: workoutDays,
+ ),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(HrRangeChart), findsOneWidget);
+
+ // Tap on HrRangeChart to test tap gestures
+ await tester.tap(find.byType(HrRangeChart));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/workout-logger/test/screens/widgets/health_cards_test.dart b/workout-logger/test/screens/widgets/health_cards_test.dart
new file mode 100644
index 0000000..b15ab16
--- /dev/null
+++ b/workout-logger/test/screens/widgets/health_cards_test.dart
@@ -0,0 +1,174 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/models/sleep_hr_models.dart';
+import 'package:repforge/screens/widgets/heart_rate_card.dart';
+import 'package:repforge/screens/widgets/readiness_card.dart';
+import 'package:repforge/screens/widgets/sleep_hr_card.dart';
+import 'package:repforge/services/interfaces/readiness_manager_interface.dart';
+import 'package:repforge/services/managers/readiness_manager.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/stub_health_connect_service.dart';
+import '../../test_utils/test_harness.dart';
+import 'package:repforge/services/settings_provider.dart';
+
+class FakeReadinessManager extends ReadinessManager {
+ FakeReadinessManager(SettingsProvider settings)
+ : super(const StubHcService(), MockStorageService(), settings);
+
+ ReadinessStatus _mockStatus = ReadinessStatus.ready;
+ ReadinessSnapshot? _mockSnapshot;
+ SleepHrSnapshot? _mockSleepHrSnapshot;
+ HrDaySnapshot? _mockHrDaySnapshot;
+
+ void setMockData({
+ ReadinessStatus status = ReadinessStatus.ready,
+ ReadinessSnapshot? snapshot,
+ SleepHrSnapshot? sleepHrSnapshot,
+ HrDaySnapshot? hrDaySnapshot,
+ }) {
+ _mockStatus = status;
+ _mockSnapshot = snapshot;
+ _mockSleepHrSnapshot = sleepHrSnapshot;
+ _mockHrDaySnapshot = hrDaySnapshot;
+ notifyListeners();
+ }
+
+ @override
+ ReadinessStatus get status => _mockStatus;
+
+ @override
+ ReadinessSnapshot? get snapshot => _mockSnapshot;
+
+ @override
+ SleepHrSnapshot? get sleepHrSnapshot => _mockSleepHrSnapshot;
+
+ @override
+ HrDaySnapshot? get hrDaySnapshot => _mockHrDaySnapshot;
+}
+
+void main() {
+ late MockStorageService storage;
+ late SettingsProvider settings;
+ late FakeReadinessManager readinessManager;
+
+ setUp(() async {
+ storage = MockStorageService();
+ settings = SettingsProvider(storage);
+ await settings.init();
+ readinessManager = FakeReadinessManager(settings);
+ });
+
+ Widget wrapWithReadiness(Widget child) {
+ return TestHarness.wrap(
+ child,
+ storage: storage,
+ settingsProvider: settings,
+ readinessManager: readinessManager,
+ );
+ }
+
+ testWidgets('Renders ReadinessCard when snapshot score is present', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ readinessManager.setMockData(
+ status: ReadinessStatus.ready,
+ snapshot: ReadinessSnapshot(
+ dateKey: '2026-05-10',
+ score: 85,
+ band: ReadinessBand.high,
+ ),
+ );
+
+ await tester.pumpWidget(wrapWithReadiness(const ReadinessCard()));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(ReadinessCard), findsOneWidget);
+ });
+
+ testWidgets('Renders SleepHrCard when sleepHrSnapshot is present', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final now = DateTime(2026, 5, 10);
+ final snapshot = SleepHrSnapshot(
+ sleepStart: now.subtract(const Duration(hours: 8)),
+ sleepEnd: now,
+ p5Bpm: 52,
+ p95Bpm: 82,
+ segments: [
+ SleepHrSegment(
+ windowStart: now.subtract(const Duration(hours: 4)),
+ minBpm: 55,
+ maxBpm: 65,
+ avgBpm: 60,
+ stage: 'deep',
+ ),
+ ],
+ stageStats: [
+ const SleepStageStats(
+ stage: 'deep',
+ minBpm: 52,
+ p25Bpm: 55,
+ avgBpm: 58,
+ p75Bpm: 62,
+ maxBpm: 70,
+ sampleCount: 20,
+ ),
+ ],
+ );
+
+ readinessManager.setMockData(
+ status: ReadinessStatus.ready,
+ sleepHrSnapshot: snapshot,
+ );
+
+ await tester.pumpWidget(wrapWithReadiness(const SleepHrCard()));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(SleepHrCard), findsOneWidget);
+
+ // Tap SleepHrCard to trigger sheet opening
+ await tester.tap(find.byType(SleepHrCard));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('Renders HeartRateCard when hrDaySnapshot is present', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final now = DateTime(2026, 5, 10);
+ final snapshot = HrDaySnapshot(
+ day: now,
+ minBpm: 50,
+ maxBpm: 155,
+ avgBpm: 72,
+ restingBpm: 54,
+ buckets: [
+ HrBucket(
+ windowStart: now.subtract(const Duration(hours: 2)),
+ minBpm: 60,
+ maxBpm: 80,
+ avgBpm: 70,
+ ),
+ ],
+ );
+
+ readinessManager.setMockData(
+ status: ReadinessStatus.ready,
+ hrDaySnapshot: snapshot,
+ );
+
+ await tester.pumpWidget(wrapWithReadiness(const HeartRateCard()));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(HeartRateCard), findsOneWidget);
+
+ // Tap HeartRateCard to test navigation
+ await tester.tap(find.byType(HeartRateCard));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/workout-logger/test/screens/widgets/health_widgets_test.dart b/workout-logger/test/screens/widgets/health_widgets_test.dart
new file mode 100644
index 0000000..c664276
--- /dev/null
+++ b/workout-logger/test/screens/widgets/health_widgets_test.dart
@@ -0,0 +1,127 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/sleep_hr_models.dart';
+import 'package:repforge/screens/widgets/sleep_hr_charts.dart';
+import 'package:repforge/screens/widgets/muscle_detail_sheet.dart';
+import 'package:repforge/screens/widgets/health_detail_shell.dart';
+import 'package:repforge/screens/widgets/sparkline_painter.dart';
+import 'package:repforge/screens/widgets/activity_heatmap.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/mock_ml_service.dart';
+import '../../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders SleepHrDayView overnight chart widget', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final snapshot = SleepHrSnapshot(
+ sleepStart: DateTime(2026, 5, 10, 0, 0),
+ sleepEnd: DateTime(2026, 5, 10, 8, 0),
+ p5Bpm: 54,
+ p95Bpm: 75,
+ segments: [
+ SleepHrSegment(
+ windowStart: DateTime(2026, 5, 10, 1, 0),
+ minBpm: 52,
+ maxBpm: 65,
+ avgBpm: 58.0,
+ stage: 'deep',
+ ),
+ SleepHrSegment(
+ windowStart: DateTime(2026, 5, 10, 3, 0),
+ minBpm: 55,
+ maxBpm: 70,
+ avgBpm: 62.0,
+ stage: 'rem',
+ ),
+ ],
+ stageStats: const [
+ SleepStageStats(stage: 'deep', minBpm: 52, p25Bpm: 55, avgBpm: 58.0, p75Bpm: 62, maxBpm: 65, sampleCount: 12),
+ SleepStageStats(stage: 'rem', minBpm: 55, p25Bpm: 58, avgBpm: 62.0, p75Bpm: 66, maxBpm: 70, sampleCount: 12),
+ ],
+ );
+
+ await tester.pumpWidget(TestHarness.wrap(
+ SleepHrDayView(snapshot: snapshot),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.textContaining('54 bpm'), findsOneWidget);
+ });
+
+ testWidgets('Renders MuscleDetailSheet with muscle breakdown', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await provider.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ MuscleDetailSheet(muscleId: 'chest', provider: provider),
+ storage: storage,
+ workoutProvider: provider,
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Chest'), findsOneWidget);
+ });
+
+ testWidgets('Renders HealthDetailShell container with granularity selection', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ HealthGranularity currentG = HealthGranularity.day;
+
+ await tester.pumpWidget(TestHarness.wrap(
+ HealthDetailShell(
+ title: 'Sleep History',
+ icon: Icons.nightlight_round,
+ iconColor: Colors.purple,
+ dateLabel: 'May 10, 2026',
+ granularity: currentG,
+ onGranularityChanged: (g) => currentG = g,
+ onPrev: () {},
+ onNext: () {},
+ canGoNext: false,
+ child: const SizedBox(height: 100, child: Text('Child Content')),
+ ),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.text('Sleep History'), findsOneWidget);
+ expect(find.text('May 10, 2026'), findsOneWidget);
+ expect(find.text('Child Content'), findsOneWidget);
+ });
+
+ testWidgets('Renders SparklinePainter canvas', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const CustomPaint(
+ size: Size(100, 30),
+ painter: SparklinePainter(
+ data: [10.0, 15.0, 8.0, 20.0, 25.0],
+ color: Colors.blue,
+ ),
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(CustomPaint), findsWidgets);
+ });
+
+ testWidgets('Renders ActivityHeatmap canvas', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final activityData = List.filled(98, 2);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ ActivityHeatmap(data: activityData),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(ActivityHeatmap), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/widgets/rf_cards_test.dart b/workout-logger/test/screens/widgets/rf_cards_test.dart
new file mode 100644
index 0000000..8677a7a
--- /dev/null
+++ b/workout-logger/test/screens/widgets/rf_cards_test.dart
@@ -0,0 +1,80 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/widgets/rf_cards.dart';
+import '../../test_utils/test_fixtures.dart';
+import '../../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders SessionCard with exercise details', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final session = TestFixtures.sampleSession();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ SessionCard(
+ session: session,
+ getExerciseName: (id) => id == 'bench_press' ? 'Bench Press' : 'Squats',
+ synced: true,
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Bench Press'), findsOneWidget);
+ });
+
+ testWidgets('Renders StatGridCard with counter label', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const StatGridCard(
+ icon: Icons.fitness_center,
+ value: '125 kg',
+ label: 'Max Bench',
+ animate: false,
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Max Bench'), findsOneWidget);
+ });
+
+ testWidgets('Renders RecentSessionTile item', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final session = TestFixtures.sampleSession();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ RecentSessionTile(
+ session: session,
+ getExerciseName: (id) => 'Bench Press',
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.textContaining('exercises'), findsOneWidget);
+ });
+
+ testWidgets('Renders RoutineCard with action triggers', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final routine = TestFixtures.sampleRoutine();
+ bool started = false;
+
+ await tester.pumpWidget(TestHarness.wrap(
+ RoutineCard(
+ routine: routine,
+ getExerciseName: (id) => id,
+ onStart: () => started = true,
+ onEdit: () {},
+ onDelete: () {},
+ ),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Upper Body Power'), findsOneWidget);
+ await tester.tap(find.byIcon(Icons.play_arrow_rounded));
+ await tester.pumpAndSettle();
+
+ expect(started, isTrue);
+ });
+}
diff --git a/workout-logger/test/screens/widgets/rf_dialogs_test.dart b/workout-logger/test/screens/widgets/rf_dialogs_test.dart
new file mode 100644
index 0000000..b7e9c72
--- /dev/null
+++ b/workout-logger/test/screens/widgets/rf_dialogs_test.dart
@@ -0,0 +1,116 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/widgets/rf_dialogs.dart';
+
+void main() {
+ testWidgets('showRFSnackBar displays all snackbar types correctly', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => Column(
+ children: [
+ ElevatedButton(
+ onPressed: () => context.showRFSnackBar('Success Toast', type: RFSnackBarType.success),
+ child: const Text('Success'),
+ ),
+ ElevatedButton(
+ onPressed: () => context.showRFSnackBar('Warning Toast', type: RFSnackBarType.warning),
+ child: const Text('Warning'),
+ ),
+ ElevatedButton(
+ onPressed: () => context.showRFSnackBar('Error Toast', type: RFSnackBarType.error),
+ child: const Text('Error'),
+ ),
+ ElevatedButton(
+ onPressed: () => context.showRFSnackBar('Info Toast', type: RFSnackBarType.info),
+ child: const Text('Info'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Success'));
+ await tester.pumpAndSettle();
+ expect(find.text('Success Toast'), findsOneWidget);
+
+ await tester.tap(find.text('Warning'));
+ await tester.pumpAndSettle();
+ expect(find.text('Warning Toast'), findsOneWidget);
+
+ await tester.tap(find.text('Error'));
+ await tester.pumpAndSettle();
+ expect(find.text('Error Toast'), findsOneWidget);
+
+ await tester.tap(find.text('Info'));
+ await tester.pumpAndSettle();
+ expect(find.text('Info Toast'), findsOneWidget);
+ });
+
+ testWidgets('showRFConfirmDialog renders normal and danger confirmation dialogs', (tester) async {
+ bool? dangerResult;
+ bool? cancelResult;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => Column(
+ children: [
+ ElevatedButton(
+ onPressed: () async {
+ dangerResult = await showRFConfirmDialog(
+ context,
+ title: 'Delete Item',
+ content: 'Are you sure you want to delete?',
+ isDanger: true,
+ confirmText: 'Delete',
+ );
+ },
+ child: const Text('Open Danger Dialog'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ cancelResult = await showRFConfirmDialog(
+ context,
+ title: 'Confirm Action',
+ content: 'Do you want to proceed?',
+ isDanger: false,
+ confirmText: 'Proceed',
+ );
+ },
+ child: const Text('Open Normal Dialog'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Test danger confirmation path
+ await tester.tap(find.text('Open Danger Dialog'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Delete Item'), findsOneWidget);
+ expect(find.text('Are you sure you want to delete?'), findsOneWidget);
+
+ await tester.tap(find.text('Delete'));
+ await tester.pumpAndSettle();
+ expect(dangerResult, isTrue);
+
+ // Test default non-danger styling path and cancellation behavior
+ await tester.tap(find.text('Open Normal Dialog'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Confirm Action'), findsOneWidget);
+ expect(find.text('Do you want to proceed?'), findsOneWidget);
+
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+ expect(cancelResult, isFalse);
+ });
+}
diff --git a/workout-logger/test/screens/widgets/rf_widgets_test.dart b/workout-logger/test/screens/widgets/rf_widgets_test.dart
new file mode 100644
index 0000000..9f8541c
--- /dev/null
+++ b/workout-logger/test/screens/widgets/rf_widgets_test.dart
@@ -0,0 +1,195 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/widgets/rf_widgets.dart';
+import 'package:repforge/theme/app_theme.dart';
+
+void main() {
+ testWidgets('slideRoute creates valid PageRouteBuilder', (tester) async {
+ final route = slideRoute(const Text('Slide Page'));
+ expect(route, isA());
+ });
+
+ testWidgets('GlassCard renders child with options', (tester) async {
+ var tapped = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: GlassCard(
+ accentBorder: true,
+ glowColor: Colors.purple,
+ onTap: () => tapped = true,
+ semanticsLabel: 'GlassCardButton',
+ child: const Text('Glass Content'),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('Glass Content'), findsOneWidget);
+ await tester.tap(find.text('Glass Content'));
+ expect(tapped, isTrue);
+ });
+
+ testWidgets('AmbientGlow renders glow effect', (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Scaffold(
+ body: Stack(
+ children: [AmbientGlow()],
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byType(AmbientGlow), findsOneWidget);
+ });
+
+ testWidgets('GlowButton handles tap and disabled state', (tester) async {
+ var tapped = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ GlowButton(
+ label: 'Active Button',
+ icon: Icons.add,
+ small: true,
+ onPressed: () => tapped = true,
+ ),
+ const GlowButton(
+ label: 'Disabled Button',
+ onPressed: null,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('Active Button'), findsOneWidget);
+ expect(find.text('Disabled Button'), findsOneWidget);
+
+ await tester.tap(find.text('Active Button'));
+ await tester.pumpAndSettle();
+ expect(tapped, isTrue);
+ });
+
+ testWidgets('OutlineGlowButton renders correctly', (tester) async {
+ var tapped = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: OutlineGlowButton(
+ label: 'Outline',
+ icon: Icons.check,
+ small: true,
+ fullWidth: true,
+ onPressed: () => tapped = true,
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('Outline'), findsOneWidget);
+ await tester.tap(find.text('Outline'));
+ expect(tapped, isTrue);
+ });
+
+ testWidgets('RFChip, RFSectionHeader, RFStatBox render correctly', (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ RFChip(label: 'Chest', small: true),
+ RFSectionHeader('Workouts', trailing: Text('View all')),
+ RFStatBox(value: '100', label: 'Volume', delta: 5.0),
+ RFStatBox(value: '50', label: 'Reps', delta: -2.0),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('Chest'), findsOneWidget);
+ expect(find.text('WORKOUTS'), findsOneWidget);
+ expect(find.text('100'), findsOneWidget);
+ expect(find.text('50'), findsOneWidget);
+ });
+
+ testWidgets('AnimatedCounter, MetricHero, RFDivider, RFEmptyState render correctly', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ const AnimatedCounter(value: 42.5, decimals: 1, suffix: 'kg'),
+ const MetricHero(value: '100', unit: 'kg'),
+ const RFDivider(indent: 16),
+ RFEmptyState(
+ icon: Icons.fitness_center,
+ title: 'No Workouts',
+ subtitle: 'Add a workout to get started',
+ action: ElevatedButton(onPressed: () {}, child: const Text('Add')),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+ expect(find.text('100'), findsOneWidget);
+ expect(find.text('No Workouts'), findsOneWidget);
+ });
+
+ testWidgets('RFLoadingDots, RFProgressBar, RestTimerRing, SkeletonBox, RFTextField render correctly', (tester) async {
+ final controller = TextEditingController();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ const RFLoadingDots(color: Colors.blue),
+ const RFProgressBar(value: 0.75, height: 8),
+ const RestTimerRing(remaining: 90, total: 120),
+ const SkeletonBox(width: 100, height: 20),
+ RFTextField(
+ controller: controller,
+ hint: 'Enter text',
+ label: 'Field Label',
+ prefixIcon: Icons.search,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byType(RFLoadingDots), findsOneWidget);
+ expect(find.byType(RFProgressBar), findsOneWidget);
+ expect(find.byType(RestTimerRing), findsOneWidget);
+ expect(find.text('Field Label'), findsOneWidget);
+
+ final containerBefore = tester.widget(
+ find.descendant(of: find.byType(RFTextField), matching: find.byType(Container)).first,
+ );
+ final boxDecBefore = containerBefore.decoration as BoxDecoration;
+ final borderBefore = boxDecBefore.border as Border;
+ expect(borderBefore.top.color, AppColors.glassBorder);
+
+ await tester.tap(find.byType(TextField));
+ await tester.pump();
+
+ final containerAfter = tester.widget(
+ find.descendant(of: find.byType(RFTextField), matching: find.byType(Container)).first,
+ );
+ final boxDecAfter = containerAfter.decoration as BoxDecoration;
+ final borderAfter = boxDecAfter.border as Border;
+ expect(borderAfter.top.color, AppColors.primary);
+
+ await tester.enterText(find.byType(TextField), 'Test input');
+ expect(controller.text, 'Test input');
+ });
+}
diff --git a/workout-logger/test/screens/widgets/routine_creator_test.dart b/workout-logger/test/screens/widgets/routine_creator_test.dart
new file mode 100644
index 0000000..5347851
--- /dev/null
+++ b/workout-logger/test/screens/widgets/routine_creator_test.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/widgets/routine_creator.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/workout_provider.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/mock_ml_service.dart';
+import '../../test_utils/test_robot.dart';
+
+void main() {
+ testWidgets('Renders CreateRoutineScreen and creates new routine', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await provider.init();
+
+ await robot.pumpScreen(
+ const CreateRoutineScreen(),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ robot.expectVisible(CreateRoutineScreen);
+
+ // Enter routine name via RFTextField
+ await robot.fill(find.byType(TextField).first, 'Upper Body Push');
+
+ // Tap Add Exercises button
+ await robot.tap('Add Exercises');
+
+ // Select exercise in sheet
+ final checks = find.byType(CheckboxListTile);
+ if (checks.evaluate().isNotEmpty) {
+ await robot.tap(checks.first);
+ }
+ });
+
+ testWidgets('Renders RoutineDetailScreen and displays exercise list', (WidgetTester tester) async {
+ final robot = TestRobot(tester);
+ final storage = MockStorageService();
+ final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await provider.init();
+
+ final routine = Routine(
+ id: 'routine_push_1',
+ name: 'Push Hypertrophy',
+ exerciseIds: ['bench_press', 'overhead_press'],
+ createdAt: DateTime.now(),
+ );
+
+ await robot.pumpScreen(
+ RoutineDetailScreen(routine: routine),
+ storage: storage,
+ workoutProvider: provider,
+ );
+
+ robot.expectVisible(RoutineDetailScreen);
+ robot.expectVisible('Push Hypertrophy');
+ });
+}
diff --git a/workout-logger/test/screens/widgets/targets_tab_test.dart b/workout-logger/test/screens/widgets/targets_tab_test.dart
new file mode 100644
index 0000000..bbb6db5
--- /dev/null
+++ b/workout-logger/test/screens/widgets/targets_tab_test.dart
@@ -0,0 +1,62 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/widgets/targets_tab.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/mock_ml_service.dart';
+import '../../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders TargetsTab with empty targets state', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await provider.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const TargetsTab(),
+ storage: storage,
+ workoutProvider: provider,
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('No Targets Set'), findsOneWidget);
+ });
+
+ testWidgets('Renders TargetsTab with active targets list', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final target = Target(
+ id: 'target_1',
+ exerciseId: 'bench_press',
+ targetType: 'weight',
+ targetValue: 100.0,
+ currentValue: 80.0,
+ );
+ await storage.saveTarget(target);
+
+ final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await provider.init();
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const TargetsTab(),
+ storage: storage,
+ workoutProvider: provider,
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('No Targets Set'), findsNothing);
+ // The target for bench_press should render its exercise name or value.
+ expect(
+ find.textContaining('Bench Press').evaluate().isNotEmpty ||
+ find.textContaining('bench').evaluate().isNotEmpty ||
+ find.textContaining('80').evaluate().isNotEmpty ||
+ find.textContaining('100').evaluate().isNotEmpty,
+ isTrue,
+ reason: 'Active target item should display the exercise name or target value',
+ );
+ });
+}
diff --git a/workout-logger/test/screens/widgets/workout_hr_section_test.dart b/workout-logger/test/screens/widgets/workout_hr_section_test.dart
new file mode 100644
index 0000000..edfff30
--- /dev/null
+++ b/workout-logger/test/screens/widgets/workout_hr_section_test.dart
@@ -0,0 +1,80 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/models/workout_hr_models.dart';
+import 'package:repforge/screens/widgets/workout_hr_section.dart';
+import 'package:repforge/services/managers/health_history_manager.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/workout_provider.dart';
+import '../../test_utils/mock_storage_service.dart';
+import '../../test_utils/mock_ml_service.dart';
+import '../../test_utils/stub_health_connect_service.dart';
+import '../../test_utils/test_fixtures.dart';
+import '../../test_utils/test_harness.dart';
+
+class StubHealthHistoryManager extends HealthHistoryManager {
+ StubHealthHistoryManager(this.stubAnalysis)
+ : super(const StubHcService(), MockStorageService());
+
+ final WorkoutHrAnalysis? stubAnalysis;
+
+ @override
+ Future workoutHr(WorkoutSession session) async {
+ return stubAnalysis;
+ }
+}
+
+void main() {
+ testWidgets('Renders WorkoutHrSection with heart rate analysis stats', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final storage = MockStorageService();
+ final session = TestFixtures.sampleSession();
+ final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage));
+ await provider.init();
+
+ final now = DateTime(2026, 5, 10, 14, 30);
+ final analysis = WorkoutHrAnalysis(
+ start: now,
+ end: now.add(const Duration(minutes: 45)),
+ avgBpm: 110,
+ peakBpm: 150,
+ minBpm: 60,
+ curve: [
+ HrCurvePoint(time: now, bpm: 70.0),
+ HrCurvePoint(time: now.add(const Duration(minutes: 15)), bpm: 140.0),
+ ],
+ rests: [
+ RestRecovery(
+ afterSet: 1,
+ restStart: now.add(const Duration(minutes: 5)),
+ durationSec: 90,
+ peakBpm: 135,
+ troughBpm: 110,
+ recoveryBpm: 25,
+ recovered: true,
+ ),
+ ],
+ exercises: [
+ ExerciseHrSpan(
+ exerciseId: 'bench_press',
+ start: now.add(const Duration(minutes: 2)),
+ end: now.add(const Duration(minutes: 10)),
+ setCount: 3,
+ ),
+ ],
+ hasRestAnalysis: true,
+ );
+
+ final customManager = StubHealthHistoryManager(analysis);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ WorkoutHrSection(session: session, provider: provider),
+ storage: storage,
+ healthHistoryManager: customManager,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(WorkoutHrSection), findsOneWidget);
+ });
+}
diff --git a/workout-logger/test/screens/workout_flow_screen_full_test.dart b/workout-logger/test/screens/workout_flow_screen_full_test.dart
new file mode 100644
index 0000000..db4df20
--- /dev/null
+++ b/workout-logger/test/screens/workout_flow_screen_full_test.dart
@@ -0,0 +1,121 @@
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/workout_flow_screen.dart';
+
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import '../test_utils/mock_storage_service.dart';
+import '../test_utils/mock_ml_service.dart';
+
+import '../test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ settingsProvider = SettingsProvider(storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ });
+
+ group('WorkoutFlowScreen Comprehensive Test Suite', () {
+ testWidgets('QuickStart workout flow: starts, adds exercises, logs sets, finishes', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const WorkoutFlowScreen(isQuickStart: true),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(WorkoutFlowScreen);
+
+ // Tap Log Set button if present
+ final logBtn = find.text('LOG SET');
+ if (logBtn.evaluate().isNotEmpty) {
+ await tester.tap(logBtn);
+ await tester.pumpAndSettle();
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('Routine-backed workout flow: loads exercises, toggles dropsets, logs sets', (tester) async {
+ final robot = TestRobot(tester);
+
+ final routine = Routine(
+ id: 'rout_flow_1',
+ name: 'Upper Hypertrophy',
+ exerciseIds: ['bench_press', 'incline_dumbbell_press'],
+ );
+ await storage.saveRoutine(routine);
+
+ await robot.pumpScreen(
+ WorkoutFlowScreen(routine: routine),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(WorkoutFlowScreen);
+
+ // Log set
+ final logBtn = find.text('LOG SET');
+ if (logBtn.evaluate().isNotEmpty) {
+ await tester.tap(logBtn);
+ await tester.pumpAndSettle();
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ProgramDay-backed workout flow with deload week', (tester) async {
+ final robot = TestRobot(tester);
+
+ final day = ProgramDay(
+ id: 'day_flow_1',
+ name: 'Leg Day A',
+ exercises: [
+ ProgramExerciseSlot(
+ exerciseId: 'squat',
+ sets: 3,
+ minReps: 5,
+ maxReps: 5,
+ restSeconds: 120,
+ ),
+ ],
+ );
+
+ final week = ProgramWeek(
+ weekNumber: 4,
+ isDeload: true,
+ deloadIntensityFactor: 0.85,
+ deloadSetReduction: 1,
+ days: [day],
+ );
+
+ await robot.pumpScreen(
+ WorkoutFlowScreen(programDay: day, programWeek: week),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(WorkoutFlowScreen);
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/screens/workout_flow_screen_test.dart b/workout-logger/test/screens/workout_flow_screen_test.dart
new file mode 100644
index 0000000..8326b73
--- /dev/null
+++ b/workout-logger/test/screens/workout_flow_screen_test.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/workout_flow_screen.dart';
+import '../test_utils/test_harness.dart';
+
+void main() {
+ testWidgets('Renders WorkoutFlowScreen with active exercise details', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ final routine = Routine(
+ id: 'chest_day',
+ name: 'Chest & Triceps',
+ exerciseIds: ['bench_press', 'incline_dumbbells'],
+ );
+
+ await tester.pumpWidget(TestHarness.wrap(
+ WorkoutFlowScreen(routine: routine),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(WorkoutFlowScreen), findsOneWidget);
+ expect(find.text('Bench Press'), findsOneWidget);
+ });
+
+ testWidgets('Renders WorkoutFlowScreen quick start mode', (WidgetTester tester) async {
+ await TestHarness.prepareTester(tester);
+
+ await tester.pumpWidget(TestHarness.wrap(
+ const WorkoutFlowScreen(isQuickStart: true),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+
+ expect(find.byType(WorkoutFlowScreen), findsOneWidget);
+ // Quick-start mode has no pre-set routine: the Add Exercise control is shown.
+ expect(
+ find.byIcon(Icons.add_rounded).evaluate().isNotEmpty ||
+ find.textContaining('Exercise').evaluate().isNotEmpty,
+ isTrue,
+ reason: 'Quick-start mode should show an add-exercise control or empty exercise area',
+ );
+ });
+}
diff --git a/workout-logger/test/services/health_connect_service_test.dart b/workout-logger/test/services/health_connect_service_test.dart
new file mode 100644
index 0000000..c3ff314
--- /dev/null
+++ b/workout-logger/test/services/health_connect_service_test.dart
@@ -0,0 +1,158 @@
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/services/health_connect_service.dart';
+import '../test_utils/test_fixtures.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ const pigeonChannels = [
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.getHealthPlatformStatus',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.initialize',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.requestPermissions',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.getPermissionStatus',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.readRecords',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.readRecord',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.writeRecords',
+ 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.writeRecord',
+ 'dev.flutter.pigeon.health_connector_hk_ios.HealthConnectorHKIOSApi.getHealthPlatformStatus',
+ 'dev.flutter.pigeon.health_connector_hk_ios.HealthConnectorHKIOSApi.initialize',
+ ];
+
+ setUp(() {
+ for (final channel in pigeonChannels) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMessageHandler(channel, (ByteData? message) async => null);
+ }
+ });
+
+ tearDown(() {
+ for (final channel in pigeonChannels) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMessageHandler(channel, null);
+ }
+ });
+
+ testWidgets('HealthConnectService reports unavailable gracefully in unit tests', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final available = await service.isAvailable();
+ expect(available, isFalse);
+ });
+
+ testWidgets('HealthConnectService returns false for permissions check on unsupported desktop test environment', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final hasPerms = await service.hasPermissions();
+ expect(hasPerms, isFalse);
+
+ final reqPerms = await service.requestPermissions();
+ expect(reqPerms, isFalse);
+
+ final reqReadPerms = await service.requestReadPermissions();
+ expect(reqReadPerms, isFalse);
+
+ final grantedTypes = await service.grantedReadTypes();
+ expect(grantedTypes, isEmpty);
+ });
+
+ testWidgets('HealthConnectService syncWorkoutSession returns false gracefully on missing platform channel', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final session = TestFixtures.sampleSession();
+ final success = await service.syncWorkoutSession(session, title: 'Custom Title');
+ expect(success, isFalse);
+ });
+
+ testWidgets('HealthConnectService handles sessions with zero reps and custom exercises', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final session = WorkoutSession(
+ id: 'sess_custom',
+ date: DateTime.now(),
+ duration: 30,
+ notes: 'Custom notes',
+ exercises: [
+ ExerciseLog(
+ exerciseId: 'custom_exercise_999',
+ sets: [
+ WorkoutSet(weight: 0.0, reps: 0, timestamp: DateTime.now()),
+ WorkoutSet(weight: 50.0, reps: 10, timestamp: DateTime.now().add(const Duration(minutes: 5))),
+ ],
+ ),
+ ],
+ );
+
+ final success = await service.syncWorkoutSession(session);
+ expect(success, isFalse);
+ });
+
+ testWidgets('HealthConnectService handles sessions with identical timestamps (fallback spacing)', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final now = DateTime.now();
+ final session = WorkoutSession(
+ id: 'sess_identical_ts',
+ date: now,
+ duration: 45,
+ exercises: [
+ ExerciseLog(
+ exerciseId: 'bench_press',
+ sets: [
+ WorkoutSet(weight: 60.0, reps: 10, timestamp: now),
+ WorkoutSet(weight: 70.0, reps: 8, timestamp: now),
+ ],
+ ),
+ ],
+ );
+
+ final success = await service.syncWorkoutSession(session, title: '');
+ expect(success, isFalse);
+ });
+
+ testWidgets('HealthConnectService handles empty sessions without exercises', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final session = WorkoutSession(
+ id: 'sess_empty',
+ date: DateTime.now(),
+ duration: 20,
+ exercises: [],
+ );
+
+ final success = await service.syncWorkoutSession(session);
+ expect(success, isFalse);
+ });
+
+ testWidgets('HealthConnectService read methods return empty lists when plugin unavailable', (WidgetTester tester) async {
+ final service = HealthConnectService();
+ final now = DateTime.now();
+ final start = now.subtract(const Duration(days: 1));
+
+ final sleep = await service.readSleepSessions(start, now);
+ expect(sleep, isEmpty);
+
+ final rhr = await service.readRestingHeartRate(start, now);
+ expect(rhr, isEmpty);
+
+ final hrv = await service.readHrvRmssd(start, now);
+ expect(hrv, isEmpty);
+
+ final hr = await service.readHeartRateSamples(start, now);
+ expect(hr, isEmpty);
+ });
+
+ test('HealthConnectService succeeds when platform response takes > 100ms within deadline', () async {
+ const channel = 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.getHealthPlatformStatus';
+ addTearDown(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler(channel, null);
+ });
+
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler(
+ channel,
+ (ByteData? message) async {
+ await Future.delayed(const Duration(milliseconds: 200));
+ return null;
+ },
+ );
+
+ final service = HealthConnectService();
+ final available = await service.isAvailable();
+ expect(available, isFalse);
+ });
+}
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..29975b1
--- /dev/null
+++ b/workout-logger/test/sleep_hr_builder_test.dart
@@ -0,0 +1,102 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/services/interfaces/health_connect_service_interface.dart';
+import 'package:repforge/services/utils/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/test_utils/test_fixtures.dart b/workout-logger/test/test_utils/test_fixtures.dart
new file mode 100644
index 0000000..9c3e443
--- /dev/null
+++ b/workout-logger/test/test_utils/test_fixtures.dart
@@ -0,0 +1,94 @@
+// test_fixtures.dart — Reusable mock data generators for unit and widget tests.
+
+import 'package:repforge/models/models.dart';
+
+class TestFixtures {
+ /// Generates a sample [WorkoutSession] with customizable parameters.
+ static WorkoutSession sampleSession({
+ String id = 'session_fixture_1',
+ DateTime? date,
+ int duration = 45,
+ String? notes = 'Sample session notes',
+ List? exercises,
+ }) {
+ final sessionDate = date ?? DateTime(2026, 5, 10, 10, 0);
+ return WorkoutSession(
+ id: id,
+ date: sessionDate,
+ duration: duration,
+ notes: notes,
+ exercises: exercises ??
+ [
+ ExerciseLog(
+ exerciseId: 'bench_press',
+ sets: [
+ WorkoutSet(weight: 80.0, reps: 10, timestamp: sessionDate.add(const Duration(minutes: 5))),
+ WorkoutSet(weight: 85.0, reps: 8, timestamp: sessionDate.add(const Duration(minutes: 10))),
+ ],
+ notes: 'Pushed hard on last set',
+ ),
+ ExerciseLog(
+ exerciseId: 'squats',
+ sets: [
+ WorkoutSet(weight: 120.0, reps: 5, timestamp: sessionDate.add(const Duration(minutes: 20))),
+ ],
+ ),
+ ],
+ );
+ }
+
+ /// Generates a sample [Routine] with customizable parameters.
+ static Routine sampleRoutine({
+ String id = 'routine_fixture_1',
+ String name = 'Upper Body Power',
+ List? exerciseIds,
+ }) {
+ return Routine(
+ id: id,
+ name: name,
+ exerciseIds: exerciseIds ?? ['bench_press', 'barbell_row', 'overhead_press'],
+ );
+ }
+
+ /// Generates a sample [TrainingProgram] with customizable parameters.
+ static TrainingProgram sampleProgram({
+ String id = 'program_fixture_1',
+ String name = 'Hypertrophy 12-Week',
+ int totalWeeks = 12,
+ }) {
+ return TrainingProgram(
+ id: id,
+ name: name,
+ totalWeeks: totalWeeks,
+ weeks: const [],
+ phases: const [],
+ );
+ }
+
+ /// Generates sample [SleepPeriod] records for health charts.
+ static List sampleSleepPeriods({DateTime? anchorDate}) {
+ final anchor = anchorDate ?? DateTime(2026, 5, 10);
+ return [
+ SleepPeriod(
+ start: anchor.subtract(const Duration(hours: 8)),
+ end: anchor,
+ deepMinutes: 120,
+ remMinutes: 90,
+ lightMinutes: 240,
+ awakeMinutes: 30,
+ ),
+ ];
+ }
+
+ /// Generates sample heart rate [HealthSample] records.
+ static List sampleHeartRateSamples({DateTime? anchorDate}) {
+ final anchor = anchorDate ?? DateTime(2026, 5, 10);
+ return List.generate(
+ 12,
+ (i) => HealthSample(
+ time: anchor.subtract(Duration(hours: 12 - i)),
+ value: 60.0 + (i * 3 % 25),
+ ),
+ );
+ }
+}
diff --git a/workout-logger/test/test_utils/test_harness.dart b/workout-logger/test/test_utils/test_harness.dart
new file mode 100644
index 0000000..ea538c8
--- /dev/null
+++ b/workout-logger/test/test_utils/test_harness.dart
@@ -0,0 +1,88 @@
+// test_harness.dart — Unified MultiProvider wrapper and viewport manager for widget tests.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/api_service.dart';
+import 'package:repforge/services/ai/gemini_ai_service.dart';
+import 'package:repforge/services/ai/coach_tool_service.dart';
+import 'package:repforge/services/managers/conversation_manager.dart';
+import 'package:repforge/services/managers/history_manager.dart';
+import 'package:repforge/services/managers/health_history_manager.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/managers/pr_manager.dart';
+import 'package:repforge/services/managers/readiness_manager.dart';
+import 'package:repforge/services/interfaces/health_connect_service_interface.dart';
+import 'package:repforge/services/interfaces/ml_service_interface.dart';
+import 'mock_storage_service.dart';
+import 'mock_ml_service.dart';
+import 'stub_health_connect_service.dart';
+
+class TestHarness {
+ /// Builds a fully-loaded MultiProvider widget tree for testing any Flutter screen or widget.
+ static Widget wrap(
+ Widget child, {
+ MockStorageService? storage,
+ WorkoutProvider? workoutProvider,
+ SettingsProvider? settingsProvider,
+ HistoryManager? historyManager,
+ HealthHistoryManager? healthHistoryManager,
+ ReadinessManager? readinessManager,
+ GeminiAiService? geminiAiService,
+ Size viewportSize = const Size(1080, 2400),
+ }) {
+ final mockStorage = storage ?? MockStorageService();
+ final wp = workoutProvider ??
+ WorkoutProvider(
+ mockStorage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(mockStorage),
+ );
+ final sp = settingsProvider ?? SettingsProvider(mockStorage);
+ final hm = historyManager ?? HistoryManager(mockStorage);
+ final hhm = healthHistoryManager ?? HealthHistoryManager(const StubHcService(), mockStorage);
+ final rm = readinessManager ?? ReadinessManager(const StubHcService(), mockStorage, sp);
+ final ai = geminiAiService ?? GeminiAiService();
+ final prm = PRManager(mockStorage);
+ final conv = ConversationManager(mockStorage);
+ final tools = CoachToolService(wp, prm);
+
+ return MultiProvider(
+ providers: [
+ ChangeNotifierProvider.value(value: wp),
+ ChangeNotifierProvider.value(value: sp),
+ ChangeNotifierProvider.value(value: hm),
+ ChangeNotifierProvider.value(value: prm),
+ ChangeNotifierProvider.value(value: ai),
+ ChangeNotifierProvider.value(value: conv),
+ ChangeNotifierProvider.value(value: rm),
+ Provider.value(value: hhm),
+ Provider.value(value: const StubHcService()),
+ Provider.value(value: ApiService()),
+ Provider.value(value: tools),
+ Provider.value(value: MockMLService()),
+ ],
+ child: MaterialApp(
+ home: MediaQuery(
+ data: MediaQueryData(size: viewportSize),
+ child: child,
+ ),
+ ),
+ );
+ }
+
+ /// Sets device physical dimensions for widget tests.
+ static Future prepareTester(WidgetTester tester, {Size size = const Size(1080, 2400)}) async {
+ await tester.binding.setSurfaceSize(size);
+ tester.view.physicalSize = size;
+ tester.view.devicePixelRatio = 1.0;
+
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ tester.binding.setSurfaceSize(null);
+ });
+ }
+}
diff --git a/workout-logger/test/test_utils/test_robot.dart b/workout-logger/test/test_utils/test_robot.dart
new file mode 100644
index 0000000..2bdb696
--- /dev/null
+++ b/workout-logger/test/test_utils/test_robot.dart
@@ -0,0 +1,93 @@
+// test_robot.dart — Fluent Page Object test automation robot for RepForge widget tests
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/history_manager.dart';
+import 'package:repforge/services/ai/gemini_ai_service.dart';
+import 'mock_storage_service.dart';
+import 'test_harness.dart';
+
+/// High-level expressive testing robot wrapping [WidgetTester].
+class TestRobot {
+ final WidgetTester tester;
+
+ TestRobot(this.tester);
+
+ /// Prepares viewport size and initializes screen widget under test.
+ Future pumpScreen(
+ Widget widget, {
+ MockStorageService? storage,
+ WorkoutProvider? workoutProvider,
+ SettingsProvider? settingsProvider,
+ HistoryManager? historyManager,
+ GeminiAiService? geminiAiService,
+ }) async {
+ await TestHarness.prepareTester(tester);
+ await tester.pumpWidget(TestHarness.wrap(
+ widget,
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ historyManager: historyManager,
+ geminiAiService: geminiAiService,
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ }
+
+ /// Taps on a target matching text, icon, key, or Finder.
+ Future tap(dynamic target) async {
+ final finder = _resolveFinder(target);
+ expect(finder, findsOneWidget);
+ await tester.tap(finder);
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ }
+
+ /// Enters text into an input field matching a label, hint, or Finder.
+ Future fill(dynamic target, String text) async {
+ final finder = _resolveFinder(target);
+ expect(finder, findsOneWidget);
+ await tester.enterText(finder, text);
+ await tester.pump();
+ expect(tester.takeException(), isNull);
+ }
+
+ /// Triggers a back navigation event on active Navigator.
+ Future handlePop() async {
+ await tester.binding.handlePopRoute();
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ }
+
+ /// Asserts that a target matching text, type, or Finder is visible.
+ void expectVisible(dynamic target, {int count = 1}) {
+ final finder = _resolveFinder(target);
+ if (count == 1) {
+ expect(finder, findsOneWidget);
+ } else {
+ expect(finder, findsNWidgets(count));
+ }
+ }
+
+ /// Asserts that a target matching text, type, or Finder is NOT visible.
+ void expectNotVisible(dynamic target) {
+ final finder = _resolveFinder(target);
+ expect(finder, findsNothing);
+ }
+
+ Finder _resolveFinder(dynamic target) {
+ if (target is Finder) return target;
+ if (target is String) {
+ final textFinder = find.text(target);
+ if (textFinder.evaluate().isNotEmpty) return textFinder;
+ return find.widgetWithText(TextField, target);
+ }
+ if (target is IconData) return find.byIcon(target);
+ if (target is Key) return find.byKey(target);
+ if (target is Type) return find.byType(target);
+ throw ArgumentError('Cannot resolve finder for target: $target');
+ }
+}
diff --git a/workout-logger/test/test_utils/test_sweep.dart b/workout-logger/test/test_utils/test_sweep.dart
new file mode 100644
index 0000000..c99bc72
--- /dev/null
+++ b/workout-logger/test/test_utils/test_sweep.dart
@@ -0,0 +1,37 @@
+// test_sweep.dart — Parametric loop helpers to sweep through UI states efficiently.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class TestSweep {
+ /// Iterates over a list of texts or icons, tapping each item and triggering pumpAndSettle.
+ static Future tapAll(WidgetTester tester, List targets) async {
+ for (final target in targets) {
+ final Finder? finder = target is String
+ ? find.text(target)
+ : target is IconData
+ ? find.byIcon(target)
+ : target is Key
+ ? find.byKey(target)
+ : null;
+ if (finder == null) continue;
+
+ if (finder.evaluate().isNotEmpty) {
+ await tester.tap(finder.first);
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ }
+ }
+ }
+
+ /// Populates a series of text fields with values and pumps frame.
+ static Future fillFields(WidgetTester tester, Map fieldValues) async {
+ for (final entry in fieldValues.entries) {
+ if (entry.key.evaluate().isNotEmpty) {
+ await tester.enterText(entry.key, entry.value);
+ await tester.pump();
+ }
+ }
+ await tester.pumpAndSettle();
+ }
+}
diff --git a/workout-logger/test/userflow_ai_coach_and_gemini_service_test.dart b/workout-logger/test/userflow_ai_coach_and_gemini_service_test.dart
new file mode 100644
index 0000000..64f566e
--- /dev/null
+++ b/workout-logger/test/userflow_ai_coach_and_gemini_service_test.dart
@@ -0,0 +1,74 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/ai_coach_screen.dart';
+import 'package:repforge/services/ai/gemini_ai_service.dart';
+import 'package:repforge/services/ai/coach_tool_service.dart';
+import 'package:repforge/services/managers/conversation_manager.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 'package:repforge/viewmodels/ai_coach_view_model.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+ late ConversationManager conversationManager;
+ late PRManager prManager;
+ late GeminiAiService geminiService;
+ late CoachToolService coachToolService;
+
+ setUp(() async {
+ storage = MockStorageService();
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ settingsProvider = SettingsProvider(storage);
+ conversationManager = ConversationManager(storage);
+ prManager = PRManager(storage);
+ geminiService = GeminiAiService();
+ coachToolService = CoachToolService(workoutProvider, prManager);
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ await prManager.load();
+ await conversationManager.loadConversations();
+ });
+
+ group('Userflow: AI Coach Screen and Gemini Service Integration', () {
+ testWidgets('Renders AiCoachScreen and displays initial empty state', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const AiCoachScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(AiCoachScreen);
+ });
+
+ testWidgets('AiCoachViewModel loads conversation and manages state changes cleanly', (tester) async {
+ final vm = AiCoachViewModel(
+ ai: geminiService,
+ coachTools: coachToolService,
+ conversations: conversationManager,
+ settings: settingsProvider,
+ );
+
+ await vm.loadConversations();
+ expect(vm.messages, isEmpty);
+ expect(vm.isLoading, isFalse);
+
+ vm.newConversation();
+ expect(vm.messages, isEmpty);
+ });
+ });
+}
diff --git a/workout-logger/test/userflow_health_and_profile_screen_test.dart b/workout-logger/test/userflow_health_and_profile_screen_test.dart
new file mode 100644
index 0000000..7013d30
--- /dev/null
+++ b/workout-logger/test/userflow_health_and_profile_screen_test.dart
@@ -0,0 +1,74 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/screens/heart_rate_detail_screen.dart';
+import 'package:repforge/screens/sleep_detail_screen.dart';
+import 'package:repforge/screens/profile_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ settingsProvider = SettingsProvider(storage);
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ });
+
+ group('Userflow: Heart Rate Detail, Sleep Detail, and Profile Screens', () {
+ testWidgets('HeartRateDetailScreen renders correctly with date anchor', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ HeartRateDetailScreen(initialDate: DateTime.now()),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(HeartRateDetailScreen);
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('SleepDetailScreen renders correctly with date anchor', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ SleepDetailScreen(initialDate: DateTime.now()),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(SleepDetailScreen);
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ProfileScreen renders settings options and user metrics', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ProfileScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(ProfileScreen);
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
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_program_design_and_generator_test.dart b/workout-logger/test/userflow_program_design_and_generator_test.dart
new file mode 100644
index 0000000..12f98d9
--- /dev/null
+++ b/workout-logger/test/userflow_program_design_and_generator_test.dart
@@ -0,0 +1,138 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/programs/programs_screen.dart';
+import 'package:repforge/screens/programs/program_designer_screen.dart';
+import 'package:repforge/screens/programs/program_detail_screen.dart';
+import 'package:repforge/screens/ai_program_generator_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late ProgramManager programManager;
+
+ setUp(() async {
+ storage = MockStorageService();
+ programManager = ProgramManager(storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: programManager,
+ );
+ await workoutProvider.init();
+ });
+
+ group('Userflow: Programs, Designer, and AI Generator', () {
+ testWidgets('Full flow: Empty Programs -> New Designer Program -> Save & View Program Detail', (tester) async {
+ final robot = TestRobot(tester);
+
+ // 1. Render empty ProgramsScreen
+ await robot.pumpScreen(
+ const ProgramsScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible(ProgramsScreen);
+ robot.expectVisible('New Program');
+
+ // 2. Render ProgramDesignerScreen for new program
+ await robot.pumpScreen(
+ const ProgramDesignerScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible(ProgramDesignerScreen);
+
+ // Fill Title and Description
+ final textFields = find.byType(TextField);
+ if (textFields.evaluate().length >= 2) {
+ await tester.enterText(textFields.at(0), 'Strength Block 1');
+ await tester.enterText(textFields.at(1), '4-week progressive overload');
+ await tester.pumpAndSettle();
+ }
+
+ // Tap Save Program button
+ final saveBtn = find.text('Save Program');
+ if (saveBtn.evaluate().isNotEmpty) {
+ await tester.tap(saveBtn);
+ await tester.pumpAndSettle();
+ }
+
+ // 3. Save a sample program into manager and view ProgramDetailScreen
+ final sampleProgram = TrainingProgram(
+ id: 'prog_test_1',
+ name: 'Hypertrophy Phase 1',
+ description: 'Targeted hypertrophy program',
+ totalWeeks: 4,
+ phases: [
+ TrainingPhase(
+ id: 'phase_1',
+ name: 'Volume Phase',
+ startWeek: 1,
+ endWeek: 4,
+ ),
+ ],
+ weeks: [
+ ProgramWeek(
+ weekNumber: 1,
+ days: [
+ ProgramDay(
+ id: 'day_1',
+ name: 'Push Day A',
+ dayOfWeek: 1,
+ exercises: [],
+ ),
+ ],
+ ),
+ ],
+ );
+ await programManager.saveProgram(sampleProgram);
+
+ await robot.pumpScreen(
+ ProgramDetailScreen(program: sampleProgram),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible('Hypertrophy Phase 1');
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('AiProgramGeneratorScreen shows prompt suggestions and validates API configuration', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const AiProgramGeneratorScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible(AiProgramGeneratorScreen);
+
+ // Verify prompt suggestion chips render
+ final chipFinder = find.text('12-week hypertrophy, 4 days/week, push-pull-legs-upper');
+ if (chipFinder.evaluate().isNotEmpty) {
+ await tester.tap(chipFinder);
+ await tester.pumpAndSettle();
+ }
+
+ // Tap Generate Program button
+ final genBtn = find.text('Generate Program');
+ if (genBtn.evaluate().isNotEmpty) {
+ await tester.tap(genBtn);
+ await tester.pumpAndSettle();
+ }
+
+ // Verify prompt check/error prompt is raised gracefully
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/userflow_programs_screen_deep_test.dart b/workout-logger/test/userflow_programs_screen_deep_test.dart
new file mode 100644
index 0000000..2149ee4
--- /dev/null
+++ b/workout-logger/test/userflow_programs_screen_deep_test.dart
@@ -0,0 +1,103 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/programs/programs_screen.dart';
+import 'package:repforge/screens/programs/program_detail_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late ProgramManager programManager;
+ late WorkoutProvider workoutProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ programManager = ProgramManager(storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: programManager,
+ );
+
+ await workoutProvider.init();
+
+ // Create a sample program in storage
+ final program = TrainingProgram(
+ id: 'prog_deep_1',
+ name: 'Powerbuilding V1',
+ description: 'Strength and hypertrophy',
+ author: 'User',
+ totalWeeks: 4,
+ phases: [
+ TrainingPhase(
+ id: 'phase_1',
+ name: 'Hypertrophy Phase',
+ startWeek: 1,
+ endWeek: 4,
+ ),
+ ],
+ weeks: [
+ ProgramWeek(
+ weekNumber: 1,
+ phaseId: 'phase_1',
+ days: [
+ ProgramDay(
+ id: 'day_1',
+ name: 'Push Day A',
+ exercises: [
+ ProgramExerciseSlot(
+ exerciseId: 'bench_press',
+ sets: 4,
+ minReps: 8,
+ maxReps: 10,
+ restSeconds: 90,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ await programManager.saveProgram(program);
+ });
+
+ group('ProgramsScreen Deep Coverage Suite', () {
+ testWidgets('Populated ProgramsScreen interactions: activate, view, and popups', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ProgramsScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible(ProgramsScreen);
+ robot.expectVisible('Powerbuilding V1');
+
+ // Tap program card to open detail screen
+ await robot.tap('Powerbuilding V1');
+ robot.expectVisible(ProgramDetailScreen);
+
+ // Pop detail screen back to ProgramsScreen
+ await tester.pageBack();
+ await tester.pumpAndSettle();
+
+ // Tap FABs
+ final fabs = find.byType(FloatingActionButton);
+ expect(fabs, findsWidgets);
+
+ for (int i = 0; i < fabs.evaluate().length; i++) {
+ await tester.tap(fabs.at(i));
+ await tester.pumpAndSettle();
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
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..c9bc7ee
--- /dev/null
+++ b/workout-logger/test/userflow_routine_creation_test.dart
@@ -0,0 +1,169 @@
+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);
+ });
+
+ testWidgets('startRoutineWorkoutFlow starts routine workout without conflict', (tester) async {
+ final routine = Routine(id: 'r1', name: 'Push Day', exerciseIds: ['bench_press']);
+ await tester.pumpWidget(_buildTestApp(
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ child: Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => startRoutineWorkoutFlow(context, routine),
+ child: const Text('Start Routine'),
+ ),
+ ),
+ ));
+
+ await tester.tap(find.text('Start Routine'));
+ await tester.pumpAndSettle();
+
+ expect(workoutProvider.hasActiveWorkout, isTrue);
+ });
+
+ testWidgets('RoutineDetailScreen renders routine details', (tester) async {
+ final routine = Routine(id: 'r2', name: 'Pull Day', exerciseIds: ['barbell_row']);
+
+ await tester.pumpWidget(_buildTestApp(
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ child: RoutineDetailScreen(routine: routine),
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Pull Day'), findsWidgets);
+ });
+
+ testWidgets('CreateRoutineScreen supports reordering exercise into final position before Add Exercises', (tester) async {
+ await tester.pumpWidget(_buildTestApp(
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ child: const CreateRoutineScreen(),
+ ));
+ await tester.pumpAndSettle();
+
+ final reorderableList = tester.widget(find.byType(ReorderableListView));
+ reorderableList.onReorderItem!(0, 1);
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/userflow_screens_sweep_test.dart b/workout-logger/test/userflow_screens_sweep_test.dart
new file mode 100644
index 0000000..44b81a1
--- /dev/null
+++ b/workout-logger/test/userflow_screens_sweep_test.dart
@@ -0,0 +1,214 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/home_screen.dart';
+import 'package:repforge/screens/profile_screen.dart';
+import 'package:repforge/screens/heart_rate_detail_screen.dart';
+import 'package:repforge/screens/sleep_detail_screen.dart';
+import 'package:repforge/screens/programs/program_designer_screen.dart';
+import 'package:repforge/screens/workout_flow_screen.dart';
+import 'package:repforge/screens/workout_summary_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/managers/history_manager.dart';
+import 'package:repforge/services/managers/pr_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+import 'test_utils/test_sweep.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+ late HistoryManager historyManager;
+ late PRManager prManager;
+
+ setUp(() async {
+ storage = MockStorageService();
+ settingsProvider = SettingsProvider(storage);
+ historyManager = HistoryManager(storage);
+ prManager = PRManager(storage);
+
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ historyManager: historyManager,
+ );
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ await historyManager.loadSessions();
+ await prManager.load();
+
+ // Save a custom session for history/home widgets
+ final session = WorkoutSession(
+ id: 'sess_sweep_1',
+ date: DateTime.now(),
+ duration: 50,
+ exercises: [
+ ExerciseLog(
+ exerciseId: 'bench_press',
+ sets: [WorkoutSet(weight: 100, reps: 10)],
+ ),
+ ],
+ );
+ await storage.saveWorkoutSession(session);
+ await historyManager.loadSessions();
+ });
+
+ group('Comprehensive User Flow Sweeps across Screens', () {
+ testWidgets('HomeScreen navigation bar tab sweep and dashboard actions', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const HomeScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ historyManager: historyManager,
+ );
+
+ robot.expectVisible(HomeScreen);
+
+ // Sweep through navigation bar tabs
+ final navIcons = [
+ Icons.layers_rounded,
+ Icons.history_rounded,
+ Icons.bar_chart_rounded,
+ Icons.home_rounded,
+ ];
+ await TestSweep.tapAll(tester, navIcons);
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ProfileScreen settings & data management sweep', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ProfileScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(ProfileScreen);
+
+ // Sweep unit preference chips
+ final profileTargets = [
+ 'kg',
+ 'lbs',
+ ];
+ await TestSweep.tapAll(tester, profileTargets);
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('HeartRateDetailScreen & SleepDetailScreen granularity chip sweep', (tester) async {
+ final robot = TestRobot(tester);
+
+ // 1. HeartRateDetailScreen
+ await robot.pumpScreen(
+ HeartRateDetailScreen(initialDate: DateTime.now()),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ await TestSweep.tapAll(tester, ['Day', 'Week', 'Month', 'Year']);
+
+ // 2. SleepDetailScreen
+ await robot.pumpScreen(
+ SleepDetailScreen(initialDate: DateTime.now()),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ await TestSweep.tapAll(tester, ['Day', 'Week', 'Month', 'Year']);
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ProgramDesignerScreen comprehensive creation flow sweep', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ProgramDesignerScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible(ProgramDesignerScreen);
+
+ // Enter form parameters
+ final fields = find.byType(TextField);
+ if (fields.evaluate().isNotEmpty) {
+ await tester.enterText(fields.first, 'Custom Power Program');
+ await tester.pump();
+ }
+
+ // Tap action buttons (Add Phase, Add Week, Save Program)
+ final actionButtons = [
+ 'Add Phase',
+ 'Add Week',
+ 'Save Program',
+ ];
+ await TestSweep.tapAll(tester, actionButtons);
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('WorkoutFlowScreen & WorkoutSummaryScreen user logging sweep', (tester) async {
+ final robot = TestRobot(tester);
+
+ workoutProvider.startWorkout(exerciseIds: ['bench_press', 'squat']);
+
+ await robot.pumpScreen(
+ const WorkoutFlowScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(WorkoutFlowScreen);
+
+ // Interact with set logging and rest timer
+ final logSetBtn = find.text('LOG SET');
+ if (logSetBtn.evaluate().isNotEmpty) {
+ await tester.tap(logSetBtn);
+ await tester.pumpAndSettle();
+
+ final restTargets = ['+30s', 'SKIP REST'];
+ await TestSweep.tapAll(tester, restTargets);
+ }
+
+ // Complete active workout and render summary screen
+ final session = WorkoutSession(
+ id: 'completed_summary_1',
+ date: DateTime.now(),
+ duration: 40,
+ exercises: [
+ ExerciseLog(
+ exerciseId: 'bench_press',
+ sets: [WorkoutSet(weight: 100, reps: 8)],
+ ),
+ ],
+ );
+
+ await robot.pumpScreen(
+ WorkoutSummaryScreen(session: session),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(WorkoutSummaryScreen);
+ expect(find.text('Workout Complete!'), findsOneWidget);
+ });
+ });
+}
diff --git a/workout-logger/test/userflow_services_and_ai_sweep_test.dart b/workout-logger/test/userflow_services_and_ai_sweep_test.dart
new file mode 100644
index 0000000..e062315
--- /dev/null
+++ b/workout-logger/test/userflow_services_and_ai_sweep_test.dart
@@ -0,0 +1,103 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:google_generative_ai/google_generative_ai.dart';
+import 'package:repforge/services/ai/gemini_ai_service.dart';
+import 'package:repforge/services/ai/coach_tool_service.dart';
+import 'package:repforge/services/gemini_context_builder.dart';
+import 'package:repforge/services/health_connect_service.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+import 'package:repforge/services/managers/pr_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+ late PRManager prManager;
+ late GeminiAiService geminiService;
+ late CoachToolService coachToolService;
+ late HealthConnectService healthConnectService;
+
+ setUp(() async {
+ storage = MockStorageService();
+ settingsProvider = SettingsProvider(storage);
+ prManager = PRManager(storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ geminiService = GeminiAiService(storage: storage);
+ coachToolService = CoachToolService(workoutProvider, prManager);
+ healthConnectService = HealthConnectService();
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ await prManager.load();
+ });
+
+ group('Deep Service & AI Engine Unit/Integration Sweeps', () {
+ test('GeminiAiService lifecycle, token usage, and model selection sweep', () async {
+ expect(geminiService.isConfigured, isFalse);
+ expect(geminiService.currentModel, equals(kDefaultGeminiModel));
+ expect(geminiService.promptTokensUsed, equals(0));
+ expect(geminiService.responseTokensUsed, equals(0));
+
+ geminiService.init('fake_test_api_key', model: 'gemini-3.5-flash');
+ expect(geminiService.isConfigured, isTrue);
+ expect(geminiService.currentModel, equals('gemini-3.5-flash'));
+
+ await geminiService.loadUsage();
+ expect(geminiService.totalTokensUsed, equals(0));
+ });
+
+ test('CoachToolService tool declaration and tool call execution sweep', () async {
+ final prRes = await coachToolService.handleCall(
+ FunctionCall('get_personal_records', {}),
+ );
+ expect(prRes, isNotNull);
+
+ final goalRes = await coachToolService.handleCall(
+ FunctionCall('get_goal_progress', {}),
+ );
+ expect(goalRes, isNotNull);
+
+ final routinesRes = await coachToolService.handleCall(
+ FunctionCall('get_all_routines', {}),
+ );
+ expect(routinesRes, isNotNull);
+ });
+
+ test('GeminiContextBuilder prompt context formatting sweep', () {
+ final contextText = GeminiContextBuilder.buildCoachSystemPrompt(
+ unitLabel: 'kg',
+ );
+
+ expect(contextText, isNotEmpty);
+ expect(contextText, contains('RepForge'));
+ });
+
+ test('HealthConnectService safe stub invocation sweep', () async {
+ final isAvailable = await healthConnectService.isAvailable();
+ expect(isAvailable, isFalse);
+
+ final hasPermission = await healthConnectService.hasPermissions();
+ expect(hasPermission, isFalse);
+
+ final now = DateTime.now();
+ final start = now.subtract(const Duration(days: 1));
+
+ final rhr = await healthConnectService.readRestingHeartRate(start, now);
+ expect(rhr, isEmpty);
+
+ final hrv = await healthConnectService.readHrvRmssd(start, now);
+ expect(hrv, isEmpty);
+
+ final sleep = await healthConnectService.readSleepSessions(start, now);
+ expect(sleep, isEmpty);
+ });
+ });
+}
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_targets_and_muscle_sheets_full_test.dart b/workout-logger/test/userflow_targets_and_muscle_sheets_full_test.dart
new file mode 100644
index 0000000..f2e2710
--- /dev/null
+++ b/workout-logger/test/userflow_targets_and_muscle_sheets_full_test.dart
@@ -0,0 +1,157 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/widgets/targets_tab.dart';
+import 'package:repforge/screens/widgets/muscle_detail_sheet.dart';
+import 'package:repforge/screens/programs/program_designer_screen.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ settingsProvider = SettingsProvider(storage);
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+
+ // Save a custom session with bench press & squat to generate muscle volume data
+ final session = WorkoutSession(
+ id: 'targets_sess_1',
+ date: DateTime.now(),
+ duration: 45,
+ exercises: [
+ ExerciseLog(
+ exerciseId: 'bench_press',
+ sets: [
+ WorkoutSet(weight: 100, reps: 10),
+ WorkoutSet(weight: 100, reps: 8),
+ ],
+ ),
+ ExerciseLog(
+ exerciseId: 'squat',
+ sets: [
+ WorkoutSet(weight: 140, reps: 5),
+ ],
+ ),
+ ],
+ );
+ await storage.saveWorkoutSession(session);
+ await workoutProvider.init();
+
+ // Save sample targets
+ final target1 = Target(
+ id: 'target_1',
+ exerciseId: 'bench_press',
+ targetType: 'weight',
+ targetValue: 120,
+ currentValue: 100,
+ createdAt: DateTime.now(),
+ );
+ final target2 = Target(
+ id: 'target_2',
+ exerciseId: 'squat',
+ targetType: 'weight',
+ targetValue: 160,
+ currentValue: 160,
+ isCompleted: true,
+ createdAt: DateTime.now(),
+ );
+ await storage.saveTarget(target1);
+ await storage.saveTarget(target2);
+ await workoutProvider.init();
+ });
+
+ group('TargetsTab and MuscleDetailSheet Full Test Suite', () {
+ testWidgets('Renders TargetsTab with active & completed target cards and triggers add dialog', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const TargetsTab(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(TargetsTab);
+
+ // Verify section headers
+ expect(find.text('ACTIVE'), findsOneWidget);
+ expect(find.text('COMPLETED'), findsOneWidget);
+
+ // Tap FAB to add new target
+ final fab = find.byType(FloatingActionButton);
+ if (fab.evaluate().isNotEmpty) {
+ await tester.tap(fab.first);
+ await tester.pumpAndSettle();
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('Renders MuscleDetailSheet for chest, back, and legs muscle groups', (tester) async {
+ final robot = TestRobot(tester);
+
+ for (final muscleId in ['chest', 'back', 'quadriceps']) {
+ await robot.pumpScreen(
+ MuscleDetailSheet(
+ muscleId: muscleId,
+ provider: workoutProvider,
+ ),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(MuscleDetailSheet);
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('ProgramDesignerScreen full phase and week builder interaction', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ProgramDesignerScreen(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ );
+
+ robot.expectVisible(ProgramDesignerScreen);
+
+ // Enter program name
+ final fields = find.byType(TextField);
+ if (fields.evaluate().isNotEmpty) {
+ await tester.enterText(fields.first, 'Strength Program 2026');
+ await tester.pump();
+ }
+
+ // Tap buttons to build phases & weeks
+ final buttons = ['Add Phase', 'Add Week', 'Save Program'];
+ for (final label in buttons) {
+ final btn = find.text(label);
+ if (btn.evaluate().isNotEmpty) {
+ await tester.tap(btn.first);
+ await tester.pump();
+ }
+ }
+
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
diff --git a/workout-logger/test/userflow_targets_and_muscle_sheets_test.dart b/workout-logger/test/userflow_targets_and_muscle_sheets_test.dart
new file mode 100644
index 0000000..01b6fbb
--- /dev/null
+++ b/workout-logger/test/userflow_targets_and_muscle_sheets_test.dart
@@ -0,0 +1,130 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:repforge/models/models.dart';
+import 'package:repforge/screens/widgets/targets_tab.dart';
+import 'package:repforge/screens/widgets/muscle_detail_sheet.dart';
+import 'package:repforge/screens/widgets/editable_exercise_card.dart';
+import 'package:repforge/screens/widgets/readiness_card.dart';
+import 'package:repforge/services/workout_provider.dart';
+import 'package:repforge/services/settings_provider.dart';
+import 'package:repforge/services/managers/program_manager.dart';
+
+import 'test_utils/mock_storage_service.dart';
+import 'test_utils/mock_ml_service.dart';
+import 'test_utils/test_robot.dart';
+
+void main() {
+ late MockStorageService storage;
+ late WorkoutProvider workoutProvider;
+ late SettingsProvider settingsProvider;
+
+ setUp(() async {
+ storage = MockStorageService();
+ workoutProvider = WorkoutProvider(
+ storage,
+ mlService: MockMLService(),
+ programManager: ProgramManager(storage),
+ );
+ settingsProvider = SettingsProvider(storage);
+
+ await workoutProvider.init();
+ await settingsProvider.init();
+ });
+
+ group('Userflow: Targets Tab, Muscle Detail Sheet, and Target Cards', () {
+ testWidgets('Renders TargetsTab in empty and populated target state', (tester) async {
+ final robot = TestRobot(tester);
+
+ // 1. Empty state
+ await robot.pumpScreen(
+ const TargetsTab(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible('No Targets Set');
+
+ // 2. Add target to storage and re-pump
+ final target = Target(
+ id: 'tgt_bench_100',
+ exerciseId: 'bench_press',
+ targetType: 'weight',
+ targetValue: 100.0,
+ currentValue: 80.0,
+ );
+ await storage.saveTarget(target);
+ await workoutProvider.init();
+
+ await robot.pumpScreen(
+ const TargetsTab(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(TargetsTab);
+ });
+
+ testWidgets('MuscleDetailSheet renders volume progression and muscle metrics', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ MuscleDetailSheet(
+ muscleId: 'chest',
+ provider: workoutProvider,
+ ),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ robot.expectVisible(MuscleDetailSheet);
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('EditableExerciseCard renders exercise parameters and handles user interactions', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SingleChildScrollView(
+ child: EditableExerciseCard(
+ exerciseName: 'Barbell Squat',
+ editableLog: EditableExerciseLog(
+ exerciseId: 'ex_squat',
+ sets: [],
+ ),
+ onSetChanged: ({
+ required int setIndex,
+ required double weight,
+ required int reps,
+ required bool isDropset,
+ List? drops,
+ }) {},
+ onAddSet: () {},
+ onDeleteSet: (_) {},
+ onDeleteExercise: () {},
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Barbell Squat'), findsOneWidget);
+ });
+
+ testWidgets('ReadinessCard renders recovery scores drill-down', (tester) async {
+ final robot = TestRobot(tester);
+
+ await robot.pumpScreen(
+ const ReadinessCard(),
+ storage: storage,
+ workoutProvider: workoutProvider,
+ settingsProvider: settingsProvider,
+ );
+
+ expect(tester.takeException(), isNull);
+ });
+ });
+}
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);
+ });
+ });
+}