From 38b723b16b9fabc212a40f107dad9f8dcb27e93d Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:58:43 +0530 Subject: [PATCH 1/3] Feat/android 17 (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add localized summaries, app metadata, and signing block information to F-Droid repository data * chore: upgrade Android SDK 16→17, Java 11→17, Gradle/AGP/Kotlin toolchain - compileSdk + targetSdk: 36 → 37 (Android 17 / API 37) - Removed compileSdkExtension (not needed for base API 37) - Java source/target compatibility: VERSION_11 → VERSION_17 - Kotlin jvmTarget: 11 → 17 - Gradle wrapper: 8.12 → 8.14.1 - AGP: 8.9.1 → 8.11.1 - Kotlin Gradle Plugin: 2.1.0 → 2.2.20 - Enable android.builtInKotlin=true + android.newDsl=true - Remove explicit id(kotlin-android) plugin (now injected by Flutter) * chore: update pubspec.lock (transitive dependency bumps) * chore: update repo name and username references to RepForge and Devasy * upadtes the build gradle kts file to match the review comment * Adds pubspec yaml --------- Co-authored-by: Devasy Patel <110348311+Devasy23@users.noreply.github.com> --- README.md | 14 +- workout-logger/android/app/build.gradle.kts | 19 ++- workout-logger/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- workout-logger/android/settings.gradle.kts | 4 +- .../android/en-US/full_description.txt | 2 +- workout-logger/pubspec.lock | 154 +++++++++--------- workout-logger/pubspec.yaml | 12 +- 8 files changed, 108 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 6662424..794e74a 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@

Flutter Badge Dart Badge - Stars Badge - Forks Badge - Issues Badge + Stars Badge + Forks Badge + Issues Badge License Badge

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