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 5689292..a0e89d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,7 +149,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: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62890d7..02e711d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,20 +2,31 @@ name: Test on: push: - branches: [main] + branches: + - main + - 'r[0-9]+.[0-9]+.*' pull_request: - branches: [main] + branches: + - main + - 'r[0-9]+.[0-9]+.*' release: types: [published] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: name: Analyze & Test runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Flutter id: flutter-action @@ -28,13 +39,14 @@ jobs: pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-${{ hashFiles('workout-logger/pubspec.lock') }}" - name: Install dependencies - if: steps.flutter-action.outputs.PUB-CACHE-HIT != 'true' + if: steps.flutter-action.outputs.CACHE-HIT != 'true' working-directory: ./workout-logger run: flutter pub get - name: Analyze working-directory: ./workout-logger run: | + set -o pipefail # Only fail on errors, ignore warnings and info messages flutter analyze --no-fatal-infos --no-fatal-warnings | tee analyze_output.txt @@ -53,3 +65,5 @@ jobs: with: files: workout-logger/coverage/lcov.info token: ${{ secrets.CODECOV_TOKEN }} + slug: Devasy/RepForge + diff --git a/.gitignore b/.gitignore index 82e5c63..70d9a18 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,12 @@ repforge_backup_*.json # Claude Code project memory & session files .claude/ + +# Hive test databases and temporary directories +*.hive +tmp_hive_*/ +**/tmp_hive_*/ + + +# Subagent-driven-development scratch workspace +.superpowers/ diff --git a/README.md b/README.md index 04b7dcf..47a480d 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ RepForge is licensed under the [Apache License 2.0](LICENSE). **Devasy Patel** - Email: patel.devasy.23@gmail.com -- GitHub: [@Devasy23](https://github.com/Devasy23) +- GitHub: [@Devasy](https://github.com/Devasy) ---
diff --git a/docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md b/docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md new file mode 100644 index 0000000..783524f --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md @@ -0,0 +1,224 @@ +# Hive → SQLite Migration + Coach SQL Query Tool — Design Spec + +**Date:** 2026-08-08 +**Status:** Approved +**Feature area:** Storage layer (`lib/services/`) + AI Coach tools (`lib/services/ai/`) + +--- + +## 1. Problem + +The AI Coach (`CoachToolService`) currently exposes ~15 narrow, purpose-built tools (`get_exercise_performance`, `get_workouts_in_range`, etc.), each hand-wrapping a specific `WorkoutProvider`/`PRManager` query. This is fine for known question shapes but can't answer arbitrary analytical questions the model wasn't given a preset tool for (e.g. ad-hoc joins, unusual aggregations, novel filters). + +The fix — a generic SQL query tool — is a poor fit for the current storage layer: RepForge persists to **Hive**, a key-value store with no query language. Any SQL tool would need a translation layer. + +Two paths were considered: +- **Ephemeral snapshot**: build a throwaway in-memory SQLite mirror on every coach tool call, rebuilt from Hive-backed in-memory lists each time. +- **Real migration**: replace Hive with SQLite as the actual persistence backend, so the coach's SQL tool queries live data directly with no translation step. + +This spec chooses the second path. `IStorageService` (`lib/services/interfaces/storage_service_interface.dart`) is already a clean DIP boundary — every method takes/returns plain Dart models, no Hive types leak through — so a `SqliteStorageService implements IStorageService` swap is architecturally sound without touching any manager, `WorkoutProvider`, or screen. `MockStorageService` already fulfills the same interface, so the existing test suite is unaffected by the backend swap. + +This is two dependent efforts: (A) migrate the storage backend, (B) add the coach's SQL tool on top of it. (A) is materially riskier — it touches real user data — and is the majority of this spec. + +--- + +## 2. Goal + +1. Replace Hive with SQLite (`sqflite`) as RepForge's persistence backend, via a new `SqliteStorageService implements IStorageService`, with a safe, reversible, one-time migration for existing installs. +2. Add `run_sql_query` to `CoachToolService`: the model submits a read-only SQL `SELECT`, executed against a dedicated read-only connection to the live database, results returned as JSON rows. + +Non-goals: no UI changes, no new user-facing features, no change to any existing `IStorageService` method signature or manager/provider code. + +--- + +## 3. Package Choice: `sqflite` + +Considered `sqlite3` (FFI, synchronous) vs `sqflite` (platform channel, async). Chose **`sqflite`**: + +- `IStorageService` is entirely `Future`-based already. `sqflite` runs DB work on a native background thread and returns via `Future` naturally — no extra isolate-management code. `sqlite3` is synchronous on the calling isolate; matching the same non-blocking behavior would require hand-rolling a background isolate, which is unjustified complexity at this app's data scale. +- `sqflite` supports `rawQuery(sql, args)` / `rawInsert` / `rawUpdate`, so the coach's arbitrary-SQL tool works identically to how it would under `sqlite3`. No capability is lost. +- No native binary bundling (`sqlite3_flutter_libs`) needed; uses the OS-provided SQLite. + +**Known tradeoff:** `sqflite` uses the Android-bundled SQLite version rather than a pinned one, so very old devices could lack newer SQL features (e.g. window functions, SQLite 3.25+/Android 9+). Accepted as low risk for this app's scale and audience. + +**Test dependency:** add `sqflite_common_ffi` (dev dependency) — required to run `sqflite`-backed code under `flutter test`, since plain `sqflite` needs a real platform binding unavailable off-device. + +--- + +## 4. Schema + +All tables live in one SQLite database file, created in `onCreate`. + +```sql +CREATE TABLE exercises ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, -- 'compound' | 'isolation' + is_custom INTEGER NOT NULL DEFAULT 0, + available_handles TEXT -- JSON array or NULL +); + +CREATE TABLE muscle_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + growth_rate REAL NOT NULL DEFAULT 0, + last_updated TEXT NOT NULL +); + +CREATE TABLE exercise_muscle_activations ( + exercise_id TEXT NOT NULL REFERENCES exercises(id), + muscle_group_id TEXT NOT NULL, + activation_percentage INTEGER NOT NULL +); + +CREATE TABLE routines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE routine_exercises ( + routine_id TEXT NOT NULL REFERENCES routines(id), + exercise_id TEXT NOT NULL, + position INTEGER NOT NULL +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + routine_id TEXT, + duration_min INTEGER NOT NULL, + notes TEXT, + hc_synced_at TEXT +); + +CREATE TABLE exercise_logs ( + id TEXT PRIMARY KEY, -- synthetic: '${session_id}_${index}' + session_id TEXT NOT NULL REFERENCES sessions(id), + exercise_id TEXT NOT NULL, + notes TEXT, + handle TEXT +); + +CREATE TABLE sets ( + id TEXT PRIMARY KEY, -- synthetic: '${exercise_log_id}_${index}' + exercise_log_id TEXT NOT NULL REFERENCES exercise_logs(id), + weight REAL NOT NULL, + reps INTEGER NOT NULL, + is_dropset INTEGER NOT NULL DEFAULT 0, + drops_json TEXT, -- JSON array of {id, weight, reps} or NULL + time_taken INTEGER, + timestamp TEXT NOT NULL, + assist_weight REAL, + extra_weight REAL, + handle TEXT +); + +CREATE TABLE targets ( + id TEXT PRIMARY KEY, + exercise_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_value REAL NOT NULL, + current_value REAL NOT NULL DEFAULT 0, + estimated_completion_date TEXT, + created_at TEXT NOT NULL, + is_completed INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE personal_records ( + exercise_id TEXT PRIMARY KEY, + best_weight REAL NOT NULL, + best_reps INTEGER NOT NULL, + best_volume REAL NOT NULL, + achieved_at TEXT NOT NULL +); + +CREATE TABLE training_programs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + total_weeks INTEGER NOT NULL, + author TEXT, + is_imported INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + phases_json TEXT NOT NULL, -- List.toJson() + weeks_json TEXT NOT NULL -- List.toJson() +); + +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'coach', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + messages_json TEXT NOT NULL -- List.toJson() +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE INDEX idx_sets_exercise_log ON sets(exercise_log_id); +CREATE INDEX idx_exercise_logs_session ON exercise_logs(session_id); +CREATE INDEX idx_exercise_logs_exercise ON exercise_logs(exercise_id); +CREATE INDEX idx_sessions_date ON sessions(date); +``` + +**Deliberately not fully normalized:** `training_programs` (phases/weeks/days/exercises) and `conversations` (messages) are stored as JSON-blob columns rather than exploded into child tables. Both are always read/written as a whole object via existing `toJson()`/`fromJson()` methods, never queried piecemeal by any manager or by the coach's SQL tool. Normalizing them would add several more tables for no query benefit — YAGNI. + +--- + +## 5. `SqliteStorageService` + +New file: `lib/services/sqlite_storage_service.dart`, `class SqliteStorageService implements IStorageService`. + +- `init()`: opens the database (`openDatabase`), runs `onCreate` (schema above) on first creation. +- Every `IStorageService` method gets a real implementation: entity writes that touch multiple tables (e.g. `saveWorkoutSession` → `sessions` + `exercise_logs` + `sets`) run inside a single `db.transaction()` — delete-then-reinsert child rows for the given parent id, so updates and inserts share one code path. +- `exportAllData()` / `importData()` keep their existing JSON contract (used by the migration below and by the user-facing export/import feature) — implemented by reading/writing through the same model `toJson()`/`fromJson()` methods already used elsewhere. + +No changes to `IStorageService`'s method signatures. + +--- + +## 6. Migration & Cutover + +**Goal:** existing installs upgrade from Hive to SQLite exactly once, safely, with no possibility of a half-migrated state. + +1. On app start, `AppInitializer` (in `main.dart`) checks `settings['storage_migrated_v1']` **in the existing Hive settings box** (the migration hasn't happened yet at this point, so Hive is still authoritative for this check). +2. If unset: instantiate both the existing `StorageService` (Hive) and a fresh `SqliteStorageService`. For every entity type, read via the existing, already-correct Hive read methods (`getAllWorkoutSessions()`, `getAllRoutines()`, `getAllTargets()`, `getAllMuscleGroups()`, `getCustomExercises()`, `getAllTrainingPrograms()`, `getAllPersonalRecords()`, `getAllConversations()`, plus raw settings keys) and write each into `SqliteStorageService` through its normal write methods. This trusts only the new write path — reads reuse logic that already works. +3. Only if every entity type migrates without throwing: write `storage_migrated_v1 = true` into the Hive settings box. +4. From that point on (this launch and all future launches), `AppInitializer` hands `WorkoutProvider` a `SqliteStorageService` instead of `StorageService`. +5. If migration throws partway through anything, the flag is never set. The app falls back to `StorageService` (Hive) for that launch, and retries the full migration on the next app start. There is no partial-migration state a user can get stuck in. +6. **Hive boxes are never deleted.** They remain on disk indefinitely as a passive backup — the data volume for a personal fitness log is small, so the disk cost is negligible next to the safety value. + +This keeps the app in exactly one of two well-defined states at all times: fully on Hive, or fully on SQLite. + +--- + +## 7. Coach SQL Tool: `run_sql_query` + +Added to `CoachToolService.buildTools()` / `handleCall()`, alongside (not replacing) the existing curated tools. + +- **Connection:** a dedicated **read-only** `sqflite` connection (`openReadOnlyDatabase`) to the same database file used by `SqliteStorageService`. This is the real safety boundary — the OS/SQLite layer itself refuses writes on this connection, regardless of what SQL text is submitted. +- **Text validation (defense-in-depth, not the primary guard):** trim the query, strip a single trailing `;`, reject if a second `;` remains (multi-statement), reject case-insensitively if it doesn't start with `SELECT` or `WITH`, reject if it contains `insert|update|delete|drop|alter|create|attach|detach|pragma|vacuum|replace|trigger` as a keyword. +- **Row cap:** wrap the model's query as `SELECT * FROM () LIMIT ?` with a default of 200, model-adjustable up to 500 — never trusts a `LIMIT` the model wrote itself. +- **Error handling:** any exception (syntax error, cap violation, etc.) returns `{'error': message}`, matching every other tool's contract — a bad query is a recoverable turn, not a crash. +- **Function description** embeds the full schema (table + column names, one line each) so the model always has it in context without a separate schema-discovery round trip. + +--- + +## 8. Testing + +- **`SqliteStorageService`**: new test file, run against an in-memory database via `sqflite_common_ffi` (`databaseFactory = databaseFactoryFfi`, `inMemoryDatabasePath`). Covers every `IStorageService` method, mirroring the existing `MockStorageService`-based test patterns for shape. +- **Migration**: seed a `StorageService` (Hive, using the existing test Hive setup) with representative data across every entity type, run the migration routine against a fresh in-memory `SqliteStorageService`, assert the data matches, assert the flag is set, assert re-running the migration is a no-op (skips already-migrated). +- **Existing test suite** (managers, `WorkoutProvider`, screens): unaffected — all depend on `IStorageService`/`MockStorageService`, never the concrete backend. +- **`run_sql_query`**: valid `SELECT` → correct JSON rows; non-`SELECT` → rejected with error; multi-statement → rejected; row cap enforced; schema-referencing query (e.g. a join across `sessions`/`exercise_logs`/`sets`) returns expected shape. + +--- + +## 9. Rollout Notes + +- `pubspec.yaml` additions: `sqflite` (runtime), `sqflite_common_ffi` (dev, for tests). +- `hive`/`hive_flutter` dependencies and `StorageService` (Hive) are **kept**, not removed — they remain the migration source and the pre-migration fallback path indefinitely (or until a future spec decides it's safe to drop them, informed by real-world migration success rates). +- No changes to `CLAUDE.md`'s documented Hive box list are needed for this spec beyond noting the SQLite migration exists; a follow-up doc update once this ships is reasonable but out of scope here. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 72e44bb..97cb3aa 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/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/fdroid/metadata/com.devasy.repforge.yml b/fdroid/metadata/com.devasy.repforge.yml index 3925e15..db8ae7f 100644 --- a/fdroid/metadata/com.devasy.repforge.yml +++ b/fdroid/metadata/com.devasy.repforge.yml @@ -30,10 +30,12 @@ Builds: - git -C $$flutter$$ checkout -f $FLUTTER_VERSION - $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter pub get --enforce-lockfile + - sed -i -e 's/-Wl,/-Wl,--build-id=none,/' $PUB_CACHE/hosted/pub.dev/jni-*/src/CMakeLists.txt scandelete: - workout-logger/.pub-cache build: - export PUB_CACHE=$(pwd)/.pub-cache + - export LDFLAGS="-Wl,--build-id=none" - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform="android-arm" - versionName: 2.0.6 @@ -50,10 +52,12 @@ Builds: - git -C $$flutter$$ checkout -f $FLUTTER_VERSION - $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter pub get --enforce-lockfile + - sed -i -e 's/-Wl,/-Wl,--build-id=none,/' $PUB_CACHE/hosted/pub.dev/jni-*/src/CMakeLists.txt scandelete: - workout-logger/.pub-cache build: - export PUB_CACHE=$(pwd)/.pub-cache + - export LDFLAGS="-Wl,--build-id=none" - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64" - versionName: 2.0.6 @@ -70,10 +74,12 @@ Builds: - git -C $$flutter$$ checkout -f $FLUTTER_VERSION - $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter pub get --enforce-lockfile + - sed -i -e 's/-Wl,/-Wl,--build-id=none,/' $PUB_CACHE/hosted/pub.dev/jni-*/src/CMakeLists.txt scandelete: - workout-logger/.pub-cache build: - export PUB_CACHE=$(pwd)/.pub-cache + - export LDFLAGS="-Wl,--build-id=none" - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform="android-x64" AutoUpdateMode: Version diff --git a/scripts/patch_so.py b/scripts/patch_so.py deleted file mode 100644 index 08638e7..0000000 --- a/scripts/patch_so.py +++ /dev/null @@ -1,191 +0,0 @@ -import sys -import zipfile -import tempfile -import os -import shutil - -def get_elf_build_id_info(data): - if not data.startswith(b"\x7fELF"): - return None - - # Parse 32-bit vs 64-bit ELF - elf_class = data[4] - is_32 = elf_class == 1 - - if is_32: - shoff = int.from_bytes(data[32:36], 'little') - shentsize = int.from_bytes(data[46:48], 'little') - shnum = int.from_bytes(data[48:50], 'little') - shstrndx = int.from_bytes(data[50:52], 'little') - else: - shoff = int.from_bytes(data[40:48], 'little') - shentsize = int.from_bytes(data[58:60], 'little') - shnum = int.from_bytes(data[60:62], 'little') - shstrndx = int.from_bytes(data[62:64], 'little') - - str_sec_offset = shoff + shstrndx * shentsize - if is_32: - str_offset = int.from_bytes(data[str_sec_offset+16:str_sec_offset+20], 'little') - else: - str_offset = int.from_bytes(data[str_sec_offset+24:str_sec_offset+32], 'little') - - for i in range(shnum): - sec_offset = shoff + i * shentsize - name_offset = int.from_bytes(data[sec_offset:sec_offset+4], 'little') - - if is_32: - offset = int.from_bytes(data[sec_offset+16:sec_offset+20], 'little') - size = int.from_bytes(data[sec_offset+20:sec_offset+24], 'little') - else: - offset = int.from_bytes(data[sec_offset+24:sec_offset+32], 'little') - size = int.from_bytes(data[sec_offset+32:sec_offset+40], 'little') - - # Read name - idx = str_offset + name_offset - name = b'' - while idx < len(data) and data[idx] != 0: - name += bytes([data[idx]]) - idx += 1 - name = name.decode('utf-8', errors='ignore') - - if name == ".note.gnu.build-id": - # Search for the actual build-id descriptor inside the section - # Format: [namesz (4 bytes)][descsz (4 bytes)][type (4 bytes)][name][desc] - sec_data = data[offset : offset + size] - if len(sec_data) >= 16: - namesz = int.from_bytes(sec_data[0:4], 'little') - descsz = int.from_bytes(sec_data[4:8], 'little') - type_id = int.from_bytes(sec_data[8:12], 'little') - if type_id == 3: # NT_GNU_BUILD_ID - # Align to 4 bytes for name - name_aligned_sz = (namesz + 3) & ~3 - build_id_offset = offset + 12 + name_aligned_sz - return { - 'offset': build_id_offset, - 'size': descsz, - 'value': data[build_id_offset : build_id_offset + descsz] - } - return None - -def patch_so_data(built_so_data, ref_so_data): - if len(built_so_data) != len(ref_so_data): - print(f"[-] Sizes differ: Built={len(built_so_data)}, Ref={len(ref_so_data)}") - return None - - built_info = get_elf_build_id_info(built_so_data) - ref_info = get_elf_build_id_info(ref_so_data) - - if not built_info or not ref_info: - print("[-] Build-ID section not found in one of the SO files") - return None - - if built_info['size'] != ref_info['size']: - print(f"[-] Build-ID size mismatch: Built={built_info['size']}, Ref={ref_info['size']}") - return None - - # Replace the build-id bytes in the built SO with the reference ones - so_mutable = bytearray(built_so_data) - start = built_info['offset'] - end = start + built_info['size'] - so_mutable[start:end] = ref_info['value'] - patched_data = bytes(so_mutable) - - # Check if they are now 100% identical - if patched_data == ref_so_data: - print("[+] Patched SO matches reference SO exactly!") - return patched_data - else: - # Check if there are other differences - diffs = [i for i in range(len(patched_data)) if patched_data[i] != ref_so_data[i]] - print(f"[-] Patched SO still differs from reference at {len(diffs)} positions.") - return None - -import zlib - -def find_cd_header_offset(data, filename): - fname_bytes = filename.encode('utf-8') - idx = 0 - while True: - idx = data.find(b"\x50\x4b\x01\x02", idx) - if idx == -1: - break - fn_len = int.from_bytes(data[idx+28:idx+30], 'little') - if fn_len == len(fname_bytes): - if data[idx+46 : idx+46+fn_len] == fname_bytes: - return idx - idx += 4 - return -1 - -def patch_apk(built_apk, ref_apk, output_apk): - try: - if os.path.abspath(built_apk) != os.path.abspath(output_apk): - shutil.copy2(built_apk, output_apk) - - with open(output_apk, "rb") as f: - apk_data = bytearray(f.read()) - - with zipfile.ZipFile(ref_apk, 'r') as z_ref: - ref_so_entries = {name: z_ref.read(name) for name in z_ref.namelist() if name.endswith(".so")} - - with zipfile.ZipFile(output_apk, 'r') as z_built: - built_so_entries = {} - built_so_info = {} - for info in z_built.infolist(): - if info.filename.endswith(".so"): - built_so_entries[info.filename] = z_built.read(info) - built_so_info[info.filename] = info - - patched_count = 0 - for name, built_data in built_so_entries.items(): - if name in ref_so_entries: - print(f"[+] Found shared library in both: {name}") - ref_data = ref_so_entries[name] - patched_so = patch_so_data(built_data, ref_data) - if patched_so: - info = built_so_info[name] - if info.compress_type != 0: - print(f"[-] Shared library {name} is compressed. In-place patching is not supported.") - return False - - new_crc = zlib.crc32(patched_so) & 0xffffffff - local_header_offset = info.header_offset - local_extra_len = int.from_bytes(apk_data[local_header_offset+28 : local_header_offset+30], 'little') - filename_len = len(name.encode('utf-8')) - - data_offset = local_header_offset + 30 + filename_len + local_extra_len - print(f"[+] Writing patched SO to APK data offset: {hex(data_offset)}") - apk_data[data_offset : data_offset + len(patched_so)] = patched_so - - print(f"[+] Updating Local Header CRC-32 to: {hex(new_crc)}") - apk_data[local_header_offset+14 : local_header_offset+18] = new_crc.to_bytes(4, 'little') - - cd_offset = find_cd_header_offset(apk_data, name) - if cd_offset == -1: - print(f"[-] Could not find Central Directory Header for {name}") - return False - - print(f"[+] Updating Central Directory CRC-32 to: {hex(new_crc)}") - apk_data[cd_offset+16 : cd_offset+20] = new_crc.to_bytes(4, 'little') - patched_count += 1 - - if patched_count == 0: - print("[-] No patchable SO files found or patching failed.") - return False - - with open(output_apk, "wb") as f: - f.write(apk_data) - - print(f"[+] Successfully patched APK in-place: {output_apk}") - return True - except Exception as e: - print(f"[-] Exception occurred during patching: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - if len(sys.argv) < 4: - print("Usage: python patch_so.py ") - sys.exit(1) - success = patch_apk(sys.argv[1], sys.argv[2], sys.argv[3]) - sys.exit(0 if success else 1) diff --git a/workout-logger/.gitignore b/workout-logger/.gitignore index 3820a95..905f107 100644 --- a/workout-logger/.gitignore +++ b/workout-logger/.gitignore @@ -43,3 +43,19 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Signing Keystore & Credentials +**/android/key.properties +*.jks +*.keystore +*.p12 +.env +.env.* + +# Hive test databases and temporary directories +*.hive +tmp_hive_*/ +**/tmp_hive_*/ + + + diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index 32e478b..d68c9d8 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -1,25 +1,28 @@ import com.android.build.gradle.internal.api.ApkVariantOutputImpl +import java.io.FileInputStream +import java.util.Properties plugins { id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + // kotlin-android is injected automatically by Flutter's built-in Kotlin support. + // (android.builtInKotlin=true in gradle.properties) id("dev.flutter.flutter-gradle-plugin") } android { namespace = "com.devasy.repforge" - compileSdk = 36 - compileSdkExtension = 19 + compileSdk = 37 ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } } // Strip AGP's "Dependency metadata" signing block from the APK. It embeds a @@ -33,11 +36,16 @@ android { signingConfigs { create("release") { - val keystorePath = System.getenv("KEYSTORE_PATH") - val storePass = System.getenv("KEY_STORE_PASSWORD") - val alias = System.getenv("KEY_ALIAS") - val keyPass = System.getenv("KEY_PASSWORD") - if (keystorePath != null && storePass != null && alias != null && keyPass != null) { + val keyProperties = Properties() + val keyPropertiesFile = rootProject.file("key.properties") + if (keyPropertiesFile.exists()) { + keyProperties.load(FileInputStream(keyPropertiesFile)) + } + val keystorePath = System.getenv("KEYSTORE_PATH") ?: keyProperties.getProperty("storeFile") + val storePass = System.getenv("KEY_STORE_PASSWORD") ?: keyProperties.getProperty("storePassword") + val alias = System.getenv("KEY_ALIAS") ?: keyProperties.getProperty("keyAlias") + val keyPass = System.getenv("KEY_PASSWORD") ?: keyProperties.getProperty("keyPassword") + if (!keystorePath.isNullOrEmpty() && !storePass.isNullOrEmpty() && !alias.isNullOrEmpty() && !keyPass.isNullOrEmpty()) { storeFile = file(keystorePath) storePassword = storePass keyAlias = alias @@ -53,7 +61,7 @@ android { // supported. If downgrading, remove the health_connector dependency and // all HealthConnectService usages, then restore minSdk to flutter.minSdkVersion. minSdk = 26 - targetSdk = 36 + targetSdk = 37 versionCode = flutter.versionCode versionName = flutter.versionName // App display name; overridden per build type below so debug installs @@ -71,6 +79,12 @@ android { manifestPlaceholders["appLabel"] = "RepForge (Debug)" } release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) // Uses the production EC P-256 keystore when KEYSTORE_PATH env var is set // (CI injects it via GitHub Secrets). Falls back to the debug key for a // local `flutter run --release` without env vars configured. diff --git a/workout-logger/android/app/proguard-rules.pro b/workout-logger/android/app/proguard-rules.pro new file mode 100644 index 0000000..3dde057 --- /dev/null +++ b/workout-logger/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Suppress missing class warnings for Play Core deferred components in Flutter engine +-dontwarn com.google.android.play.core.** + +# Flutter Wrapper Rules +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.embedding.** { *; } +-keep class io.flutter.provider.** { *; } +-keep class io.flutter.plugin.editing.** { *; } + +# Keep Native plugins and Health Connect interfaces +-dontwarn com.google.android.gms.** diff --git a/workout-logger/android/gradle.properties b/workout-logger/android/gradle.properties index f018a61..aae5292 100644 --- a/workout-logger/android/gradle.properties +++ b/workout-logger/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=true +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=true diff --git a/workout-logger/android/gradle/wrapper/gradle-wrapper.properties b/workout-logger/android/gradle/wrapper/gradle-wrapper.properties index ac3b479..f587a47 100644 --- a/workout-logger/android/gradle/wrapper/gradle-wrapper.properties +++ b/workout-logger/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip diff --git a/workout-logger/android/key.properties.example b/workout-logger/android/key.properties.example new file mode 100644 index 0000000..24133ee --- /dev/null +++ b/workout-logger/android/key.properties.example @@ -0,0 +1,8 @@ +# Local Android Release Keystore Configuration +# Fill in your local keystore path and passwords below. +# Note: This file should NEVER be committed to Git. + +storeFile=C:/path/to/your/upload-keystore.jks +storePassword=your_store_password +keyAlias=your_key_alias +keyPassword=your_key_password diff --git a/workout-logger/android/settings.gradle.kts b/workout-logger/android/settings.gradle.kts index fb605bc..ca7fe06 100644 --- a/workout-logger/android/settings.gradle.kts +++ b/workout-logger/android/settings.gradle.kts @@ -19,8 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.9.1" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/workout-logger/lib/data/exercise_database.dart b/workout-logger/lib/data/exercise_database.dart index 117b099..31b04e8 100644 --- a/workout-logger/lib/data/exercise_database.dart +++ b/workout-logger/lib/data/exercise_database.dart @@ -116,6 +116,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.chest, activationPercentage: 85), ], category: 'isolation', + availableHandles: ['D-Handles', 'Single Arm'], ), Exercise( id: 'pec_deck', @@ -156,6 +157,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.rearDelts, activationPercentage: 15), ], category: 'compound', + availableHandles: ['Wide Bar', 'Close Grip V-Bar', 'Neutral Handles'], ), Exercise( id: 'pull_ups', @@ -206,6 +208,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.biceps, activationPercentage: 25), ], category: 'compound', + availableHandles: ['V-Bar', 'Straight Bar', 'D-Handles'], ), Exercise( id: 't_bar_row', @@ -238,6 +241,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.back, activationPercentage: 20), ], category: 'isolation', + availableHandles: ['Rope', 'V-Bar'], ), // ==================== SHOULDERS ==================== @@ -378,6 +382,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.biceps, activationPercentage: 95), ], category: 'isolation', + availableHandles: ['Barbell', 'Dumbbell', 'EZ-Bar', 'Cable Rope'], ), Exercise( id: 'hammer_curl', @@ -411,6 +416,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.triceps, activationPercentage: 90), ], category: 'isolation', + availableHandles: ['Rope', 'Bar', 'V-Bar'], ), Exercise( id: 'skull_crushers', @@ -427,6 +433,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.triceps, activationPercentage: 90), ], category: 'isolation', + availableHandles: ['Rope', 'Bar', 'Dumbbell'], ), Exercise( id: 'close_grip_bench', @@ -478,6 +485,7 @@ class ExerciseDatabase { MuscleActivation(muscleGroupId: MuscleGroups.core, activationPercentage: 90), ], category: 'isolation', + availableHandles: ['Rope', 'Bar'], ), ]; diff --git a/workout-logger/lib/genui/a2ui.dart b/workout-logger/lib/genui/a2ui.dart new file mode 100644 index 0000000..c8b4873 --- /dev/null +++ b/workout-logger/lib/genui/a2ui.dart @@ -0,0 +1,18 @@ +/// A2UI — a domain-free, model-driven UI layer. +/// +/// Parse untrusted LLM JSON with [A2UiParser], render the resulting +/// [A2UiNode] with [A2UiRenderer], and generate the model's instructions from +/// the same registry with `buildA2UiPromptSection`, so the vocabulary the model +/// is told about and the vocabulary the app can render never diverge. +library; + +export 'src/a2ui_node.dart'; +export 'src/a2ui_parser.dart'; +export 'src/a2ui_prompt.dart'; +export 'src/a2ui_props.dart'; +export 'src/a2ui_registry.dart'; +export 'src/a2ui_renderer.dart'; +export 'src/a2ui_series.dart'; +export 'src/a2ui_spec.dart'; +export 'src/a2ui_theme.dart'; +export 'src/default_registry.dart'; diff --git a/workout-logger/lib/genui/src/a2ui_node.dart b/workout-logger/lib/genui/src/a2ui_node.dart new file mode 100644 index 0000000..ed6e708 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_node.dart @@ -0,0 +1,26 @@ +import 'a2ui_props.dart'; + +/// A single parsed node in an A2UI tree. +/// +/// [name] is always canonical (as produced by `A2UiRegistry.canonicalName`), so +/// downstream code never re-normalizes. [children] is populated by the parser +/// for any node that carried a `children` array, which keeps container-ness out +/// of individual specs. +/// +/// [children] is not defensively copied (this is a `const`-constructible +/// value type). Callers must not retain a mutable reference to the list they +/// pass in and mutate it afterward. +class A2UiNode { + const A2UiNode({ + required this.name, + required this.props, + this.children = const [], + }); + + final String name; + final A2UiProps props; + final List children; + + @override + String toString() => 'A2UiNode($name, ${children.length} children)'; +} diff --git a/workout-logger/lib/genui/src/a2ui_panels.dart b/workout-logger/lib/genui/src/a2ui_panels.dart new file mode 100644 index 0000000..1b6ba64 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_panels.dart @@ -0,0 +1,145 @@ +import 'package:flutter/widgets.dart'; + +import 'a2ui_theme.dart'; + +/// The card chrome every A2UI component sits inside. +class A2UiPanel extends StatelessWidget { + const A2UiPanel({ + super.key, + required this.child, + required this.theme, + this.padded = true, + }); + + final Widget child; + final A2UiTheme theme; + final bool padded; + + @override + Widget build(BuildContext context) => Container( + padding: padded ? EdgeInsets.all(theme.spacing) : EdgeInsets.zero, + decoration: BoxDecoration( + color: theme.surface, + borderRadius: BorderRadius.circular(theme.radius), + border: Border.all(color: theme.border), + ), + child: child, + ); +} + +/// A panel heading with optional right-aligned trailing text. +class A2UiPanelTitle extends StatelessWidget { + const A2UiPanelTitle({ + super.key, + required this.title, + required this.theme, + this.trailing, + }); + + final String title; + final String? trailing; + final A2UiTheme theme; + + @override + Widget build(BuildContext context) { + final label = trailing; + return Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + if (label != null && label.isNotEmpty) + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: theme.textFaint, fontSize: 11), + ), + ), + ], + ); + } +} + +/// Shown in place of a chart when a component parsed but carries no data. +/// +/// Deliberately visible rather than a blank `SizedBox`: a silent disappearance +/// hides model errors, a labelled panel surfaces them. +class A2UiEmptyPanel extends StatelessWidget { + const A2UiEmptyPanel({ + super.key, + required this.message, + required this.theme, + }); + + final String message; + final A2UiTheme theme; + + @override + Widget build(BuildContext context) => A2UiPanel( + theme: theme, + child: Center( + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: theme.textMuted, fontSize: 12), + ), + ), + ); +} + +/// Series legend shared by the line, bar and radar renderers. +class A2UiLegend extends StatelessWidget { + const A2UiLegend({ + super.key, + required this.names, + required this.theme, + this.dots = false, + }); + + final List names; + final A2UiTheme theme; + final bool dots; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 12, + runSpacing: 4, + children: [ + for (var i = 0; i < names.length; i++) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: dots ? 8 : 10, + height: dots ? 8 : 3, + decoration: BoxDecoration( + color: theme.seriesColor(i), + shape: dots ? BoxShape.circle : BoxShape.rectangle, + borderRadius: dots ? null : BorderRadius.circular(2), + ), + ), + const SizedBox(width: 4), + Text( + names[i], + style: TextStyle( + color: theme.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ); +} diff --git a/workout-logger/lib/genui/src/a2ui_parser.dart b/workout-logger/lib/genui/src/a2ui_parser.dart new file mode 100644 index 0000000..7d5ee66 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_parser.dart @@ -0,0 +1,295 @@ +import 'dart:convert'; + +import 'a2ui_node.dart'; +import 'a2ui_props.dart'; +import 'a2ui_registry.dart'; + +/// Turns model output into an [A2UiNode] tree, or null when the text is prose. +/// +/// This is the single gate that decides whether a reply is a UI payload. Once a +/// tree exists, every spec's `parseProps` is guaranteed to succeed, so no +/// component-level validation is needed or wanted. +class A2UiParser { + const A2UiParser(this.registry); + + final A2UiRegistry registry; + + /// Canonical name used when auto-wrapping a bare list of components. + static const String _containerName = 'GridContainer'; + + /// Keys that may hold an envelope of components at the top level. + static const List _envelopeKeys = [ + 'components', + 'children', + 'ui', + 'elements', + ]; + + /// Keys that may hold a node's structural child components. + /// + /// This mirrors `_envelopeKeys` minus `ui` (which only makes sense as a + /// whole-document envelope, not a per-node prop): `components`/`elements`/ + /// `content` are accepted tolerantly alongside the canonical `children`, + /// but `items` is deliberately excluded — see `_firstChildList` for why. + static const List _childKeys = [ + 'children', + 'components', + 'elements', + 'content', + ]; + + A2UiNode? parse(String text) { + final json = _extractJson(text); + if (json == null) return null; + // A bare top-level array is ambiguous when it holds exactly one item — + // it may be an intentional list or just a single component that happens + // to be array-wrapped, so a single item collapses to itself rather than + // being wrapped in a container. + if (json is List) return _wrap(json, collapseSingle: true); + if (json is Map) return parseJson(A2UiProps.stringKeyed(json)); + return null; + } + + A2UiNode? parseJson(Map json) { + final props = A2UiProps(json); + + final rawName = props.textOrNull('component'); + final spec = rawName == null ? null : registry.specFor(rawName); + + if (spec == null) { + // No component key — try each envelope shape before giving up. An + // envelope key is an explicit "this is a container of components" + // signal from the model, so even a single-item envelope still + // produces a GridContainer rather than collapsing to the bare child. + for (final key in _envelopeKeys) { + final candidate = json[key]; + if (candidate is List) { + final wrapped = _wrap( + candidate, + columns: props.integer('columns', or: 1), + collapseSingle: false, + ); + if (wrapped != null) return wrapped; + } + } + return null; + } + + // Accept both `{component, props:{...}}` and the flat `{component, ...}`. + final rawProps = json['props']; + final Map effective; + if (rawProps is Map) { + final merged = A2UiProps.stringKeyed(rawProps); + // A model may write a node's children as a sibling of `props` rather + // than nested inside it, e.g. `{component, props:{...}, children:[...]}`. + // Fold any such outer child-key into `effective` when `props` doesn't + // already define it — `props` always wins on a genuine conflict. + for (final key in _childKeys) { + if (!merged.containsKey(key) && json.containsKey(key)) { + merged[key] = json[key]; + } + } + effective = merged; + } else { + effective = Map.from(json)..remove('component'); + } + + final children = _parseChildren(A2UiProps(effective)); + + // A container that lost every child carries no information — treat the + // whole payload as unusable so the caller falls back to Markdown. + if (children.isEmpty && _declaresChildren(effective)) return null; + + return A2UiNode( + name: spec.name, + props: A2UiProps(effective), + children: children, + ); + } + + /// True when the text is on its way to being a JSON payload, so a streaming + /// UI can show a "building" indicator instead of raw JSON. + bool looksLikeUi(String partialText) { + final t = stripFences(partialText).trimLeft(); + if (t.isNotEmpty && (t.startsWith('{') || t.startsWith('['))) return true; + + // `stripFences` only strips a *leading* fence, so a model that writes a + // sentence before opening a fenced block (e.g. "Here's your data:\n```json\n{...") + // falls through to here. Cheaply check for a ``` fence opened anywhere + // in the streamed-so-far text that hasn't been closed yet — that's a + // strong signal a payload is arriving inside it, without re-scanning or + // parsing the whole string on every frame. + final openFence = partialText.indexOf('```'); + if (openFence == -1) return false; + final closeFence = partialText.indexOf('```', openFence + 3); + return closeFence == -1; + } + + /// Removes a leading ``` fence (with or without a language tag) and a + /// trailing ``` fence, tolerating an unterminated fence mid-stream. + static String stripFences(String text) { + var t = text.trim(); + if (!t.startsWith('```')) return t; + final firstLineEnd = t.indexOf('\n'); + t = firstLineEnd == -1 ? '' : t.substring(firstLineEnd + 1); + if (t.endsWith('```')) t = t.substring(0, t.length - 3); + return t.trim(); + } + + // Structural children are a tree-shape signal, not a semantic content + // value like `title` — so unlike other props they must NOT go through + // A2UiProps.lookup's alias resolution. `keyAliases['children']` includes + // `items` as a convenience alias, but `items` is also DataListGroup's own + // canonical key for its (non-component) data rows; resolving it there + // would make the parser mistake a DataListGroup's `items` list for child + // nodes, fail to parse any of them as components, and then discard the + // whole node as if it had declared-but-empty children. `_childKeys` checks + // a fixed, literal set of keys instead — the same tolerant spelling + // `_envelopeKeys` already accepts at the top level (`components`/ + // `elements`/`content` alongside `children`), while still deliberately + // excluding `items`, which is the one key that actually collides. + List _parseChildren(A2UiProps props) { + final raw = _firstChildList(props.raw); + if (raw is! List) return const []; + final out = []; + for (final child in raw) { + if (child is! Map) continue; + final node = parseJson(A2UiProps.stringKeyed(child)); + if (node != null) out.add(node); + } + return out; + } + + bool _declaresChildren(Map props) => + _firstChildList(props) is List; + + /// Returns the value of the first key in `_childKeys` present in [props], + /// or null if none of them are — a literal, non-alias-resolved lookup. + Object? _firstChildList(Map props) { + for (final key in _childKeys) { + final value = props[key]; + if (value != null) return value; + } + return null; + } + + /// Wraps [items] in a `GridContainer`, dropping any item that isn't a + /// recognised component. When [collapseSingle] is true, a single + /// surviving child is returned bare instead of wrapped — used for the + /// bare top-level array case, where a one-item array is ambiguous + /// between "a list with one component" and "just a component". Envelope + /// keys (`components`, `children`, `ui`, `elements`) pass + /// `collapseSingle: false` because naming an envelope key is an explicit + /// request for a container, even with one child. + A2UiNode? _wrap( + List items, { + int columns = 1, + required bool collapseSingle, + }) { + final children = []; + for (final item in items) { + if (item is! Map) continue; + final node = parseJson(A2UiProps.stringKeyed(item)); + if (node != null) children.add(node); + } + if (children.isEmpty) return null; + if (collapseSingle && children.length == 1) return children.single; + return A2UiNode( + name: _containerName, + props: A2UiProps({'columns': columns}), + children: children, + ); + } + + /// Pulls a JSON object or array out of [text], tolerating fences and + /// surrounding prose. Returns null when nothing decodes. + /// + /// Rather than slicing from the first `{`/`[` to the last `}`/`]` in the + /// whole text (which breaks the moment prose contains any stray brace, + /// e.g. "add reps {optional}"), this scans every position that could + /// start a JSON value, walks forward with a bracket-depth counter that + /// tracks whether it's inside a string literal (so quoted brackets don't + /// affect balance and `\"` doesn't end a string early), and attempts + /// `jsonDecode` on each balanced span found. Among all spans that decode + /// successfully to a Map or List, the longest one wins: the actual + /// payload is normally the largest well-formed JSON structure in the + /// text, while incidental prose braces either fail to decode (not valid + /// JSON) or are short. + static Object? _extractJson(String text) { + final t = stripFences(text); + if (t.isEmpty) return null; + + // Fast path: the common case is a reply that's nothing but JSON, with no + // surrounding prose. Trying the whole trimmed text first avoids the + // per-position balanced-span scan below for that case; it changes no + // behaviour, since a fully-decodable whole string is always the longest + // possible candidate the scan could have found anyway. + try { + final whole = jsonDecode(t); + if (whole is Map || whole is List) return whole; + } catch (_) { + // Not decodable as-is — fall through to the scan for prose-wrapped JSON. + } + + String? bestCandidate; + Object? bestValue; + + for (var i = 0; i < t.length; i++) { + final ch = t[i]; + if (ch != '{' && ch != '[') continue; + final end = _findBalancedEnd(t, i); + if (end == -1) continue; + + final candidate = t.substring(i, end + 1); + Object? decoded; + try { + decoded = jsonDecode(candidate); + } catch (_) { + continue; + } + if (decoded is! Map && decoded is! List) continue; + + if (bestCandidate == null || candidate.length > bestCandidate.length) { + bestCandidate = candidate; + bestValue = decoded; + } + } + + return bestValue; + } + + /// Returns the index of the character that closes the bracket opened at + /// [start] (a `{` or `[`), or -1 if the text ends before it balances. + /// Characters inside a `"..."` string literal never affect the depth + /// count, and a `\` inside a string escapes the next character so `\"` + /// doesn't end the string early. + static int _findBalancedEnd(String t, int start) { + var depth = 0; + var inString = false; + var escaped = false; + for (var i = start; i < t.length; i++) { + final ch = t[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + inString = false; + } + continue; + } + if (ch == '"') { + inString = true; + continue; + } + if (ch == '{' || ch == '[') { + depth++; + } else if (ch == '}' || ch == ']') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } +} diff --git a/workout-logger/lib/genui/src/a2ui_prompt.dart b/workout-logger/lib/genui/src/a2ui_prompt.dart new file mode 100644 index 0000000..fe9162f --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_prompt.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'a2ui_registry.dart'; + +/// Builds the A2UI instruction block for an LLM system prompt. +/// +/// Generated from [registry] rather than hand-written, so a schema change in a +/// spec reaches the model automatically and the vocabulary advertised can never +/// exceed the vocabulary the renderer supports. +/// +/// Output is deterministic for a given registry so it can sit inside a cached +/// prompt prefix. +String buildA2UiPromptSection(A2UiRegistry registry, {String? envelopeNote}) { + final buf = StringBuffer() + ..writeln( + 'To answer with a visual dashboard instead of prose, return ONE JSON ' + 'object and nothing else — no Markdown fence, no commentary before or ' + 'after. Wrap multiple components in a GridContainer.', + ) + ..writeln() + ..writeln('Envelope: {"component": "", "props": { ... }}') + ..writeln(); + + if (envelopeNote != null && envelopeNote.isNotEmpty) { + buf + ..writeln(envelopeNote) + ..writeln(); + } + + buf.writeln('AVAILABLE COMPONENTS — use these names and props only:'); + for (final spec in registry.specs) { + buf + ..writeln(' ${spec.doc.schema}') + ..writeln(' ${spec.doc.purpose}'); + } + + buf + ..writeln() + ..writeln('WORKED EXAMPLE:') + ..writeln(_example(registry)) + ..writeln() + ..writeln( + 'TOLERANCES — you do not need to be perfect: a number may be sent as a ' + 'number or a numeric string, prop names are matched ignoring case and ' + 'underscores, unknown props are ignored, and any prop marked ? may be ' + 'omitted. Prefer real numbers and the exact names above.', + ) + ..writeln( + 'Never invent a component name that is not listed. If you have no data ' + 'to show, reply in prose instead of returning an empty dashboard.', + ); + + return buf.toString(); +} + +/// A GridContainer wrapping the first two non-container examples, pretty-printed +/// so the model sees the nesting clearly. +String _example(A2UiRegistry registry) { + final children = [ + for (final spec in registry.specs) + if (spec.name != 'GridContainer') spec.doc.example, + ].take(2).toList(); + + return const JsonEncoder.withIndent(' ').convert({ + 'component': 'GridContainer', + 'props': {'columns': 2, 'children': children}, + }); +} diff --git a/workout-logger/lib/genui/src/a2ui_props.dart b/workout-logger/lib/genui/src/a2ui_props.dart new file mode 100644 index 0000000..ba5087c --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_props.dart @@ -0,0 +1,143 @@ +/// A never-throwing, alias-aware, coercing view over a component's raw props. +/// +/// Model-generated JSON is unreliable: keys arrive in the wrong case, numbers +/// arrive as strings, optional keys go missing. Every accessor here degrades to +/// a documented fallback instead of throwing, so renderer widgets can be +/// written against typed data with no defensive casting. +class A2UiProps { + const A2UiProps(this.raw); + + final Map raw; + + static const A2UiProps empty = A2UiProps({}); + + /// Semantic aliases, keyed by the canonical name a component asks for. + /// + /// Resolution is per-requested-key, so the same alias may appear under more + /// than one canonical key (`data` means `values` to a chart and `items` to a + /// list) without ambiguity — each component only asks for keys it owns. + static const Map> keyAliases = { + 'title': ['name', 'label', 'heading', 'header'], + 'subtitle': ['caption', 'description', 'sub', 'summary'], + 'value': ['val', 'amount', 'number', 'metric', 'score'], + 'unit': ['units', 'suffix'], + 'labels': ['axes', 'categories', 'xLabels', 'xAxis', 'x'], + 'values': ['data', 'ys', 'y', 'points'], + 'series': ['datasets', 'lines', 'groups'], + 'items': ['rows', 'entries', 'records', 'data'], + 'children': ['components', 'elements', 'content', 'items'], + 'points': ['data', 'coordinates', 'coords', 'pairs'], + 'options': ['chips', 'choices', 'tags', 'filters'], + 'activeOption': ['active', 'selected', 'selectedOption', 'current'], + 'type': ['chartType', 'kind', 'variant'], + 'trend': ['direction', 'change'], + 'status': ['state', 'badge'], + 'columns': ['cols', 'columnCount'], + 'xLabel': ['xTitle', 'xAxisLabel'], + 'yLabel': ['yTitle', 'yAxisLabel'], + 'primaryText': ['primary', 'title', 'name', 'left'], + 'secondaryText': ['secondary', 'subtitle', 'detail', 'description'], + 'trailingValue': ['trailing', 'value', 'right', 'amount'], + 'correlation': ['r', 'pearson', 'pearsonR'], + 'min': ['minimum', 'minValue'], + 'max': ['maximum', 'maxValue'], + }; + + /// Strips case, underscores, hyphens and spaces so `x_label`, `X Label` and + /// `XLABEL` all collapse to the same lookup token. + static String normalizeKey(String key) { + final buf = StringBuffer(); + for (final rune in key.runes) { + final ch = String.fromCharCode(rune); + if (ch == '_' || ch == '-' || ch == ' ') continue; + buf.write(ch.toLowerCase()); + } + return buf.toString(); + } + + /// Resolves [key] against the raw map: exact hit, then normalized hit, then + /// each semantic alias in declaration order. Returns null when nothing + /// matches or the matched value is null. + Object? lookup(String key) { + final direct = raw[key]; + if (direct != null) return direct; + + final wanted = normalizeKey(key); + for (final entry in raw.entries) { + if (entry.value == null) continue; + if (normalizeKey(entry.key) == wanted) return entry.value; + } + + for (final alias in keyAliases[key] ?? const []) { + final aliasWanted = normalizeKey(alias); + for (final entry in raw.entries) { + if (entry.value == null) continue; + if (normalizeKey(entry.key) == aliasWanted) return entry.value; + } + } + return null; + } + + String? textOrNull(String key) { + final v = lookup(key); + if (v == null) return null; + if (v is String) return v; + if (v is num || v is bool) return v.toString(); + return null; + } + + String text(String key, {String or = ''}) => textOrNull(key) ?? or; + + double? numberOrNull(String key) => _toNumber(lookup(key)); + + double number(String key, {double or = 0}) => numberOrNull(key) ?? or; + + int integer(String key, {int or = 0}) => numberOrNull(key)?.toInt() ?? or; + + List stringList(String key) { + final v = lookup(key); + if (v is! List) return const []; + return [ + for (final item in v) + if (item != null) item.toString(), + ]; + } + + List numberList(String key) { + final v = lookup(key); + if (v is! List) return const []; + return [ + for (final item in v) + if (_toNumber(item) case final double n) n, + ]; + } + + List objectList(String key) { + final v = lookup(key); + if (v is! List) return const []; + return [ + for (final item in v) + if (item is Map) A2UiProps(stringKeyed(item)), + ]; + } + + /// Re-keys a decoded JSON map to `Map`. + static Map stringKeyed(Map input) => { + for (final entry in input.entries) entry.key.toString(): entry.value, + }; + + static double? _toNumber(Object? value) { + if (value is num) { + if (value.isNaN || value.isInfinite) return null; + return value.toDouble(); + } + if (value is String) { + final cleaned = value.replaceAll(',', '').replaceAll('%', '').trim(); + final parsed = double.tryParse(cleaned); + if (parsed == null || parsed.isNaN || parsed.isInfinite) return null; + return parsed; + } + if (value is bool) return value ? 1 : 0; + return null; + } +} diff --git a/workout-logger/lib/genui/src/a2ui_registry.dart b/workout-logger/lib/genui/src/a2ui_registry.dart new file mode 100644 index 0000000..3a917ef --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_registry.dart @@ -0,0 +1,79 @@ +import 'package:flutter/widgets.dart'; + +import 'a2ui_props.dart'; +import 'a2ui_spec.dart'; +import 'default_registry.dart'; + +/// Normalized-name → spec lookup. +/// +/// Replaces the old triple of `allowedA2UiComponents`, the validation `switch` +/// and the render `switch`: registering a spec adds it to all three at once. +class A2UiRegistry { + A2UiRegistry(List specs) : _specs = List.unmodifiable(specs) { + for (final spec in _specs) { + _register(A2UiProps.normalizeKey(spec.name), spec); + for (final alias in spec.aliases) { + _register(A2UiProps.normalizeKey(alias), spec); + } + } + } + + /// Inserts [spec] under [key], throwing if [key] is already claimed — + /// whether by a canonical name, an alias, or a repeat registration of the + /// same spec class. Collisions are checked at insertion time in + /// registration order so the error always names both the spec already + /// registered and the one that collided with it, rather than silently + /// overwriting or being silently dropped. (Const specs with identical + /// fields canonicalize to `==` instances, so identity/equality checks + /// can't be used to distinguish "same spec registered twice" from "two + /// different specs that happen to collide" — every repeat claim of a key + /// is treated as a collision.) + void _register(String key, A2UiSpec spec) { + final existing = _byName[key]; + if (existing != null) { + throw StateError( + 'A2UiRegistry: "$key" is claimed by both ' + '${existing.name} and ${spec.name} (canonical name or alias ' + 'collision). Component names and aliases must be unique across ' + 'the registry.', + ); + } + _byName[key] = spec; + } + + final List _specs; + final Map _byName = {}; + + List get specs => _specs; + + /// Looks up a spec by canonical name or any alias, ignoring case and + /// separators (`stat_card`, `Stat Card` and `STATCARD` all match `StatCard`). + A2UiSpec? specFor(String rawName) => + _byName[A2UiProps.normalizeKey(rawName)]; + + String? canonicalName(String rawName) => specFor(rawName)?.name; +} + +/// Supplies an [A2UiRegistry] to the renderer subtree. +/// +/// Absent a provider, [of] returns [defaultA2UiRegistry] so the package +/// renders standalone in tests and previews. +class A2UiRegistryProvider extends InheritedWidget { + const A2UiRegistryProvider({ + super.key, + required this.registry, + required super.child, + }); + + final A2UiRegistry registry; + + static A2UiRegistry of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.registry ?? + defaultA2UiRegistry; + + @override + bool updateShouldNotify(A2UiRegistryProvider oldWidget) => + oldWidget.registry != registry; +} diff --git a/workout-logger/lib/genui/src/a2ui_renderer.dart b/workout-logger/lib/genui/src/a2ui_renderer.dart new file mode 100644 index 0000000..8938523 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_renderer.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'a2ui_node.dart'; +import 'a2ui_registry.dart'; +import 'a2ui_theme.dart'; + +/// Renders an [A2UiNode] tree as Flutter widgets. +/// +/// Purely presentational and fully local — no network, no side effects. Theme +/// comes from the nearest [A2UiThemeProvider], falling back to +/// [A2UiTheme.dark]. Registry comes from the explicit [registry] override if +/// given, else the nearest [A2UiRegistryProvider], falling back to +/// [defaultA2UiRegistry] — and whichever registry is resolved here is made +/// ambient to nested [A2UiRenderer] calls (e.g. from `GridContainer`) via +/// [A2UiRegistryProvider], so an override at any level of the tree propagates +/// to everything below it instead of silently reverting to the default past +/// one level of nesting. +class A2UiRenderer extends StatelessWidget { + const A2UiRenderer({super.key, required this.node, this.registry}); + + final A2UiNode node; + + /// Defaults to [defaultA2UiRegistry]; override to render a custom vocabulary. + final A2UiRegistry? registry; + + @override + Widget build(BuildContext context) { + final resolvedRegistry = registry ?? A2UiRegistryProvider.of(context); + final spec = resolvedRegistry.specFor(node.name); + if (spec == null) { + if (kDebugMode) { + debugPrint('A2UiRenderer: no spec registered for "${node.name}"'); + } + return const SizedBox.shrink(); + } + return A2UiRegistryProvider( + registry: resolvedRegistry, + child: spec.render(context, node, A2UiThemeProvider.of(context)), + ); + } +} diff --git a/workout-logger/lib/genui/src/a2ui_series.dart b/workout-logger/lib/genui/src/a2ui_series.dart new file mode 100644 index 0000000..2f49db1 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_series.dart @@ -0,0 +1,70 @@ +import 'a2ui_props.dart'; + +/// One named run of numbers plotted against a shared categorical axis. +/// +/// This is the single categorical shape in A2UI: line, bar, pie and radar all +/// consume it, so a model that learns `{labels, series}` once can drive four +/// components. +class A2UiSeries { + const A2UiSeries({required this.name, required this.values}); + + final String name; + final List values; + + /// Pulls series out of [props], accepting either the full + /// `series:[{name, values}]` form or the `values:[...]` shorthand. + /// + /// Entries with no parseable numbers are dropped, so callers can treat a + /// non-empty result as renderable. + static List extract( + A2UiProps props, { + String fallbackName = 'Value', + }) { + final rawSeries = props.objectList('series'); + if (rawSeries.isNotEmpty) { + final out = []; + for (var i = 0; i < rawSeries.length; i++) { + final values = rawSeries[i].numberList('values'); + if (values.isEmpty) continue; + out.add(A2UiSeries( + name: rawSeries[i].text('name', or: 'Series ${i + 1}'), + values: values, + )); + } + if (out.isNotEmpty) return out; + } + + final flat = props.numberList('values'); + if (flat.isNotEmpty) { + return [A2UiSeries(name: fallbackName, values: flat)]; + } + + return const []; + } + + /// Largest value across [series], or 0 when there is nothing to plot. + static double maxValue(List series) { + double? max; + for (final s in series) { + for (final v in s.values) { + if (max == null || v > max) max = v; + } + } + return max ?? 0.0; + } + + /// Smallest value across [series], or 0 when there is nothing to plot. + /// + /// Mirrors [maxValue]: returns the true minimum (which may be negative or + /// positive) rather than clamping to 0, so callers can distinguish "no + /// data" from "all values are positive/negative". + static double minValue(List series) { + double? min; + for (final s in series) { + for (final v in s.values) { + if (min == null || v < min) min = v; + } + } + return min ?? 0.0; + } +} diff --git a/workout-logger/lib/genui/src/a2ui_spec.dart b/workout-logger/lib/genui/src/a2ui_spec.dart new file mode 100644 index 0000000..6735080 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_spec.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; + +import 'a2ui_node.dart'; +import 'a2ui_theme.dart'; + +/// Prompt-facing documentation for a component. +/// +/// This is the single source the LLM system prompt is generated from, so a +/// schema change here propagates to the model automatically. +@immutable +class A2UiDoc { + const A2UiDoc({ + required this.schema, + required this.purpose, + required this.example, + }); + + /// One-line prop signature, e.g. `StatCard {title, value, subtitle?, trend?}`. + final String schema; + + /// When the model should reach for this component, in one sentence. + final String purpose; + + /// A complete, valid payload used as a few-shot example. + final Map example; +} + +/// The four-in-one contract for an A2UI component: it names itself, parses its +/// own props into a typed record, builds itself from that record, and documents +/// itself for the prompt. +/// +/// Because all four live on one object, the vocabulary advertised to the model, +/// the shapes accepted by the parser and the shapes consumed by the renderer +/// cannot drift apart. +abstract class A2UiSpec

{ + const A2UiSpec(); + + /// Canonical component name as it appears in JSON, e.g. `StatCard`. + String get name; + + /// Additional names accepted for this component. Matching is case- and + /// separator-insensitive, so only semantically distinct spellings belong here. + List get aliases => const []; + + A2UiDoc get doc; + + /// Converts a node into a typed props record. + /// + /// Implementations MUST NOT throw and MUST NOT return null — degrade to + /// documented fallbacks instead. Deciding whether a payload is UI at all is + /// the parser's job, not this method's. + P parseProps(A2UiNode node); + + // `buildWidget`/`render` deliberately keep positional arguments rather than + // named ones: this is a build-style API (context, then the thing being + // built, then ambient config), mirroring Flutter's own `Widget + // build(BuildContext context)` convention that every implementation and + // call site in this codebase already follows. Every implementation is a + // one-line override, so argument-order mistakes surface immediately as a + // type error rather than silently compiling wrong — named parameters would + // add call-site noise without a corresponding safety win here. + Widget buildWidget(BuildContext context, P props, A2UiTheme theme); + + /// Type-erased entry point used by the renderer. + Widget render(BuildContext context, A2UiNode node, A2UiTheme theme) => + buildWidget(context, parseProps(node), theme); +} diff --git a/workout-logger/lib/genui/src/a2ui_theme.dart b/workout-logger/lib/genui/src/a2ui_theme.dart new file mode 100644 index 0000000..1611352 --- /dev/null +++ b/workout-logger/lib/genui/src/a2ui_theme.dart @@ -0,0 +1,99 @@ +import 'package:flutter/widgets.dart'; + +/// Visual tokens the A2UI renderer draws with. +/// +/// Injected rather than imported so `lib/genui/` carries no dependency on any +/// particular app's design system. +@immutable +class A2UiTheme { + const A2UiTheme({ + required this.surface, + required this.border, + required this.divider, + required this.textPrimary, + required this.textSoft, + required this.textMuted, + required this.textFaint, + required this.accent, + required this.positive, + required this.negative, + required this.seriesPalette, + required this.spacing, + required this.radius, + required this.pillRadius, + }); + + final Color surface; + final Color border; + final Color divider; + final Color textPrimary; + final Color textSoft; + final Color textMuted; + final Color textFaint; + final Color accent; + final Color positive; + final Color negative; + final List seriesPalette; + final double spacing; + final double radius; + final double pillRadius; + + /// Colour for series index [i], cycling through [seriesPalette]. + Color seriesColor(int i) { + assert( + seriesPalette.isNotEmpty, + 'seriesPalette must not be empty — seriesColor() indexes into it ' + 'with a modulo, which throws on an empty list.', + ); + if (seriesPalette.isEmpty) return accent; + return seriesPalette[i % seriesPalette.length]; + } + + /// Neutral dark default so the package renders standalone. + static const A2UiTheme dark = A2UiTheme( + surface: Color(0xFF11111A), + border: Color(0x12FFFFFF), + divider: Color(0x0FFFFFFF), + textPrimary: Color(0xFFF4F4F8), + textSoft: Color(0xB8F4F4F8), + textMuted: Color(0x7AF4F4F8), + textFaint: Color(0x52F4F4F8), + accent: Color(0xFF7C3AED), + positive: Color(0xFF00C89B), + negative: Color(0xFFE05040), + seriesPalette: [ + Color(0xFF7C3AED), + Color(0xFF00C2D4), + Color(0xFF00C89B), + Color(0xFFDBA520), + Color(0xFFE05040), + ], + spacing: 16, + radius: 16, + pillRadius: 999, + ); +} + +/// Supplies an [A2UiTheme] to the renderer subtree. +/// +/// Absent a provider, [of] returns [A2UiTheme.dark] so the package renders +/// standalone in tests and previews. +class A2UiThemeProvider extends InheritedWidget { + const A2UiThemeProvider({ + super.key, + required this.theme, + required super.child, + }); + + final A2UiTheme theme; + + static A2UiTheme of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.theme ?? + A2UiTheme.dark; + + @override + bool updateShouldNotify(A2UiThemeProvider oldWidget) => + oldWidget.theme != theme; +} diff --git a/workout-logger/lib/genui/src/components/data_list_group.dart b/workout-logger/lib/genui/src/components/data_list_group.dart new file mode 100644 index 0000000..a2db1d0 --- /dev/null +++ b/workout-logger/lib/genui/src/components/data_list_group.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_panels.dart'; +import '../a2ui_props.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +@immutable +class A2UiListRow { + const A2UiListRow({ + required this.primaryText, + this.secondaryText, + this.trailingValue, + }); + + final String primaryText; + final String? secondaryText; + final String? trailingValue; +} + +@immutable +class DataListGroupProps { + const DataListGroupProps({required this.rows, this.title}); + + /// Null renders no header — the old code cast this to a non-null String. + final String? title; + final List rows; + + bool get hasData => rows.isNotEmpty; +} + +/// A titled list of primary / secondary / trailing rows. +class DataListGroupSpec extends A2UiSpec { + const DataListGroupSpec(); + + @override + String get name => 'DataListGroup'; + + @override + List get aliases => const ['DataList', 'ListGroup', 'Table', 'List']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'DataListGroup {title?, items: ' + '[{primaryText, secondaryText?, trailingValue?}]}', + purpose: + 'A short ranked or dated list. Use for records, recent sessions ' + 'and top-N breakdowns.', + example: { + 'component': 'DataListGroup', + 'props': { + 'title': 'Recent Personal Records', + 'items': [ + { + 'primaryText': 'Bench Press', + 'secondaryText': '2026-07-04', + 'trailingValue': '102.5 kg', + }, + { + 'primaryText': 'Back Squat', + 'secondaryText': '2026-06-28', + 'trailingValue': '140 kg', + }, + ], + }, + }, + ); + + @override + DataListGroupProps parseProps(A2UiNode node) { + final p = node.props; + final title = p.textOrNull('title'); + + final rows = []; + final raw = p.lookup('items'); + if (raw is List) { + for (final item in raw) { + final row = _row(item); + if (row != null) rows.add(row); + } + } + + return DataListGroupProps( + title: (title == null || title.isEmpty) ? null : title, + rows: rows, + ); + } + + /// Builds a row from a map or a bare scalar, or returns null when the item + /// carries nothing displayable. + A2UiListRow? _row(Object? item) { + if (item is String || item is num || item is bool) { + return A2UiListRow(primaryText: item.toString()); + } + if (item is! Map) return null; + + final props = A2UiProps(A2UiProps.stringKeyed(item)); + var primary = props.textOrNull('primaryText'); + + // Last resort: the first value in the map that stringifies, so a row keyed + // with unexpected names still shows something. + if (primary == null || primary.isEmpty) { + for (final value in props.raw.values) { + if (value is String && value.isNotEmpty) { + primary = value; + break; + } + if (value is num || value is bool) { + primary = value.toString(); + break; + } + } + } + if (primary == null || primary.isEmpty) return null; + + final secondary = props.textOrNull('secondaryText'); + final trailing = props.textOrNull('trailingValue'); + + return A2UiListRow( + primaryText: primary, + secondaryText: + (secondary == null || secondary.isEmpty || secondary == primary) + ? null + : secondary, + trailingValue: (trailing == null || trailing.isEmpty || trailing == primary) + ? null + : trailing, + ); + } + + @override + Widget buildWidget( + BuildContext context, + DataListGroupProps props, + A2UiTheme theme, + ) { + if (!props.hasData) { + return A2UiEmptyPanel( + message: '${props.title ?? 'List'}: No items available', + theme: theme, + ); + } + + return A2UiPanel( + theme: theme, + padded: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (props.title case final String title) + Padding( + padding: EdgeInsets.all(theme.spacing), + child: Text( + title, + style: TextStyle( + color: theme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + for (var i = 0; i < props.rows.length; i++) + _Row( + row: props.rows[i], + theme: theme, + showDivider: i < props.rows.length - 1, + ), + ], + ), + ); + } +} + +class _Row extends StatelessWidget { + const _Row({ + required this.row, + required this.theme, + required this.showDivider, + }); + + final A2UiListRow row; + final A2UiTheme theme; + final bool showDivider; + + @override + Widget build(BuildContext context) => Container( + padding: EdgeInsets.symmetric( + horizontal: theme.spacing, + vertical: theme.spacing / 2 + 2, + ), + decoration: BoxDecoration( + border: showDivider + ? Border(bottom: BorderSide(color: theme.divider)) + : null, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + row.primaryText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (row.secondaryText case final String secondary) ...[ + const SizedBox(height: 2), + Text( + secondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: theme.textMuted, fontSize: 11), + ), + ], + ], + ), + ), + if (row.trailingValue case final String trailing) ...[ + SizedBox(width: theme.spacing / 2), + Text( + trailing, + style: TextStyle( + color: theme.seriesColor(1), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ), + ); +} diff --git a/workout-logger/lib/genui/src/components/dynamic_chart.dart b/workout-logger/lib/genui/src/components/dynamic_chart.dart new file mode 100644 index 0000000..961d8e1 --- /dev/null +++ b/workout-logger/lib/genui/src/components/dynamic_chart.dart @@ -0,0 +1,370 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_panels.dart'; +import '../a2ui_series.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +enum A2UiChartType { + line, + bar, + pie; + + /// Normalizes separators and common model spellings (`LineChart`, + /// `bar_chart`, `donut`) onto the three supported types, defaulting to line. + static A2UiChartType parse(String? raw) { + final t = raw?.toLowerCase().replaceAll(RegExp(r'[\s_\-]'), '') ?? ''; + if (t.contains('pie') || t.contains('donut') || t.contains('doughnut')) { + return A2UiChartType.pie; + } + if (t.contains('bar') || t.contains('column') || t.contains('histogram')) { + return A2UiChartType.bar; + } + return A2UiChartType.line; + } +} + +@immutable +class DynamicChartProps { + const DynamicChartProps({ + required this.title, + required this.type, + required this.labels, + required this.series, + this.subtitle, + }); + + final String title; + final String? subtitle; + final A2UiChartType type; + + /// Always at least as long as the longest series, padded with empty strings, + /// so axis label lookup by index can never go out of range. + final List labels; + final List series; + + bool get hasData => series.isNotEmpty; +} + +/// Line, bar or pie over the shared `{labels, series}` shape. +class DynamicChartSpec extends A2UiSpec { + const DynamicChartSpec(); + + @override + String get name => 'DynamicChart'; + + @override + List get aliases => const [ + 'Chart', + 'LineChart', + 'BarChart', + 'PieChart', + 'TimeSeries', + ]; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'DynamicChart {type: line|bar|pie, title, labels: [string], ' + 'series: [{name, values: [number]}]} ' + '// or values: [number] for a single series', + purpose: + 'Trends over time (line), category comparisons (bar), or a share ' + 'breakdown (pie). Use multiple series to compare.', + example: { + 'component': 'DynamicChart', + 'props': { + 'type': 'line', + 'title': 'Biceps vs Triceps Volume', + 'labels': ['07-06', '07-09', '07-12'], + 'series': [ + {'name': 'Biceps', 'values': [640, 720, 810]}, + {'name': 'Triceps', 'values': [1200, 1150, 1290]}, + ], + }, + }, + ); + + @override + DynamicChartProps parseProps(A2UiNode node) { + final p = node.props; + final title = p.text('title', or: 'Chart'); + final series = A2UiSeries.extract(p, fallbackName: title); + + var longest = 0; + for (final s in series) { + if (s.values.length > longest) longest = s.values.length; + } + final labels = p.stringList('labels'); + final padded = [ + ...labels, + for (var i = labels.length; i < longest; i++) '', + ]; + + final subtitle = p.textOrNull('subtitle'); + + return DynamicChartProps( + title: title, + subtitle: (subtitle == null || subtitle.isEmpty) ? null : subtitle, + type: A2UiChartType.parse(p.textOrNull('type')), + labels: padded, + series: series, + ); + } + + @override + Widget buildWidget( + BuildContext context, + DynamicChartProps props, + A2UiTheme theme, + ) { + if (!props.hasData) { + return A2UiEmptyPanel( + message: '${props.title}: No chart data available', + theme: theme, + ); + } + + final showLegend = + props.series.length > 1 && props.type != A2UiChartType.pie; + + return A2UiPanel( + theme: theme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + A2UiPanelTitle( + title: props.title, + trailing: props.type == A2UiChartType.pie ? props.subtitle : null, + theme: theme, + ), + if (showLegend) ...[ + const SizedBox(height: 6), + A2UiLegend( + names: [for (final s in props.series) s.name], + theme: theme, + ), + ], + SizedBox(height: theme.spacing), + SizedBox( + height: 195, + child: switch (props.type) { + A2UiChartType.bar => _bar(props, theme), + A2UiChartType.pie => _pie(props, theme), + A2UiChartType.line => _line(props, theme), + }, + ), + ], + ), + ); + } + + Widget _line(DynamicChartProps props, A2UiTheme theme) { + final (minY, maxY) = _yBounds(props.series); + return LineChart( + LineChartData( + minY: minY, + maxY: maxY, + gridData: a2uiGridData(theme), + borderData: FlBorderData(show: false), + titlesData: a2uiTitlesData(props.labels, theme), + lineBarsData: [ + for (var i = 0; i < props.series.length; i++) + LineChartBarData( + spots: [ + for (var x = 0; x < props.series[i].values.length; x++) + FlSpot(x.toDouble(), props.series[i].values[x]), + ], + isCurved: true, + color: theme.seriesColor(i), + barWidth: 3, + dotData: FlDotData(show: props.series[i].values.length < 10), + belowBarData: BarAreaData( + show: props.series.length == 1, + color: theme.seriesColor(i).withValues(alpha: 0.12), + ), + ), + ], + ), + ); + } + + Widget _bar(DynamicChartProps props, A2UiTheme theme) { + final (minY, maxY) = _yBounds(props.series); + return BarChart( + BarChartData( + minY: minY, + maxY: maxY, + gridData: a2uiGridData(theme), + borderData: FlBorderData(show: false), + titlesData: a2uiTitlesData(props.labels, theme), + barGroups: [ + for (var group = 0; group < props.labels.length; group++) + BarChartGroupData( + x: group, + barRods: [ + for (var i = 0; i < props.series.length; i++) + if (group < props.series[i].values.length) + BarChartRodData( + toY: props.series[i].values[group], + width: props.series.length > 1 ? 8 : 14, + borderRadius: BorderRadius.circular(6), + color: theme.seriesColor(i), + ), + ], + ), + ], + ), + ); + } + + Widget _pie(DynamicChartProps props, A2UiTheme theme) { + final rawValues = props.series.first.values; + // A pie slice needs a positive share of the whole; negative or zero + // entries have no geometric meaning. Filter them out, but keep each + // surviving entry's ORIGINAL index so theme.seriesColor(i) and + // props.labels[i] — both indexed by original position — stay aligned. + final positive = [ + for (var i = 0; i < rawValues.length; i++) + if (rawValues[i] > 0) i, + ]; + if (positive.isEmpty) { + return A2UiEmptyPanel( + message: '${props.title}: No positive values to chart', + theme: theme, + ); + } + final total = positive.fold(0, (sum, i) => sum + rawValues[i]); + + return Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 32, + sections: [ + for (final i in positive) + PieChartSectionData( + value: rawValues[i], + color: theme.seriesColor(i), + radius: 44, + title: '${(rawValues[i] / total * 100).round()}%', + titleStyle: TextStyle( + color: theme.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + SizedBox(width: theme.spacing / 2), + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final i in positive) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.seriesColor(i), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '${i < props.labels.length ? props.labels[i] : ''} ' + '(${rawValues[i].round()})', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + TextStyle(color: theme.textMuted, fontSize: 11), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// Y-axis bounds for [series], shared by `_line` and `_bar` so both charts +/// agree on the same visible range. +/// +/// When every value is non-negative, the axis starts at 0 (existing +/// behavior), with a 15% headroom margin above the max — clamped to a +/// minimum span of 1 so an all-zero series doesn't collapse to a +/// zero-height axis. +/// +/// When any value is negative, both bounds are derived from the true min +/// and max (via [A2UiSeries.minValue]/[A2UiSeries.maxValue], which return +/// real negative extrema rather than clamping to 0) so every data point — +/// including an all-negative series — falls within the visible range with +/// a margin, instead of silently rendering off-chart. +(double, double) _yBounds(List series) { + final max = A2UiSeries.maxValue(series); + final min = A2UiSeries.minValue(series); + if (min >= 0) { + return (0, max <= 0 ? 1 : max * 1.15); + } + final minY = min * 1.15; + final maxY = max <= 0 ? max * 0.85 : max * 1.15; + return (minY, maxY); +} + +/// Horizontal-only grid lines in the theme's border colour. +FlGridData a2uiGridData(A2UiTheme theme) => FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: theme.border, strokeWidth: 1), + ); + +/// Bottom axis labelled from [labels] by index, with a bounds check so an +/// out-of-range tick renders nothing rather than throwing. +FlTitlesData a2uiTitlesData(List labels, A2UiTheme theme) => + FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 34), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + final index = value.round(); + if (index < 0 || index >= labels.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + labels[index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: theme.textFaint, fontSize: 10), + ), + ); + }, + ), + ), + ); diff --git a/workout-logger/lib/genui/src/components/filter_chips.dart b/workout-logger/lib/genui/src/components/filter_chips.dart new file mode 100644 index 0000000..7c09fcf --- /dev/null +++ b/workout-logger/lib/genui/src/components/filter_chips.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +@immutable +class FilterChipsProps { + const FilterChipsProps({required this.options, this.activeOption}); + + final List options; + + /// Null when the model omitted it or named an option that does not exist. + /// The old renderer cast this to a non-null String and crashed. + final String? activeOption; + + bool get hasData => options.isNotEmpty; +} + +/// A decorative row of context chips showing the window a dashboard covers. +/// +/// Deliberately non-interactive: A2UI has no action contract yet, so a tappable +/// chip would imply behaviour the renderer cannot deliver. Adding interactivity +/// means threading an `onAction` callback through `A2UiRenderer` first. +class FilterChipsSpec extends A2UiSpec { + const FilterChipsSpec(); + + @override + String get name => 'FilterChips'; + + @override + List get aliases => const ['Chips', 'FilterRow', 'Tags']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'FilterChips {options: [string], activeOption?}', + purpose: + 'Labels the window or scope a dashboard covers. Decorative — the ' + 'chips are not tappable.', + example: { + 'component': 'FilterChips', + 'props': { + 'options': ['7 days', '30 days', '90 days'], + 'activeOption': '30 days', + }, + }, + ); + + @override + FilterChipsProps parseProps(A2UiNode node) { + final p = node.props; + final options = p.stringList('options'); + final requested = p.textOrNull('activeOption'); + + String? active; + if (requested != null) { + for (final option in options) { + if (option.toLowerCase() == requested.toLowerCase()) { + active = option; + break; + } + } + } + + return FilterChipsProps(options: options, activeOption: active); + } + + @override + Widget buildWidget( + BuildContext context, + FilterChipsProps props, + A2UiTheme theme, + ) { + // Deliberately blank rather than an empty-state panel — chips are + // decorative chrome describing a dashboard's scope, not data the model + // attempted to show; an empty panel here would be noise, not a useful + // error signal. + if (!props.hasData) return const SizedBox.shrink(); + + return Wrap( + spacing: theme.spacing / 2, + runSpacing: theme.spacing / 2, + children: [ + for (final option in props.options) + _Chip( + label: option, + active: option == props.activeOption, + theme: theme, + ), + ], + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.active, + required this.theme, + }); + + final String label; + final bool active; + final A2UiTheme theme; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: active + ? theme.accent.withValues(alpha: 0.18) + : theme.border, + borderRadius: BorderRadius.circular(theme.pillRadius), + border: Border.all( + color: active + ? theme.accent.withValues(alpha: 0.45) + : theme.border, + ), + ), + child: Text( + label, + style: TextStyle( + color: active ? theme.accent : theme.textSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); +} diff --git a/workout-logger/lib/genui/src/components/grid_container.dart b/workout-logger/lib/genui/src/components/grid_container.dart new file mode 100644 index 0000000..407e3d7 --- /dev/null +++ b/workout-logger/lib/genui/src/components/grid_container.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_renderer.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +@immutable +class GridContainerProps { + const GridContainerProps({required this.columns, required this.children}); + + /// Always 1 or 2. + final int columns; + final List children; +} + +/// Vertical stack or two-column grid of other components. +/// +/// Children are already parsed by [A2UiParser]; this spec only lays them out, +/// and recursion runs through the public [A2UiRenderer] so the injected theme +/// keeps flowing down the tree. +class GridContainerSpec extends A2UiSpec { + const GridContainerSpec(); + + /// Below this width a two-column grid squeezes charts unreadably. + static const double _collapseWidth = 420; + + @override + String get name => 'GridContainer'; + + @override + List get aliases => const ['Grid', 'Dashboard', 'Container', 'Layout']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'GridContainer {columns: 1|2, children: [component, ...]}', + purpose: + 'The wrapper for a multi-part dashboard. Use columns:2 for compact ' + 'StatCards and columns:1 when it contains charts.', + example: { + 'component': 'GridContainer', + 'props': { + 'columns': 2, + 'children': [ + { + 'component': 'StatCard', + 'props': {'title': 'Sessions', 'value': 14, 'trend': 'up'}, + }, + { + 'component': 'StatCard', + 'props': {'title': 'Volume', 'value': 128000, 'unit': 'kg'}, + }, + ], + }, + }, + ); + + @override + GridContainerProps parseProps(A2UiNode node) => GridContainerProps( + columns: node.props.integer('columns', or: 1).clamp(1, 2), + children: node.children, + ); + + @override + Widget buildWidget( + BuildContext context, + GridContainerProps props, + A2UiTheme theme, + ) { + final children = props.children; + if (children.isEmpty) return const SizedBox.shrink(); + + return LayoutBuilder( + builder: (context, constraints) { + final columns = + constraints.maxWidth < _collapseWidth ? 1 : props.columns; + + if (columns == 1) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + A2UiRenderer(node: children[i]), + if (i < children.length - 1) + SizedBox(height: theme.spacing / 2), + ], + ], + ); + } + + final rows = []; + for (var i = 0; i < children.length; i += 2) { + final right = i + 1 < children.length ? children[i + 1] : null; + rows.add( + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: A2UiRenderer(node: children[i])), + SizedBox(width: theme.spacing / 2), + Expanded( + child: right == null + ? const SizedBox.shrink() + : A2UiRenderer(node: right), + ), + ], + ), + ), + ); + if (i + 2 < children.length) { + rows.add(SizedBox(height: theme.spacing / 2)); + } + } + return Column(mainAxisSize: MainAxisSize.min, children: rows); + }, + ); + } +} diff --git a/workout-logger/lib/genui/src/components/metric_gauge.dart b/workout-logger/lib/genui/src/components/metric_gauge.dart new file mode 100644 index 0000000..22be6f0 --- /dev/null +++ b/workout-logger/lib/genui/src/components/metric_gauge.dart @@ -0,0 +1,223 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_panels.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +@immutable +class MetricGaugeProps { + const MetricGaugeProps({ + required this.title, + required this.value, + required this.min, + required this.max, + required this.unit, + this.status, + }); + + final String title; + + /// Null when the model supplied nothing parseable — the renderer shows an + /// empty panel rather than drawing an arc from a bogus number. + final double? value; + final double min; + final double max; + final String unit; + final String? status; + + /// Fill fraction in `[0, 1]`. Returns 0 for a degenerate range so a NaN + /// sweep angle can never reach the canvas. + double get progress { + final v = value; + if (v == null) return 0; + final span = max - min; + if (span <= 0) return 0; + final raw = (v - min) / span; + if (raw.isNaN || raw.isInfinite) return 0; + return raw.clamp(0.0, 1.0); + } +} + +/// A radial gauge for a bounded score such as readiness or recovery. +class MetricGaugeSpec extends A2UiSpec { + const MetricGaugeSpec(); + + @override + String get name => 'MetricGauge'; + + @override + List get aliases => const ['Gauge', 'Dial', 'ScoreGauge']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: + 'MetricGauge {title, value: number, min?, max?, unit?, status?}', + purpose: + 'A bounded score shown as a dial. Use when the number has a natural ' + 'floor and ceiling.', + example: { + 'component': 'MetricGauge', + 'props': { + 'title': 'Readiness', + 'value': 82, + 'min': 0, + 'max': 100, + 'unit': 'pts', + 'status': 'Optimal', + }, + }, + ); + + @override + MetricGaugeProps parseProps(A2UiNode node) { + final p = node.props; + final status = p.textOrNull('status'); + return MetricGaugeProps( + title: p.text('title', or: 'Metric'), + value: p.numberOrNull('value'), + min: p.number('min', or: 0), + max: p.number('max', or: 100), + unit: p.text('unit'), + status: (status == null || status.isEmpty) ? null : status, + ); + } + + @override + Widget buildWidget( + BuildContext context, + MetricGaugeProps props, + A2UiTheme theme, + ) { + final value = props.value; + if (value == null) { + return A2UiEmptyPanel( + message: '${props.title}: No value available', + theme: theme, + ); + } + + final display = + value % 1 == 0 ? value.toInt().toString() : value.toStringAsFixed(1); + + return A2UiPanel( + theme: theme, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + props.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: theme.spacing), + SizedBox( + height: 120, + width: 120, + child: CustomPaint( + painter: _GaugeArcPainter( + progress: props.progress, + track: theme.border, + from: theme.accent, + to: theme.seriesColor(1), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + display, + style: TextStyle( + color: theme.textPrimary, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + if (props.unit.isNotEmpty) + Text( + props.unit, + style: TextStyle(color: theme.textMuted, fontSize: 11), + ), + ], + ), + ), + ), + ), + if (props.status case final String status) ...[ + SizedBox(height: theme.spacing / 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: theme.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(theme.pillRadius), + border: Border.all(color: theme.accent.withValues(alpha: 0.3)), + ), + child: Text( + status, + style: TextStyle( + color: theme.accent, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ); + } +} + +class _GaugeArcPainter extends CustomPainter { + const _GaugeArcPainter({ + required this.progress, + required this.track, + required this.from, + required this.to, + }); + + final double progress; + final Color track; + final Color from; + final Color to; + + static const double _startAngle = math.pi * 0.75; + static const double _sweepAngle = math.pi * 1.5; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2 - 8; + if (radius <= 0) return; + final rect = Rect.fromCircle(center: center, radius: radius); + + final bg = Paint() + ..color = track + ..style = PaintingStyle.stroke + ..strokeWidth = 10 + ..strokeCap = StrokeCap.round; + + final fg = Paint() + ..shader = LinearGradient(colors: [from, to]).createShader(rect) + ..style = PaintingStyle.stroke + ..strokeWidth = 10 + ..strokeCap = StrokeCap.round; + + canvas.drawArc(rect, _startAngle, _sweepAngle, false, bg); + canvas.drawArc(rect, _startAngle, _sweepAngle * progress, false, fg); + } + + @override + bool shouldRepaint(_GaugeArcPainter oldDelegate) => + oldDelegate.progress != progress || + oldDelegate.track != track || + oldDelegate.from != from || + oldDelegate.to != to; +} diff --git a/workout-logger/lib/genui/src/components/radar_chart.dart b/workout-logger/lib/genui/src/components/radar_chart.dart new file mode 100644 index 0000000..2ec2725 --- /dev/null +++ b/workout-logger/lib/genui/src/components/radar_chart.dart @@ -0,0 +1,152 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_panels.dart'; +import '../a2ui_series.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +@immutable +class RadarChartProps { + const RadarChartProps({ + required this.title, + required this.labels, + required this.series, + }); + + final String title; + final List labels; + + /// Every series is exactly [labels].length long — fl_chart requires a uniform + /// entry count across datasets, so normalization happens at parse time. + final List series; + + /// fl_chart's radar needs at least three axes to form a polygon. + bool get hasData => labels.length >= 3 && series.isNotEmpty; +} + +/// Multi-axis balance view over the shared `{labels, series}` shape. +class RadarChartSpec extends A2UiSpec { + const RadarChartSpec(); + + @override + String get name => 'RadarChart'; + + @override + List get aliases => const ['Radar', 'SpiderChart', 'BalanceChart']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'RadarChart {title, labels: [string], ' + 'series: [{name, values: [number]}]}', + purpose: + 'Balance across 3+ comparable axes. Use for holistic summaries ' + 'where every axis shares a scale.', + example: { + 'component': 'RadarChart', + 'props': { + 'title': 'Recovery Balance', + 'labels': ['Readiness', 'Sleep', 'Volume', 'Intensity'], + 'series': [ + {'name': 'This week', 'values': [85, 90, 75, 80]}, + {'name': 'Baseline', 'values': [70, 70, 70, 70]}, + ], + }, + }, + ); + + @override + RadarChartProps parseProps(A2UiNode node) { + final p = node.props; + final labels = p.stringList('labels'); + final raw = A2UiSeries.extract(p); + + // fl_chart throws when datasets disagree on entry count, so pad or truncate + // every series to the axis count before it can reach the widget. + final normalized = [ + for (final s in raw) + A2UiSeries( + name: s.name, + values: [ + for (var i = 0; i < labels.length; i++) + i < s.values.length ? s.values[i] : 0.0, + ], + ), + ]; + + return RadarChartProps( + title: p.text('title', or: 'Radar Chart'), + labels: labels, + series: normalized, + ); + } + + @override + Widget buildWidget( + BuildContext context, + RadarChartProps props, + A2UiTheme theme, + ) { + if (!props.hasData) { + return A2UiEmptyPanel( + message: '${props.title}: No radar data available', + theme: theme, + ); + } + + return A2UiPanel( + theme: theme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + A2UiPanelTitle(title: props.title, theme: theme), + if (props.series.length > 1) ...[ + const SizedBox(height: 6), + A2UiLegend( + names: [for (final s in props.series) s.name], + theme: theme, + dots: true, + ), + ], + SizedBox(height: theme.spacing), + SizedBox( + height: 200, + child: RadarChart( + RadarChartData( + dataSets: [ + for (var i = 0; i < props.series.length; i++) + RadarDataSet( + fillColor: + theme.seriesColor(i).withValues(alpha: 0.2), + borderColor: theme.seriesColor(i), + entryRadius: 3, + borderWidth: 2, + dataEntries: [ + for (final v in props.series[i].values) + RadarEntry(value: v), + ], + ), + ], + radarBorderData: BorderSide(color: theme.border), + gridBorderData: BorderSide(color: theme.border, width: 0.8), + tickBorderData: const BorderSide(color: Color(0x00000000)), + ticksTextStyle: const TextStyle(color: Color(0x00000000)), + getTitle: (index, angle) => RadarChartTitle( + text: index < props.labels.length ? props.labels[index] : '', + positionPercentageOffset: 0.1, + ), + titleTextStyle: TextStyle( + color: theme.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/genui/src/components/scatter_plot.dart b/workout-logger/lib/genui/src/components/scatter_plot.dart new file mode 100644 index 0000000..e062b07 --- /dev/null +++ b/workout-logger/lib/genui/src/components/scatter_plot.dart @@ -0,0 +1,232 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_panels.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +@immutable +class A2UiPoint { + const A2UiPoint(this.x, this.y); + final double x; + final double y; +} + +@immutable +class ScatterPlotProps { + const ScatterPlotProps({ + required this.title, + required this.xLabel, + required this.yLabel, + required this.points, + this.correlation, + }); + + final String title; + final String xLabel; + final String yLabel; + final List points; + final double? correlation; + + bool get hasData => points.isNotEmpty; + + /// Axis bounds with a 10% margin, widened to ±1 when every point shares a + /// coordinate so fl_chart never receives a zero-span axis. + ({double minX, double maxX, double minY, double maxY}) get bounds { + if (points.isEmpty) { + return (minX: 0, maxX: 10, minY: 0, maxY: 10); + } + var minX = points.first.x, maxX = points.first.x; + var minY = points.first.y, maxY = points.first.y; + for (final p in points) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + final xMargin = (maxX - minX) * 0.1; + final yMargin = (maxY - minY) * 0.1; + return ( + minX: (minX - (xMargin == 0 ? 1 : xMargin)).floorToDouble(), + maxX: (maxX + (xMargin == 0 ? 1 : xMargin)).ceilToDouble(), + minY: (minY - (yMargin == 0 ? 1 : yMargin)).floorToDouble(), + maxY: (maxY + (yMargin == 0 ? 1 : yMargin)).ceilToDouble(), + ); + } +} + +/// Paired x/y observations with an optional correlation badge. +class ScatterPlotSpec extends A2UiSpec { + const ScatterPlotSpec(); + + @override + String get name => 'ScatterPlot'; + + @override + List get aliases => const ['Scatter', 'XYPlot', 'Correlation']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'ScatterPlot {title, xLabel, yLabel, ' + 'points: [{x: number, y: number}], correlation?: number}', + purpose: + 'Relationship between two measures. Use when showing whether one ' + 'metric moves with another.', + example: { + 'component': 'ScatterPlot', + 'props': { + 'title': 'Sleep vs Training Volume', + 'xLabel': 'Sleep Hours', + 'yLabel': 'Volume (kg)', + 'correlation': 0.62, + 'points': [ + {'x': 6.2, 'y': 8200}, + {'x': 7.4, 'y': 11500}, + {'x': 8.1, 'y': 12900}, + ], + }, + }, + ); + + @override + ScatterPlotProps parseProps(A2UiNode node) { + final p = node.props; + final points = []; + for (final raw in p.objectList('points')) { + final x = raw.numberOrNull('x'); + final y = raw.numberOrNull('y'); + if (x == null || y == null) continue; + points.add(A2UiPoint(x, y)); + } + + return ScatterPlotProps( + title: p.text('title', or: 'Scatter Plot'), + xLabel: p.text('xLabel', or: 'X'), + yLabel: p.text('yLabel', or: 'Y'), + points: points, + correlation: p.numberOrNull('correlation'), + ); + } + + @override + Widget buildWidget( + BuildContext context, + ScatterPlotProps props, + A2UiTheme theme, + ) { + if (!props.hasData) { + return A2UiEmptyPanel( + message: '${props.title}: No paired data available', + theme: theme, + ); + } + + final b = props.bounds; + final r = props.correlation; + final strong = r != null && r.abs() >= 0.5; + final badgeColor = strong ? theme.accent : theme.seriesColor(1); + + return A2UiPanel( + theme: theme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: A2UiPanelTitle(title: props.title, theme: theme), + ), + if (r != null) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: + Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + child: Text( + 'r = ${r >= 0 ? '+' : ''}${r.toStringAsFixed(2)}', + style: TextStyle( + color: badgeColor, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${props.yLabel} vs. ${props.xLabel}', + style: TextStyle(color: theme.textMuted, fontSize: 11), + ), + SizedBox(height: theme.spacing), + SizedBox( + height: 195, + child: ScatterChart( + ScatterChartData( + minX: b.minX, + maxX: b.maxX, + minY: b.minY, + maxY: b.maxY, + scatterSpots: [ + for (final p in props.points) ScatterSpot(p.x, p.y), + ], + gridData: FlGridData( + show: true, + drawVerticalLine: true, + getDrawingHorizontalLine: (_) => + FlLine(color: theme.border, strokeWidth: 1), + getDrawingVerticalLine: (_) => + FlLine(color: theme.border, strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + axisNameWidget: Text( + props.xLabel, + style: TextStyle(color: theme.textFaint, fontSize: 10), + ), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + getTitlesWidget: (v, meta) => Text( + v.round().toString(), + style: + TextStyle(color: theme.textFaint, fontSize: 10), + ), + ), + ), + leftTitles: AxisTitles( + axisNameWidget: Text( + props.yLabel, + style: TextStyle(color: theme.textFaint, fontSize: 10), + ), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (v, meta) => Text( + v.round().toString(), + style: + TextStyle(color: theme.textFaint, fontSize: 10), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/genui/src/components/stat_card.dart b/workout-logger/lib/genui/src/components/stat_card.dart new file mode 100644 index 0000000..e1f2e1a --- /dev/null +++ b/workout-logger/lib/genui/src/components/stat_card.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import '../a2ui_node.dart'; +import '../a2ui_panels.dart'; +import '../a2ui_spec.dart'; +import '../a2ui_theme.dart'; + +/// Direction badge shown on a [StatCardSpec]. +enum A2UiTrend { + up, + down, + neutral; + + /// Accepts the canonical words plus the synonyms models reach for, so + /// `improving` and `declining` do not silently render as neutral. + static A2UiTrend parse(String? raw) { + switch (raw?.toLowerCase().trim()) { + case 'up': + case 'improving': + case 'positive': + case 'rising': + case 'increasing': + case 'better': + return A2UiTrend.up; + case 'down': + case 'declining': + case 'decline': + case 'negative': + case 'falling': + case 'decreasing': + case 'worse': + return A2UiTrend.down; + default: + return A2UiTrend.neutral; + } + } +} + +@immutable +class StatCardProps { + const StatCardProps({ + required this.title, + required this.value, + required this.trend, + this.subtitle, + }); + + final String title; + final String value; + final String? subtitle; + final A2UiTrend trend; +} + +/// A single headline number with an optional caption and direction badge. +class StatCardSpec extends A2UiSpec { + const StatCardSpec(); + + @override + String get name => 'StatCard'; + + @override + List get aliases => const ['Stat', 'KpiCard', 'Kpi', 'MetricCard']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: + 'StatCard {title, value, unit?, subtitle?, trend?: up|down|neutral}', + purpose: 'One headline number. Use for totals, averages and deltas.', + example: { + 'component': 'StatCard', + 'props': { + 'title': 'Weekly Volume', + 'value': 12400, + 'unit': 'kg', + 'subtitle': 'Last 7 days', + 'trend': 'up', + }, + }, + ); + + @override + StatCardProps parseProps(A2UiNode node) { + final p = node.props; + + final rawValue = p.textOrNull('value'); + final unit = p.textOrNull('unit'); + final String value; + if (rawValue == null) { + value = '—'; + } else if (unit == null || + unit.isEmpty || + rawValue.trimRight().endsWith(unit)) { + // Only a trailing-suffix match counts as "already present" — a naive + // substring check would false-positive on e.g. value "10 reps" with + // unit "s" (a substring of "reps"), silently dropping a real unit. + value = rawValue; + } else { + value = '$rawValue $unit'; + } + + final subtitle = p.textOrNull('subtitle'); + + return StatCardProps( + title: p.text('title', or: 'Metric'), + value: value, + subtitle: (subtitle == null || subtitle.isEmpty) ? null : subtitle, + trend: A2UiTrend.parse(p.textOrNull('trend')), + ); + } + + @override + Widget buildWidget( + BuildContext context, + StatCardProps props, + A2UiTheme theme, + ) { + final (icon, color) = switch (props.trend) { + A2UiTrend.up => (Icons.trending_up_rounded, theme.positive), + A2UiTrend.down => (Icons.trending_down_rounded, theme.negative), + A2UiTrend.neutral => (Icons.trending_flat_rounded, theme.textMuted), + }; + + return A2UiPanel( + theme: theme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + props.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon(icon, color: color, size: 18), + ], + ), + SizedBox(height: theme.spacing / 2), + FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + props.value, + style: TextStyle( + color: theme.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + ), + if (props.subtitle case final String subtitle) ...[ + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: theme.textFaint, fontSize: 11), + ), + ], + ], + ), + ); + } +} diff --git a/workout-logger/lib/genui/src/default_registry.dart b/workout-logger/lib/genui/src/default_registry.dart new file mode 100644 index 0000000..5283428 --- /dev/null +++ b/workout-logger/lib/genui/src/default_registry.dart @@ -0,0 +1,25 @@ +import 'a2ui_registry.dart'; +import 'components/data_list_group.dart'; +import 'components/dynamic_chart.dart'; +import 'components/filter_chips.dart'; +import 'components/grid_container.dart'; +import 'components/metric_gauge.dart'; +import 'components/radar_chart.dart'; +import 'components/scatter_plot.dart'; +import 'components/stat_card.dart'; + +/// The standard A2UI vocabulary. +/// +/// Registration order is the order components appear in the generated prompt, +/// so the most commonly useful ones come first. Adding a component here adds it +/// to the parser, the renderer and the model's instructions at once. +final A2UiRegistry defaultA2UiRegistry = A2UiRegistry(const [ + GridContainerSpec(), + StatCardSpec(), + DynamicChartSpec(), + DataListGroupSpec(), + MetricGaugeSpec(), + ScatterPlotSpec(), + RadarChartSpec(), + FilterChipsSpec(), +]); diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 8d512d6..0991b49 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -27,6 +27,8 @@ import 'services/managers/readiness_manager.dart'; import 'services/managers/health_history_manager.dart'; import 'services/managers/conversation_manager.dart'; import 'theme/app_theme.dart'; +import 'genui/a2ui.dart'; +import 'theme/a2ui_app_theme.dart'; import 'screens/home_screen.dart'; import 'screens/onboarding_screen.dart'; @@ -134,16 +136,20 @@ class WorkoutLoggerApp extends StatelessWidget { // CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager. Provider( create: (ctx) => CoachToolService( - ctx.read(), - ctx.read(), + workoutProvider: ctx.read(), + prManager: ctx.read(), + healthHistory: ctx.read(), ), ), ], - child: MaterialApp( - title: 'Workout Logger', - debugShowCheckedModeBanner: false, - theme: AppTheme.darkTheme, - home: const AppInitializer(), + child: A2UiThemeProvider( + theme: repforgeA2UiTheme, + child: MaterialApp( + title: 'Workout Logger', + debugShowCheckedModeBanner: false, + theme: AppTheme.darkTheme, + home: const AppInitializer(), + ), ), ); } diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 493013a..2fb5ec6 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -70,6 +70,7 @@ class Exercise { final List muscleActivations; final String category; // 'compound' or 'isolation' final bool isCustom; // User-created exercise + final List? availableHandles; // Attachment/handle options e.g. ['Rope', 'Bar'] Exercise({ required this.id, @@ -77,6 +78,7 @@ class Exercise { required this.muscleActivations, required this.category, this.isCustom = false, + this.availableHandles, }); String get primaryMuscle { @@ -93,6 +95,7 @@ class Exercise { 'muscleActivations': muscleActivations.map((m) => m.toJson()).toList(), 'category': category, 'isCustom': isCustom, + 'availableHandles': availableHandles, }; factory Exercise.fromJson(Map json) => Exercise( @@ -103,6 +106,7 @@ class Exercise { .toList(), category: json['category'], isCustom: json['isCustom'] ?? false, + availableHandles: (json['availableHandles'] as List?)?.cast(), ); } @@ -115,6 +119,10 @@ class WorkoutSet { final List? drops; // For dropsets final int? timeTaken; // seconds final DateTime timestamp; + final double? assistWeight; + final double? extraWeight; + final String? handle; + final double? bodyWeightAtLog; WorkoutSet({ required this.weight, @@ -123,18 +131,47 @@ class WorkoutSet { this.drops, this.timeTaken, DateTime? timestamp, + this.assistWeight, + this.extraWeight, + this.handle, + this.bodyWeightAtLog, }) : timestamp = timestamp ?? DateTime.now(); - double get volume { - double vol = weight * reps; + /// Per-rep effective load for the main (non-drop) entry of this set: for + /// assisted-bodyweight sets (i.e. [assistWeight] is set) this is + /// `bodyweight − assist + extra`, snapshotted against [bodyWeightAtLog] + /// (falling back to 70.0) so historical values stay correct even if the + /// user's current bodyweight later changes. Conventional (non-assisted) + /// sets just use [weight]. Use this (not raw [weight]) wherever a + /// "how heavy was this set" comparison needs to be consistent with + /// [calculateVolume] for assisted-bodyweight exercises. + double get effectiveWeight { + final assist = assistWeight; + if (assist == null) return weight; + final bw = bodyWeightAtLog ?? 70.0; + return max(0.0, bw - assist + (extraWeight ?? 0.0)); + } + + double calculateVolume({double? userBodyWeight, bool? isAssistedBW}) { + final assisted = isAssistedBW ?? (assistWeight != null); + final bw = bodyWeightAtLog ?? userBodyWeight ?? 70.0; + final effW = assisted + ? max(0.0, bw - (assistWeight ?? weight) + (extraWeight ?? 0.0)) + : weight; + double vol = effW * reps; if (isDropset && drops != null) { - for (var drop in drops!) { - vol += drop.weight * drop.reps; + for (final drop in drops!) { + final dropEff = assisted + ? max(0.0, bw - drop.weight + (extraWeight ?? 0.0)) + : drop.weight; + vol += dropEff * drop.reps; } } return vol; } + double get volume => calculateVolume(); + Map toJson() => { 'weight': weight, 'reps': reps, @@ -142,6 +179,10 @@ class WorkoutSet { 'drops': drops?.map((d) => d.toJson()).toList(), 'timeTaken': timeTaken, 'timestamp': timestamp.toIso8601String(), + 'assistWeight': assistWeight, + 'extraWeight': extraWeight, + 'handle': handle, + 'bodyWeightAtLog': bodyWeightAtLog, }; factory WorkoutSet.fromJson(Map json) => WorkoutSet( @@ -153,6 +194,10 @@ class WorkoutSet { : null, timeTaken: json['timeTaken'], timestamp: DateTime.parse(json['timestamp']), + assistWeight: (json['assistWeight'] as num?)?.toDouble(), + extraWeight: (json['extraWeight'] as num?)?.toDouble(), + handle: json['handle'] as String?, + bodyWeightAtLog: (json['bodyWeightAtLog'] as num?)?.toDouble(), ); WorkoutSet copyWith({ @@ -162,6 +207,10 @@ class WorkoutSet { Object? drops = _sentinel, Object? timeTaken = _sentinel, Object? timestamp = _sentinel, + Object? assistWeight = _sentinel, + Object? extraWeight = _sentinel, + Object? handle = _sentinel, + Object? bodyWeightAtLog = _sentinel, }) => WorkoutSet( weight: weight == _sentinel ? this.weight : weight as double, reps: reps == _sentinel ? this.reps : reps as int, @@ -169,6 +218,10 @@ class WorkoutSet { drops: drops == _sentinel ? this.drops : drops as List?, timeTaken: timeTaken == _sentinel ? this.timeTaken : timeTaken as int?, timestamp: timestamp == _sentinel ? this.timestamp : timestamp as DateTime?, + assistWeight: assistWeight == _sentinel ? this.assistWeight : assistWeight as double?, + extraWeight: extraWeight == _sentinel ? this.extraWeight : extraWeight as double?, + handle: handle == _sentinel ? this.handle : handle as String?, + bodyWeightAtLog: bodyWeightAtLog == _sentinel ? this.bodyWeightAtLog : bodyWeightAtLog as double?, ); } @@ -195,8 +248,17 @@ class ExerciseLog { final String exerciseId; final List sets; final String? notes; + final String? handle; + + ExerciseLog({ + required this.exerciseId, + required this.sets, + this.notes, + this.handle, + }); - ExerciseLog({required this.exerciseId, required this.sets, this.notes}); + double calculateTotalVolume({double? userBodyWeight, bool? isAssistedBW}) => + sets.fold(0.0, (sum, set) => sum + set.calculateVolume(userBodyWeight: userBodyWeight, isAssistedBW: isAssistedBW)); double get totalVolume => sets.fold(0.0, (sum, set) => sum + set.volume); @@ -204,24 +266,28 @@ class ExerciseLog { 'exerciseId': exerciseId, 'sets': sets.map((s) => s.toJson()).toList(), 'notes': notes, + 'handle': handle, }; factory ExerciseLog.fromJson(Map json) => ExerciseLog( exerciseId: json['exerciseId'], sets: (json['sets'] as List).map((s) => WorkoutSet.fromJson(s)).toList(), notes: json['notes'], + handle: json['handle'] as String?, ); ExerciseLog copyWith({ Object? exerciseId = _sentinel, Object? sets = _sentinel, Object? notes = _sentinel, + Object? handle = _sentinel, }) => ExerciseLog( exerciseId: exerciseId == _sentinel ? this.exerciseId : exerciseId as String, sets: sets == _sentinel ? this.sets : sets as List, notes: notes == _sentinel ? this.notes : notes as String?, + handle: handle == _sentinel ? this.handle : handle as String?, ); } diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index f24758f..a97406f 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import '../models/models.dart'; +import '../genui/a2ui.dart'; import '../viewmodels/ai_coach_view_model.dart'; import '../services/ai/gemini_ai_service.dart'; import '../services/ai/coach_tool_service.dart'; @@ -745,7 +746,7 @@ class _MessageBubble extends StatelessWidget { height: 1.55, ), ) - : _CoachMarkdown(text: message.text), + : CoachMessageContent(text: message.text), ), ), ], @@ -785,7 +786,7 @@ class _StreamingBubble extends StatelessWidget { ), child: text.isEmpty ? const RFLoadingDots() - : _CoachMarkdown(text: text), + : CoachMessageContent(text: text, streaming: true), ), ), ], @@ -794,7 +795,82 @@ class _StreamingBubble extends StatelessWidget { } } -/// Markdown renderer for coach replies, styled to the app theme. +/// Renders one coach reply: an A2UI dashboard when the text is a UI payload, +/// otherwise Markdown. +/// +/// Public so widget tests can drive it directly. Parsing is memoized per text +/// value — the old code re-parsed on every rebuild, including on every partial +/// frame of a stream. +class CoachMessageContent extends StatefulWidget { + const CoachMessageContent({ + super.key, + required this.text, + this.streaming = false, + }); + + final String text; + + /// True while tokens are still arriving, so a half-written JSON payload + /// shows a placeholder instead of raw braces. + final bool streaming; + + @override + State createState() => _CoachMessageContentState(); +} + +class _CoachMessageContentState extends State { + static final _parser = A2UiParser(defaultA2UiRegistry); + + A2UiNode? _node; + String? _parsedFrom; + + @override + void didUpdateWidget(CoachMessageContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) _parsedFrom = null; + } + + A2UiNode? get _resolved { + if (_parsedFrom != widget.text) { + _parsedFrom = widget.text; + _node = _parser.parse(widget.text); + } + return _node; + } + + @override + Widget build(BuildContext context) { + final node = _resolved; + if (node != null) return A2UiRenderer(node: node); + + // Mid-stream JSON: hide the braces behind a progress row rather than + // letting the Markdown renderer spill raw payload into the bubble. + if (widget.streaming && _parser.looksLikeUi(widget.text)) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppSpacing.sm), + Text( + 'Building dashboard…', + style: TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ], + ); + } + + return _CoachMarkdown(text: widget.text); + } +} + class _CoachMarkdown extends StatelessWidget { const _CoachMarkdown({required this.text}); final String text; diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index 94da3db..90495e1 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -9,6 +9,7 @@ import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; +import 'widgets/rf_dialogs.dart'; import 'widgets/editable_exercise_card.dart'; class EditWorkoutSessionScreen extends StatefulWidget { @@ -206,50 +207,20 @@ class _EditWorkoutSessionScreenState extends State { Future _onWillPop() async { if (!_hasChanges) return true; - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - title: const Text( - 'Discard Changes?', - style: TextStyle(color: AppColors.textPrimary), - ), - content: const Text( - 'You have unsaved changes. Discard them?', - style: TextStyle(color: AppColors.textSoft), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text( - 'Cancel', - style: TextStyle(color: AppColors.textSoft), - ), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Discard'), - ), - ], - ), + final result = await showRFConfirmDialog( + context, + title: 'Discard Changes?', + content: 'You have unsaved changes. Discard them?', + confirmText: 'Discard', + isDanger: true, ); return result ?? false; } void _snack(String msg, {bool isError = false}) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)), - backgroundColor: isError ? AppColors.error : AppColors.cardHigh, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - ), - ), + context.showRFSnackBar( + msg, + type: isError ? RFSnackBarType.error : RFSnackBarType.info, ); } diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 5ad5b8f..caf412d 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -22,6 +22,7 @@ import 'widgets/readiness_card.dart'; import 'widgets/sleep_hr_card.dart'; import 'widgets/heart_rate_card.dart'; import 'widgets/rf_widgets.dart'; +import 'widgets/floating_nav_bar.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; import 'widgets/body_heatmap.dart'; @@ -39,19 +40,34 @@ class _HomeScreenState extends State { int _currentIndex = 0; static const _navItems = [ - RFNavItem(icon: Icons.home_rounded, label: 'Home'), - RFNavItem(icon: Icons.layers_rounded, label: 'Routines'), - RFNavItem(icon: Icons.history_rounded, label: 'History'), - RFNavItem(icon: Icons.bar_chart_rounded, label: 'Stats'), + FloatingNavItem(icon: Icons.home_rounded, label: 'Home'), + FloatingNavItem(icon: Icons.layers_rounded, label: 'Routines'), + FloatingNavItem(icon: Icons.history_rounded, label: 'History'), + FloatingNavItem(icon: Icons.bar_chart_rounded, label: 'Stats'), ]; void switchTab(int index) => setState(() => _currentIndex = index); @override Widget build(BuildContext context) { - return Scaffold( - extendBody: true, - backgroundColor: AppColors.background, + return FloatingNavBarScaffold( + scaffoldBackgroundColor: AppColors.background, + // App-specific colour overrides — all other values use the + // FloatingNavBarTheme defaults which adapt to ThemeData.colorScheme. + theme: FloatingNavBarTheme( + backgroundColor: AppColors.surface, + borderColor: AppColors.glassBorderStrong, + // Chip colours (replaces old pill API) + selectedChipColor: AppColors.glass3, + selectedChipBorderColor: AppColors.primary.withValues(alpha: 0.25), + selectedChipShadowColor: AppColors.primary.withValues(alpha: 0.15), + selectedContentColor: AppColors.textPrimary, + inactiveIconColor: AppColors.textMuted, + outerGlowColor: AppColors.primary.withValues(alpha: 0.08), + ), + items: _navItems, + currentIndex: _currentIndex, + onTabChanged: switchTab, body: IndexedStack( index: _currentIndex, children: const [ @@ -61,11 +77,6 @@ class _HomeScreenState extends State { AnalyticsScreen(), ], ), - bottomNavigationBar: RFNavBar( - currentIndex: _currentIndex, - onTap: switchTab, - items: _navItems, - ), ); } diff --git a/workout-logger/lib/screens/programs/import_program_screen.dart b/workout-logger/lib/screens/programs/import_program_screen.dart index 81de167..84f2a05 100644 --- a/workout-logger/lib/screens/programs/import_program_screen.dart +++ b/workout-logger/lib/screens/programs/import_program_screen.dart @@ -269,7 +269,6 @@ class _ImportProgramScreenState extends State { final result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['json'], - allowMultiple: false, ); if (result == null || result.files.isEmpty) return; diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 687809d..acc511f 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -7,6 +7,19 @@ import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +// Exercise IDs treated as bodyweight-assisted (e.g. an assisted-dip/pull-up +// machine). Computed once here so the load panel and the input row never +// drift out of sync on which exercises count as "assisted". +const Set _assistedBodyweightExerciseIds = { + 'pull_ups', + 'chin_ups', + 'dips', + 'push_ups', +}; + +bool isAssistedBodyweightExercise(String? exerciseId) => + exerciseId != null && _assistedBodyweightExerciseIds.contains(exerciseId); + // ── ExerciseInputSection ────────────────────────────────────────────────────── // Renders: AI suggestion card, weight/reps inputs, dropset section, // LOG SET button, previous sets, last session info, program metadata banner. @@ -37,6 +50,9 @@ class ExerciseInputSection extends StatelessWidget { this.programSlot, this.programWeek, this.exerciseId, + this.availableHandles, + this.selectedHandle, + this.onHandleChanged, }); final double currentWeight; @@ -63,9 +79,18 @@ class ExerciseInputSection extends StatelessWidget { final ProgramExerciseSlot? programSlot; final ProgramWeek? programWeek; final String? exerciseId; + final List? availableHandles; + final String? selectedHandle; + final ValueChanged? onHandleChanged; @override Widget build(BuildContext context) { + final isAssistedBW = isAssistedBodyweightExercise(exerciseId); + final effectiveWeight = (settings.userBodyWeight - currentWeight).clamp(0.0, 500.0); + final effectiveWeightDisplay = settings.toDisplay(effectiveWeight); + final bodyWeightDisplay = settings.toDisplay(settings.userBodyWeight); + final currentWeightDisplay = settings.toDisplay(currentWeight); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -73,6 +98,20 @@ class ExerciseInputSection extends StatelessWidget { if (programSlot != null && programWeek != null) _ProgramMetaBanner(slot: programSlot!, week: programWeek!), + // Handle / Attachment Selector + if (availableHandles != null && availableHandles!.isNotEmpty) ...[ + _HandleSelector( + availableHandles: availableHandles!, + selectedHandle: selectedHandle, + onChanged: onHandleChanged, + // Once a set has been logged for this exercise instance, the + // handle is locked — the selector must not let the user (or + // silently appear to) relabel already-recorded sets. + locked: previousSets.isNotEmpty, + ), + const SizedBox(height: AppSpacing.sm), + ], + // AI suggestion if (recommendations.isNotEmpty) _RecommendationCard( @@ -91,10 +130,31 @@ class ExerciseInputSection extends StatelessWidget { currentWeight: currentWeight, currentReps: currentReps, settings: settings, - exerciseId: exerciseId, + isAssistedBW: isAssistedBW, onWeightChanged: onWeightChanged, onRepsChanged: onRepsChanged, ), + if (isAssistedBW) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Icon(Icons.fitness_center_rounded, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Text( + 'Effective Volume Load: ${effectiveWeightDisplay.toStringAsFixed(1)} ${settings.unitLabel} (${bodyWeightDisplay.toStringAsFixed(1)} BW − ${currentWeightDisplay.toStringAsFixed(1)} Assist) × $currentReps reps', + style: const TextStyle(fontSize: 11, color: AppColors.textSoft, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], const SizedBox(height: AppSpacing.md), ], @@ -139,6 +199,76 @@ class ExerciseInputSection extends StatelessWidget { } } +// ── Handle Selector ────────────────────────────────────────────────────────── +class _HandleSelector extends StatelessWidget { + const _HandleSelector({ + required this.availableHandles, + required this.selectedHandle, + required this.onChanged, + this.locked = false, + }); + + final List availableHandles; + final String? selectedHandle; + final ValueChanged? onChanged; + final bool locked; + + @override + Widget build(BuildContext context) { + // Only show a chip as selected once the user (or a restored draft) has + // actually chosen it — never default-highlight the first handle just + // because nothing has been persisted yet. + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'ATTACHMENT / HANDLE VARIATION', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: availableHandles.map((handle) { + final isSelected = selectedHandle == handle; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(handle), + selected: isSelected, + onSelected: locked + ? null + : (selected) { + if (selected && onChanged != null) { + onChanged!(handle); + } + }, + selectedColor: AppColors.primary.withValues(alpha: 0.25), + backgroundColor: AppColors.surface, + checkmarkColor: AppColors.primary, + labelStyle: TextStyle( + color: isSelected ? AppColors.primary : AppColors.textSoft, + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + side: BorderSide( + color: isSelected ? AppColors.primary : AppColors.glassBorder, + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } +} + // ── Recommendation Card ──────────────────────────────────────────────────────── class _RecommendationCard extends StatelessWidget { const _RecommendationCard({ @@ -259,7 +389,7 @@ class _InputRow extends StatelessWidget { required this.settings, required this.onWeightChanged, required this.onRepsChanged, - this.exerciseId, + this.isAssistedBW = false, }); final double currentWeight; @@ -267,12 +397,10 @@ class _InputRow extends StatelessWidget { final SettingsProvider settings; final ValueChanged onWeightChanged; final ValueChanged onRepsChanged; - final String? exerciseId; + final bool isAssistedBW; @override Widget build(BuildContext context) { - final isAssistedBW = - exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; final weightLabel = isAssistedBW ? 'Assist (${settings.unitLabel})' : settings.unitLabel; final displayWeight = settings.toDisplay(currentWeight); diff --git a/workout-logger/lib/screens/widgets/floating_nav_bar.dart b/workout-logger/lib/screens/widgets/floating_nav_bar.dart new file mode 100644 index 0000000..d3e9af8 --- /dev/null +++ b/workout-logger/lib/screens/widgets/floating_nav_bar.dart @@ -0,0 +1,768 @@ +// floating_nav_bar.dart — Self-contained floating navigation bar for Flutter. +// +// Redesigned with the "expanding chip" pattern from EssentialsFloatingToolbar: +// • Selected tab expands horizontally (spring physics) to reveal an inline label +// • Unselected tabs show icon-only at a fixed compact size +// • Badge dot support on any nav item +// • Glassmorphic container — backdrop blur + border + outer glow +// • Scroll-aware hide/show via FloatingNavBarScaffold +// +// ─── Quick start ────────────────────────────────────────────────────────────── +// +// ```dart +// const items = [ +// FloatingNavItem(icon: Icons.home, label: 'Home'), +// FloatingNavItem(icon: Icons.search, label: 'Search'), +// FloatingNavItem(icon: Icons.person, label: 'Profile'), +// ]; +// +// FloatingNavBarScaffold( +// items: items, +// currentIndex: _index, +// onTabChanged: (i) => setState(() => _index = i), +// body: IndexedStack(index: _index, children: _pages), +// ) +// ``` +// +// ─── Drop-in dependency ─────────────────────────────────────────────────────── +// Only needs the Flutter SDK (material.dart · dart:ui · flutter/physics.dart). + +import 'dart:ui' show ImageFilter; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/services.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavItem +// ───────────────────────────────────────────────────────────────────────────── + +/// A single tab entry for [FloatingNavBar]. +/// +/// [label] is displayed as an expanding inline text when the tab is active +/// and is also used for screen-reader semantics (TalkBack / VoiceOver). +/// Supply [activeIcon] for a distinct icon when selected. +/// Set [hasBadge] to `true` to render a small red indicator dot. +@immutable +class FloatingNavItem { + const FloatingNavItem({ + required this.icon, + required this.label, + this.activeIcon, + this.hasBadge = false, + }); + + /// Icon shown when this tab is **inactive**. + final IconData icon; + + /// Optional icon shown when this tab is **active**. Falls back to [icon]. + final IconData? activeIcon; + + /// Text displayed as an expanding label when active, and used for semantics. + final String label; + + /// When `true`, a small red dot is painted at the top-right of the icon. + final bool hasBadge; + + /// Returns the correct icon for the given [active] state. + IconData iconFor(bool active) => active ? (activeIcon ?? icon) : icon; +} + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavBarTheme +// ───────────────────────────────────────────────────────────────────────────── + +/// Visual and behavioural configuration for [FloatingNavBar] and +/// [FloatingNavBarScaffold]. +/// +/// All colour fields are nullable — `null` values derive from the ambient +/// [ThemeData.colorScheme] at runtime. Override only what you need. +@immutable +class FloatingNavBarTheme { + const FloatingNavBarTheme({ + // ── Colours ────────────────────────────────────────────────────────────── + this.backgroundColor, + this.backgroundOpacity = 0.82, + this.borderColor, + this.borderWidth = 1.2, + /// Background of the selected tab chip. + this.selectedChipColor, + /// Border of the selected tab chip. + this.selectedChipBorderColor, + /// Glow shadow of the selected tab chip. + this.selectedChipShadowColor, + /// Icon + label colour inside the selected chip. + this.selectedContentColor, + this.inactiveIconColor, + this.outerShadowColor, + this.outerGlowColor, + // ── Sizes ──────────────────────────────────────────────────────────────── + this.navHeight = 60.0, + this.chipHeight = 46.0, + /// Fixed width of each icon tap cell (active and inactive). + this.iconCellSize = 48.0, + /// Extra width that slides open when a chip becomes active (label area). + this.labelWidth = 80.0, + this.iconSize = 22.0, + /// Symmetric horizontal inset inside the container. + this.horizontalPadding = 8.0, + /// Gap between adjacent chips. + this.itemSpacing = 4.0, + this.blurSigma = 18.0, + // ── Label ──────────────────────────────────────────────────────────────── + /// Set to `false` to disable label expansion (icon-only compact mode). + this.showLabels = true, + /// Override the label [TextStyle]. Colour is always resolved from theme. + this.labelStyle, + // ── Spring animation ───────────────────────────────────────────────────── + /// Spring mass (heavier = slower). + this.springMass = 1.0, + /// Spring stiffness (higher = snappier). + this.springStiffness = 500.0, + /// Damping ratio: 0.5 = bouncy, 1.0 = critically damped. + this.springDampingRatio = 0.72, + /// Duration for collapsing (non-spring ease-in). + this.collapseDuration = const Duration(milliseconds: 200), + // ── Show / hide animation ───────────────────────────────────────────────── + this.showDuration = const Duration(milliseconds: 250), + this.hideDuration = const Duration(milliseconds: 280), + this.showCurve = Curves.easeInOutCubic, + this.hideCurve = Curves.easeInOutCubic, + // ── Scroll behaviour ───────────────────────────────────────────────────── + /// Set to false to keep the nav bar permanently visible. + this.hideOnScroll = true, + this.scrollDownThreshold = 2.0, + this.scrollUpThreshold = 2.0, + // ── Misc ───────────────────────────────────────────────────────────────── + this.bottomMargin = 16.0, + this.hapticFeedback = true, + // ── Fallback colour opacities ───────────────────────────────────────────── + this.defaultBorderOpacity = 0.13, + this.defaultChipOpacity = 0.10, + this.defaultChipBorderOpacity = 0.25, + this.defaultChipShadowOpacity = 0.15, + this.defaultInactiveOpacity = 0.48, + this.defaultShadowOpacity = 0.45, + this.defaultGlowOpacity = 0.08, + // ── Shadow geometry ─────────────────────────────────────────────────────── + this.outerShadowBlurRadius = 28.0, + this.outerShadowSpread = -4.0, + this.outerShadowOffset = const Offset(0, 10), + this.outerGlowBlurRadius = 32.0, + // ── Slide animation ─────────────────────────────────────────────────────── + /// Offset applied to [AnimatedSlide] when the nav bar hides. + this.slideHideOffset = const Offset(0, 1.5), + /// Fade-out duration = hideDuration × fadeOutDurationFactor. + this.fadeOutDurationFactor = 0.75, + }); + + // ── Colours ───────────────────────────────────────────────────────────────── + final Color? backgroundColor; + final double backgroundOpacity; + final Color? borderColor; + final double borderWidth; + final Color? selectedChipColor; + final Color? selectedChipBorderColor; + final Color? selectedChipShadowColor; + final Color? selectedContentColor; + final Color? inactiveIconColor; + final Color? outerShadowColor; + final Color? outerGlowColor; + + // ── Sizes ──────────────────────────────────────────────────────────────────── + final double navHeight; + final double chipHeight; + final double iconCellSize; + final double labelWidth; + final double iconSize; + final double horizontalPadding; + final double itemSpacing; + final double blurSigma; + + // ── Label ──────────────────────────────────────────────────────────────────── + final bool showLabels; + final TextStyle? labelStyle; + + // ── Spring animation ───────────────────────────────────────────────────────── + final double springMass; + final double springStiffness; + final double springDampingRatio; + final Duration collapseDuration; + + // ── Show / hide animation ──────────────────────────────────────────────────── + final Duration showDuration; + final Duration hideDuration; + final Curve showCurve; + final Curve hideCurve; + + // ── Scroll ─────────────────────────────────────────────────────────────────── + final bool hideOnScroll; + final double scrollDownThreshold; + final double scrollUpThreshold; + + // ── Misc ───────────────────────────────────────────────────────────────────── + final double bottomMargin; + final bool hapticFeedback; + + // ── Fallback opacities ─────────────────────────────────────────────────────── + final double defaultBorderOpacity; + final double defaultChipOpacity; + final double defaultChipBorderOpacity; + final double defaultChipShadowOpacity; + final double defaultInactiveOpacity; + final double defaultShadowOpacity; + final double defaultGlowOpacity; + + // ── Shadow geometry ────────────────────────────────────────────────────────── + final double outerShadowBlurRadius; + final double outerShadowSpread; + final Offset outerShadowOffset; + final double outerGlowBlurRadius; + + // ── Slide animation ────────────────────────────────────────────────────────── + final Offset slideHideOffset; + final double fadeOutDurationFactor; +} + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavBar — pure stateless presentation widget +// ───────────────────────────────────────────────────────────────────────────── + +/// A compact, pill-shaped, glassmorphic navigation bar with expanding chip tabs. +/// +/// The selected tab chips open sideways with a spring animation to reveal the +/// tab label; inactive tabs show the icon only. Inspired by the +/// EssentialsFloatingToolbar pattern from the Compose world. +/// +/// **Purely presentational** — no internal state, no scroll listening. +/// +/// Use [FloatingNavBarScaffold] for the full scroll-aware experience. +class FloatingNavBar extends StatelessWidget { + const FloatingNavBar({ + super.key, + required this.currentIndex, + required this.onTap, + required this.items, + this.theme = const FloatingNavBarTheme(), + }) : assert(items.length >= 2, 'FloatingNavBar requires at least 2 items.'); + + /// Index of the currently selected tab. + final int currentIndex; + + /// Called with the tapped tab index. + final ValueChanged onTap; + + /// Tab definitions. Minimum 2. + final List items; + + /// Visual and layout configuration. + final FloatingNavBarTheme theme; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bottomPad = MediaQuery.of(context).padding.bottom; + + // ── Resolve colours ────────────────────────────────────────────────────── + final bg = theme.backgroundColor ?? + cs.surface.withValues(alpha: theme.backgroundOpacity); + final border = theme.borderColor ?? + cs.outline.withValues(alpha: theme.defaultBorderOpacity); + final chipBg = theme.selectedChipColor ?? + cs.primary.withValues(alpha: theme.defaultChipOpacity); + final chipContent = theme.selectedContentColor ?? cs.onSurface; + final inactiveContent = theme.inactiveIconColor ?? + cs.onSurface.withValues(alpha: theme.defaultInactiveOpacity); + final outerShadow = theme.outerShadowColor ?? + Colors.black.withValues(alpha: theme.defaultShadowOpacity); + final outerGlow = theme.outerGlowColor ?? + cs.primary.withValues(alpha: theme.defaultGlowOpacity); + + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: bottomPad > 0 ? bottomPad : theme.bottomMargin, + ), + child: _ShadowWrapper( + outerShadow: outerShadow, + outerGlow: outerGlow, + shadowBlurRadius: theme.outerShadowBlurRadius, + shadowSpread: theme.outerShadowSpread, + shadowOffset: theme.outerShadowOffset, + glowBlurRadius: theme.outerGlowBlurRadius, + child: ClipRRect( + borderRadius: BorderRadius.circular(9999), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.blurSigma, + sigmaY: theme.blurSigma, + ), + child: Container( + height: theme.navHeight, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(9999), + border: Border.all(color: border, width: theme.borderWidth), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: theme.horizontalPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (int i = 0; i < items.length; i++) ...[ + _NavCell( + item: items[i], + active: i == currentIndex, + theme: theme, + chipBg: chipBg, + chipContent: chipContent, + inactiveContent: inactiveContent, + onTap: () => onTap(i), + ), + if (i < items.length - 1) + SizedBox(width: theme.itemSpacing), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// _ShadowWrapper — outer drop-shadow + ambient glow +// ───────────────────────────────────────────────────────────────────────────── + +/// Applies a drop-shadow and ambient glow **outside** the clipped pill shape. +/// Must be a separate widget because [ClipRRect] clips its own BoxDecoration +/// shadows. +class _ShadowWrapper extends StatelessWidget { + const _ShadowWrapper({ + required this.outerShadow, + required this.outerGlow, + required this.shadowBlurRadius, + required this.shadowSpread, + required this.shadowOffset, + required this.glowBlurRadius, + required this.child, + }); + + final Color outerShadow; + final Color outerGlow; + final double shadowBlurRadius; + final double shadowSpread; + final Offset shadowOffset; + final double glowBlurRadius; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9999), + boxShadow: [ + BoxShadow( + color: outerShadow, + blurRadius: shadowBlurRadius, + spreadRadius: shadowSpread, + offset: shadowOffset, + ), + BoxShadow(color: outerGlow, blurRadius: glowBlurRadius), + ], + ), + child: child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// _NavCell — stateful, spring-animated expanding chip +// ───────────────────────────────────────────────────────────────────────────── + +/// A single tappable chip inside [FloatingNavBar]. +/// +/// When [active] becomes `true`, the chip expands rightward using a +/// [SpringSimulation] (bouncy, lively) to reveal the label text. +/// When [active] becomes `false`, the chip collapses with a quick ease-in. +class _NavCell extends StatefulWidget { + const _NavCell({ + required this.item, + required this.active, + required this.theme, + required this.chipBg, + required this.chipContent, + required this.inactiveContent, + required this.onTap, + }); + + final FloatingNavItem item; + final bool active; + final FloatingNavBarTheme theme; + final Color chipBg; + final Color chipContent; + final Color inactiveContent; + final VoidCallback onTap; + + @override + State<_NavCell> createState() => _NavCellState(); +} + +class _NavCellState extends State<_NavCell> + with SingleTickerProviderStateMixin { + /// Unbounded controller so the spring can overshoot > 1.0 naturally, + /// producing the satisfying bounce on expansion. + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController.unbounded(vsync: this) + ..value = widget.active ? 1.0 : 0.0; + } + + @override + void didUpdateWidget(_NavCell old) { + super.didUpdateWidget(old); + if (old.active == widget.active) return; + + if (widget.active) { + // Spring expand — medium bounce feel, matching DampingRatioMediumBouncy. + _ctrl.animateWith( + SpringSimulation( + SpringDescription.withDampingRatio( + mass: widget.theme.springMass, + stiffness: widget.theme.springStiffness, + ratio: widget.theme.springDampingRatio, + ), + _ctrl.value, + 1.0, + 0.0, // initial velocity + ), + ); + } else { + // Quick ease-in collapse — no spring, feels intentional / snappy. + _ctrl.animateTo( + 0.0, + duration: widget.theme.collapseDuration, + curve: Curves.easeIn, + ); + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = widget.theme; + + return Semantics( + button: true, + label: widget.item.label, + selected: widget.active, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (t.hapticFeedback) HapticFeedback.lightImpact(); + widget.onTap(); + }, + child: AnimatedBuilder( + animation: _ctrl, + builder: (context, _) { + // Raw spring value — may overshoot [0,1] during bounce. + final raw = _ctrl.value; + + // Clamped to [0,1] for colour interpolation (no weird colours). + final colorP = raw.clamp(0.0, 1.0); + + // Width can overshoot slightly for the spring bounce feel. + // Clamp at 1.2× to prevent excessively wide chips on large oscillation. + final widthP = raw.clamp(0.0, 1.2); + + // Label fades in during the second half of expansion. + final labelOpacity = ((colorP - 0.5) * 2.0).clamp(0.0, 1.0); + + // Extra width contributed by the label area. + final extraW = + t.showLabels ? widthP * t.labelWidth : 0.0; + + // Right padding inside chip (breathing room for the label). + final rightPad = t.showLabels ? colorP * 10.0 : 0.0; + + final iconColor = Color.lerp( + widget.inactiveContent, + widget.chipContent, + colorP, + )!; + + return Container( + height: t.chipHeight, + width: (t.iconCellSize + extraW).clamp( + t.iconCellSize, + t.iconCellSize + t.labelWidth * 1.2, + ), + decoration: BoxDecoration( + color: Color.lerp(Colors.transparent, widget.chipBg, colorP), + borderRadius: BorderRadius.circular(9999), + border: colorP > 0.05 + ? Border.all( + color: (t.selectedChipBorderColor ?? widget.chipContent) + .withValues(alpha: 0.28 * colorP), + width: 1.0, + ) + : null, + boxShadow: colorP > 0.05 + ? [ + BoxShadow( + color: + (t.selectedChipShadowColor ?? widget.chipContent) + .withValues(alpha: 0.18 * colorP), + blurRadius: 14, + spreadRadius: -2, + ), + ] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(9999), + child: ClipRect( + child: OverflowBox( + maxWidth: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // ── Icon (fixed width cell) ────────────────────────────── + SizedBox( + width: t.iconCellSize, + height: t.chipHeight, + child: Center( + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Icon( + widget.item.iconFor(widget.active), + size: t.iconSize, + color: iconColor, + ), + // Badge dot + if (widget.item.hasBadge) + Positioned( + right: -3, + top: -3, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all( + // border matches the chip bg for a + // "punched out" halo effect + color: widget.chipBg, + width: 1.5, + ), + ), + ), + ), + ], + ), + ), + ), + + // ── Expanding label area ───────────────────────────────── + if (t.showLabels && extraW > 1.0) ...[ + Opacity( + opacity: labelOpacity, + child: Text( + widget.item.label, + style: (t.labelStyle ?? + const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + )) + .copyWith(color: widget.chipContent), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.clip, + ), + ), + SizedBox(width: rightPad), + ], + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// FloatingNavBarScaffold — all-in-one convenience wrapper +// ───────────────────────────────────────────────────────────────────────────── + +/// A ready-to-use [Scaffold] that wires [FloatingNavBar] with automatic +/// scroll-aware hide/show logic. +/// +/// Scroll notifications propagate from **any** nested scrollable without +/// any [ScrollController] wiring in child widgets. +/// +/// ### Visibility rules +/// | Event | Result | +/// |---|---| +/// | Scroll down `> scrollDownThreshold` | Nav hides | +/// | Scroll up `> scrollUpThreshold` | Nav shows | +/// | Scroll reaches position `0` (top) | Nav always shows | +/// | Tab switch via [onTabChanged] | Nav always shows | +/// +/// ### Bottom content padding +/// The floating nav overlaps content. Add bottom padding to inner lists: +/// ```dart +/// ListView( +/// padding: EdgeInsets.only( +/// bottom: MediaQuery.of(context).padding.bottom +/// + theme.navHeight +/// + theme.bottomMargin +/// + 8, +/// ), +/// ) +/// ``` +class FloatingNavBarScaffold extends StatefulWidget { + const FloatingNavBarScaffold({ + super.key, + required this.items, + required this.body, + required this.currentIndex, + required this.onTabChanged, + this.theme = const FloatingNavBarTheme(), + this.scaffoldBackgroundColor, + }) : assert( + items.length >= 2, + 'FloatingNavBarScaffold requires at least 2 items.', + ); + + /// Tab definitions. Minimum 2. + final List items; + + /// Main content — typically an [IndexedStack] or [PageView]. + final Widget body; + + /// Currently selected index, managed by the parent. + final int currentIndex; + + /// Called when the user taps a tab. The parent must update [currentIndex]. + final ValueChanged onTabChanged; + + /// Visual and behavioural config. + final FloatingNavBarTheme theme; + + /// [Scaffold] background colour. + final Color? scaffoldBackgroundColor; + + @override + State createState() => _FloatingNavBarScaffoldState(); +} + +class _FloatingNavBarScaffoldState extends State { + bool _visible = true; + + // ── Tab change — always restore visibility ──────────────────────────────── + + void _handleTabChange(int index) { + if (!_visible) setState(() => _visible = true); + widget.onTabChanged(index); + } + + // ── Scroll detection ────────────────────────────────────────────────────── + + bool _handleScrollNotification(ScrollNotification n) { + if (!widget.theme.hideOnScroll) return false; + + if (n is ScrollUpdateNotification) { + final delta = n.scrollDelta ?? 0; + + if (delta > widget.theme.scrollDownThreshold && _visible) { + setState(() => _visible = false); + } else if (delta < -widget.theme.scrollUpThreshold && !_visible) { + setState(() => _visible = true); + } + + // At the very top → always show. + if (n.metrics.pixels <= 0 && !_visible) { + setState(() => _visible = true); + } + } + + // Never absorb — let notifications keep bubbling. + return false; + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: widget.scaffoldBackgroundColor, + body: Stack( + children: [ + // Content: scroll notifications propagate upward from here. + NotificationListener( + onNotification: _handleScrollNotification, + child: widget.body, + ), + + // Floating nav bar with slide + fade animation. + AnimatedSlide( + offset: _visible ? Offset.zero : widget.theme.slideHideOffset, + duration: _visible + ? widget.theme.showDuration + : widget.theme.hideDuration, + curve: + _visible ? widget.theme.showCurve : widget.theme.hideCurve, + child: AnimatedOpacity( + opacity: _visible ? 1.0 : 0.0, + duration: _visible + ? widget.theme.showDuration + : Duration( + milliseconds: (widget.theme.hideDuration.inMilliseconds * + widget.theme.fadeOutDurationFactor) + .round(), + ), + curve: + _visible ? widget.theme.showCurve : widget.theme.hideCurve, + // Disable hit-testing when fully hidden. + child: IgnorePointer( + ignoring: !_visible, + child: FloatingNavBar( + currentIndex: widget.currentIndex, + onTap: _handleTabChange, + items: widget.items, + theme: widget.theme, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/health_detail_shell.dart b/workout-logger/lib/screens/widgets/health_detail_shell.dart index fbac10c..25914d6 100644 --- a/workout-logger/lib/screens/widgets/health_detail_shell.dart +++ b/workout-logger/lib/screens/widgets/health_detail_shell.dart @@ -40,7 +40,7 @@ class HealthDetailShell extends StatelessWidget { backgroundColor: AppColors.background, body: Stack( children: [ - const Positioned.fill(child: AmbientGlow()), + const AmbientGlow(), SafeArea( child: Column( children: [ diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart index 521243e..fc6d3cf 100644 --- a/workout-logger/lib/screens/widgets/readiness_card.dart +++ b/workout-logger/lib/screens/widgets/readiness_card.dart @@ -107,22 +107,23 @@ class ReadinessCard extends StatelessWidget { /// One line of evidence from the weakest available component. static String _subtitle(ReadinessSnapshot s) { final parts = <(int, String)>[ - if (s.sleepScore != null) + if (s.sleepScore != null && s.sleepMinutes != null && s.sleepBaselineMinutes != null) ( s.sleepScore!, 'Sleep ${_fmtSleep(s.sleepMinutes!)} vs ${_fmtSleep(s.sleepBaselineMinutes!.round())} avg' ), - if (s.rhrScore != null) + if (s.rhrScore != null && s.restingHr != null && s.rhrBaseline != null) ( s.rhrScore!, 'Resting HR ${s.restingHr!.round()} vs ${s.rhrBaseline!.round()} avg' ), - if (s.hrvScore != null) + if (s.hrvScore != null && s.hrvMs != null && s.hrvBaseline != null) ( s.hrvScore!, 'HRV ${s.hrvMs!.round()}ms vs ${s.hrvBaseline!.round()}ms avg' ), ]; + if (parts.isEmpty) return 'Ready to train'; parts.sort((a, b) => a.$1.compareTo(b.$1)); return parts.first.$2; } diff --git a/workout-logger/lib/screens/widgets/rf_dialogs.dart b/workout-logger/lib/screens/widgets/rf_dialogs.dart new file mode 100644 index 0000000..15b5e1a --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_dialogs.dart @@ -0,0 +1,136 @@ +// rf_dialogs.dart — Reusable RepForge confirmation dialogs and floating toast notifications + +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +/// Types of snackbar toast notifications. +enum RFSnackBarType { info, success, warning, error } + +extension RFSnackBarContext on BuildContext { + /// Displays a standardized RepForge floating SnackBar. + void showRFSnackBar( + String message, { + RFSnackBarType type = RFSnackBarType.info, + Duration duration = const Duration(seconds: 3), + }) { + final Color bgColor; + final Color fgColor; + final IconData icon; + + switch (type) { + case RFSnackBarType.success: + bgColor = AppColors.success; + fgColor = AppColors.textPrimary; // #F4F4F8 on #00C89B: ~4.6:1 ✓ + icon = Icons.check_circle_outline_rounded; + break; + case RFSnackBarType.warning: + bgColor = AppColors.warning; + fgColor = const Color(0xFF1A1200); // near-black on #DBA520: >7:1 ✓ + icon = Icons.warning_amber_rounded; + break; + case RFSnackBarType.error: + bgColor = AppColors.error; + fgColor = AppColors.textPrimary; // #F4F4F8 on #E05040: ~4.7:1 ✓ + icon = Icons.error_outline_rounded; + break; + case RFSnackBarType.info: + bgColor = AppColors.cardHigh; + fgColor = AppColors.textPrimary; // neutral — unchanged + icon = Icons.info_outline_rounded; + break; + } + + ScaffoldMessenger.of(this).hideCurrentSnackBar(); + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + duration: duration, + behavior: SnackBarBehavior.floating, + backgroundColor: bgColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + side: const BorderSide(color: AppColors.glassBorder), + ), + content: Row( + children: [ + Icon(icon, color: fgColor, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + message, + style: TextStyle( + fontFamily: 'Geist', + color: fgColor, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Displays a standardized glassmorphic confirm dialog. +Future showRFConfirmDialog( + BuildContext context, { + required String title, + required String content, + String cancelText = 'Cancel', + String confirmText = 'Confirm', + bool isDanger = false, +}) { + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + side: const BorderSide(color: AppColors.glassBorder), + ), + title: Text( + title, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + content: Text( + content, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 14, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text( + cancelText, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + ), + ), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom( + foregroundColor: isDanger ? AppColors.error : AppColors.primary, + ), + child: Text( + confirmText, + style: TextStyle( + fontFamily: 'Geist', + fontWeight: FontWeight.w600, + color: isDanger ? AppColors.error : AppColors.primary, + ), + ), + ), + ], + ), + ); +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index 261a498..a08d842 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -2,7 +2,6 @@ // All widgets consume AppColors/AppSpacing/AppRadius tokens only. import 'dart:math' as math; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../theme/app_theme.dart'; @@ -135,148 +134,10 @@ class AmbientGlow extends StatelessWidget { } } -// ── RFNavBar ───────────────────────────────────────────────────────────────── -// Premium floating glassmorphic bottom navigation bar with perfect rounded blur, -// deep drop shadow, and clean transparent padding so it sits elegantly above the content. -class RFNavBar extends StatelessWidget { - const RFNavBar({ - super.key, - required this.currentIndex, - required this.onTap, - required this.items, - }); +// ── Nav bar ────────────────────────────────────────────────────────────────── +// Moved to floating_nav_bar.dart (zero-dependency, drop-in portable widget). +// Import and use FloatingNavBar / FloatingNavBarScaffold / FloatingNavItem. - final int currentIndex; - final ValueChanged onTap; - final List items; - - @override - Widget build(BuildContext context) { - final bottomPadding = MediaQuery.of(context).padding.bottom; - return Container( - color: Colors.transparent, // Completely transparent outer container - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - bottomPadding > 0 ? bottomPadding + 8 : 16, - ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.xxl), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 28, - spreadRadius: -4, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.xxl), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.8), // Sleek transparent surface - borderRadius: BorderRadius.circular(AppRadius.xxl), - border: Border.all( - color: AppColors.glassBorderStrong, - width: 1.5, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(items.length, (i) { - final active = i == currentIndex; - return _NavItem( - item: items[i], - active: active, - onTap: () => onTap(i), - ); - }), - ), - ), - ), - ), - ), - ); - } -} - -class RFNavItem { - const RFNavItem({required this.icon, required this.label}); - final IconData icon; - final String label; -} - -class _NavItem extends StatelessWidget { - const _NavItem({ - required this.item, - required this.active, - required this.onTap, - }); - - final RFNavItem item; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: item.label, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 60, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Accent indicator above icon - AnimatedContainer( - duration: AppDurations.normal, - width: active ? 18 : 0, - height: 2, - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(2), - color: AppColors.primary, - boxShadow: active - ? [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.6), - blurRadius: 6, - ), - ] - : null, - ), - ), - Icon( - item.icon, - size: 19, - color: active ? AppColors.textPrimary : AppColors.textMuted, - ), - const SizedBox(height: 4), - Text( - item.label, - style: TextStyle(fontFamily: 'Geist', - fontSize: 10, - fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? AppColors.textPrimary : AppColors.textMuted, - letterSpacing: 0.2, - ), - ), - ], - ), - ), - ), - ); - } -} // ── GlowButton ────────────────────────────────────────────────────────────── // Full-width primary action button with glow shadow + haptic feedback. @@ -1037,3 +898,110 @@ class _SkeletonBoxState extends State ); } } + +// ── RFTextField ───────────────────────────────────────────────────────────── +/// Standardized RepForge glassmorphic text input field. +class RFTextField extends StatefulWidget { + const RFTextField({ + super.key, + required this.controller, + required this.hint, + this.label, + this.keyboardType, + this.inputFormatters, + this.maxLines = 1, + this.onChanged, + this.prefixIcon, + this.suffixIcon, + }); + + final TextEditingController controller; + final String hint; + final String? label; + final TextInputType? keyboardType; + final List? inputFormatters; + final int maxLines; + final ValueChanged? onChanged; + final IconData? prefixIcon; + final Widget? suffixIcon; + + @override + State createState() => _RFTextFieldState(); +} + +class _RFTextFieldState extends State { + late final FocusNode _focusNode; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + setState(() => _isFocused = _focusNode.hasFocus); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label != null) ...[ + Text( + widget.label!, + style: const TextStyle( + fontFamily: 'GeistMono', + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + ], + Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: _isFocused ? AppColors.primary : AppColors.glassBorder, + width: _isFocused ? 1.5 : 1.0, + ), + ), + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, + maxLines: widget.maxLines, + onChanged: widget.onChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(color: AppColors.textMuted, fontSize: 14), + prefixIcon: widget.prefixIcon != null + ? Icon(widget.prefixIcon, color: AppColors.textSoft, size: 20) + : null, + suffixIcon: widget.suffixIcon, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + border: InputBorder.none, + ), + ), + ), + ], + ); + } +} + diff --git a/workout-logger/lib/screens/widgets/routine_creator.dart b/workout-logger/lib/screens/widgets/routine_creator.dart index 97fc6b5..deaa717 100644 --- a/workout-logger/lib/screens/widgets/routine_creator.dart +++ b/workout-logger/lib/screens/widgets/routine_creator.dart @@ -10,6 +10,7 @@ import '../../data/exercise_database.dart'; import '../workout_flow_screen.dart'; import 'rf_widgets.dart'; import 'rf_cards.dart'; +import 'rf_dialogs.dart'; import 'workout_conflict_dialog.dart'; // ── Start routine workout (shared helper) ───────────────────────────────────── @@ -101,25 +102,10 @@ class _CreateRoutineScreenState extends State { children: [ Padding( padding: const EdgeInsets.all(AppSpacing.md), - child: Container( - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppColors.glassBorder), - ), - child: TextField( - controller: _nameController, - style: const TextStyle(color: AppColors.textPrimary), - decoration: const InputDecoration( - hintText: 'Routine name (e.g. Push Day)', - hintStyle: TextStyle(color: AppColors.textMuted), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.md, - ), - ), - ), + child: RFTextField( + controller: _nameController, + hint: 'Routine name (e.g. Push Day)', + prefixIcon: Icons.fitness_center_rounded, ), ), Padding( @@ -159,15 +145,16 @@ class _CreateRoutineScreenState extends State { AppSpacing.md, ), itemCount: _selectedIds.length + 1, - onReorder: (old, next) { + onReorderItem: (old, next) { if (old >= _selectedIds.length || - next >= _selectedIds.length + 1) { + next > _selectedIds.length) { return; } setState(() { - if (next > old) next--; final item = _selectedIds.removeAt(old); - _selectedIds.insert(next, item); + final targetIndex = + next > _selectedIds.length ? _selectedIds.length : next; + _selectedIds.insert(targetIndex, item); }); }, itemBuilder: (_, i) { @@ -409,14 +396,16 @@ class _CreateRoutineScreenState extends State { Future _save() async { if (_nameController.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please enter a routine name')), + context.showRFSnackBar( + 'Please enter a routine name', + type: RFSnackBarType.warning, ); return; } if (_selectedIds.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please add at least one exercise')), + context.showRFSnackBar( + 'Please add at least one exercise', + type: RFSnackBarType.warning, ); return; } @@ -440,8 +429,9 @@ class _CreateRoutineScreenState extends State { if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save routine: $e')), + context.showRFSnackBar( + 'Failed to save routine: $e', + type: RFSnackBarType.error, ); } } diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 24bbd90..56ac25c 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -168,7 +168,11 @@ class _WorkoutFlowScreenState extends State { final exercise = provider.currentExercise; if (exercise == null) return; - final last = provider.getLastSessionForExercise(exercise.id); + final currentHandle = provider.currentExerciseLog?.handle; + final last = provider.getLastSessionForExercise( + exercise.id, + handle: currentHandle, + ); if (last != null && last.sets.isNotEmpty) { final lastSet = last.sets.last; setState(() { @@ -265,12 +269,13 @@ class _WorkoutFlowScreenState extends State { final isFirst = idx == 0; final isLast = idx >= totalExercises - 1; + final selectedHandle = log?.handle; final recommendations = exercise != null - ? provider.getRecommendations(exercise.id) + ? provider.getRecommendations(exercise.id, handle: selectedHandle) : []; final lastSession = exercise != null - ? provider.getLastSessionForExercise(exercise.id) + ? provider.getLastSessionForExercise(exercise.id, handle: selectedHandle) : null; return Column( @@ -316,6 +321,12 @@ class _WorkoutFlowScreenState extends State { lastSession: lastSession, settings: settings, exerciseId: exercise?.id, + availableHandles: exercise?.availableHandles, + selectedHandle: selectedHandle, + onHandleChanged: (h) { + provider.setExerciseHandle(h); + _loadLastSessionData(); + }, programSlot: _slot(idx, p: provider), programWeek: _resolvedWeek(provider), onWeightChanged: (v) => setState(() => _currentWeight = v), @@ -534,15 +545,26 @@ class _WorkoutFlowScreenState extends State { void _completeSet() { final provider = context.read(); + final settings = context.read(); final idx = provider.currentExerciseIndex; final currentSlot = _slot(idx, p: provider); final nextSlot = _slot(idx + 1, p: provider); + // For bodyweight-assisted exercises (assisted dips/pull-ups/etc.) the + // weight input represents the assist load, not the lifted load. Snapshot + // the assist weight and the bodyweight it was computed against so + // historical volume stays correct even if the user's bodyweight later + // changes in settings. + final isAssistedBW = + isAssistedBodyweightExercise(provider.currentExercise?.id); + final set = WorkoutSet( weight: _currentWeight, reps: _currentReps, isDropset: _isDropset, drops: _isDropset ? List.from(_drops) : null, + assistWeight: isAssistedBW ? _currentWeight : null, + bodyWeightAtLog: isAssistedBW ? settings.userBodyWeight : null, ); provider.addSet(set); @@ -684,8 +706,7 @@ class _WorkoutFlowScreenState extends State { await context.read().finishWorkout(); final newPRs = await prManager.checkAndUpdatePRs(session); if (!mounted) return; - nav.pop(); - nav.push(MaterialPageRoute( + nav.pushReplacement(MaterialPageRoute( builder: (_) => WorkoutSummaryScreen( session: session, newPRs: newPRs, diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index c43c384..b377052 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -5,11 +5,15 @@ // query methods on WorkoutProvider / PRManager — no new analytics logic lives // here, only the schema + arg parsing + JSON shaping. +import 'dart:math' as math; + import 'package:google_generative_ai/google_generative_ai.dart'; import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; import '../workout_provider.dart'; import '../managers/pr_manager.dart'; +import '../managers/health_history_manager.dart'; class AmbiguousMatchException implements Exception { const AmbiguousMatchException(this.candidates); @@ -19,8 +23,15 @@ class AmbiguousMatchException implements Exception { class CoachToolService { final WorkoutProvider _wp; final PRManager _pr; + final HealthHistoryManager? _hh; - CoachToolService(this._wp, this._pr); + CoachToolService({ + required WorkoutProvider workoutProvider, + required PRManager prManager, + HealthHistoryManager? healthHistory, + }) : _wp = workoutProvider, + _pr = prManager, + _hh = healthHistory; /// Tool declaration for the optimizer screen's `ask_user_questions` flow. /// NOT included in the coach's tool list — only the optimizer adds it. @@ -70,6 +81,30 @@ class CoachToolService { /// Tool declarations advertised to the model. List buildTools() => [ Tool(functionDeclarations: [ + FunctionDeclaration( + 'get_muscle_group_volume', + 'Get volume history over time for one or multiple muscle groups ' + '(e.g. ["Biceps", "Triceps"] or ["Chest", "Back"]). Returns dates, ' + 'per-muscle volume series over time, and totals. Use for muscle ' + 'comparisons (like "biceps vs triceps graph") or muscle volume ' + 'distribution breakdown.', + Schema.object( + properties: { + 'muscle_groups': Schema.array( + items: Schema.string(), + description: + 'List of muscle group names, e.g. ["Biceps", "Triceps"] or ' + '["Chest", "Back", "Legs"].', + ), + 'days': Schema.integer( + description: + 'Optional. Number of days to look back (defaults to 60).', + nullable: true, + ), + }, + requiredProperties: ['muscle_groups'], + ), + ), FunctionDeclaration( 'get_exercise_performance', 'Get how a specific exercise has progressed: per-session volume ' @@ -273,6 +308,63 @@ class CoachToolService { requiredProperties: ['name', 'category', 'primary_muscle'], ), ), + FunctionDeclaration( + 'get_health_metrics', + 'Fetch historical sleep sessions and sleep stage breakdown (deep, REM, ' + 'light, awake minutes) over the last N days. Use for sleep & ' + 'recovery queries.', + Schema.object( + properties: { + 'days': Schema.integer( + description: 'Optional. Number of days to look back (defaults to 30).', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'analyze_health_workout_correlation', + 'Run an analytical statistical pipeline calculating Mean (µ), Standard Deviation (σ), ' + 'Pearson Correlation Coefficient (r), and linear regression (y = mx + b) between a health metric ' + '(sleep_hours, deep_sleep_min) and a workout metric ' + '(workout_volume, session_duration, exercise_max_weight). Returns analytical stats ' + 'and paired coordinates ready to visualize.', + Schema.object( + properties: { + 'x_metric': Schema.string( + description: 'Health metric, e.g. "sleep_hours", "deep_sleep_min".', + ), + 'y_metric': Schema.string( + description: 'Workout metric, e.g. "workout_volume", "session_duration", "exercise_max_weight".', + ), + 'exercise_name': Schema.string( + description: 'Optional. Specific exercise name if y_metric is "exercise_max_weight".', + nullable: true, + ), + 'days': Schema.integer( + description: 'Optional. Number of days to consider (defaults to 60).', + nullable: true, + ), + }, + requiredProperties: ['x_metric', 'y_metric'], + ), + ), + FunctionDeclaration( + 'get_sleeping_hr_analytics', + 'Fetch and compute sleeping heart rate statistics over the past N days (e.g. 14 days). ' + 'Returns overnight p5 (5th percentile sleeping HR floor), p25, median, p75, p95, mean, min, max, ' + 'standard deviation (stdev), variance, linear trend (slope/direction), and nightly ' + 'time-series data as labels + series ready to chart. ' + 'Use whenever the user asks to analyze sleeping HR, overnight HR variation, or recovery trends.', + Schema.object( + properties: { + 'days': Schema.integer( + description: 'Optional. Number of days to analyze (defaults to 14).', + nullable: true, + ), + }, + ), + ), ]), ]; @@ -280,6 +372,14 @@ class CoachToolService { /// JSON-serializable result map. Future> handleCall(FunctionCall call) async { switch (call.name) { + case 'get_sleeping_hr_analytics': + return await _getSleepingHrAnalytics(call.args); + case 'get_health_metrics': + return await _getHealthMetrics(call.args); + case 'analyze_health_workout_correlation': + return await _analyzeHealthWorkoutCorrelation(call.args); + case 'get_muscle_group_volume': + return _muscleGroupVolume(call.args); case 'get_exercise_performance': return _exercisePerformance(call.args); case 'get_workouts_in_range': @@ -307,14 +407,391 @@ class CoachToolService { // ── Tool implementations ─────────────────────────────────────────────────── + Future> _getSleepingHrAnalytics( + Map args) async { + final hh = _hh; + if (hh == null) { + return { + 'error': + 'Health Connect integration is not active or HealthHistoryManager unavailable.' + }; + } + + // Clamp before the per-day loop below — an unbounded model-supplied value + // (e.g. `days: 99999`) would otherwise fan out into a huge number of + // sequential hh.sleepNight() lookups. + final days = _limitArg(args, 14, key: 'days', max: 60); + final now = DateTime.now(); + final dailyStats = >[]; + final p5List = []; + final p25List = []; + final meanList = []; + final labels = []; + + for (var i = days - 1; i >= 0; i--) { + final morning = now.subtract(Duration(days: i)); + final dateStr = _d(morning); + final snap = await hh.sleepNight(morning); + + if (snap != null) { + final p5 = snap.p5Bpm.toDouble(); + final p95 = snap.p95Bpm.toDouble(); + + double meanBpm = 0; + double stdevBpm = 0; + double varianceBpm = 0; + double p25Bpm = p5; + + if (snap.segments.isNotEmpty) { + final avgs = snap.segments.map((s) => s.avgBpm).toList()..sort(); + meanBpm = avgs.reduce((a, b) => a + b) / avgs.length; + p25Bpm = avgs[(avgs.length * 0.25).floor().clamp(0, avgs.length - 1)]; + + final varSum = + avgs.fold(0.0, (sum, x) => sum + (x - meanBpm) * (x - meanBpm)); + varianceBpm = varSum / avgs.length; + stdevBpm = math.sqrt(varianceBpm); + } else { + meanBpm = (p5 + p95) / 2.0; + } + + p5List.add(p5); + p25List.add(_round(p25Bpm)); + meanList.add(_round(meanBpm)); + labels.add('${morning.month}/${morning.day}'); + + dailyStats.add({ + 'date': dateStr, + 'p5_bpm': snap.p5Bpm, + 'p25_bpm': _round(p25Bpm), + 'mean_bpm': _round(meanBpm), + 'p95_bpm': snap.p95Bpm, + 'stdev': _round(stdevBpm), + 'variance': _round(varianceBpm), + 'segment_count': snap.segments.length, + }); + } + } + + if (p5List.isEmpty) { + return { + 'error': 'No sleeping heart rate records found in the last $days days.' + }; + } + + final p5Mean = p5List.reduce((a, b) => a + b) / p5List.length; + final p5VarSum = + p5List.fold(0.0, (sum, x) => sum + (x - p5Mean) * (x - p5Mean)); + final p5Variance = p5VarSum / p5List.length; + final p5Stdev = math.sqrt(p5Variance); + + double slope = 0.0; + if (p5List.length > 1) { + final n = p5List.length; + double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; + for (var i = 0; i < n; i++) { + sumX += i; + sumY += p5List[i]; + sumXY += i * p5List[i]; + sumXX += i * i; + } + final denom = n * sumXX - sumX * sumX; + if (denom != 0) { + slope = (n * sumXY - sumX * sumY) / denom; + } + } + + final trendDirection = + slope < -0.1 ? 'improving' : (slope > 0.1 ? 'elevated' : 'stable'); + + return { + 'days_analyzed': days, + 'valid_nights_count': p5List.length, + 'overall_summary': { + 'mean_p5_sleeping_hr': _round(p5Mean), + 'stdev_p5_sleeping_hr': _round(p5Stdev), + 'variance_p5_sleeping_hr': _round(p5Variance), + 'min_p5_sleeping_hr': p5List.reduce(math.min), + 'max_p5_sleeping_hr': p5List.reduce(math.max), + 'linear_trend_slope': _round(slope), + 'trend_direction': trendDirection, + }, + 'daily_breakdown': dailyStats, + // Domain-neutral series the model can shape into any component. The tool + // layer deliberately does not name A2UI components: presentation is the + // prompt's decision, not the data layer's. + 'labels': labels, + 'series': [ + {'name': 'P5 Sleeping HR', 'values': p5List}, + {'name': 'P25 HR', 'values': p25List}, + {'name': 'Mean HR', 'values': meanList}, + ], + }; + } + + Future> _getHealthMetrics(Map args) async { + final hh = _hh; + if (hh == null) { + return {'error': 'Health Connect integration is not active or HealthHistoryManager unavailable.'}; + } + final days = _limitArg(args, 30, key: 'days', max: 31); + final now = DateTime.now(); + // Week granularity only covers the last 7 days; anything wider needs the + // month bucket. Both return per-night bars, so trim to the exact window. + final granularity = + days <= 7 ? HealthGranularity.week : HealthGranularity.month; + final allBars = await hh.sleepBars(now, granularity); + final bars = + allBars.length > days ? allBars.sublist(allBars.length - days) : allBars; + + return { + 'days': days, + 'sleep_records': [ + for (final b in bars) + { + 'date': _d(b.date), + 'total_hours': _round(b.totalMinutes / 60.0), + 'deep_min': b.deepMin, + 'rem_min': b.remMin, + 'light_min': b.lightMin, + 'awake_min': b.awakeMin, + } + ], + }; + } + + Future> _analyzeHealthWorkoutCorrelation( + Map args) async { + final xMetric = (args['x_metric'] as String?)?.trim() ?? 'sleep_hours'; + final yMetric = (args['y_metric'] as String?)?.trim() ?? 'workout_volume'; + final exName = (args['exercise_name'] as String?)?.trim(); + final days = (args['days'] as num?)?.toInt() ?? 60; + + final cutoff = DateTime.now().subtract(Duration(days: days)); + final sessions = _wp.sessions.where((s) => !s.date.isBefore(cutoff)).toList(); + + if (sessions.isEmpty) { + return {'error': 'No workout sessions logged in the last $days days.'}; + } + + final dayData = >{}; + + for (final s in sessions) { + final key = _d(s.date); + final m = dayData.putIfAbsent(key, () => {}); + + if (yMetric == 'workout_volume') { + var vol = 0.0; + for (final exLog in s.exercises) { + for (final set in exLog.sets) { + vol += (set.weight * set.reps); + } + } + m['y'] = vol; + } else if (yMetric == 'session_duration') { + m['y'] = s.duration.toDouble(); + } else if (yMetric == 'exercise_max_weight' && exName != null) { + var maxW = 0.0; + final ex = _resolveExercise(exName); + if (ex != null) { + for (final exLog in s.exercises.where((e) => e.exerciseId == ex.id)) { + for (final set in exLog.sets) { + if (set.weight > maxW) maxW = set.weight; + } + } + } + if (maxW > 0) m['y'] = maxW; + } + } + + final hh = _hh; + if (hh != null) { + final bars = await hh.sleepBars(DateTime.now(), HealthGranularity.week); + for (final b in bars) { + final key = _d(b.date); + final m = dayData[key]; + if (m != null) { + if (xMetric == 'sleep_hours') { + m['x'] = _round(b.totalMinutes / 60.0); + } else if (xMetric == 'deep_sleep_min') { + m['x'] = b.deepMin.toDouble(); + } + } + } + } + + final points = >[]; + final xVals = []; + final yVals = []; + + for (final entry in dayData.entries) { + final x = entry.value['x']; + final y = entry.value['y']; + if (x != null && y != null && x > 0 && y > 0) { + xVals.add(x); + yVals.add(y); + points.add({'x': x, 'y': y, 'date': entry.key}); + } + } + + final n = xVals.length; + if (n < 2) { + return {'error': 'Insufficient paired data points for correlation analysis.'}; + } + + final xMean = xVals.reduce((a, b) => a + b) / n; + final yMean = yVals.reduce((a, b) => a + b) / n; + + var xVarSum = 0.0, yVarSum = 0.0, covSum = 0.0; + for (var i = 0; i < n; i++) { + final dx = xVals[i] - xMean; + final dy = yVals[i] - yMean; + xVarSum += dx * dx; + yVarSum += dy * dy; + covSum += dx * dy; + } + + final xStd = n > 1 ? math.sqrt(xVarSum / (n - 1)) : 0.0; + final yStd = n > 1 ? math.sqrt(yVarSum / (n - 1)) : 0.0; + final r = (xVarSum > 0 && yVarSum > 0) ? (covSum / math.sqrt(xVarSum * yVarSum)) : 0.0; + + final slope = xVarSum > 0 ? (covSum / xVarSum) : 0.0; + final intercept = yMean - (slope * xMean); + + String corrType; + if (r >= 0.7) { + corrType = 'strong_positive'; + } else if (r >= 0.3) { + corrType = 'moderate_positive'; + } else if (r <= -0.7) { + corrType = 'strong_negative'; + } else if (r <= -0.3) { + corrType = 'moderate_negative'; + } else { + corrType = 'neutral'; + } + + return { + 'pipeline': 'Health & Workout Statistical Correlation', + 'sample_count': n, + 'x_metric': xMetric, + 'x_mean': _round(xMean), + 'x_std_dev': _round(xStd), + 'y_metric': yMetric, + 'y_mean': _round(yMean), + 'y_std_dev': _round(yStd), + 'pearson_r': _round(r), + 'correlation_type': corrType, + 'trendline': { + 'slope': _round(slope), + 'intercept': _round(intercept), + }, + 'points': points, + }; + } + + Map _muscleGroupVolume(Map args) { + final rawGroups = (args['muscle_groups'] as List?)?.cast() ?? []; + final days = (args['days'] as num?)?.toInt() ?? 60; + final cutoff = DateTime.now().subtract(Duration(days: days)); + + final allExercises = _wp.allExercises; + final allSessions = _wp.sessions + .where((s) => !s.date.isBefore(cutoff)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + final dateMap = >{}; + final muscleTotals = {}; + + for (final groupName in rawGroups) { + muscleTotals[groupName] = 0.0; + + // Resolve the requested display name to its muscle group ID first — + // `Exercise.primaryMuscle` is itself an ID (e.g. "quads"), not a + // display name, so comparing it against the raw group name via + // substring matching is unreliable (false misses for e.g. "Quadriceps" + // vs id "quads", false matches for unrelated short ids). Matching by + // resolved ID also lets us include secondary muscle activations, not + // just each exercise's primary one. + MuscleGroup? resolvedGroup; + try { + resolvedGroup = _resolveMuscleGroup(groupName); + } on AmbiguousMatchException { + resolvedGroup = null; + } + if (resolvedGroup == null) continue; + final targetId = resolvedGroup.id; + + final matchingExerciseIds = allExercises + .where((e) => e.muscleActivations.any((m) => m.muscleGroupId == targetId)) + .map((e) => e.id) + .toSet(); + + for (final session in allSessions) { + final dateKey = _d(session.date); + var groupVol = 0.0; + for (final exLog in session.exercises) { + if (matchingExerciseIds.contains(exLog.exerciseId)) { + for (final set in exLog.sets) { + groupVol += (set.weight * set.reps); + } + } + } + if (groupVol > 0) { + dateMap.putIfAbsent(dateKey, () => {})[groupName] = + (dateMap[dateKey]?[groupName] ?? 0.0) + groupVol; + muscleTotals[groupName] = (muscleTotals[groupName] ?? 0) + groupVol; + } + } + } + + final dates = dateMap.keys.toList()..sort(); + final series = >[]; + for (final groupName in rawGroups) { + final values = []; + for (final d in dates) { + values.add(_round(dateMap[d]?[groupName] ?? 0.0)); + } + series.add({ + 'name': groupName, + 'values': values, + }); + } + + return { + 'dates': dates, + 'labels': dates.map((d) => d.length > 5 ? d.substring(5) : d).toList(), + 'series': series, + 'totals': { + for (final entry in muscleTotals.entries) + entry.key: _round(entry.value), + }, + }; + } + Map _exercisePerformance(Map args) { final name = (args['exercise_name'] as String?)?.trim() ?? ''; + final days = (args['days'] as num?)?.toInt(); + final Exercise exercise; try { final resolved = _resolveExercise(name); if (resolved == null) { + // Fallback: check if the prompt queried a muscle group (e.g., "biceps", "triceps") + final muscleRes = _muscleGroupVolume({'muscle_groups': [name], 'days': days ?? 60}); + final series = (muscleRes['series'] as List?) ?? []; + if (series.isNotEmpty && (series[0]['values'] as List).isNotEmpty) { + return { + 'is_muscle_group': true, + 'muscle_group': name, + 'labels': muscleRes['labels'], + 'series': series, + 'totals': muscleRes['totals'], + }; + } return { - 'error': 'No exercise found matching "$name".', + 'error': 'No exercise or muscle group found matching "$name".', 'available_examples': _exampleExerciseNames(), }; } @@ -326,7 +803,6 @@ class CoachToolService { }; } - final days = (args['days'] as num?)?.toInt(); final cutoff = days != null ? DateTime.now().subtract(Duration(days: days)) : null; @@ -863,10 +1339,13 @@ class CoachToolService { double _round(double v) => (v * 10).round() / 10; double? _roundOrNull(double? v) => v == null ? null : _round(v); - /// Read an optional `limit` arg, clamped to [1, 40]; [fallback] when absent. - int _limitArg(Map args, int fallback) { - final n = (args['limit'] as num?)?.toInt(); + /// Read an optional numeric arg (defaults to the `limit` key), clamped to + /// [1, max]; [fallback] when absent. Reused by any tool that accepts a + /// model-supplied bound (e.g. `limit`, `days`) to prevent runaway loops. + int _limitArg(Map args, int fallback, + {String key = 'limit', int max = 40}) { + final n = (args[key] as num?)?.toInt(); if (n == null) return fallback; - return n.clamp(1, 40); + return n.clamp(1, max); } } diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index de8729c..58cdf89 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -24,19 +24,20 @@ import '../interfaces/storage_service_interface.dart'; // Ordered list of available Gemini models shown in the picker. const kGeminiModels = [ ('gemini-2.5-flash', 'Gemini 2.5 Flash'), - ('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'), ('gemini-3.1-flash-lite', 'Gemini 3.1 Flash Lite'), + ('gemini-3.5-flash-lite', 'Gemini 3.5 Flash Lite'), ('gemini-3.5-flash', 'Gemini 3.5 Flash'), + ('gemini-3.6-flash', 'Gemini 3.6 Flash'), ]; // Default to the latest GA model. -const kDefaultGeminiModel = 'gemini-3.5-flash'; +const kDefaultGeminiModel = 'gemini-3.6-flash'; // Upper bound on tool-resolution rounds per user turn, to bound runaway loops. const int _kMaxToolRounds = 5; // Retry policy for transient (5xx / 429) errors. Total attempts = 1 + retries. -const int _kMaxRetries = 2; +const int _kMaxRetries = 3; const String _apiBase = 'https://generativelanguage.googleapis.com/v1beta/models'; @@ -49,6 +50,66 @@ bool _isRetryableStatus(int code) => code == 429 || (code >= 500 && code < 600); Duration _retryBackoff(int attempt) => Duration(milliseconds: 500 * (1 << attempt)); +/// Extracts exact retryDelay provided by Google in 429/503 payloads. +/// Checks error.details (google.rpc.RetryInfo) or error.message ("Please retry in Xs"). +Duration? _extractRetryDelay(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map && decoded['error'] is Map) { + final errMap = decoded['error'] as Map; + // 1. Check error.details for google.rpc.RetryInfo + final details = errMap['details']; + if (details is List) { + for (final item in details) { + if (item is Map && item['retryDelay'] is String) { + final delayStr = (item['retryDelay'] as String).replaceAll('s', '').trim(); + final seconds = double.tryParse(delayStr); + if (seconds != null && seconds > 0) { + final ms = (seconds * 1000).ceil() + 350; + return Duration(milliseconds: ms.clamp(500, 45000)); + } + } + } + } + // 2. Regex match in error.message (e.g. "Please retry in 23.690750876s.") + final message = errMap['message']; + if (message is String) { + final match = RegExp(r'retry in\s+([\d.]+)\s*s', caseSensitive: false).firstMatch(message); + if (match != null) { + final seconds = double.tryParse(match.group(1)!); + if (seconds != null && seconds > 0) { + final ms = (seconds * 1000).ceil() + 350; + return Duration(milliseconds: ms.clamp(500, 45000)); + } + } + } + } + } catch (_) {} + return null; +} + +// Deliberately narrow: only match identifiers Gemini uses for DAILY-scale +// quota metrics. Generic markers like "QuotaExceeded"/"RESOURCE_EXHAUSTED" +// also fire for per-minute rate limits, which should fall through to the +// normal retry-with-delay handling instead of triggering a model fallback. +bool _isDailyQuotaExhausted(String body) { + return body.contains('GenerateRequestsPerDay') || + body.contains('free_tier_requests'); +} + +String? _getFallbackModel(String currentModel) { + switch (currentModel) { + case 'gemini-3.6-flash': + return 'gemini-3.5-flash'; + case 'gemini-3.5-flash': + return 'gemini-3.5-flash-lite'; + case 'gemini-3.5-flash-lite': + return 'gemini-2.5-flash'; + default: + return null; + } +} + // Gemini error bodies look like {"error":{"code":503,"message":"…","status":"…"}}. // Surface just the human-readable message rather than the whole JSON blob. String _errorMessage(int code, String body) { @@ -196,13 +257,20 @@ class GeminiAiService extends ChangeNotifier implements IAiService { }, if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), 'generationConfig': { - // Disable thinking tokens so SDK-incompatible thoughtSignature parts - // are never returned by Gemini 3.x models. - 'thinkingConfig': {'thinkingBudget': 0}, + 'thinkingConfig': _thinkingConfig, if (jsonMode) 'responseMimeType': 'application/json', }, }; + // gemini-2.5-flash predates the Gemini 3.x thinking-level enum and only + // understands the older thinkingBudget (integer token budget) shape; + // 3.x models take thinkingLevel (minimal/medium/high). Since the daily + // quota fallback chain can land on either family mid-conversation, the + // config shape must match whichever model is currently selected. + Map get _thinkingConfig => _model == 'gemini-2.5-flash' + ? {'thinkingBudget': 0} + : {'thinkingLevel': 'minimal'}; + // Extracts non-thought text strings from a candidate object. Iterable _textFromCandidate(Map candidate) sync* { final content = candidate['content'] as Map?; @@ -219,16 +287,15 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // Streams parsed SSE chunks from the streamGenerateContent endpoint. Stream> _streamSse(Map body) async* { - final uri = Uri.parse( - '$_apiBase/$_model:streamGenerateContent?alt=sse&key=$_apiKey', - ); - // Establish the connection with retries. Retrying is only safe here — // before any bytes are yielded — so a transient 503 never reaches the user, // but a mid-stream failure is not retried (it would duplicate output). http.Client client = http.Client(); http.StreamedResponse streamed; for (var attempt = 0;; attempt++) { + final uri = Uri.parse( + '$_apiBase/$_model:streamGenerateContent?alt=sse&key=$_apiKey', + ); final request = http.Request('POST', uri) ..headers['Content-Type'] = 'application/json' ..body = jsonEncode(body); @@ -238,9 +305,25 @@ class GeminiAiService extends ChangeNotifier implements IAiService { break; } final err = await resp.stream.bytesToString(); - if (_isRetryableStatus(resp.statusCode) && attempt < _kMaxRetries) { + + // Automatically fallback to next model when daily free quota limit is reached. + if (_isDailyQuotaExhausted(err)) { + final fallback = _getFallbackModel(_model); + if (fallback != null) { + _model = fallback; + notifyListeners(); + client.close(); + client = http.Client(); + continue; + } + } + + final customDelay = _extractRetryDelay(err); + if (_isRetryableStatus(resp.statusCode) && + (attempt < _kMaxRetries || (customDelay != null && attempt < 4))) { client.close(); - await Future.delayed(_retryBackoff(attempt)); + final delay = customDelay ?? _retryBackoff(attempt); + await Future.delayed(delay); client = http.Client(); continue; } @@ -280,9 +363,9 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // Single-shot (non-streaming) generateContent call, with retry on 5xx/429. Future> _generate(Map body) async { - final uri = Uri.parse('$_apiBase/$_model:generateContent?key=$_apiKey'); final payload = jsonEncode(body); for (var attempt = 0;; attempt++) { + final uri = Uri.parse('$_apiBase/$_model:generateContent?key=$_apiKey'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, @@ -291,8 +374,21 @@ class GeminiAiService extends ChangeNotifier implements IAiService { if (response.statusCode == 200) { return jsonDecode(response.body) as Map; } - if (_isRetryableStatus(response.statusCode) && attempt < _kMaxRetries) { - await Future.delayed(_retryBackoff(attempt)); + + if (_isDailyQuotaExhausted(response.body)) { + final fallback = _getFallbackModel(_model); + if (fallback != null) { + _model = fallback; + notifyListeners(); + continue; + } + } + + final customDelay = _extractRetryDelay(response.body); + if (_isRetryableStatus(response.statusCode) && + (attempt < _kMaxRetries || (customDelay != null && attempt < 4))) { + final delay = customDelay ?? _retryBackoff(attempt); + await Future.delayed(delay); continue; } throw Exception(_errorMessage(response.statusCode, response.body)); @@ -305,10 +401,23 @@ class GeminiAiService extends ChangeNotifier implements IAiService { return _textFromCandidate(candidates[0] as Map).join(); } - // ── Coach chat (streaming + optional tool-call loop) ─────────────────────── - // [history] is the prior conversation as alternating user/model Content. - // When [tools] + [onToolCall] are supplied, function calls the model emits - // are dispatched and their results fed back until a text answer is produced. + // ── Generic & domain chat (streaming + optional tool-call loop) ─────────── + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + @override Stream streamCoachReply({ required String userMessage, @@ -340,6 +449,11 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // we echo this turn back to the API in the next round. final rawModelParts = >[]; final calls = []; + // Parallel to `calls` — the SDK's FunctionCall type has no `id` + // field, so ids are tracked alongside it and matched back up when + // building functionResponse parts (needed to correlate responses in + // multi-tool-call turns). + final callIds = []; Map? lastUsage; await for (final chunk in _streamSse(body)) { @@ -362,6 +476,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { (fc['args'] as Map? ?? {}) .cast(), )); + callIds.add(fc['id'] as String?); } } } @@ -379,22 +494,29 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // Resolve every call and feed the results back as one function turn. final responseParts = >[]; - for (final call in calls) { + for (var i = 0; i < calls.length; i++) { + final call = calls[i]; + final id = callIds[i]; try { final result = await onToolCall(call); responseParts.add({ - 'functionResponse': {'name': call.name, 'response': result} + 'functionResponse': { + 'name': call.name, + 'id': ?id, + 'response': result, + } }); } catch (e) { responseParts.add({ 'functionResponse': { 'name': call.name, + 'id': ?id, 'response': {'error': '$e'} } }); } } - contents.add({'role': 'function', 'parts': responseParts}); + contents.add({'role': 'user', 'parts': responseParts}); } // Exhausted the tool-round budget without a final text answer. yield '\n\n_(Stopped after $_kMaxToolRounds tool steps — try rephrasing.)_'; @@ -403,16 +525,43 @@ class GeminiAiService extends ChangeNotifier implements IAiService { } } - // ── Program generator (structured JSON output) ──────────────────────────── + // ── Generic domain-agnostic structured JSON generator ─────────────────── @override - Future generateProgram({ + Future generateStructuredJson({ + required String systemPrompt, required String userPrompt, - required List allExercises, + required T Function(Map json) fromJson, }) async { if (!isConfigured) { throw StateError('Gemini API key not configured.'); } + try { + final data = await _generate( + _makeBody( + contents: [Content.text(userPrompt).toJson()], + system: systemPrompt, + jsonMode: true, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final raw = _textFromResponse(data); + if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); + + final map = jsonDecode(raw) as Map; + return fromJson(map); + } on FormatException catch (e) { + throw Exception('Could not parse JSON output: $e'); + } catch (e) { + throw Exception('Gemini API error: $e'); + } + } + // ── Program generator (structured JSON output) ──────────────────────────── + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) async { final exerciseList = allExercises .map((e) => ' "${e.id}": "${e.name} [${e.primaryMuscle}]"') .join('\n'); @@ -469,29 +618,17 @@ Required JSON schema (follow exactly): final prompt = 'Available exercises (ID: name [primary muscle]):\n$exerciseList\n\nUser request: $userPrompt'; - try { - final data = await _generate( - _makeBody( - contents: [Content.text(prompt).toJson()], - system: systemPrompt, - jsonMode: true, - ), - ); - _recordRawUsage(data['usageMetadata'] as Map?); - final raw = _textFromResponse(data); - if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); - - final map = jsonDecode(raw) as Map; - // Ensure a fresh UUID so it never collides with an existing program. - map['id'] = const Uuid().v4(); - map['isImported'] = true; - map['author'] = 'AI Coach'; - return TrainingProgram.fromJson(map); - } on FormatException catch (e) { - throw Exception('Could not parse program JSON: $e'); - } catch (e) { - throw Exception('Gemini API error: $e'); - } + return generateStructuredJson( + systemPrompt: systemPrompt, + userPrompt: prompt, + fromJson: (map) { + // Ensure a fresh UUID so it never collides with an existing program. + map['id'] = const Uuid().v4(); + map['isImported'] = true; + map['author'] = 'AI Coach'; + return TrainingProgram.fromJson(map); + }, + ); } // ── Weekly insights (single-shot text) ──────────────────────────────────── diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart index 083ce5d..f3c340b 100644 --- a/workout-logger/lib/services/gemini_context_builder.dart +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -1,5 +1,6 @@ // gemini_context_builder.dart — Builds rich context strings from app data for Gemini prompts. +import '../genui/a2ui.dart'; import '../models/models.dart'; class GeminiContextBuilder { @@ -46,8 +47,37 @@ class GeminiContextBuilder { 'with add_custom_exercise first, then reference it by name.', ) ..writeln( - 'Weights are in $unitLabel. Format replies with Markdown (lists, bold, ' - 'tables) where it aids clarity.', + 'Weights are in $unitLabel. Format normal replies with Markdown (lists, ' + 'bold, tables) where it aids clarity.', + ) + ..writeln() + ..writeln( + 'When the user asks for a dashboard, chart, visual summary, KPI view, ' + 'health & recovery analysis, sleeping HR variation, statistical ' + 'correlation, or analytics panel: first call the relevant query or ' + 'analytics tools, then answer with an A2UI payload.', + ) + ..writeln() + ..writeln(buildA2UiPromptSection(defaultA2UiRegistry)) + ..writeln( + 'WHICH COMPONENT TO REACH FOR, given this app is a workout tracker: ' + '1) Sleeping HR analytics (e.g. "how is my sleeping hr varying over 14 ' + 'days") — call get_sleeping_hr_analytics, then a DynamicChart line plot ' + 'of the P5/P25/mean series alongside StatCards for mean, stdev, ' + 'variance and trend. ' + '2) Statistical correlations (e.g. "does sleep affect my bench press") ' + '— call analyze_health_workout_correlation, then a ScatterPlot. ' + '3) Recovery and holistic summaries — RadarChart for multi-axis ' + 'balance, MetricGauge for a single readiness score. ' + '4) Comparisons (e.g. "biceps vs triceps") — DynamicChart with multiple ' + 'series. ' + '5) Distributions and breakdowns — DynamicChart with type "pie". ' + '6) Records, recent sessions, top-N lists — DataListGroup.', + ) + ..writeln( + 'Vary the layout to suit the question and keep it scannable. If the ' + 'tools returned no usable data, say so in prose rather than rendering ' + 'an empty dashboard.', ); if (userName != null && userName.isNotEmpty) { 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/lib/services/interfaces/ai_service_interface.dart b/workout-logger/lib/services/interfaces/ai_service_interface.dart index 4a840ec..ca589e2 100644 --- a/workout-logger/lib/services/interfaces/ai_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ai_service_interface.dart @@ -15,19 +15,32 @@ import '../../models/models.dart'; /// Contract for the AI backend used across RepForge (coach chat, program /// generation, insights). Implemented by [GeminiAiService] today. -abstract class IAiService { +abstract mixin class IAiService { /// True once an API key (or equivalent credential) has been supplied. bool get isConfigured; - /// The model identifier currently in use (e.g. `gemini-3.1-flash-lite`). + /// The model identifier currently in use (e.g. `gemini-3.6-flash`). String get currentModel; - /// Stream a coach reply token-by-token. + /// Stream a chat reply token-by-token across any domain. /// - /// When [tools] and [onToolCall] are provided, the implementation runs a - /// tool-call loop: any function calls the model emits are dispatched through - /// [onToolCall] and their results fed back, until the model produces a final - /// natural-language answer. Only text is yielded to the caller. + /// Defaults to calling [streamCoachReply] for backward compatibility. + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + /// Stream a coach reply (alias for backward compatibility). Stream streamCoachReply({ required String userMessage, required String systemPrompt, @@ -36,6 +49,17 @@ abstract class IAiService { Future> Function(FunctionCall call)? onToolCall, }); + /// Generic domain-agnostic structured JSON generator. + /// Generates a structured object [T] by prompting the LLM for JSON and + /// decoding it via [fromJson]. + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) async { + throw UnimplementedError('generateStructuredJson not implemented.'); + } + /// Generate a structured multi-week training program from a natural-language /// prompt, constrained to the provided exercise catalogue. Future generateProgram({ @@ -43,11 +67,10 @@ abstract class IAiService { required List allExercises, }); - /// One-shot weekly training summary in conversational prose. + /// One-shot weekly summary in conversational prose. Future generateWeeklyInsights(String contextText); /// Generic one-shot contextual insight given a [system] instruction and /// [context] payload. Future generateInsight(String system, String context); - } diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart index dcc9530..fc7478f 100644 --- a/workout-logger/lib/services/interfaces/ml_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -75,11 +75,15 @@ abstract class IMLService { DateTime? asOf, }); - /// Get recommended sets based on last session and growth model. + /// Get recommended sets based on last session, recent-session trend, and growth model. + /// [pastSessions], if provided, only has its first two entries read for + /// deload/recovery detection: index 0 is the latest prior session, index 1 + /// is the session immediately before that. Any further entries are ignored. /// [minReps]/[maxReps] define the double-progression rep range. /// Pass [recoveryScores] + [primaryMuscleIds] for recovery-aware advice. List recommendSets({ required List lastSession, + List>? pastSessions, GrowthModel? growthModel, int minReps = 6, int maxReps = 12, diff --git a/workout-logger/lib/services/managers/pr_manager.dart b/workout-logger/lib/services/managers/pr_manager.dart index 073c38b..45e0f80 100644 --- a/workout-logger/lib/services/managers/pr_manager.dart +++ b/workout-logger/lib/services/managers/pr_manager.dart @@ -44,7 +44,10 @@ class PRManager extends ChangeNotifier { } } - PersonalRecord? getRecord(String exerciseId) => _cache[exerciseId]; + PersonalRecord? getRecord(String exerciseId, {String? handle}) { + final key = (handle != null && handle.isNotEmpty) ? '$exerciseId:$handle' : exerciseId; + return _cache[key] ?? _cache[exerciseId]; + } /// Compare each exercise log in [session] against stored PRs. /// @@ -67,7 +70,9 @@ class PRManager extends ChangeNotifier { } Future> _checkExercise(ExerciseLog log, DateTime date) async { - final existing = _cache[log.exerciseId]; + final handle = log.handle ?? log.sets.where((s) => s.handle != null).firstOrNull?.handle; + final key = (handle != null && handle.isNotEmpty) ? '${log.exerciseId}:$handle' : log.exerciseId; + final existing = _cache[key]; double newBestWeight = existing?.bestWeight ?? 0; int newBestReps = existing?.bestReps ?? 0; @@ -91,13 +96,13 @@ class PRManager extends ChangeNotifier { if (broken.isEmpty) return broken; final updated = PersonalRecord( - exerciseId: log.exerciseId, + exerciseId: key, bestWeight: newBestWeight, bestReps: newBestReps, bestVolume: newBestVolume, achievedAt: date, ); - _cache[log.exerciseId] = updated; + _cache[key] = updated; await _storage.savePersonalRecord(updated); return broken; diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index ffae2a4..7ec025d 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -357,13 +357,54 @@ class MLService implements IMLService { @override List recommendSets({ required List lastSession, + List>? pastSessions, GrowthModel? growthModel, int minReps = 6, int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, }) { - if (lastSession.isEmpty) return []; + if (lastSession.isEmpty && (pastSessions == null || pastSessions.isEmpty)) { + return []; + } + + // Determine target reference sets and deload status based on past 3 sessions trend + List refSets = lastSession; + bool isPostDeloadRecovery = false; + + if (pastSessions != null && pastSessions.length >= 2) { + final s0 = pastSessions[0]; + final s1 = pastSessions[1]; + + if (s0.isNotEmpty && s1.isNotEmpty) { + // Use effective load (bodyweight − assist + extra for assisted-BW + // sets), not raw set.weight, so assist changes on machines like + // assisted dips/pull-ups aren't misread as a deload/progression. + final w0 = s0.map((s) => s.effectiveWeight).reduce(max); + final w1 = s1.map((s) => s.effectiveWeight).reduce(max); + final v0 = s0.fold(0.0, (sum, s) => sum + s.volume); + final v1 = s1.fold(0.0, (sum, s) => sum + s.volume); + + // Only treat this as "recovering from a deload" if the most recent + // session (s0) is actually recent — otherwise an old, unrelated dip + // between two stale sessions after a long break would be + // misread as an active deload to recover from. + final mostRecentTimestamp = + s0.map((s) => s.timestamp).reduce((a, b) => a.isAfter(b) ? a : b); + final isRecent = + DateTime.now().difference(mostRecentTimestamp).inDays <= 21; + + // If the last session (s0) was a deload (weight < 85% of s1 or volume < 70% of s1) + if (isRecent && + ((w1 > 0 && w0 < w1 * 0.85) || (v1 > 0 && v0 < v1 * 0.70))) { + refSets = s1; + isPostDeloadRecovery = true; + } + } + } + + if (refSets.isEmpty) refSets = lastSession; + if (refSets.isEmpty) return []; final trendIsTrustworthy = growthModel != null && growthModel.r2 > _minR2ForTrendSignal; @@ -384,7 +425,7 @@ class MLService implements IMLService { .fold(100, (a, b) => a < b ? a : b) : null; - return lastSession + return refSets .map((set) => _doubleProgression( set: set, minReps: minReps, @@ -393,6 +434,7 @@ class MLService implements IMLService { isDeclining: isDeclining, isUnderRecovered: isUnderRecovered, recoveryPercent: worstRecovery, + isPostDeloadRecovery: isPostDeloadRecovery, )) .toList(); } @@ -405,6 +447,7 @@ class MLService implements IMLService { required bool isDeclining, required bool isUnderRecovered, int? recoveryPercent, + bool isPostDeloadRecovery = false, }) { if (isUnderRecovered) { return SetRecommendation( @@ -416,6 +459,19 @@ class MLService implements IMLService { ); } + if (isPostDeloadRecovery) { + return SetRecommendation( + weight: set.weight, + reps: set.reps, + confidence: 'high', + // No raw weight value embedded here — the recommended weight/unit + // is already surfaced via SetRecommendation.weight and formatted by + // the presentation layer according to the user's unit preference. + reasoning: + 'Resuming training after deload — anchored on pre-deload baseline (${set.reps} reps)', + ); + } + if (isDeclining) { // Round the deload to the plate increment users can actually load. final deloaded = max(0.0, ((set.weight * 0.9) / 2.5).round() * 2.5); diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 164df92..020fc2c 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -16,11 +16,13 @@ class SettingsProvider extends ChangeNotifier { String? _userName; String? _lastSeenVersion; String _geminiApiKey = ''; - String _geminiModel = 'gemini-2.5-flash'; + String _geminiModel = 'gemini-3.6-flash'; String _weeklyInsights = ''; DateTime? _weeklyInsightsDate; bool _showAdvancedMetrics = false; + double _userBodyWeight = 70.0; + WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; @@ -33,6 +35,7 @@ class SettingsProvider extends ChangeNotifier { String get weeklyInsights => _weeklyInsights; DateTime? get weeklyInsightsDate => _weeklyInsightsDate; bool get showAdvancedMetrics => _showAdvancedMetrics; + double get userBodyWeight => _userBodyWeight; SettingsProvider(this._storage); @@ -45,6 +48,10 @@ class SettingsProvider extends ChangeNotifier { ? (double.tryParse(increment) ?? _defaultIncrement) : _defaultIncrement; + final bw = await _storage.getSetting('userBodyWeight'); + final parsedBw = bw != null ? double.tryParse(bw) : null; + _userBodyWeight = _isValidBodyWeight(parsedBw) ? parsedBw! : 70.0; + final hcEnabled = await _storage.getSetting('healthConnectEnabled'); _healthConnectEnabled = hcEnabled == 'true'; @@ -54,7 +61,7 @@ class SettingsProvider extends ChangeNotifier { _userName = await _storage.getSetting('userName'); _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; - _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-2.5-flash'; + _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-3.6-flash'; _weeklyInsights = await _storage.getSetting('weeklyInsights') ?? ''; final dateStr = await _storage.getSetting('weeklyInsightsDate'); _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; @@ -62,6 +69,17 @@ class SettingsProvider extends ChangeNotifier { _showAdvancedMetrics = advMetrics == 'true'; } + /// A valid bodyweight must be finite (not NaN/Infinity) and strictly positive. + static bool _isValidBodyWeight(double? weight) => + weight != null && weight.isFinite && weight > 0; + + Future setUserBodyWeight(double weight) async { + if (!_isValidBodyWeight(weight)) return; + _userBodyWeight = weight; + await _storage.saveSetting('userBodyWeight', weight.toString()); + notifyListeners(); + } + Future setUserName(String name) async { _userName = name.trim(); await _storage.saveSetting('userName', _userName!); diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 5fd1b48..52b812c 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -26,7 +26,6 @@ import 'ml_service.dart'; import 'strategies/target_calculator.dart'; import 'managers/program_manager.dart'; import 'managers/history_manager.dart'; -import 'utils/exercise_history.dart'; enum StartWorkoutConflictAction { resume, discardAndStart, cancel } @@ -504,14 +503,36 @@ class WorkoutProvider extends ChangeNotifier { return _currentExerciseLogs[_currentExerciseIndex]; } + /// Set handle variation for current exercise. + /// + /// Locked once a set has been logged for this exercise instance — changing + /// the selector afterward must not retroactively relabel already-recorded + /// sets, so the handle is a no-op past that point. + void setExerciseHandle(String? handle) { + if (_currentExerciseIndex < _currentExerciseLogs.length) { + final currentLog = _currentExerciseLogs[_currentExerciseIndex]; + if (currentLog.sets.isNotEmpty) return; + _currentExerciseLogs[_currentExerciseIndex] = ExerciseLog( + exerciseId: currentLog.exerciseId, + sets: currentLog.sets, + notes: currentLog.notes, + handle: handle, + ); + notifyListeners(); + unawaited(_persistDraft()); + } + } + /// Add a set to current exercise void addSet(WorkoutSet set) { if (_currentExerciseIndex < _currentExerciseLogs.length) { final currentLog = _currentExerciseLogs[_currentExerciseIndex]; + final setWithHandle = set.copyWith(handle: set.handle ?? currentLog.handle); _currentExerciseLogs[_currentExerciseIndex] = ExerciseLog( exerciseId: currentLog.exerciseId, - sets: [...currentLog.sets, set], + sets: [...currentLog.sets, setWithHandle], notes: currentLog.notes, + handle: currentLog.handle, ); notifyListeners(); unawaited(_persistDraft()); @@ -637,26 +658,85 @@ class WorkoutProvider extends ChangeNotifier { // ==================== RECOMMENDATIONS ==================== - /// Get set recommendations for an exercise. + /// Get set recommendations for an exercise, optionally scoped by [handle]. /// - /// Uses the most-recently-dated session that contains this exercise as the - /// basis for the recommendation. Order in `_sessions` is not assumed. - List getRecommendations(String exerciseId) { - final lastLog = findMostRecentExerciseLog(exerciseId, _sessions); + /// Uses up to 3 past sessions for this exercise (and handle variation) as the + /// basis for trend analysis and deload recovery. + List getRecommendations(String exerciseId, {String? handle}) { + final recent = getRecentSessionsForExercise(exerciseId, handle: handle, limit: 3); - if (lastLog == null || lastLog.sets.isEmpty) { + if (recent.isEmpty) { return _mlService.getDefaultRecommendations(3); } + // _growthModels is trained per-exerciseId across every handle variation, + // so it must not back a handle-scoped recommendation — that would mix + // e.g. "Rope pushdown" trend data into a "Bar pushdown" recommendation. + final useHandle = handle != null && handle.isNotEmpty; return _mlService.recommendSets( - lastSession: lastLog.sets, - growthModel: _growthModels[exerciseId], + lastSession: recent.first, + pastSessions: recent, + growthModel: useHandle ? null : _growthModels[exerciseId], ); } + /// Get up to [limit] recent sessions for [exerciseId], optionally matching [handle]. + /// + /// When [handle] is given, requires an EXACT handle match (excluding logs + /// with a null or different handle) so a "Cable curl" lookup never + /// surfaces "Barbell curl" history. Falls back to legacy (handle-less) + /// matching only when no exact match exists at all. + List> getRecentSessionsForExercise( + String exerciseId, { + String? handle, + int limit = 3, + }) { + final sortedSessions = [..._sessions]..sort((a, b) => b.date.compareTo(a.date)); + final useHandle = handle != null && handle.isNotEmpty; + + List> collect(bool Function(ExerciseLog) matches) { + final results = >[]; + for (final s in sortedSessions) { + for (final exLog in s.exercises.where((e) => e.exerciseId == exerciseId)) { + if (!matches(exLog)) continue; + if (exLog.sets.isNotEmpty) { + results.add(exLog.sets); + if (results.length >= limit) return results; + } + } + } + return results; + } + + if (useHandle) { + final exact = collect((exLog) => exLog.handle == handle); + if (exact.isNotEmpty) return exact; + } + return collect((_) => true); + } + /// Get the most recent exercise log for [exerciseId], or null if never logged. - ExerciseLog? getLastSessionForExercise(String exerciseId) { - return findMostRecentExerciseLog(exerciseId, _sessions); + /// + /// Same exact-match-first, legacy-fallback semantics as + /// [getRecentSessionsForExercise] — see its doc for details. + ExerciseLog? getLastSessionForExercise(String exerciseId, {String? handle}) { + final sortedSessions = [..._sessions]..sort((a, b) => b.date.compareTo(a.date)); + final useHandle = handle != null && handle.isNotEmpty; + + ExerciseLog? find(bool Function(ExerciseLog) matches) { + for (final s in sortedSessions) { + for (final exLog in s.exercises.where((e) => e.exerciseId == exerciseId)) { + if (matches(exLog)) return exLog; + } + } + return null; + } + + if (useHandle) { + final exact = find((exLog) => exLog.handle == handle); + if (exact != null) return exact; + } + return find((_) => true); } // ==================== SESSION MANAGEMENT ==================== diff --git a/workout-logger/lib/theme/a2ui_app_theme.dart b/workout-logger/lib/theme/a2ui_app_theme.dart new file mode 100644 index 0000000..4e6d46c --- /dev/null +++ b/workout-logger/lib/theme/a2ui_app_theme.dart @@ -0,0 +1,28 @@ +import 'package:repforge/genui/a2ui.dart'; + +import 'app_theme.dart'; + +/// Maps RepForge design tokens onto the domain-free [A2UiTheme] the GenUI +/// renderer consumes. This adapter is the only place the two systems meet. +const A2UiTheme repforgeA2UiTheme = A2UiTheme( + surface: AppColors.card, + border: AppColors.glassBorder, + divider: AppColors.divider, + textPrimary: AppColors.textPrimary, + textSoft: AppColors.textSoft, + textMuted: AppColors.textMuted, + textFaint: AppColors.textFaint, + accent: AppColors.primary, + positive: AppColors.success, + negative: AppColors.error, + seriesPalette: [ + AppColors.primary, + AppColors.secondary, + AppColors.success, + AppColors.warning, + AppColors.error, + ], + spacing: AppSpacing.md, + radius: AppRadius.lg, + pillRadius: AppRadius.full, +); diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock index a22582b..24bd7ac 100644 --- a/workout-logger/pubspec.lock +++ b/workout-logger/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b url: "https://pub.dev" source: hosted - version: "93.0.0" + version: "100.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "13.0.0" archive: dependency: transitive description: @@ -53,34 +53,34 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.1" code_builder: dependency: transitive description: @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+4" crypto: dependency: transitive description: @@ -189,26 +189,26 @@ packages: dependency: transitive description: name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" url: "https://pub.dev" source: hosted - version: "3.1.7" + version: "3.1.9" dbus: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" equatable: dependency: transitive description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" fake_async: dependency: transitive description: @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -237,10 +245,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "12.0.0-beta.7" fixnum: dependency: transitive description: @@ -266,10 +274,10 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -290,10 +298,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_svg: dependency: transitive description: @@ -332,10 +340,10 @@ packages: dependency: "direct main" description: name: gpt_markdown - sha256: c14c2a4599a67df5b6a984808cbb7631b8d15c91984ffdd7998597b93a6ff136 + sha256: ab6fe339f500104816139a034b8a23a125dadbc1988eda1641c6616562a4962b url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.8" graphs: dependency: transitive description: @@ -348,34 +356,34 @@ packages: dependency: "direct main" description: name: health_connector - sha256: "5d2d785077e1004457808ee568efc1264e5e090258fac507c00542066fe12064" + sha256: "3caec088ae94023117b30e804a2bc19c369121a0618a5baa0849472e17d7b6c8" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "3.9.3" health_connector_core: dependency: transitive description: name: health_connector_core - sha256: "452823baeb89c8e63e6bf775b1eda86cfc42e50ad777addde899c77bea710136" + sha256: "8a2aa99574dcd8868447af7ab16dc9a26caba48a194e8141e6f6e54f7848f82d" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.9.2" health_connector_hc_android: dependency: transitive description: name: health_connector_hc_android - sha256: "21834d80e8d0c65f5263c3da7076c2933cf98b61e28547d2603f0b7f66caf8c7" + sha256: "063023a7ee4ec2acb4e2d4166de68df145ddaaa58e67d251926f22eed28a306c" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.6.2" health_connector_hk_ios: dependency: transitive description: name: health_connector_hk_ios - sha256: "617bc9d52c7a57b15d4a01663a1484f86b7d7460f93fdbf24e4ea4b54c7f0974" + sha256: "12e958e5481c493319a1972460be369f38ac494d103359fc09ba121b46f5d6eb" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.9.2" health_connector_logger: dependency: transitive description: @@ -404,10 +412,10 @@ packages: dependency: transitive description: name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.2" http: dependency: "direct main" description: @@ -444,10 +452,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.3" io: dependency: transitive description: @@ -540,10 +548,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -556,18 +564,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 - url: "https://pub.dev" - source: hosted - version: "5.6.4" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + sha256: c8d040d754367108fbe482dcb79dd72b8fe60ac6727abd15b4783c5560297ee6 url: "https://pub.dev" source: hosted - version: "0.17.6" + version: "5.7.0" nested: dependency: transitive description: @@ -580,10 +580,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "9.4.1" package_config: dependency: transitive description: @@ -596,18 +596,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "10.2.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: transitive description: @@ -628,10 +628,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -652,18 +652,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -708,10 +708,10 @@ packages: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.2" provider: dependency: "direct main" description: @@ -748,18 +748,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" url: "https://pub.dev" source: hosted - version: "12.0.2" + version: "13.3.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.2.0" shelf: dependency: transitive description: @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.2.4" source_span: dependency: transitive description: @@ -841,10 +841,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" tuple: dependency: transitive description: @@ -897,10 +897,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_graphics: dependency: transitive description: @@ -921,10 +921,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "09854c7633b215e6f7bb2a9adb607bc525bae8655c7fc29db880c33e62f72230" + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "1.2.6" vector_math: dependency: transitive description: @@ -977,10 +977,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.3.0" xdg_directories: dependency: transitive description: @@ -1007,4 +1007,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.4 <4.0.0" - flutter: "3.41.6" + flutter: "3.44.8" diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 138fe2b..c25c3a2 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -20,7 +20,7 @@ version: 2.0.9+30 environment: sdk: ^3.11.4 - flutter: 3.41.6 + flutter: 3.44.8 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -48,9 +48,9 @@ dependencies: # Utilities uuid: ^4.5.1 - intl: ^0.19.0 + intl: ^0.20.3 http: ^1.2.1 - package_info_plus: ^8.3.1 + package_info_plus: ^10.2.1 # Health Connect integration health_connector: ^3.9.1 @@ -59,9 +59,9 @@ dependencies: google_generative_ai: ^0.4.3 # Backup export/import - file_picker: ^11.0.2 + file_picker: ^12.0.0-beta.7 path_provider: ^2.1.5 - share_plus: ^12.0.1 + share_plus: ^13.2.1 gpt_markdown: ^1.1.7 dev_dependencies: @@ -74,7 +74,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_launcher_icons: ^0.14.4 # Testing mockito: ^5.4.4 diff --git a/workout-logger/scripts/build_release.py b/workout-logger/scripts/build_release.py new file mode 100644 index 0000000..619b442 --- /dev/null +++ b/workout-logger/scripts/build_release.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys +from pathlib import Path + +def load_env_file(env_path: Path): + """Loads key-value pairs from a .env file into os.environ.""" + if not env_path.exists(): + return + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + # Remove whitespace and wrapping quotes if present + val = val.strip().strip("'\"") + os.environ[key.strip()] = val + +def main(): + script_dir = Path(__file__).resolve().parent + project_dir = script_dir.parent if script_dir.name == "scripts" else script_dir + os.chdir(project_dir) + + env_file = project_dir / ".env" + if env_file.exists(): + print(f"Loading environment from {env_file}") + load_env_file(env_file) + else: + print("No .env file found. Using existing environment variables.") + + cmd = "flutter build apk --release --target-platform android-arm64 --obfuscate --split-debug-info=build/app/outputs/symbols" + + print(f"Executing: {cmd}") + result = subprocess.run(cmd, env=os.environ, shell=True) + sys.exit(result.returncode) + +if __name__ == "__main__": + main() diff --git a/workout-logger/scripts/test_gemini_api.py b/workout-logger/scripts/test_gemini_api.py new file mode 100644 index 0000000..13a8f10 --- /dev/null +++ b/workout-logger/scripts/test_gemini_api.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +test_gemini_api.py - Standalone Python script testing Gemini 3.6 Flash tool calling & GenUI dashboard response. + +Executes a live 2-turn conversation flow: + 1. Sends initial user prompt ("Generate a volume graph for my triceps vs biceps"). + 2. Parses the model's returned function call & thinking/thought_signature. + 3. Echoes back the model's turn verbatim, followed by the tool response under `role: "user"`. + 4. Prints the final model output (e.g. A2UI dashboard JSON). +""" + +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request + + +def load_env_file(filepath: str) -> None: + if not os.path.exists(filepath): + return + with open(filepath, "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) + key = key.strip().strip("'\"") + if key and not os.environ.get(key): + os.environ[key] = val + + +def extract_retry_delay(body_str: str) -> float | None: + """Mirrors Dart _extractRetryDelay implementation in gemini_ai_service.dart.""" + try: + data = json.loads(body_str) + if isinstance(data, dict) and "error" in data: + err = data["error"] + # 1. Check google.rpc.RetryInfo in error.details + details = err.get("details", []) + if isinstance(details, list): + for item in details: + if isinstance(item, dict) and "retryDelay" in item: + delay_str = str(item["retryDelay"]).replace("s", "").strip() + val = float(delay_str) + if val > 0: + return val + 0.35 + # 2. Regex search in error.message (e.g. "Please retry in 23.690750876s.") + msg = err.get("message", "") + if isinstance(msg, str): + match = re.search(r"retry in\s+([\d.]+)\s*s", msg, re.IGNORECASE) + if match: + val = float(match.group(1)) + if val > 0: + return val + 0.35 + except Exception: + pass + return None + + +def is_daily_quota_exhausted(body: str) -> bool: + # Mirrors Dart _isDailyQuotaExhausted: only daily-limit-specific + # identifiers. Generic "QuotaExceeded"/"RESOURCE_EXHAUSTED" markers also + # fire for per-minute rate limits, which should retry-with-delay instead + # of triggering a model fallback. + return "GenerateRequestsPerDay" in body or "free_tier_requests" in body + + +def get_fallback_model(current_model: str) -> str | None: + fallbacks = { + "gemini-3.6-flash": "gemini-3.5-flash", + "gemini-3.5-flash": "gemini-3.5-flash-lite", + "gemini-3.5-flash-lite": "gemini-2.5-flash", + } + return fallbacks.get(current_model) + + +def thinking_config_for(model: str) -> dict: + """Mirrors Dart _thinkingConfig: gemini-2.5-flash predates the Gemini 3.x + thinkingLevel enum and only understands the older thinkingBudget shape.""" + if model == "gemini-2.5-flash": + return {"thinkingBudget": 0} + return {"thinkingLevel": "minimal"} + + +def post_generate_content_with_retry(model: str, api_key: str, payload: dict, max_attempts: int = 4) -> dict: + current_model = model + attempt = 0 + + while True: + # Rebuild the request body for whichever model is currently selected — + # a daily-quota fallback mid-retry can switch to a model needing a + # different thinkingConfig shape (see thinking_config_for()), so the + # previous model's config must not be reused verbatim. + body = dict(payload) + gen_cfg = dict(body.get("generationConfig", {})) + gen_cfg["thinkingConfig"] = thinking_config_for(current_model) + body["generationConfig"] = gen_cfg + data_bytes = json.dumps(body).encode("utf-8") + + url = f"https://generativelanguage.googleapis.com/v1beta/models/{current_model}:generateContent?key={api_key}" + req = urllib.request.Request( + url, + data=data_bytes, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8") + if is_daily_quota_exhausted(body): + fallback = get_fallback_model(current_model) + if fallback: + print(f" [QUOTA EXHAUSTED] {current_model} daily free quota reached! Automatically falling back to {fallback}...") + current_model = fallback + continue + + if e.code in (429, 500, 502, 503, 504): + retry_sec = extract_retry_delay(body) or (0.5 * (2**attempt)) + print(f" [HTTP {e.code}] Rate limit/server error detected. Google retryDelay: {retry_sec:.2f}s (Attempt {attempt+1}/{max_attempts})") + if attempt < max_attempts - 1: + print(f" --> Waiting {retry_sec:.2f}s before retry...") + time.sleep(retry_sec) + attempt += 1 + continue + print(f"\n[!] HTTP {e.code} Error Body:\n{body}") + raise e + + +def parse_genui_component(text: str) -> dict | None: + """Mirrors Dart A2UiComponent.tryParse + property normalization.""" + trimmed = text.strip() + if not trimmed or not trimmed.startswith("{"): + return None + try: + data = json.loads(trimmed) + if not isinstance(data, dict): + return None + comp = data.get("component") + if not comp or not isinstance(comp, str): + return None + # Extract props (supporting both wrapped 'props' and flat properties) + if isinstance(data.get("props"), dict): + props = data["props"] + else: + props = {k: v for k, v in data.items() if k != "component"} + return {"component": comp, "props": props} + except Exception: + return None + + +def main() -> None: + script_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(script_dir, "..")) + load_env_file(os.path.join(root_dir, ".env")) + load_env_file(os.path.join(os.getcwd(), ".env")) + + api_key = os.environ.get("GEMINI_API_KEY", "").strip() + if not api_key: + print("[!] GEMINI_API_KEY not found in environment or .env file.") + sys.exit(1) + + model = "gemini-3.6-flash" + + tools = [ + { + "functionDeclarations": [ + { + "name": "get_muscle_group_volume", + "description": "Fetch volume history for muscle groups.", + "parameters": { + "type": "OBJECT", + "properties": { + "muscle_groups": { + "type": "ARRAY", + "items": {"type": "STRING"}, + } + }, + "required": ["muscle_groups"], + }, + } + ] + } + ] + + system_instruction = { + "parts": [ + { + "text": ( + "You are an expert personal trainer embedded in RepForge. " + 'When asked for dashboards or comparison charts, return ONLY valid A2UI JSON: ' + '{"component":"GridContainer","props":{"columns":1,"children":[...]}}' + ) + } + ] + } + + print("=" * 70) + print("VERIFYING GEMINI API RETRY & COMPONENT PARSING IN A LOOP") + print("=" * 70) + + # Unit Test: Retry parsing regex & RetryInfo extraction + sample_error = json.dumps({ + "error": { + "code": 429, + "message": "Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20, model: gemini-3.6-flash. Please retry in 23.690750876s.", + "status": "RESOURCE_EXHAUSTED", + "details": [{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "23.690750876s"}] + } + }) + parsed_delay = extract_retry_delay(sample_error) + print(f"[TEST 1] Testing retryDelay parser on sample 429 payload:") + print(f" Extracted Retry Delay: {parsed_delay:.3f} seconds (Expected ~24.04s)") + assert parsed_delay is not None and 23.0 <= parsed_delay <= 25.0, "Parser failed!" + print(" --> PASSED!\n") + + # Unit Test: Flat vs Wrapped GenUI Component Parser + print("[TEST 2] Testing GenUI Flat & Wrapped Property Normalization:") + flat_json = '{"component":"DynamicChart","type":"line","title":"Biceps vs Triceps","labels":["W1"],"series":[{"name":"Biceps","values":[100]}]}' + parsed_flat = parse_genui_component(flat_json) + print(" Parsed Flat JSON:", json.dumps(parsed_flat, indent=2)) + assert parsed_flat is not None and "props" in parsed_flat and parsed_flat["props"]["type"] == "line" + print(" --> PASSED!\n") + + # Live Executions Loop + num_runs = 2 + for run in range(1, num_runs + 1): + print("=" * 70) + print(f"RUN {run}/{num_runs}: Executing Live Multi-Turn Query against {model}...") + print("=" * 70) + + contents = [ + {"role": "user", "parts": [{"text": "Generate a volume graph for my triceps vs biceps"}]} + ] + + payload1 = { + "contents": contents, + "systemInstruction": system_instruction, + "tools": tools, + "generationConfig": {"thinkingConfig": {"thinkingLevel": "minimal"}}, + } + + try: + res1 = post_generate_content_with_retry(model, api_key, payload1) + except urllib.error.HTTPError as e: + if e.code == 400: + print(f"[NOTE] Live call skipped: API key in .env is invalid or unconfigured.") + print("[SUCCESS] All local timeout parsing & component normalization tests verified!") + sys.exit(0) + raise e + candidates = res1.get("candidates", []) + first_cand = candidates[0] + model_content = first_cand.get("content", {}) + raw_parts = model_content.get("parts", []) + + function_calls = [p["functionCall"] for p in raw_parts if "functionCall" in p] + print(f" Turn 1 Model Response: {len(function_calls)} function call(s) received.") + + if function_calls: + contents.append(model_content) + func_response_parts = [{ + "functionResponse": { + "name": fc["name"], + **({"id": fc["id"]} if "id" in fc else {}), + "response": { + "dates": ["2026-07-06", "2026-07-09", "2026-07-16"], + "series": [ + {"name": "Biceps", "values": [600, 750, 900]}, + {"name": "Triceps", "values": [1200, 1400, 1600]} + ] + } + } + } for fc in function_calls] + + contents.append({"role": "user", "parts": func_response_parts}) + + payload2 = { + "contents": contents, + "systemInstruction": system_instruction, + "tools": tools, + "generationConfig": {"thinkingConfig": {"thinkingLevel": "minimal"}}, + } + + res2 = post_generate_content_with_retry(model, api_key, payload2) + cands2 = res2.get("candidates", []) + final_text = "" + for part in cands2[0].get("content", {}).get("parts", []): + if "text" in part: + final_text += part["text"] + + print(f" Turn 2 Final Model Output (Length: {len(final_text)} chars):") + parsed_comp = parse_genui_component(final_text) + if parsed_comp: + print(" [SUCCESS] Successfully parsed GenUI Component structure!") + print(f" Root Component: {parsed_comp['component']}") + else: + print(" Output Text:\n", final_text[:300]) + + print(f"\n[SUCCESS] Run {run} completed successfully.\n") + + +if __name__ == "__main__": + main() + diff --git a/workout-logger/test/ai_coach_view_model_test.dart b/workout-logger/test/ai_coach_view_model_test.dart index 13bebbe..213b254 100644 --- a/workout-logger/test/ai_coach_view_model_test.dart +++ b/workout-logger/test/ai_coach_view_model_test.dart @@ -59,6 +59,29 @@ class _FakeAiService implements IAiService { @override Future generateInsight(String system, String context) async => ''; + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + @override + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) => + throw UnimplementedError(); } void main() { @@ -80,7 +103,7 @@ void main() { conversations = ConversationManager(storage); return AiCoachViewModel( ai: ai, - coachTools: CoachToolService(provider, pr), + coachTools: CoachToolService(workoutProvider: provider, prManager: pr), conversations: conversations, settings: settings, ); 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/coach_tool_service_test.dart b/workout-logger/test/coach_tool_service_test.dart index 4cecf55..fbde20f 100644 --- a/workout-logger/test/coach_tool_service_test.dart +++ b/workout-logger/test/coach_tool_service_test.dart @@ -59,7 +59,7 @@ void main() { pr = PRManager(storage); await pr.backfillFromSessions(provider.sessions); - tools = CoachToolService(provider, pr); + tools = CoachToolService(workoutProvider: provider, prManager: pr); }); test('exposes the expected tool declarations', () { 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..6eeb7b6 --- /dev/null +++ b/workout-logger/test/gemini_context_builder_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/a2ui.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')); + }); + }); + + group('coach prompt A2UI section', () { + final prompt = GeminiContextBuilder.buildCoachSystemPrompt( + now: DateTime(2026, 8, 5), + ); + + test('embeds the generated A2UI section', () { + expect(prompt, contains(buildA2UiPromptSection(defaultA2UiRegistry))); + }); + + test('no longer hand-writes component schemas', () { + // The old prose listed props inline; the generated section owns that now. + expect(prompt, isNot(contains('StatCard {title,value,subtitle?,trend}'))); + expect(prompt, isNot(contains('RadarChart {title,axes:[string]'))); + }); + + test('domain playbook survives and names components only', () { + expect(prompt, contains('biceps vs triceps')); + expect(prompt, contains('get_sleeping_hr_analytics')); + }); + + test('is stable for a fixed date so the cache prefix stays byte-identical', + () { + expect( + GeminiContextBuilder.buildCoachSystemPrompt(now: DateTime(2026, 8, 5)), + prompt, + ); + }); + + // Pins the hand-written "WHICH COMPONENT TO REACH FOR" playbook against + // drift: this prose can't be generated from the registry (it's + // domain-specific routing guidance a domain-free lib/genui/ package can't + // know about), so if a component named here is ever renamed or removed + // from the registry, this test must fail loudly rather than the mismatch + // going silent the way it did before the registry refactor. + test( + 'every component named in the WHICH COMPONENT TO REACH FOR playbook ' + 'resolves in the default registry', () { + // Names as semantically referenced by the prose (e.g. the prose says + // "StatCards" — the plural reads naturally in a sentence but the + // canonical component is "StatCard"; `contains` below tolerates the + // trailing "s"). + const mentionedComponents = [ + 'DynamicChart', + 'StatCard', + 'ScatterPlot', + 'RadarChart', + 'MetricGauge', + 'DataListGroup', + ]; + + for (final name in mentionedComponents) { + expect( + prompt, + contains(name), + reason: '"$name" is expected in the component-routing playbook ' + 'but was not found — did the prose get edited?', + ); + expect( + defaultA2UiRegistry.specFor(name), + isNotNull, + reason: '"$name" is named in the component-routing playbook but ' + 'does not resolve in defaultA2UiRegistry — it was likely ' + 'renamed or removed without updating the prose.', + ); + } + }); + + test('default registry has exactly the expected number of components', + () { + // A deliberate, visible tripwire: if a component is ever added or + // removed, this assertion should force a conscious update rather than + // the count silently drifting. + expect(defaultA2UiRegistry.specs.length, 8); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_custom_registry_test.dart b/workout-logger/test/genui/a2ui_custom_registry_test.dart new file mode 100644 index 0000000..3b91ce6 --- /dev/null +++ b/workout-logger/test/genui/a2ui_custom_registry_test.dart @@ -0,0 +1,117 @@ +// Regression coverage for the registry-propagation fix to A2UiRenderer. +// +// `A2UiRenderer`'s `registry` constructor override used to only apply to the +// top-level node: `GridContainerSpec.buildWidget` recurses via bare +// `A2UiRenderer(node: children[i])` with no registry forwarded, so nested +// children silently fell back to `defaultA2UiRegistry` even when the caller +// passed a custom registry at the root. If the custom registry's components +// weren't in the default one, those children silently rendered +// `SizedBox.shrink()` — blank, with no error. +// +// The fix mirrors the existing theme-injection pattern: `A2UiRenderer` now +// wraps its own subtree in an `A2UiRegistryProvider` carrying the resolved +// registry (explicit override, or whatever was already ambient), so nested +// bare `A2UiRenderer` calls made without an explicit override pick up the +// ambient registry via `A2UiRegistryProvider.of(context)` instead of +// reverting to the default. +// +// This file replaces the old `a2ui_parser_stub_test.dart`, which was +// temporary Task 3 scaffolding (a hand-rolled fake registry, needed only +// because `default_registry.dart` didn't exist yet at that point in the +// refactor) and had become redundant with `a2ui_parser_test.dart`, which +// covers the same parsing behaviors against the real registry. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_registry.dart'; +import 'package:repforge/genui/src/a2ui_renderer.dart'; +import 'package:repforge/genui/src/a2ui_spec.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/grid_container.dart'; + +/// A minimal spec not present in `defaultA2UiRegistry`, so successfully +/// rendering it proves a custom registry was actually consulted. +class _CustomWidgetSpec extends A2UiSpec { + const _CustomWidgetSpec(); + + @override + String get name => 'CustomWidget'; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'CustomWidget {label}', + purpose: 'test-only stub component', + example: {'component': 'CustomWidget', 'props': {'label': 'x'}}, + ); + + @override + String parseProps(A2UiNode node) => node.props.text('label'); + + @override + Widget buildWidget(BuildContext context, String props, A2UiTheme theme) => + Text('custom:$props'); +} + +void main() { + final customRegistry = A2UiRegistry(const [ + GridContainerSpec(), + _CustomWidgetSpec(), + ]); + + testWidgets( + 'a custom registry propagates through GridContainer to nested children', + (tester) async { + final node = A2UiNode( + name: 'GridContainer', + props: const A2UiProps({'columns': 1}), + children: const [ + A2UiNode( + name: 'CustomWidget', + props: A2UiProps({'label': 'first'}), + ), + A2UiNode( + name: 'CustomWidget', + props: A2UiProps({'label': 'second'}), + ), + ], + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: A2UiRenderer(node: node, registry: customRegistry), + ), + )); + + // Before the fix, nested children resolved against `defaultA2UiRegistry` + // (which does not know `CustomWidget`) and silently rendered + // `SizedBox.shrink()` instead of this text. + expect(find.text('custom:first'), findsOneWidget); + expect(find.text('custom:second'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'without a custom registry, an unknown component silently renders ' + 'nothing rather than crashing', (tester) async { + final node = A2UiNode( + name: 'GridContainer', + props: const A2UiProps({'columns': 1}), + children: const [ + A2UiNode(name: 'CustomWidget', props: A2UiProps({'label': 'x'})), + ], + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + // No `registry:` override — falls back to `defaultA2UiRegistry`, + // which does not know `CustomWidget`. + body: A2UiRenderer(node: node), + ), + )); + + expect(find.text('custom:x'), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/workout-logger/test/genui/a2ui_parser_test.dart b/workout-logger/test/genui/a2ui_parser_test.dart new file mode 100644 index 0000000..9cc9950 --- /dev/null +++ b/workout-logger/test/genui/a2ui_parser_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_parser.dart'; +import 'package:repforge/genui/src/default_registry.dart'; + +void main() { + final parser = A2UiParser(defaultA2UiRegistry); + + group('payload gate', () { + test('returns null for ordinary prose', () { + expect(parser.parse('**Nice work.** Keep going.'), isNull); + expect(parser.parse(''), isNull); + expect(parser.parse('Your bench went up 5kg { nice }.'), isNull); + }); + + test('returns null for valid JSON with no known component', () { + expect(parser.parse('{"component":"HeroBanner","props":{}}'), isNull); + expect(parser.parse('{"foo":1}'), isNull); + }); + + test('returns null rather than throwing on malformed JSON', () { + expect(parser.parse('{"component":"StatCard", "props":'), isNull); + expect(parser.parse('{{{{'), isNull); + }); + }); + + group('extraction', () { + test('parses a bare object', () { + final node = parser.parse( + '{"component":"StatCard","props":{"title":"Volume","value":"12k"}}', + ); + expect(node?.name, 'StatCard'); + expect(node?.props.text('title'), 'Volume'); + }); + + test('strips a fenced code block with a language tag', () { + final node = parser.parse( + '```json\n{"component":"StatCard","props":{"title":"V","value":"1"}}\n```', + ); + expect(node?.name, 'StatCard'); + }); + + test('strips a fenced code block without a language tag', () { + final node = parser.parse( + '```\n{"component":"StatCard","props":{"title":"V","value":"1"}}\n```', + ); + expect(node?.name, 'StatCard'); + }); + + test('extracts the object from surrounding prose', () { + final node = parser.parse( + 'Here you go:\n{"component":"StatCard","props":{"title":"V","value":"1"}}\nHope that helps!', + ); + expect(node?.name, 'StatCard'); + }); + }); + + group('shape tolerance', () { + test('accepts the flat form without a props wrapper', () { + final node = parser.parse( + '{"component":"StatCard","title":"Volume","value":"12k"}', + ); + expect(node?.name, 'StatCard'); + expect(node?.props.text('value'), '12k'); + }); + + test('canonicalises a misspelled component name', () { + expect(parser.parse('{"component":"stat_card","title":"V"}')?.name, + 'StatCard'); + expect(parser.parse('{"component":"Stat Card","title":"V"}')?.name, + 'StatCard'); + }); + + test('auto-wraps a bare array of components in a GridContainer', () { + final node = parser.parse( + '[{"component":"StatCard","title":"A","value":"1"},' + '{"component":"StatCard","title":"B","value":"2"}]', + ); + expect(node?.name, 'GridContainer'); + expect(node?.children, hasLength(2)); + }); + + test('auto-wraps a {"components":[...]} envelope', () { + final node = parser.parse( + '{"components":[{"component":"StatCard","title":"A","value":"1"}]}', + ); + expect(node?.name, 'GridContainer'); + expect(node?.children, hasLength(1)); + }); + }); + + group('recursion', () { + test('parses nested children', () { + final node = parser.parse(''' +{"component":"GridContainer","props":{"columns":2,"children":[ + {"component":"StatCard","props":{"title":"A","value":"1"}}, + {"component":"DynamicChart","props":{"type":"bar","title":"C", + "labels":["Mon"],"values":[1]}} +]}} +'''); + expect(node?.name, 'GridContainer'); + expect(node?.children.map((c) => c.name), ['StatCard', 'DynamicChart']); + }); + + test('drops unrecognised children but keeps the rest', () { + final node = parser.parse(''' +{"component":"GridContainer","children":[ + {"component":"StatCard","title":"A","value":"1"}, + {"component":"HeroBanner","title":"nope"}, + "garbage" +]} +'''); + expect(node?.children, hasLength(1)); + expect(node?.children.single.name, 'StatCard'); + }); + + test('returns null when a container loses every child', () { + expect( + parser.parse('{"component":"GridContainer","children":[' + '{"component":"HeroBanner"}]}'), + isNull, + ); + }); + }); + + group('looksLikeUi', () { + test('is true for a partial payload that has started a JSON object', () { + expect(parser.looksLikeUi('{"component":"Stat'), isTrue); + expect(parser.looksLikeUi('```json\n{"comp'), isTrue); + expect(parser.looksLikeUi(' \n{'), isTrue); + }); + + test('is false for prose and for empty text', () { + expect(parser.looksLikeUi('Your bench is'), isFalse); + expect(parser.looksLikeUi(''), isFalse); + expect(parser.looksLikeUi('**Great** work'), isFalse); + }); + + test('is true for a prose sentence followed by an unclosed fence', () { + // A model that narrates before opening a fenced payload: the fence + // isn't at position 0, so a naive "starts with ``` " check misses it. + expect( + parser.looksLikeUi( + 'Here is your data:\n```json\n{"component":"Stat', + ), + isTrue, + ); + }); + + test('is false for plain prose containing no fence or JSON at all', () { + expect( + parser.looksLikeUi('Your bench is trending nicely, keep going!'), + isFalse, + ); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_prompt_test.dart b/workout-logger/test/genui/a2ui_prompt_test.dart new file mode 100644 index 0000000..d81441a --- /dev/null +++ b/workout-logger/test/genui/a2ui_prompt_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/a2ui.dart'; + +void main() { + final section = buildA2UiPromptSection(defaultA2UiRegistry); + + test('names every registered component', () { + for (final spec in defaultA2UiRegistry.specs) { + expect(section, contains(spec.name), reason: spec.name); + } + }); + + test('includes every schema line verbatim', () { + for (final spec in defaultA2UiRegistry.specs) { + expect(section, contains(spec.doc.schema), reason: spec.name); + } + }); + + test('includes every purpose line', () { + for (final spec in defaultA2UiRegistry.specs) { + expect(section, contains(spec.doc.purpose), reason: spec.name); + } + }); + + test('mentions no component the registry does not have', () { + expect(section, isNot(contains('HeroCard'))); + expect(section, isNot(contains('axes:'))); + }); + + test('contains a worked example that the parser accepts', () { + // The prompt's "Envelope: {...}" description line uses placeholder + // braces (, ...) that aren't valid JSON, so the real example must + // be located after the "WORKED EXAMPLE:" marker rather than by the + // section's first '{' overall. + final markerIndex = section.indexOf('WORKED EXAMPLE:'); + expect(markerIndex, greaterThan(-1)); + final start = section.indexOf('{', markerIndex); + expect(start, greaterThan(-1)); + // Walk forward counting brace depth so the extracted region is exactly + // the balanced JSON object starting at `start`, regardless of whether + // prompt content appended after the worked example also contains '}'. + var depth = 0; + var end = -1; + for (var i = start; i < section.length; i++) { + if (section[i] == '{') depth++; + if (section[i] == '}') { + depth--; + if (depth == 0) { + end = i; + break; + } + } + } + expect(end, greaterThan(-1)); + final example = section.substring(start, end + 1); + + final decoded = jsonDecode(example); + expect(decoded, isA>()); + + final node = A2UiParser(defaultA2UiRegistry) + .parseJson(decoded as Map); + expect(node, isNotNull); + expect(node!.name, 'GridContainer'); + expect(node.children, isNotEmpty); + }); + + test('states the tolerance rules so the model is not over-constrained', () { + expect(section.toLowerCase(), contains('number')); + expect(section.toLowerCase(), contains('ignored')); + }); + + test('is deterministic across calls so prompt caching can engage', () { + expect(buildA2UiPromptSection(defaultA2UiRegistry), section); + }); +} diff --git a/workout-logger/test/genui/a2ui_props_test.dart b/workout-logger/test/genui/a2ui_props_test.dart new file mode 100644 index 0000000..1682987 --- /dev/null +++ b/workout-logger/test/genui/a2ui_props_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; + +void main() { + group('A2UiProps key resolution', () { + test('finds a key by exact match', () { + const p = A2UiProps({'title': 'Volume'}); + expect(p.text('title'), 'Volume'); + }); + + test('finds a key ignoring case, underscores, spaces and hyphens', () { + expect(const A2UiProps({'x_label': 'Sleep'}).text('xLabel'), 'Sleep'); + expect(const A2UiProps({'X Label': 'Sleep'}).text('xLabel'), 'Sleep'); + expect(const A2UiProps({'XLABEL': 'Sleep'}).text('xLabel'), 'Sleep'); + expect(const A2UiProps({'x-label': 'Sleep'}).text('xLabel'), 'Sleep'); + }); + + test('finds a key through a semantic alias', () { + expect(const A2UiProps({'axes': ['A', 'B']}).stringList('labels'), + ['A', 'B']); + expect(const A2UiProps({'name': 'Bench'}).text('title'), 'Bench'); + expect(const A2UiProps({'val': 5}).number('value'), 5); + }); + + test('prefers an exact match over an alias', () { + const p = A2UiProps({'title': 'Real', 'name': 'Alias'}); + expect(p.text('title'), 'Real'); + }); + }); + + group('A2UiProps coercion', () { + test('text() stringifies numbers and returns fallback for null', () { + expect(const A2UiProps({'value': 12.5}).text('value'), '12.5'); + expect(const A2UiProps({}).text('value', or: '—'), '—'); + }); + + test('number() parses numeric strings and returns fallback otherwise', () { + expect(const A2UiProps({'value': '12.5'}).number('value'), 12.5); + expect(const A2UiProps({'value': 'n/a'}).number('value', or: -1), -1); + expect(const A2UiProps({'value': 7}).number('value'), 7); + }); + + test('numberOrNull() distinguishes absent from zero', () { + expect(const A2UiProps({}).numberOrNull('min'), isNull); + expect(const A2UiProps({'min': 0}).numberOrNull('min'), 0); + }); + + test('stringList() stringifies mixed element types', () { + expect(const A2UiProps({'labels': [1, 'B', 2.5]}).stringList('labels'), + ['1', 'B', '2.5']); + }); + + test('numberList() coerces string elements and drops unparseable ones', () { + expect(const A2UiProps({'values': ['1', 2, 'x']}).numberList('values'), + [1.0, 2.0]); + }); + + test('list accessors return empty for a wrong-typed or missing key', () { + expect(const A2UiProps({'labels': 'not a list'}).stringList('labels'), + isEmpty); + expect(const A2UiProps({}).numberList('values'), isEmpty); + expect(const A2UiProps({'items': 5}).objectList('items'), isEmpty); + }); + + test('objectList() wraps maps and skips non-maps', () { + final rows = const A2UiProps({ + 'items': [ + {'primaryText': 'Bench'}, + 'garbage', + {'primaryText': 'Squat'}, + ], + }).objectList('items'); + expect(rows, hasLength(2)); + expect(rows[0].text('primaryText'), 'Bench'); + expect(rows[1].text('primaryText'), 'Squat'); + }); + + test('integer() truncates and falls back', () { + expect(const A2UiProps({'columns': 2.9}).integer('columns'), 2); + expect(const A2UiProps({'columns': '2'}).integer('columns'), 2); + expect(const A2UiProps({}).integer('columns', or: 1), 1); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_purity_test.dart b/workout-logger/test/genui/a2ui_purity_test.dart new file mode 100644 index 0000000..c7932e2 --- /dev/null +++ b/workout-logger/test/genui/a2ui_purity_test.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Matches an `import`/`export` path that reaches into one of RepForge's +/// app-specific top-level directories, regardless of how many `../` hops +/// precede it (e.g. `'../theme/...'`, `'../../../theme/...'`) or whether it +/// is written as a `package:repforge/...` path. +final RegExp _forbiddenPathPattern = RegExp( + r"""['"](?:(?:\.\./)+|package:repforge/)(theme|models|services|screens|data)/""", +); + +/// Matches an `import` or `export` directive line, so we only flag genuine +/// dependency declarations and not, say, doc comments that happen to mention +/// a forbidden directory name. +final RegExp _directiveLine = RegExp(r'^(import|export)\s'); + +/// Matches an explicit cast to a common type, used to flag unchecked casts on +/// model-supplied data in component renderers. +final RegExp _castPattern = + RegExp(r'\bas (String|num|int|double|List|Map|bool|Object|dynamic)\b'); + +void main() { + test('lib/genui imports nothing app-specific', () { + // The whole point of the refactor: this package must be liftable into + // another app without dragging RepForge's models, theme or services along. + final violations = []; + var scannedFileCount = 0; + final dir = Directory('lib/genui'); + for (final entity in dir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + scannedFileCount++; + final lines = entity.readAsStringSync().split('\n'); + for (var i = 0; i < lines.length; i++) { + final trimmed = lines[i].trimLeft(); + if (!_directiveLine.hasMatch(trimmed)) continue; + if (_forbiddenPathPattern.hasMatch(trimmed)) { + violations.add('${entity.path}:${i + 1}: ${lines[i].trim()}'); + } + } + } + + // Guards against a vacuous pass: if `lib/genui` were ever empty or + // unreachable (wrong CWD, a path typo), the loop above would scan zero + // files and `violations` would be trivially empty. The package has 20+ + // Dart files at time of writing; a sane floor below that still catches a + // broken scan without being brittle to file-count churn. + expect(scannedFileCount, greaterThan(15), + reason: 'expected to scan a substantial number of lib/genui files, ' + 'but only found $scannedFileCount — is the CWD wrong?'); + + expect(violations, isEmpty, + reason: 'genui must stay domain-free:\n${violations.join('\n')}'); + }); + + test('forbidden-path regex catches the violation shapes it must', () { + // Regression test for the guard itself: a depth-blind, literal + // needle-list version of this check silently passed a real + // `'../../../theme/app_theme.dart'` import from + // lib/genui/src/components/ (three `../` hops) because only one- and + // two-hop needles were listed. Pin down that every realistic depth and + // form of a forbidden import is actually matched, using in-memory + // strings rather than mutating real source files. + const mustMatch = [ + "import '../theme/app_theme.dart';", + "import '../../theme/app_theme.dart';", + "import '../../../theme/app_theme.dart';", + "import '../../../../models/models.dart';", + "import 'package:repforge/theme/app_theme.dart';", + "import 'package:repforge/models/models.dart';", + "export 'package:repforge/services/workout_provider.dart';", + "import '../screens/home_screen.dart';", + "import '../../data/exercise_database.dart';", + "import 'package:repforge/data/exercise_database.dart';", + ]; + for (final line in mustMatch) { + expect(_forbiddenPathPattern.hasMatch(line), isTrue, + reason: 'expected forbidden-path regex to match: $line'); + } + + const mustNotMatch = [ + "import 'package:flutter/material.dart';", + "import 'a2ui_registry.dart';", + "import '../src/a2ui_parser.dart';", + "import 'package:repforge/genui/a2ui.dart';", + ]; + for (final line in mustNotMatch) { + expect(_forbiddenPathPattern.hasMatch(line), isFalse, + reason: 'expected forbidden-path regex NOT to match: $line'); + } + }); + + test('component renderers contain no casts on model-supplied data', () { + final violations = []; + var scannedFileCount = 0; + final dir = Directory('lib/genui/src/components'); + for (final entity in dir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + scannedFileCount++; + final lines = entity.readAsStringSync().split('\n'); + for (var i = 0; i < lines.length; i++) { + if (_castPattern.hasMatch(lines[i])) { + violations.add('${entity.path}:${i + 1}: ${lines[i].trim()}'); + } + } + } + + // Same vacuous-pass guard as above: there are 8 component files at time + // of writing, so a floor comfortably below that still catches a broken + // scan (wrong CWD, empty/unreachable directory) without being brittle. + expect(scannedFileCount, greaterThan(5), + reason: 'expected to scan several component files, but only found ' + '$scannedFileCount — is the CWD wrong?'); + + expect(violations, isEmpty, + reason: 'use A2UiProps accessors, not casts:\n${violations.join('\n')}'); + }); +} diff --git a/workout-logger/test/genui/a2ui_registry_test.dart b/workout-logger/test/genui/a2ui_registry_test.dart new file mode 100644 index 0000000..c5e0e87 --- /dev/null +++ b/workout-logger/test/genui/a2ui_registry_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_registry.dart'; +import 'package:repforge/genui/src/a2ui_spec.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; + +class _FakeProps { + const _FakeProps(this.title); + final String title; +} + +class _FakeSpec extends A2UiSpec<_FakeProps> { + const _FakeSpec(); + + @override + String get name => 'StatCard'; + + @override + List get aliases => const ['Stat', 'KpiCard']; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'StatCard {title, value}', + purpose: 'A single headline number.', + example: { + 'component': 'StatCard', + 'props': {'title': 'Volume', 'value': '12000 kg'}, + }, + ); + + @override + _FakeProps parseProps(A2UiNode node) => _FakeProps(node.props.text('title')); + + @override + Widget buildWidget(BuildContext context, _FakeProps props, A2UiTheme theme) => + Text(props.title, textDirection: TextDirection.ltr); +} + +/// A minimal fake spec with a configurable name/aliases, for exercising +/// registry collision detection. +class _NamedFakeSpec extends A2UiSpec<_FakeProps> { + const _NamedFakeSpec(this.name, {this.aliases = const []}); + + @override + final String name; + + @override + final List aliases; + + @override + A2UiDoc get doc => const A2UiDoc( + schema: 'Fake {}', + purpose: 'A fake component for tests.', + example: {'component': 'Fake', 'props': {}}, + ); + + @override + _FakeProps parseProps(A2UiNode node) => _FakeProps(node.props.text('title')); + + @override + Widget buildWidget(BuildContext context, _FakeProps props, A2UiTheme theme) => + Text(props.title, textDirection: TextDirection.ltr); +} + +void main() { + final registry = A2UiRegistry(const [_FakeSpec()]); + + group('A2UiRegistry lookup', () { + test('resolves the canonical name', () { + expect(registry.specFor('StatCard'), isNotNull); + }); + + test('resolves case, underscore and space variants', () { + for (final variant in ['statcard', 'STAT_CARD', 'Stat Card', 'stat-card']) { + expect(registry.specFor(variant), isNotNull, reason: variant); + } + }); + + test('resolves declared aliases', () { + expect(registry.specFor('KpiCard')?.name, 'StatCard'); + expect(registry.specFor('stat')?.name, 'StatCard'); + }); + + test('returns null for an unknown name', () { + expect(registry.specFor('HeroBanner'), isNull); + }); + + test('canonicalName maps any accepted variant to the canonical name', () { + expect(registry.canonicalName('kpi_card'), 'StatCard'); + expect(registry.canonicalName('nope'), isNull); + }); + + test('exposes specs in registration order', () { + expect(registry.specs.map((s) => s.name), ['StatCard']); + }); + + test('throws when two specs share a canonical name', () { + expect( + () => A2UiRegistry(const [ + _NamedFakeSpec('LineChart'), + _NamedFakeSpec('LineChart'), + ]), + throwsStateError, + ); + }); + + test("throws when a spec's alias matches another spec's canonical name", + () { + expect( + () => A2UiRegistry(const [ + _NamedFakeSpec('LineChart'), + _NamedFakeSpec('BarChart', aliases: ['LineChart']), + ]), + throwsStateError, + ); + }); + + test('throws when two specs share an alias', () { + expect( + () => A2UiRegistry(const [ + _NamedFakeSpec('LineChart', aliases: ['Chart']), + _NamedFakeSpec('BarChart', aliases: ['Chart']), + ]), + throwsStateError, + ); + }); + }); + + group('A2UiSpec', () { + testWidgets('render() parses then builds', (tester) async { + const node = A2UiNode( + name: 'StatCard', + props: A2UiProps({'title': 'Weekly Volume'}), + ); + await tester.pumpWidget( + Builder( + builder: (context) => + registry.specFor('StatCard')!.render(context, node, A2UiTheme.dark), + ), + ); + expect(find.text('Weekly Volume'), findsOneWidget); + }); + }); + + group('A2UiNode', () { + test('defaults to no children', () { + const node = A2UiNode(name: 'StatCard', props: A2UiProps.empty); + expect(node.children, isEmpty); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_renderer_test.dart b/workout-logger/test/genui/a2ui_renderer_test.dart new file mode 100644 index 0000000..520175c --- /dev/null +++ b/workout-logger/test/genui/a2ui_renderer_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/a2ui.dart'; + +Future pumpText(WidgetTester tester, String text, + {Size size = const Size(800, 600)}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final node = A2UiParser(defaultA2UiRegistry).parse(text); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: node == null + ? const Text('PROSE') + : A2UiRenderer(node: node), + ), + ), + )); +} + +void main() { + group('registry completeness', () { + test('registers all eight components', () { + expect( + defaultA2UiRegistry.specs.map((s) => s.name).toList()..sort(), + [ + 'DataListGroup', + 'DynamicChart', + 'FilterChips', + 'GridContainer', + 'MetricGauge', + 'RadarChart', + 'ScatterPlot', + 'StatCard', + ], + ); + }); + + test('every spec example parses back to its own component', () { + final parser = A2UiParser(defaultA2UiRegistry); + for (final spec in defaultA2UiRegistry.specs) { + if (spec.name == 'GridContainer') continue; + final node = parser.parseJson(spec.doc.example); + expect(node?.name, spec.name, reason: '${spec.name} example'); + } + }); + }); + + // Regression coverage for a Task 13 fix to a2ui_parser.dart (a Task 3 + // file), discovered during registry integration: `_parseChildren` and + // `_declaresChildren` used to resolve `children` through A2UiProps' + // alias-aware `lookup()`, which treats `items` as an alias for `children`. + // That collided with DataListGroup, whose own canonical data-row key is + // also `items` — so a DataListGroup node's `items` list of + // `{primaryText, ...}` maps was mistaken for a list of child *components*, + // none of them parsed as one, and the node was then discarded outright as + // "declared children, ended up with none." + // + // First fix pass restricted per-node structural recursion to the literal + // `children` key only. That was too narrow: it silently dropped + // `components`/`elements`/`content` tolerance at the per-node level even + // though those keys never collided with anything — only `items` did. A + // payload like `{"component":"GridContainer","props":{"components":[...]}}` + // resolved fine before the original bug and regressed to zero children + // after the first fix, with `_declaresChildren` no longer even recognizing + // it as "declared children" — so instead of falling back to `null` (which + // at least lets the caller show the raw text as prose), it silently + // rendered as an empty, blank `GridContainer`. Fixed by widening the + // per-node lookup to the same literal key set `_envelopeKeys` already + // tolerates (`children`/`components`/`elements`/`content`), still + // excluding `items`, still without going through the alias-aware + // `A2UiProps.lookup()`. + group('children vs items key collision (a2ui_parser.dart fix)', () { + test('DataListGroup example parses instead of being swallowed', () { + // Before the fix this returned null: `items` resolved as an alias for + // `children`, none of the rows parsed as components, and the node was + // discarded as an emptied-out container. + final parser = A2UiParser(defaultA2UiRegistry); + final spec = defaultA2UiRegistry.specFor('DataListGroup')!; + final node = parser.parseJson(spec.doc.example); + expect(node?.name, 'DataListGroup'); + }); + + testWidgets('DataListGroup items render end to end through the parser', + (tester) async { + await pumpText(tester, ''' +{"component":"DataListGroup","props":{"items":[ + {"primaryText":"Bench Press","trailingValue":"102.5 kg"} +]}} +'''); + expect(find.text('Bench Press'), findsOneWidget); + expect(find.text('102.5 kg'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets("GridContainer's literal children key still resolves", + (tester) async { + await pumpText(tester, ''' +{"component":"GridContainer","props":{"columns":1,"children":[ + {"component":"StatCard","props":{"title":"Still Works","value":"1"}} +]}} +'''); + expect(find.text('Still Works'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + test('per-node components/elements/content keys resolve to real children', + () { + // Regression for the too-narrow first fix pass: these are literal + // (non-`items`) child-list keys at the *per-node* level, not the + // top-level envelope path — a different code path (`_parseChildren` + // via `parseJson`, not `parse`'s top-level envelope scan). + final parser = A2UiParser(defaultA2UiRegistry); + for (final key in ['components', 'elements', 'content']) { + final node = parser.parseJson({ + 'component': 'GridContainer', + 'props': { + 'columns': 1, + key: [ + { + 'component': 'StatCard', + 'props': {'title': 'Via $key', 'value': '1'}, + }, + ], + }, + }); + expect(node?.name, 'GridContainer', reason: 'per-node key "$key"'); + expect(node?.children, hasLength(1), reason: 'per-node key "$key"'); + expect(node?.children.single.name, 'StatCard', + reason: 'per-node key "$key"'); + } + }); + + test('per-node items key stays excluded from child resolution', () { + // Confirms the widened fix did not accidentally let `items` back in + // as a per-node child-list key — it must still be treated as + // DataListGroup's own data, not a list of child components. + final parser = A2UiParser(defaultA2UiRegistry); + final node = parser.parseJson({ + 'component': 'GridContainer', + 'props': { + 'columns': 1, + 'items': [ + { + 'component': 'StatCard', + 'props': {'title': 'Should not be a child', 'value': '1'}, + }, + ], + }, + }); + // `_declaresChildren` does not fire for `items`, so the node is not + // rejected: it parses into a real GridContainer with zero children. + // That is the expected non-crashing behavior — `items` stays + // DataListGroup's own data key and is never read as child components. + expect(node?.name, 'GridContainer'); + expect(node?.children, isEmpty); + }); + + test('top-level envelope aliases (components/elements/ui) are unaffected', + () { + // A single-item envelope still wraps in a GridContainer rather than + // collapsing to the bare child — naming an envelope key is an explicit + // "this is a container" signal (see A2UiParser._wrap's + // collapseSingle doc). That behavior predates this fix and must be + // unaffected by it. + final parser = A2UiParser(defaultA2UiRegistry); + for (final key in ['components', 'elements', 'ui']) { + final node = parser.parse( + '{"$key":[{"component":"StatCard","props":{"title":"E","value":"1"}}]}', + ); + expect(node?.name, 'GridContainer', reason: 'envelope key "$key"'); + expect(node?.children.single.name, 'StatCard', + reason: 'envelope key "$key"'); + } + }); + }); + + group('GridContainer', () { + testWidgets('renders children side by side at two columns', + (tester) async { + await pumpText(tester, ''' +{"component":"GridContainer","props":{"columns":2,"children":[ + {"component":"StatCard","props":{"title":"A","value":"1"}}, + {"component":"StatCard","props":{"title":"B","value":"2"}} +]}} +'''); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('collapses to one column on a narrow viewport', + (tester) async { + await pumpText(tester, ''' +{"component":"GridContainer","props":{"columns":2,"children":[ + {"component":"StatCard","props":{"title":"A","value":"1"}}, + {"component":"StatCard","props":{"title":"B","value":"2"}} +]}} +''', size: const Size(360, 800)); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.byType(IntrinsicHeight), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('handles an odd child count', (tester) async { + await pumpText(tester, ''' +{"component":"GridContainer","props":{"columns":2,"children":[ + {"component":"StatCard","props":{"title":"A","value":"1"}}, + {"component":"StatCard","props":{"title":"B","value":"2"}}, + {"component":"StatCard","props":{"title":"C","value":"3"}} +]}} +'''); + expect(find.text('C'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders a mixed dashboard end to end', (tester) async { + await pumpText(tester, ''' +{"component":"GridContainer","props":{"columns":1,"children":[ + {"component":"StatCard","props":{"title":"Volume","value":12400,"unit":"kg","trend":"improving"}}, + {"component":"DynamicChart","props":{"type":"bar","title":"Sets","labels":["Mon","Wed"],"values":[12,15]}}, + {"component":"MetricGauge","props":{"title":"Readiness","value":"82"}}, + {"component":"DataListGroup","props":{"items":[{"primaryText":"Bench","trailingValue":102.5}]}}, + {"component":"FilterChips","props":{"options":["7d","30d"]}} +]}} +'''); + expect(find.text('Volume'), findsOneWidget); + expect(find.text('12400 kg'), findsOneWidget); + expect(find.text('Sets'), findsOneWidget); + expect(find.text('Readiness'), findsOneWidget); + expect(find.text('Bench'), findsOneWidget); + expect(find.text('7d'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('A2UiRenderer', () { + testWidgets('renders nothing for a node the registry does not know', + (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: A2UiRenderer( + node: A2UiNode(name: 'Unregistered', props: A2UiProps.empty), + ), + ), + )); + expect(tester.takeException(), isNull); + }); + + testWidgets('picks up an injected theme', (tester) async { + const custom = A2UiTheme.dark; + await tester.pumpWidget(const MaterialApp( + home: A2UiThemeProvider( + theme: custom, + child: Scaffold( + body: A2UiRenderer( + node: A2UiNode( + name: 'StatCard', + props: A2UiProps({'title': 'Themed', 'value': '1'}), + ), + ), + ), + ), + )); + expect(find.text('Themed'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_robustness_test.dart b/workout-logger/test/genui/a2ui_robustness_test.dart new file mode 100644 index 0000000..a453ce9 --- /dev/null +++ b/workout-logger/test/genui/a2ui_robustness_test.dart @@ -0,0 +1,171 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/a2ui.dart'; + +final _parser = A2UiParser(defaultA2UiRegistry); + +/// Every payload here is something a weak model plausibly emits. None may +/// throw; each either renders or is cleanly rejected as prose. +const _payloads = [ + // Well-formed. + '{"component":"StatCard","props":{"title":"Volume","value":12000,"unit":"kg","trend":"improving"}}', + // Flat, no props wrapper. + '{"component":"StatCard","title":"Volume","value":"12k"}', + // Snake-case component and props. + '{"component":"stat_card","props":{"title":"V","value":1}}', + // Fenced. + '```json\n{"component":"MetricGauge","props":{"title":"R","value":"82"}}\n```', + // Prose wrapper. + 'Sure!\n{"component":"FilterChips","props":{"options":["7d","30d"]}}\nHope that helps.', + // Bare array. + '[{"component":"StatCard","title":"A","value":1},{"component":"StatCard","title":"B","value":2}]', + // Envelope key. + '{"components":[{"component":"StatCard","title":"A","value":1}]}', + // Legacy radar with axes. + '{"component":"RadarChart","props":{"title":"R","axes":["A","B","C"],"series":[{"name":"S","values":[1,2,3]}]}}', + // Radar with mismatched series length. + '{"component":"RadarChart","props":{"labels":["A","B","C","D"],"series":[{"name":"S","values":[1,2]}]}}', + // Numbers as strings throughout. + '{"component":"DynamicChart","props":{"type":"bar","title":"T","labels":[1,2],"values":["10","20"]}}', + // More values than labels. + '{"component":"DynamicChart","props":{"labels":["A"],"series":[{"name":"S","values":[1,2,3,4]}]}}', + // Missing every optional prop. + '{"component":"DynamicChart","props":{"values":[1,2,3]}}', + // Gauge with a degenerate range. + '{"component":"MetricGauge","props":{"title":"G","value":5,"min":5,"max":5}}', + // Gauge with a non-numeric value. + '{"component":"MetricGauge","props":{"title":"G","value":"optimal"}}', + // List with a missing title and numeric trailing values. + '{"component":"DataListGroup","props":{"items":[{"primaryText":"Bench","trailingValue":102.5}]}}', + // List of bare strings. + '{"component":"DataListGroup","props":{"title":"T","items":["Bench","Squat"]}}', + // Chips with no active option. + '{"component":"FilterChips","props":{"options":["7d","30d"]}}', + // Scatter with broken points mixed in. + '{"component":"ScatterPlot","props":{"points":[{"x":1,"y":2},{"x":"a","y":3},{"y":4}]}}', + // Scatter with a single point. + '{"component":"ScatterPlot","props":{"points":[{"x":5,"y":5}]}}', + // Grid with a mix of good and unknown children. + '{"component":"GridContainer","props":{"columns":2,"children":[' + '{"component":"StatCard","title":"A","value":1},' + '{"component":"HeroBanner","title":"nope"}]}}', + // Deeply nested grids. + '{"component":"GridContainer","children":[{"component":"GridContainer","children":[' + '{"component":"StatCard","title":"A","value":1}]}]}', + // Grid using "components" as an alias for "children" (regression: Task 13 + // widened the parser's per-node child-key lookup to accept + // components/elements/content, not just children). + '{"component":"GridContainer","props":{"components":[' + '{"component":"StatCard","title":"A","value":1}]}}', + // All-negative DynamicChart values (regression: Task 8 fixed the axis + // bounds — via A2UiSeries.minValue/_yBounds — so an all-negative series + // is bracketed instead of silently clamped to a 0-start axis that + // excludes every real data point). + '{"component":"DynamicChart","props":{"title":"T","labels":["A","B","C"],"values":[-50,-30,-10]}}', + // Empty data everywhere. + '{"component":"DynamicChart","props":{"title":"T","labels":[],"series":[]}}', + // Hostile types. + '{"component":"StatCard","props":{"title":[],"value":{},"trend":7}}', + // Prose only. + 'Great session — your bench is up 5kg since June.', + // Broken JSON. + '{"component":"StatCard","props":', + // Empty. + '', +]; + +/// Truncated payload text used as a stable test name, so a failure identifies +/// the payload directly instead of an index that shifts whenever `_payloads` +/// gains or loses an entry. +String _label(String payload) => + payload.isEmpty ? '' : payload.substring(0, payload.length.clamp(0, 60)); + +void main() { + group('parser never throws', () { + for (final payload in _payloads) { + test(_label(payload), () { + expect(() => _parser.parse(payload), returnsNormally); + }); + } + }); + + group('renderer never throws', () { + for (final payload in _payloads) { + testWidgets(_label(payload), (tester) async { + final node = _parser.parse(payload); + if (node == null) return; + + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: A2UiRenderer(node: node)), + ), + )); + expect(tester.takeException(), isNull); + }); + } + }); + + group('no silent blanks', () { + testWidgets('a component with no data shows a visible empty panel', + (tester) async { + final node = _parser + .parse('{"component":"DynamicChart","props":{"title":"Volume"}}'); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: A2UiRenderer(node: node!)), + )); + expect(find.textContaining('No chart data'), findsOneWidget); + }); + }); + + // These two regressions were both SILENT-VISUAL, not throwing — a + // no-exception check structurally can't catch either, so each gets a + // positive assertion pinning the actual fixed behavior, not just + // "didn't crash". + group('silent-visual regressions stay fixed', () { + testWidgets( + 'all-negative DynamicChart values render an axis that brackets ' + 'the data instead of clamping to a 0-start range (Task 8)', + (tester) async { + final node = _parser.parse( + '{"component":"DynamicChart","props":{"title":"T",' + '"labels":["A","B","C"],"values":[-50,-30,-10]}}', + )!; + + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: A2UiRenderer(node: node)), + )); + + final chart = tester.widget(find.byType(LineChart)); + // The true minimum is -50; a broken axis that clamps at 0 would give + // minY == 0 and silently drop every point off the visible chart. The + // axis must actually bracket the real minimum, not just dip below + // some weak threshold that a partially-broken bound could still clear. + expect(chart.data.minY, lessThanOrEqualTo(-50)); + expect(chart.data.maxY, greaterThanOrEqualTo(-10)); + }); + + test( + 'GridContainer accepts "components" as an alias for "children" ' + 'and actually populates the node tree (Task 13)', () { + final node = _parser.parse( + '{"component":"GridContainer","props":{"components":[' + '{"component":"StatCard","title":"A","value":1}]}}', + ); + + expect(node, isNotNull); + // A broken alias lookup would still parse without throwing but leave + // children empty, silently rendering an empty grid. + expect(node!.children, isNotEmpty); + expect(node.children.single.name, 'StatCard'); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_series_test.dart b/workout-logger/test/genui/a2ui_series_test.dart new file mode 100644 index 0000000..f77ee8a --- /dev/null +++ b/workout-logger/test/genui/a2ui_series_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_series.dart'; + +void main() { + group('A2UiSeries.extract', () { + test('reads an explicit series array', () { + final series = A2UiSeries.extract(const A2UiProps({ + 'series': [ + {'name': 'Biceps', 'values': [1, 2, 3]}, + {'name': 'Triceps', 'values': [4, 5, 6]}, + ], + })); + expect(series.map((s) => s.name), ['Biceps', 'Triceps']); + expect(series[1].values, [4.0, 5.0, 6.0]); + }); + + test('treats a bare values array as one unnamed series', () { + final series = A2UiSeries.extract( + const A2UiProps({'title': 'Weekly Sets', 'values': [10, 12]}), + fallbackName: 'Weekly Sets', + ); + expect(series, hasLength(1)); + expect(series.single.name, 'Weekly Sets'); + expect(series.single.values, [10.0, 12.0]); + }); + + test('prefers series over values when both are present', () { + final series = A2UiSeries.extract(const A2UiProps({ + 'values': [1], + 'series': [ + {'name': 'A', 'values': [7, 8]} + ], + })); + expect(series, hasLength(1)); + expect(series.single.name, 'A'); + expect(series.single.values, [7.0, 8.0]); + }); + + test("coerces a stringified number inside a series entry's values", () { + final series = A2UiSeries.extract(const A2UiProps({ + 'series': [ + {'name': 'Current', 'values': ['85', 90]} + ], + })); + expect(series.single.values, [85.0, 90.0]); + }); + + test('names an unnamed series entry positionally', () { + final series = A2UiSeries.extract(const A2UiProps({ + 'series': [ + {'values': [1, 2]}, + {'values': [3, 4]}, + ], + })); + expect(series.map((s) => s.name), ['Series 1', 'Series 2']); + }); + + test('drops series entries that carry no numeric values', () { + final series = A2UiSeries.extract(const A2UiProps({ + 'series': [ + {'name': 'Good', 'values': [1]}, + {'name': 'Empty', 'values': []}, + {'name': 'Junk', 'values': ['x', 'y']}, + ], + })); + expect(series.map((s) => s.name), ['Good']); + }); + + test('returns empty when there is no usable data', () { + expect(A2UiSeries.extract(const A2UiProps({})), isEmpty); + expect(A2UiSeries.extract(const A2UiProps({'values': 'nope'})), isEmpty); + }); + + test('falls back to values: when every series entry drops to empty values', () { + final series = A2UiSeries.extract( + const A2UiProps({ + 'series': [ + {'name': 'A', 'values': []}, + {'name': 'B', 'values': ['x', 'y']}, // unparseable, also drops + ], + 'values': [10, 20], + }), + fallbackName: 'Fallback', + ); + expect(series, hasLength(1)); + expect(series.single.name, 'Fallback'); + expect(series.single.values, [10.0, 20.0]); + }); + + test('falls back to values: when series is an empty list', () { + final series = A2UiSeries.extract( + const A2UiProps({'series': [], 'values': [5, 6]}), + fallbackName: 'Fallback', + ); + expect(series, hasLength(1)); + expect(series.single.values, [5.0, 6.0]); + }); + }); + + group('A2UiSeries.maxValue', () { + test('returns the largest value across all series', () { + expect( + A2UiSeries.maxValue(const [ + A2UiSeries(name: 'a', values: [1, 9]), + A2UiSeries(name: 'b', values: [4, 2]), + ]), + 9, + ); + }); + + test('returns 0 for empty input', () { + expect(A2UiSeries.maxValue(const []), 0); + }); + + test('returns the true max when all values are negative', () { + expect( + A2UiSeries.maxValue(const [ + A2UiSeries(name: 'a', values: [-5, -2]), + ]), + -2.0, + ); + }); + }); + + group('A2UiSeries.minValue', () { + test('returns the smallest value across all series', () { + expect( + A2UiSeries.minValue(const [ + A2UiSeries(name: 'a', values: [1, 9]), + A2UiSeries(name: 'b', values: [4, 2]), + ]), + 1, + ); + }); + + test('returns 0 for empty input', () { + expect(A2UiSeries.minValue(const []), 0); + }); + + test('returns the true min when all values are negative', () { + expect( + A2UiSeries.minValue(const [ + A2UiSeries(name: 'a', values: [-5, -2]), + ]), + -5.0, + ); + }); + }); +} diff --git a/workout-logger/test/genui/a2ui_theme_test.dart b/workout-logger/test/genui/a2ui_theme_test.dart new file mode 100644 index 0000000..f24b445 --- /dev/null +++ b/workout-logger/test/genui/a2ui_theme_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_panels.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; + +/// A theme deliberately distinct from [A2UiTheme.dark] in every field the +/// injection tests check, so those tests can only pass if +/// [A2UiThemeProvider.of] genuinely performed the InheritedWidget lookup +/// rather than falling through to the default. +const _injectedTestTheme = A2UiTheme( + surface: Color(0xFF000001), + border: Color(0xFF000002), + divider: Color(0xFF000003), + textPrimary: Color(0xFF000004), + textSoft: Color(0xFF000005), + textMuted: Color(0xFF000006), + textFaint: Color(0xFF000007), + accent: Color(0xFF00FF00), + positive: Color(0xFF000008), + negative: Color(0xFF000009), + seriesPalette: [Color(0xFF00000A)], + spacing: 99, + radius: 98, + pillRadius: 97, +); + +void main() { + group('A2UiThemeProvider', () { + testWidgets('falls back to A2UiTheme.dark when no provider is present', + (tester) async { + late A2UiTheme resolved; + await tester.pumpWidget( + Builder(builder: (context) { + resolved = A2UiThemeProvider.of(context); + return const SizedBox.shrink(); + }), + ); + expect(resolved.accent, A2UiTheme.dark.accent); + }); + + testWidgets( + 'supplies the injected theme to descendants and falls back for ' + 'non-descendants', (tester) async { + late A2UiTheme resolvedInside; + late A2UiTheme resolvedOutside; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + A2UiThemeProvider( + theme: _injectedTestTheme, + child: Builder(builder: (context) { + resolvedInside = A2UiThemeProvider.of(context); + return const SizedBox.shrink(); + }), + ), + // Sibling of the provider, not a descendant of it: must still + // fall back to A2UiTheme.dark. + Builder(builder: (context) { + resolvedOutside = A2UiThemeProvider.of(context); + return const SizedBox.shrink(); + }), + ], + ), + ), + ); + + expect(resolvedInside.accent, _injectedTestTheme.accent); + expect(resolvedInside.surface, _injectedTestTheme.surface); + expect(resolvedInside.spacing, _injectedTestTheme.spacing); + + expect(resolvedOutside.accent, A2UiTheme.dark.accent); + expect(resolvedOutside.surface, A2UiTheme.dark.surface); + }); + }); + + group('A2UiTheme', () { + test('seriesColor cycles through the palette', () { + const t = A2UiTheme.dark; + expect(t.seriesColor(0), t.seriesPalette[0]); + expect(t.seriesColor(5), t.seriesPalette[0]); + expect(t.seriesColor(6), t.seriesPalette[1]); + }); + }); + + group('shared chrome', () { + testWidgets('A2UiEmptyPanel shows its message', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: A2UiEmptyPanel(message: 'No chart data', theme: A2UiTheme.dark), + ), + )); + expect(find.text('No chart data'), findsOneWidget); + }); + + testWidgets('A2UiPanelTitle renders title and trailing text', + (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: A2UiPanelTitle( + title: 'Volume', + trailing: 'r = +0.82', + theme: A2UiTheme.dark, + ), + ), + )); + expect(find.text('Volume'), findsOneWidget); + expect(find.text('r = +0.82'), findsOneWidget); + }); + + testWidgets('A2UiLegend renders one entry per name', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: A2UiLegend(names: ['Biceps', 'Triceps'], theme: A2UiTheme.dark), + ), + )); + expect(find.text('Biceps'), findsOneWidget); + expect(find.text('Triceps'), findsOneWidget); + }); + }); + + group('A2UiPanel', () { + testWidgets( + 'pads with theme.spacing, decorates with theme colors, and renders ' + 'its child by default', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: A2UiPanel( + theme: A2UiTheme.dark, + child: Text('probe'), + ), + ), + )); + + expect(find.text('probe'), findsOneWidget); + + final container = tester.widget(find.descendant( + of: find.byType(A2UiPanel), + matching: find.byType(Container), + )); + expect(container.padding, EdgeInsets.all(A2UiTheme.dark.spacing)); + + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, A2UiTheme.dark.surface); + expect(decoration.border, Border.all(color: A2UiTheme.dark.border)); + }); + + testWidgets('uses zero padding when padded is false', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: A2UiPanel( + theme: A2UiTheme.dark, + padded: false, + child: Text('probe'), + ), + ), + )); + + expect(find.text('probe'), findsOneWidget); + + final container = tester.widget(find.descendant( + of: find.byType(A2UiPanel), + matching: find.byType(Container), + )); + expect(container.padding, EdgeInsets.zero); + }); + }); +} diff --git a/workout-logger/test/genui/components/data_list_group_test.dart b/workout-logger/test/genui/components/data_list_group_test.dart new file mode 100644 index 0000000..bd2b03c --- /dev/null +++ b/workout-logger/test/genui/components/data_list_group_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/data_list_group.dart'; + +DataListGroupProps parse(Map props) => + const DataListGroupSpec() + .parseProps(A2UiNode(name: 'DataListGroup', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => const DataListGroupSpec().render( + context, + A2UiNode(name: 'DataListGroup', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + )); + +void main() { + group('DataListGroupProps parsing', () { + test('reads the happy path', () { + final p = parse({ + 'title': 'Recent PRs', + 'items': [ + { + 'primaryText': 'Bench Press', + 'secondaryText': '2026-07-04', + 'trailingValue': '102.5 kg', + }, + ], + }); + expect(p.title, 'Recent PRs'); + expect(p.rows.single.primaryText, 'Bench Press'); + expect(p.rows.single.trailingValue, '102.5 kg'); + }); + + test('treats a missing title as no header, not a crash', () { + final p = parse({ + 'items': [ + {'primaryText': 'Bench'} + ] + }); + expect(p.title, isNull); + expect(p.rows, hasLength(1)); + }); + + test('stringifies a numeric trailing value', () { + final p = parse({ + 'items': [ + {'primaryText': 'Bench', 'trailingValue': 102.5} + ] + }); + expect(p.rows.single.trailingValue, '102.5'); + }); + + test('accepts plain-string items', () { + final p = parse({'items': ['Bench Press', 'Squat']}); + expect(p.rows.map((r) => r.primaryText), ['Bench Press', 'Squat']); + expect(p.rows.first.secondaryText, isNull); + }); + + test('falls back to the first stringifiable value when primaryText is absent', + () { + final p = parse({ + 'items': [ + {'exercise': 'Deadlift', 'volume': 4200} + ] + }); + expect(p.rows.single.primaryText, 'Deadlift'); + }); + + test('drops items with nothing renderable', () { + final p = parse({ + 'items': [ + {'primaryText': 'Bench'}, + {}, + {'nested': {}}, + ], + }); + expect(p.rows, hasLength(1)); + }); + + test('resolves row key aliases', () { + final p = parse({ + 'rows': [ + {'primary': 'Bench', 'detail': 'Mon', 'right': '100 kg'} + ] + }); + expect(p.rows.single.primaryText, 'Bench'); + expect(p.rows.single.secondaryText, 'Mon'); + expect(p.rows.single.trailingValue, '100 kg'); + }); + + test('never throws on hostile input', () { + expect(() => parse({'items': 5, 'title': []}), returnsNormally); + }); + }); + + group('DataListGroup rendering', () { + testWidgets('renders title and all rows', (tester) async { + await pump(tester, { + 'title': 'Recent PRs', + 'items': [ + {'primaryText': 'Bench', 'secondaryText': 'Mon', 'trailingValue': '100'}, + {'primaryText': 'Squat', 'secondaryText': 'Wed', 'trailingValue': '140'}, + ], + }); + expect(find.text('Recent PRs'), findsOneWidget); + expect(find.text('Bench'), findsOneWidget); + expect(find.text('Squat'), findsOneWidget); + expect(find.text('140'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders rows with only a primary text', (tester) async { + await pump(tester, {'items': ['Bench Press']}); + expect(find.text('Bench Press'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders an empty panel when there are no rows', + (tester) async { + await pump(tester, {'title': 'Recent PRs', 'items': []}); + expect(find.textContaining('No items'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('DataListGroupSpec doc', () { + test('example payload is renderable', () { + final props = const DataListGroupSpec().doc.example['props']! + as Map; + expect(parse(props).hasData, isTrue); + }); + }); +} diff --git a/workout-logger/test/genui/components/dynamic_chart_test.dart b/workout-logger/test/genui/components/dynamic_chart_test.dart new file mode 100644 index 0000000..49707c1 --- /dev/null +++ b/workout-logger/test/genui/components/dynamic_chart_test.dart @@ -0,0 +1,286 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_panels.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/dynamic_chart.dart'; + +DynamicChartProps parse(Map props) => const DynamicChartSpec() + .parseProps(A2UiNode(name: 'DynamicChart', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 400, + child: Builder( + builder: (context) => const DynamicChartSpec().render( + context, + A2UiNode(name: 'DynamicChart', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + ), + )); + +void main() { + group('chart type', () { + test('defaults to line and normalizes spellings', () { + expect(parse({}).type, A2UiChartType.line); + expect(parse({'type': 'bar'}).type, A2UiChartType.bar); + expect(parse({'type': 'PIE'}).type, A2UiChartType.pie); + expect(parse({'type': 'bar_chart'}).type, A2UiChartType.bar); + expect(parse({'type': 'LineChart'}).type, A2UiChartType.line); + expect(parse({'type': 'donut'}).type, A2UiChartType.pie); + expect(parse({'type': 'nonsense'}).type, A2UiChartType.line); + }); + }); + + group('DynamicChartProps parsing', () { + test('reads multi-series payloads', () { + final p = parse({ + 'type': 'bar', + 'title': 'Biceps vs Triceps', + 'labels': ['07-06', '07-09'], + 'series': [ + {'name': 'Biceps', 'values': [0, 645]}, + {'name': 'Triceps', 'values': [2390, 0]}, + ], + }); + expect(p.title, 'Biceps vs Triceps'); + expect(p.series, hasLength(2)); + expect(p.labels, ['07-06', '07-09']); + expect(p.hasData, isTrue); + }); + + test('reads the single-values shorthand', () { + final p = parse({ + 'title': 'Weekly Sets', + 'labels': ['Mon', 'Wed'], + 'values': [12, 15], + }); + expect(p.series, hasLength(1)); + expect(p.series.single.name, 'Weekly Sets'); + }); + + test('stringifies numeric labels instead of throwing', () { + expect(parse({'labels': [1, 2, 3], 'values': [1, 2, 3]}).labels, + ['1', '2', '3']); + }); + + test('stringifies a numeric title', () { + expect(parse({'title': 2024, 'values': [1]}).title, '2024'); + }); + + test('coerces stringified series values', () { + final p = parse({ + 'labels': ['a'], + 'series': [ + {'name': 'S', 'values': ['1.5']} + ], + }); + expect(p.series.single.values, [1.5]); + }); + + test('pads labels up to the longest series length', () { + final p = parse({ + 'labels': ['Mon'], + 'series': [ + {'name': 'S', 'values': [1, 2, 3]} + ], + }); + expect(p.labels, ['Mon', '', '']); + }); + + test('pads labels using the longest of multiple series, not just the first', + () { + final p = parse({ + 'labels': ['Mon'], + 'series': [ + {'name': 'Short', 'values': [1, 2]}, + {'name': 'Long', 'values': [1, 2, 3, 4]}, + ], + }); + expect(p.labels, ['Mon', '', '', '']); + }); + + test('hasData is false when there is nothing to plot', () { + expect(parse({}).hasData, isFalse); + expect(parse({'labels': ['a', 'b']}).hasData, isFalse); + expect(parse({'values': [1, 2]}).hasData, isTrue); + }); + + test('never throws on hostile input', () { + expect( + () => parse({ + 'labels': 'nope', + 'series': [42, null], + 'values': {}, + 'title': [], + }), + returnsNormally, + ); + }); + }); + + group('DynamicChart rendering', () { + testWidgets('renders a line chart', (tester) async { + await pump(tester, { + 'type': 'line', + 'title': 'Volume', + 'labels': ['A', 'B'], + 'values': [1, 2], + }); + expect(find.byType(LineChart), findsOneWidget); + expect(find.text('Volume'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders a bar chart', (tester) async { + await pump(tester, { + 'type': 'bar', + 'labels': ['A', 'B'], + 'values': [1, 2], + }); + expect(find.byType(BarChart), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders a pie chart with a label list', (tester) async { + await pump(tester, { + 'type': 'pie', + 'labels': ['Chest', 'Back'], + 'values': [60, 40], + }); + expect(find.byType(PieChart), findsOneWidget); + expect(find.textContaining('Chest'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'pie chart with mixed-sign values renders only the positive slice', + (tester) async { + // Regression test for the fix in DynamicChart._pie: negative/zero + // values have no geometric meaning in a pie and must be filtered out + // before building sections, rather than crashing or silently + // corrupting the percentage math. + await pump(tester, { + 'type': 'pie', + 'labels': ['Chest', 'Back'], + 'values': [60, -40], + }); + expect(tester.takeException(), isNull); + final pieChart = tester.widget(find.byType(PieChart)); + expect(pieChart.data.sections, hasLength(1)); + expect(pieChart.data.sections.single.title, '100%'); + }); + + testWidgets('pie chart with all-negative values falls back to the ' + 'empty panel instead of throwing', (tester) async { + await pump(tester, { + 'type': 'pie', + 'labels': ['Chest', 'Back'], + 'values': [-60, -40], + }); + expect(tester.takeException(), isNull); + expect(find.byType(PieChart), findsNothing); + expect(find.textContaining('No positive values to chart'), + findsOneWidget); + }); + + testWidgets('renders a legend only for multi-series non-pie charts', + (tester) async { + await pump(tester, { + 'type': 'line', + 'labels': ['A'], + 'series': [ + {'name': 'Biceps', 'values': [1]}, + {'name': 'Triceps', 'values': [2]}, + ], + }); + expect(find.text('Biceps'), findsOneWidget); + expect(find.text('Triceps'), findsOneWidget); + expect(find.byType(A2UiLegend), findsOneWidget); + + await pump(tester, { + 'type': 'line', + 'labels': ['A'], + 'values': [1], + }); + expect(find.byType(A2UiLegend), findsNothing); + + await pump(tester, { + 'type': 'pie', + 'labels': ['A', 'B'], + 'series': [ + {'name': 'Biceps', 'values': [1]}, + {'name': 'Triceps', 'values': [2]}, + ], + }); + expect(find.byType(A2UiLegend), findsNothing); + }); + + testWidgets('renders an empty panel with no data', (tester) async { + await pump(tester, {'title': 'Volume'}); + expect(find.textContaining('No chart data'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives more series values than labels', (tester) async { + await pump(tester, { + 'type': 'bar', + 'labels': ['A'], + 'series': [ + {'name': 'S', 'values': [1, 2, 3, 4]} + ], + }); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives all-zero values without a zero-height axis', + (tester) async { + await pump(tester, {'labels': ['A', 'B'], 'values': [0, 0]}); + expect(tester.takeException(), isNull); + }); + + testWidgets('all-negative line chart brackets its data within minY/maxY', + (tester) async { + await pump(tester, { + 'type': 'line', + 'labels': ['A', 'B', 'C'], + 'values': [-10, -5, -3], + }); + expect(tester.takeException(), isNull); + final data = tester.widget(find.byType(LineChart)).data; + expect(data.minY, lessThanOrEqualTo(-10)); + expect(data.maxY, greaterThanOrEqualTo(-3)); + expect(data.minY, lessThan(data.maxY)); + }); + + testWidgets('all-negative bar chart brackets its data within minY/maxY', + (tester) async { + await pump(tester, { + 'type': 'bar', + 'labels': ['A', 'B', 'C'], + 'values': [-10, -5, -3], + }); + expect(tester.takeException(), isNull); + final data = tester.widget(find.byType(BarChart)).data; + expect(data.minY, lessThanOrEqualTo(-10)); + expect(data.maxY, greaterThanOrEqualTo(-3)); + expect(data.minY, lessThan(data.maxY)); + }); + }); + + group('DynamicChartSpec doc', () { + test('example payload is renderable', () { + final props = const DynamicChartSpec().doc.example['props']! + as Map; + expect(parse(props).hasData, isTrue); + }); + }); +} diff --git a/workout-logger/test/genui/components/filter_chips_test.dart b/workout-logger/test/genui/components/filter_chips_test.dart new file mode 100644 index 0000000..976c639 --- /dev/null +++ b/workout-logger/test/genui/components/filter_chips_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/filter_chips.dart'; + +FilterChipsProps parse(Map props) => const FilterChipsSpec() + .parseProps(A2UiNode(name: 'FilterChips', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => const FilterChipsSpec().render( + context, + A2UiNode(name: 'FilterChips', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + )); + +void main() { + group('FilterChipsProps parsing', () { + test('reads options and the active option', () { + final p = parse({ + 'options': ['7d', '30d', '90d'], + 'activeOption': '30d', + }); + expect(p.options, ['7d', '30d', '90d']); + expect(p.activeOption, '30d'); + }); + + test('nulls a missing active option instead of crashing', () { + expect(parse({'options': ['7d', '30d']}).activeOption, isNull); + }); + + test('matches the active option case-insensitively', () { + expect(parse({'options': ['Week', 'Month'], 'active': 'MONTH'}) + .activeOption, 'Month'); + }); + + test('nulls an active option that is not in the list', () { + expect( + parse({'options': ['7d'], 'activeOption': '365d'}).activeOption, + isNull, + ); + }); + + test('stringifies non-string options', () { + expect(parse({'options': [7, 30, 90]}).options, ['7', '30', '90']); + }); + + test('hasData is false without options', () { + expect(parse({}).hasData, isFalse); + expect(parse({'options': []}).hasData, isFalse); + expect(parse({'options': ['a']}).hasData, isTrue); + }); + + test('never throws on hostile input', () { + expect( + () => parse({'options': 5, 'activeOption': {}}), + returnsNormally, + ); + }); + }); + + group('FilterChips rendering', () { + testWidgets('renders every option', (tester) async { + await pump(tester, { + 'options': ['7d', '30d', '90d'], + 'activeOption': '30d', + }); + expect(find.text('7d'), findsOneWidget); + expect(find.text('30d'), findsOneWidget); + expect(find.text('90d'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders with no active option', (tester) async { + await pump(tester, {'options': ['7d', '30d']}); + expect(find.text('7d'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders nothing when there are no options', (tester) async { + await pump(tester, {'options': []}); + expect(find.byType(Wrap), findsNothing); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/genui/components/metric_gauge_test.dart b/workout-logger/test/genui/components/metric_gauge_test.dart new file mode 100644 index 0000000..bb090d4 --- /dev/null +++ b/workout-logger/test/genui/components/metric_gauge_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/metric_gauge.dart'; + +MetricGaugeProps parse(Map props) => const MetricGaugeSpec() + .parseProps(A2UiNode(name: 'MetricGauge', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => const MetricGaugeSpec().render( + context, + A2UiNode(name: 'MetricGauge', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + )); + +void main() { + group('MetricGaugeProps parsing', () { + test('reads the happy path', () { + final p = parse({ + 'title': 'Readiness', + 'value': 88, + 'min': 0, + 'max': 100, + 'unit': '/ 100', + 'status': 'Optimal', + }); + expect(p.title, 'Readiness'); + expect(p.value, 88); + expect(p.progress, closeTo(0.88, 0.001)); + expect(p.status, 'Optimal'); + }); + + test('accepts a numeric string value — the old validator/renderer mismatch', + () { + expect(parse({'value': '88'}).value, 88); + expect(parse({'value': '88.5'}).value, 88.5); + }); + + test('yields a null value for missing or unparseable input', () { + expect(parse({}).value, isNull); + expect(parse({'value': 'optimal'}).value, isNull); + expect(parse({'value': []}).value, isNull); + }); + + test('defaults min to 0 and max to 100', () { + final p = parse({'value': 50}); + expect(p.min, 0); + expect(p.max, 100); + expect(p.progress, closeTo(0.5, 0.001)); + }); + + test('returns 0 progress when max <= min instead of NaN', () { + final same = parse({'value': 5, 'min': 5, 'max': 5}); + expect(same.progress, 0); + expect(same.progress.isNaN, isFalse); + + final inverted = parse({'value': 5, 'min': 10, 'max': 2}); + expect(inverted.progress, 0); + }); + + test('clamps progress into [0, 1]', () { + expect(parse({'value': 500, 'max': 100}).progress, 1); + expect(parse({'value': -20, 'min': 0, 'max': 100}).progress, 0); + }); + + test('never throws on hostile input', () { + expect( + () => parse({'value': {}, 'min': [], 'max': 'x', 'unit': 5}), + returnsNormally, + ); + }); + }); + + group('MetricGauge rendering', () { + testWidgets('renders the value, unit and status', (tester) async { + await pump(tester, { + 'title': 'Readiness', + 'value': 88, + 'unit': 'pts', + 'status': 'Optimal', + }); + expect(find.text('Readiness'), findsOneWidget); + expect(find.text('88'), findsOneWidget); + expect(find.text('pts'), findsOneWidget); + expect(find.text('Optimal'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders an empty panel when the value is unusable', + (tester) async { + await pump(tester, {'title': 'Readiness', 'value': 'unknown'}); + expect(find.textContaining('No value'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders a whole number without a trailing .0', (tester) async { + await pump(tester, {'value': 88.0}); + expect(find.text('88'), findsOneWidget); + }); + }); + + group('MetricGaugeSpec doc', () { + test('example payload produces a renderable value', () { + final props = + const MetricGaugeSpec().doc.example['props']! as Map; + expect(parse(props).value, isNotNull); + }); + }); +} diff --git a/workout-logger/test/genui/components/radar_chart_test.dart b/workout-logger/test/genui/components/radar_chart_test.dart new file mode 100644 index 0000000..881dbe1 --- /dev/null +++ b/workout-logger/test/genui/components/radar_chart_test.dart @@ -0,0 +1,156 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/radar_chart.dart'; + +RadarChartProps parse(Map props) => const RadarChartSpec() + .parseProps(A2UiNode(name: 'RadarChart', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 400, + child: Builder( + builder: (context) => const RadarChartSpec().render( + context, + A2UiNode(name: 'RadarChart', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + ), + )); + +const _fourAxes = ['Readiness', 'Sleep', 'Volume', 'Intensity']; + +void main() { + group('RadarChartProps parsing', () { + test('reads the legacy axes key', () { + final p = parse({ + 'title': 'Recovery', + 'axes': _fourAxes, + 'series': [ + {'name': 'Current', 'values': [85, 90, 75, 80]} + ], + }); + expect(p.labels, _fourAxes); + expect(p.series.single.values, [85.0, 90.0, 75.0, 80.0]); + expect(p.hasData, isTrue); + }); + + test('reads the labels key identically', () { + expect( + parse({ + 'labels': _fourAxes, + 'series': [ + {'name': 'Current', 'values': [1, 2, 3, 4]} + ], + }).labels, + _fourAxes, + ); + }); + + test('zero-pads a series shorter than the axis count', () { + final p = parse({ + 'axes': _fourAxes, + 'series': [ + {'name': 'Short', 'values': [1, 2]} + ], + }); + expect(p.series.single.values, [1.0, 2.0, 0.0, 0.0]); + }); + + test('truncates a series longer than the axis count', () { + final p = parse({ + 'axes': _fourAxes, + 'series': [ + {'name': 'Long', 'values': [1, 2, 3, 4, 5, 6]} + ], + }); + expect(p.series.single.values, [1.0, 2.0, 3.0, 4.0]); + }); + + test('coerces stringified values', () { + final p = parse({ + 'axes': _fourAxes, + 'series': [ + {'name': 'S', 'values': ['85', 90, '75', 80]} + ], + }); + expect(p.series.single.values, [85.0, 90.0, 75.0, 80.0]); + }); + + test('names an unnamed series positionally', () { + final p = parse({ + 'axes': _fourAxes, + 'series': [ + {'values': [1, 2, 3, 4]} + ], + }); + expect(p.series.single.name, 'Series 1'); + }); + + test('hasData is false with fewer than three axes or no series', () { + expect(parse({'axes': ['A', 'B'], 'series': [ + {'name': 'S', 'values': [1, 2]} + ]}).hasData, isFalse); + expect(parse({'axes': _fourAxes}).hasData, isFalse); + expect(parse({}).hasData, isFalse); + }); + + test('never throws on hostile input', () { + expect( + () => parse({'axes': 5, 'series': ['junk', 7], 'title': []}), + returnsNormally, + ); + }); + }); + + group('RadarChart rendering', () { + testWidgets('renders the chart and a multi-series legend', (tester) async { + await pump(tester, { + 'title': 'Recovery', + 'axes': _fourAxes, + 'series': [ + {'name': 'Current', 'values': [85, 90, 75, 80]}, + {'name': 'Baseline', 'values': [70, 70, 70, 70]}, + ], + }); + expect(find.byType(RadarChart), findsOneWidget); + expect(find.text('Recovery'), findsOneWidget); + expect(find.text('Current'), findsOneWidget); + expect(find.text('Baseline'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('hides the legend for a single series', (tester) async { + await pump(tester, { + 'axes': _fourAxes, + 'series': [ + {'name': 'Current', 'values': [1, 2, 3, 4]} + ], + }); + expect(find.text('Current'), findsNothing); + }); + + testWidgets('renders an empty panel when there is nothing to plot', + (tester) async { + await pump(tester, {'title': 'Recovery', 'axes': ['A', 'B']}); + expect(find.textContaining('No radar data'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('RadarChartSpec doc', () { + test('example payload is renderable', () { + final props = + const RadarChartSpec().doc.example['props']! as Map; + expect(parse(props).hasData, isTrue); + }); + }); +} diff --git a/workout-logger/test/genui/components/scatter_plot_test.dart b/workout-logger/test/genui/components/scatter_plot_test.dart new file mode 100644 index 0000000..150668c --- /dev/null +++ b/workout-logger/test/genui/components/scatter_plot_test.dart @@ -0,0 +1,213 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/scatter_plot.dart'; + +ScatterPlotProps parse(Map props) => const ScatterPlotSpec() + .parseProps(A2UiNode(name: 'ScatterPlot', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 400, + child: Builder( + builder: (context) => const ScatterPlotSpec().render( + context, + A2UiNode(name: 'ScatterPlot', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + ), + )); + +void main() { + group('ScatterPlotProps parsing', () { + test('reads the happy path', () { + final p = parse({ + 'title': 'Sleep vs Volume', + 'xLabel': 'Sleep Hours', + 'yLabel': 'Volume', + 'correlation': 0.82, + 'points': [ + {'x': 7.5, 'y': 1600}, + {'x': 6.0, 'y': 1200}, + ], + }); + expect(p.title, 'Sleep vs Volume'); + expect(p.xLabel, 'Sleep Hours'); + expect(p.points, hasLength(2)); + expect(p.correlation, 0.82); + }); + + test('coerces stringified coordinates', () { + final p = parse({ + 'points': [ + {'x': '7.5', 'y': '1600'} + ] + }); + expect(p.points.single.x, 7.5); + expect(p.points.single.y, 1600); + }); + + test('drops points missing a coordinate instead of throwing', () { + final p = parse({ + 'points': [ + {'x': 1, 'y': 2}, + {'x': 3}, + {'y': 4}, + {'x': 'abc', 'y': 5}, + 'garbage', + ], + }); + expect(p.points, hasLength(1)); + }); + + test('resolves the x_label snake_case alias', () { + expect(parse({'x_label': 'Sleep'}).xLabel, 'Sleep'); + expect(parse({'y_label': 'Volume'}).yLabel, 'Volume'); + }); + + test('falls back to X and Y axis labels', () { + final p = parse({}); + expect(p.xLabel, 'X'); + expect(p.yLabel, 'Y'); + expect(p.title, 'Scatter Plot'); + }); + + test('nulls an unparseable correlation', () { + expect(parse({'correlation': 'strong'}).correlation, isNull); + expect(parse({}).correlation, isNull); + expect(parse({'r': -0.4}).correlation, -0.4); + }); + + test('never throws on hostile input', () { + expect(() => parse({'points': 5, 'correlation': []}), returnsNormally); + }); + + test('drops structurally invalid point entries (nested objects, list entries)', + () { + final p = parse({ + 'points': [ + {'x': 1, 'y': 2}, + { + 'x': {'nested': true}, + 'y': 5, + }, + [3, 4], + 'garbage', + ], + }); + expect(p.points, hasLength(1)); + expect(p.points.single.x, 1); + }); + + test('drops points with non-finite "NaN"/"Infinity" string coordinates', + () { + final p = parse({ + 'points': [ + {'x': 1, 'y': 2}, + {'x': 'NaN', 'y': 3}, + {'x': 4, 'y': 'Infinity'}, + {'x': '-Infinity', 'y': 5}, + ], + }); + expect(p.points, hasLength(1)); + expect(p.points.single.x, 1); + final b = p.bounds; + expect(b.minX.isFinite, isTrue); + expect(b.maxX.isFinite, isTrue); + expect(b.minY.isFinite, isTrue); + expect(b.maxY.isFinite, isTrue); + }); + }); + + group('ScatterPlotProps bounds', () { + test('widens a degenerate axis so the span is never zero', () { + final b = parse({ + 'points': [ + {'x': 5, 'y': 5} + ] + }).bounds; + expect(b.maxX - b.minX, greaterThan(0)); + expect(b.maxY - b.minY, greaterThan(0)); + }); + + test('adds a margin around a real spread', () { + final b = parse({ + 'points': [ + {'x': 0, 'y': 0}, + {'x': 10, 'y': 100}, + ], + }).bounds; + expect(b.minX, lessThanOrEqualTo(0)); + expect(b.maxX, greaterThanOrEqualTo(10)); + expect(b.minY, lessThanOrEqualTo(0)); + expect(b.maxY, greaterThanOrEqualTo(100)); + }); + + test('brackets an all-negative coordinate spread', () { + final b = parse({ + 'points': [ + {'x': -20, 'y': -10}, + {'x': -5, 'y': -3}, + ], + }).bounds; + expect(b.minX, lessThanOrEqualTo(-20)); + expect(b.maxX, greaterThanOrEqualTo(-5)); + expect(b.minY, lessThanOrEqualTo(-10)); + expect(b.maxY, greaterThanOrEqualTo(-3)); + }); + }); + + group('ScatterPlot rendering', () { + testWidgets('renders the chart, axis caption and correlation badge', + (tester) async { + await pump(tester, { + 'title': 'Sleep vs Volume', + 'xLabel': 'Sleep', + 'yLabel': 'Volume', + 'correlation': 0.82, + 'points': [ + {'x': 1, 'y': 2}, + {'x': 3, 'y': 4}, + ], + }); + expect(find.byType(ScatterChart), findsOneWidget); + expect(find.text('Sleep vs Volume'), findsOneWidget); + expect(find.text('Volume vs. Sleep'), findsOneWidget); + expect(find.text('r = +0.82'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('formats a negative correlation without a plus sign', + (tester) async { + await pump(tester, { + 'correlation': -0.35, + 'points': [ + {'x': 1, 'y': 2} + ], + }); + expect(find.text('r = -0.35'), findsOneWidget); + }); + + testWidgets('renders an empty panel with no usable points', (tester) async { + await pump(tester, {'title': 'Sleep vs Volume', 'points': []}); + expect(find.textContaining('No paired data'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('ScatterPlotSpec doc', () { + test('example payload is renderable', () { + final props = const ScatterPlotSpec().doc.example['props']! + as Map; + expect(parse(props).points, isNotEmpty); + }); + }); +} diff --git a/workout-logger/test/genui/components/stat_card_test.dart b/workout-logger/test/genui/components/stat_card_test.dart new file mode 100644 index 0000000..24b9d95 --- /dev/null +++ b/workout-logger/test/genui/components/stat_card_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/src/a2ui_node.dart'; +import 'package:repforge/genui/src/a2ui_props.dart'; +import 'package:repforge/genui/src/a2ui_theme.dart'; +import 'package:repforge/genui/src/components/stat_card.dart'; + +StatCardProps parse(Map props) => const StatCardSpec() + .parseProps(A2UiNode(name: 'StatCard', props: A2UiProps(props))); + +Future pump(WidgetTester tester, Map props) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => const StatCardSpec().render( + context, + A2UiNode(name: 'StatCard', props: A2UiProps(props)), + A2UiTheme.dark, + ), + ), + ), + )); +} + +void main() { + group('StatCardProps parsing', () { + test('reads the happy path', () { + final p = parse({ + 'title': 'Weekly Volume', + 'value': '12,400 kg', + 'subtitle': 'Last 7 days', + 'trend': 'up', + }); + expect(p.title, 'Weekly Volume'); + expect(p.value, '12,400 kg'); + expect(p.subtitle, 'Last 7 days'); + expect(p.trend, A2UiTrend.up); + }); + + test('falls back when title and value are missing', () { + final p = parse({}); + expect(p.title, 'Metric'); + expect(p.value, '—'); + expect(p.subtitle, isNull); + expect(p.trend, A2UiTrend.neutral); + }); + + test('stringifies a numeric value', () { + expect(parse({'value': 88}).value, '88'); + expect(parse({'value': 88.5}).value, '88.5'); + }); + + test('appends a unit that is not already present', () { + expect(parse({'value': 88, 'unit': 'kg'}).value, '88 kg'); + expect(parse({'value': '88 kg', 'unit': 'kg'}).value, '88 kg'); + }); + + test('appends the unit when it only appears as a substring elsewhere in ' + 'the value, not as the actual trailing unit', () { + // Regression test: a naive `.contains(unit)` check is a false positive + // here — 'reps' contains the letter 's' — even though the value does + // NOT actually end with the unit 's' (it ends with "total"). The fix + // checks the trimmed value's actual suffix instead of a raw substring + // `contains`, so the unit must still be appended. + expect(parse({'value': '12 reps total', 'unit': 's'}).value, + '12 reps total s'); + }); + + test('accepts loose trend synonyms', () { + for (final up in ['up', 'improving', 'positive', 'RISING']) { + expect(parse({'trend': up}).trend, A2UiTrend.up, reason: up); + } + for (final down in ['down', 'declining', 'negative', 'falling']) { + expect(parse({'trend': down}).trend, A2UiTrend.down, reason: down); + } + expect(parse({'trend': 'sideways'}).trend, A2UiTrend.neutral); + expect(parse({'trend': 42}).trend, A2UiTrend.neutral); + }); + + test('resolves aliased keys', () { + final p = parse({'name': 'Bench', 'val': 100}); + expect(p.title, 'Bench'); + expect(p.value, '100'); + }); + + test('never throws on hostile input', () { + expect(() => parse({'title': [], 'value': {}, 'trend': []}), returnsNormally); + }); + }); + + group('StatCard rendering', () { + testWidgets('renders title, value and subtitle', (tester) async { + await pump(tester, { + 'title': 'Volume', + 'value': '12k', + 'subtitle': 'week', + 'trend': 'up', + }); + expect(find.text('Volume'), findsOneWidget); + expect(find.text('12k'), findsOneWidget); + expect(find.text('week'), findsOneWidget); + expect(find.byIcon(Icons.trending_up_rounded), findsOneWidget); + }); + + testWidgets('renders without crashing on empty props', (tester) async { + await pump(tester, {}); + expect(find.text('Metric'), findsOneWidget); + expect(find.text('—'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('StatCardSpec doc', () { + test('example payload round-trips through the spec', () { + final example = const StatCardSpec().doc.example; + expect(example['component'], 'StatCard'); + final props = example['props']! as Map; + final p = parse(props); + expect(p.title, isNotEmpty); + expect(p.value, isNot('—')); + }); + }); +} diff --git a/workout-logger/test/new_features_test.dart b/workout-logger/test/new_features_test.dart new file mode 100644 index 0000000..cf6d056 --- /dev/null +++ b/workout-logger/test/new_features_test.dart @@ -0,0 +1,364 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/data/exercise_database.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/interfaces/storage_service_interface.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/ml_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +class FakeStorageService implements IStorageService { + final Map _settings = {}; + final Map _prs = {}; + + // Populated by tests that need WorkoutProvider.load() to actually see + // data (e.g. muscle-group resolution needs real MuscleGroup/Exercise + // rows). Left empty for tests that never call load(). + List sessions = []; + List exercises = []; + + @override + Future getSetting(String key) async => _settings[key]; + + @override + Future saveSetting(String key, String value) async { + _settings[key] = value; + } + + @override + Future> getAllPersonalRecords() async => _prs.values.toList(); + + @override + Future getPersonalRecord(String exerciseId) async => _prs[exerciseId]; + + @override + Future savePersonalRecord(PersonalRecord record) async { + _prs[record.exerciseId] = record; + } + + @override + Future> getAllWorkoutSessions() async => List.from(sessions); + + @override + Future> getAllRoutines() async => []; + + @override + Future> getAllTargets() async => []; + + @override + Future> getAllMuscleGroups() async => MuscleGroups.getAll(); + + @override + Future> getAllExercises() async => List.from(exercises); + + @override + Future> getAllTrainingPrograms() async => []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class FakeHealthConnectService implements IHealthConnectService { + @override + Future> grantedReadTypes() async => { + HealthReadType.sleep, + HealthReadType.heartRate, + HealthReadType.restingHeartRate, + }; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class FakeHealthHistoryManager extends HealthHistoryManager { + FakeHealthHistoryManager(super.hc, super.storage); + + @override + Future sleepNight(DateTime morning) async { + return SleepHrSnapshot( + sleepStart: morning.subtract(const Duration(hours: 8)), + sleepEnd: morning, + p5Bpm: 52 + (morning.day % 4), + p95Bpm: 70, + segments: [ + SleepHrSegment( + windowStart: morning.subtract(const Duration(hours: 7)), + minBpm: 50, + maxBpm: 65, + avgBpm: 55.0, + stage: 'deep', + ), + SleepHrSegment( + windowStart: morning.subtract(const Duration(hours: 5)), + minBpm: 52, + maxBpm: 68, + avgBpm: 58.0, + stage: 'light', + ), + ], + stageStats: [], + ); + } +} + +class FakeWorkoutProvider extends WorkoutProvider { + FakeWorkoutProvider(super.storage) + : super( + mlService: MLService(), + programManager: ProgramManager(storage), + ); +} + +void main() { + group('Pullups Volume Calculation', () { + test('standard exercise volume defaults to weight * reps', () { + final set = WorkoutSet(weight: 80.0, reps: 8); + expect(set.calculateVolume(userBodyWeight: 75.0, isAssistedBW: false), 640.0); + }); + + test('assisted pullups volume uses (BW - assist + extra) * reps', () { + // weight and assistWeight are deliberately DIFFERENT here: weight is + // set to a value (99 kg) that would never plausibly be used as the + // assist amount, so this test can only pass if calculateVolume + // actually reads assistWeight (15 kg) rather than weight. + // 75 kg bodyweight, 15 kg assist weight, 8 reps + // Effective load = 75 - 15 = 60 kg -> 60 * 8 = 480 kg volume + // (Using `weight` instead of `assistWeight` would instead give + // max(0, 75 - 99) * 8 = 0 kg volume.) + final set = WorkoutSet(weight: 99.0, reps: 8, assistWeight: 15.0); + expect(set.calculateVolume(userBodyWeight: 75.0, isAssistedBW: true), 480.0); + }); + + test('weighted pullups with assist=0 and extraWeight', () { + // 75 kg bodyweight, 0 kg assist, +10 kg extra, 5 reps + // Effective load = 75 - 0 + 10 = 85 kg -> 85 * 5 = 425 kg volume + final set = WorkoutSet(weight: 0.0, reps: 5, assistWeight: 0.0, extraWeight: 10.0); + expect(set.calculateVolume(userBodyWeight: 75.0, isAssistedBW: true), 425.0); + }); + }); + + group('MLService - Past 3 Sessions Trend & Deload Protection', () { + final mlService = MLService(); + + test('recommends double progression based on last session when normal', () { + final s0 = [WorkoutSet(weight: 50.0, reps: 10)]; + final recs = mlService.recommendSets(lastSession: s0, maxReps: 12); + expect(recs.first.weight, 50.0); + expect(recs.first.reps, 11); + }); + + test('recovers correctly from deload week using pre-deload baseline', () { + // Session 1 (pre-deload): 60kg x 10 + final s1 = [WorkoutSet(weight: 60.0, reps: 10)]; + // Session 0 (deload week): 40kg x 8 (significant drop in load) + final s0 = [WorkoutSet(weight: 40.0, reps: 8)]; + + final recs = mlService.recommendSets( + lastSession: s0, + pastSessions: [s0, s1], + maxReps: 12, + ); + + // Should anchor on pre-deload 60kg baseline instead of 40kg deload + expect(recs.first.weight, 60.0); + expect(recs.first.reps, 10); + expect(recs.first.reasoning, contains('Resuming training after deload')); + }); + }); + + group('PRManager - Handle Variations Scoping', () { + late FakeStorageService fakeStorage; + late PRManager prManager; + + setUp(() { + fakeStorage = FakeStorageService(); + prManager = PRManager(fakeStorage); + }); + + test('tracks PRs separately for Rope vs Bar handles', () async { + await prManager.load(); + + final ropeSession = WorkoutSession( + id: 's1', + date: DateTime.now(), + exercises: [ + ExerciseLog( + exerciseId: 'tricep_pushdown', + handle: 'Rope', + sets: [WorkoutSet(weight: 30.0, reps: 10, handle: 'Rope')], + ), + ], + duration: 30, + ); + + final barSession = WorkoutSession( + id: 's2', + date: DateTime.now(), + exercises: [ + ExerciseLog( + exerciseId: 'tricep_pushdown', + handle: 'Bar', + sets: [WorkoutSet(weight: 40.0, reps: 10, handle: 'Bar')], + ), + ], + duration: 30, + ); + + await prManager.checkAndUpdatePRs(ropeSession); + await prManager.checkAndUpdatePRs(barSession); + + final ropePR = prManager.getRecord('tricep_pushdown', handle: 'Rope'); + final barPR = prManager.getRecord('tricep_pushdown', handle: 'Bar'); + + expect(ropePR?.bestWeight, 30.0); + expect(barPR?.bestWeight, 40.0); + }); + }); + + group('CoachToolService - Sleeping HR Analytics Tool', () { + late FakeStorageService storage; + late FakeWorkoutProvider wp; + late FakeHealthConnectService hc; + late FakeHealthHistoryManager hh; + late PRManager pr; + late CoachToolService coachToolService; + + setUp(() { + storage = FakeStorageService(); + wp = FakeWorkoutProvider(storage); + hc = FakeHealthConnectService(); + hh = FakeHealthHistoryManager(hc, storage); + pr = PRManager(storage); + coachToolService = CoachToolService(workoutProvider: wp, prManager: pr, healthHistory: hh); + }); + + test('get_sleeping_hr_analytics computes p5, p25, mean, stdev, variance and chart series', + () async { + final call = FunctionCall('get_sleeping_hr_analytics', {'days': 14}); + final res = await coachToolService.handleCall(call); + + expect(res.containsKey('error'), isFalse); + expect(res['days_analyzed'], 14); + expect(res['valid_nights_count'], 14); + + final summary = res['overall_summary'] as Map; + expect(summary.containsKey('mean_p5_sleeping_hr'), isTrue); + expect(summary.containsKey('stdev_p5_sleeping_hr'), isTrue); + expect(summary.containsKey('variance_p5_sleeping_hr'), isTrue); + expect(summary.containsKey('trend_direction'), isTrue); + + expect(res['labels'], isA>()); + final series = res['series']! as List; + expect(series, hasLength(3)); // P5, P25, Mean + expect((series[0] as Map)['name'], 'P5 Sleeping HR'); + expect((series[0] as Map)['values'], hasLength(14)); + expect(res.containsKey('genui_chart_props'), isFalse); + }); + }); + + group('CoachToolService - health/muscle-group tool correctness fixes', () { + late FakeStorageService storage; + late FakeWorkoutProvider wp; + late PRManager pr; + + setUp(() { + storage = FakeStorageService(); + wp = FakeWorkoutProvider(storage); + pr = PRManager(storage); + }); + + test('get_health_metrics returns an error when no HealthHistoryManager ' + 'is wired up (_hh == null), instead of throwing', () async { + final coachToolService = CoachToolService(workoutProvider: wp, prManager: pr); // no healthHistory + final call = FunctionCall('get_health_metrics', {'days': 14}); + final res = await coachToolService.handleCall(call); + + expect(res.containsKey('error'), isTrue); + expect(res['error'], contains('Health Connect')); + }); + + test('analyze_health_workout_correlation returns an error for ' + 'insufficient paired data instead of fabricating a result', () async { + // Regression test: this tool used to fall back to synthetic data when + // there weren't enough real (sleep, workout) pairs on the same day. + // With no HealthHistoryManager wired up, no x (sleep) values are ever + // collected, so even a real logged workout session yields zero valid + // (x, y) pairs — the tool must report that honestly rather than + // inventing a correlation. + storage.sessions = [ + WorkoutSession( + id: 's1', + date: DateTime.now(), + exercises: [ + ExerciseLog( + exerciseId: 'squat', + sets: [WorkoutSet(weight: 100.0, reps: 5)], + ), + ], + duration: 30, + ), + ]; + await wp.loadAllData(); + + final coachToolService = CoachToolService(workoutProvider: wp, prManager: pr); // no healthHistory + final call = FunctionCall( + 'analyze_health_workout_correlation', + {'days': 60}, + ); + final res = await coachToolService.handleCall(call); + + expect(res.containsKey('error'), isTrue); + expect(res['error'], contains('Insufficient paired data')); + }); + + test('get_muscle_group_volume resolves a multi-word display name ' + '("Quadriceps") to its muscle-group id and aggregates real volume', + () async { + // Regression test: resolution used to compare the raw group name + // against Exercise.primaryMuscle (an id like "quads") via substring + // matching, which false-missed "Quadriceps". With ID-based resolution + // via _resolveMuscleGroup, a squat session's volume must actually show + // up under the "Quadriceps" total, not silently stay at zero. + storage.exercises = [ + Exercise( + id: 'squat', + name: 'Squat', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quads', activationPercentage: 100), + ], + ), + ]; + storage.sessions = [ + WorkoutSession( + id: 's1', + date: DateTime.now(), + exercises: [ + ExerciseLog( + exerciseId: 'squat', + sets: [WorkoutSet(weight: 100.0, reps: 5)], + ), + ], + duration: 30, + ), + ]; + await wp.loadAllData(); + + final coachToolService = CoachToolService(workoutProvider: wp, prManager: pr); // no healthHistory + final call = FunctionCall( + 'get_muscle_group_volume', + {'muscle_groups': ['Quadriceps'], 'days': 60}, + ); + final res = await coachToolService.handleCall(call); + + expect(res.containsKey('error'), isFalse); + final totals = res['totals'] as Map; + expect(totals['Quadriceps'], 500.0); // 100kg * 5 reps + }); + }); +} diff --git a/workout-logger/test/routine_optimizer_screen_test.dart b/workout-logger/test/routine_optimizer_screen_test.dart index 1be665d..b0d0924 100644 --- a/workout-logger/test/routine_optimizer_screen_test.dart +++ b/workout-logger/test/routine_optimizer_screen_test.dart @@ -59,6 +59,30 @@ class _ImmediateAi implements IAiService { @override Future generateInsight(String system, String context) async => ''; + + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + @override + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) => + throw UnimplementedError(); } /// AI that hangs indefinitely — keeps `isLoading` true for the entire test. @@ -94,6 +118,30 @@ class _HangingAi implements IAiService { @override Future generateInsight(String system, String context) async => ''; + + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + @override + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) => + throw UnimplementedError(); } /// AI that fires an `ask_user_questions` tool call before yielding a reply. @@ -138,6 +186,30 @@ class _QuestionAi implements IAiService { @override Future generateInsight(String system, String context) async => ''; + + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + @override + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) => + throw UnimplementedError(); } // ── Test helpers ─────────────────────────────────────────────────────────── @@ -150,7 +222,7 @@ RoutineOptimizerViewModel _buildVm(IAiService ai) { final pr = PRManager(storage); final conversations = ConversationManager(storage, kind: 'optimizer'); final settings = SettingsProvider(storage); - final coachTools = CoachToolService(wp, pr); + final coachTools = CoachToolService(workoutProvider: wp, prManager: pr); return RoutineOptimizerViewModel( ai: ai, coachTools: coachTools, diff --git a/workout-logger/test/routine_optimizer_view_model_test.dart b/workout-logger/test/routine_optimizer_view_model_test.dart index 4338cd0..c78b5a0 100644 --- a/workout-logger/test/routine_optimizer_view_model_test.dart +++ b/workout-logger/test/routine_optimizer_view_model_test.dart @@ -60,6 +60,30 @@ class _SimpleAi implements IAiService { @override Future generateInsight(String system, String context) async => ''; + + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + @override + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) => + throw UnimplementedError(); } class _ThrowingAi implements IAiService { @@ -93,6 +117,30 @@ class _ThrowingAi implements IAiService { @override Future generateInsight(String system, String context) => throw UnimplementedError(); + + @override + Stream streamChatReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) => + streamCoachReply( + userMessage: userMessage, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: onToolCall, + ); + + @override + Future generateStructuredJson({ + required String systemPrompt, + required String userPrompt, + required T Function(Map json) fromJson, + }) => + throw UnimplementedError(); } // ── Helper ──────────────────────────────────────────────────────────────── @@ -105,7 +153,7 @@ RoutineOptimizerViewModel _buildVm({ final pr = PRManager(storage); final conversations = ConversationManager(storage, kind: 'optimizer'); final settings = SettingsProvider(storage); - final coachTools = CoachToolService(wp, pr); + final coachTools = CoachToolService(workoutProvider: wp, prManager: pr); return RoutineOptimizerViewModel( ai: ai, coachTools: coachTools, diff --git a/workout-logger/test/screens/ai_coach_genui_test.dart b/workout-logger/test/screens/ai_coach_genui_test.dart new file mode 100644 index 0000000..94e98ab --- /dev/null +++ b/workout-logger/test/screens/ai_coach_genui_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/genui/a2ui.dart'; +import 'package:repforge/screens/ai_coach_screen.dart'; + +Future pump( + WidgetTester tester, + String text, { + bool streaming = false, +}) => + tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CoachMessageContent(text: text, streaming: streaming), + ), + ), + )); + +void main() { + const dashboard = + '{"component":"StatCard","props":{"title":"Volume","value":"12k"}}'; + + group('completed messages', () { + testWidgets('renders a dashboard payload as widgets', (tester) async { + await pump(tester, dashboard); + expect(find.byType(A2UiRenderer), findsOneWidget); + expect(find.text('Volume'), findsOneWidget); + expect(find.textContaining('component'), findsNothing); + }); + + testWidgets('renders prose as markdown', (tester) async { + await pump(tester, '**Nice work.** Keep going.'); + expect(find.byType(A2UiRenderer), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders a fenced payload as widgets', (tester) async { + await pump(tester, '```json\n$dashboard\n```'); + expect(find.byType(A2UiRenderer), findsOneWidget); + }); + }); + + group('streaming messages', () { + testWidgets('shows a building indicator instead of partial JSON', + (tester) async { + await pump(tester, '{"component":"Stat', streaming: true); + expect(find.textContaining('Building'), findsOneWidget); + expect(find.textContaining('"component"'), findsNothing); + expect(find.byType(A2UiRenderer), findsNothing); + }); + + testWidgets('still shows a complete payload as widgets mid-stream', + (tester) async { + await pump(tester, dashboard, streaming: true); + expect(find.byType(A2UiRenderer), findsOneWidget); + }); + + testWidgets('streams prose live', (tester) async { + await pump(tester, 'Your bench is trend', streaming: true); + expect(find.textContaining('Building'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'shows a building indicator for a prose sentence before an unclosed fence', + (tester) async { + await pump( + tester, + 'Here is your data:\n```json\n{"component":"Stat', + streaming: true, + ); + expect(find.textContaining('Building'), findsOneWidget); + expect(find.textContaining('"component"'), findsNothing); + expect(find.byType(A2UiRenderer), findsNothing); + }); + }); + + group('memoization', () { + testWidgets('does not reparse when rebuilt with the same text', + (tester) async { + await pump(tester, dashboard); + final first = tester.widget(find.byType(A2UiRenderer)).node; + + // Pump a fresh CoachMessageContent instance with the SAME text at the + // same tree location: no key change means the existing State is + // reused and didUpdateWidget genuinely fires, forcing a real build() + // — unlike a bare `tester.pump()`, which doesn't mark anything dirty + // and so can't distinguish "memoized" from "never rebuilds at all". + await pump(tester, dashboard); + final second = tester.widget(find.byType(A2UiRenderer)).node; + + expect(identical(first, second), isTrue); + }); + }); +} diff --git a/workout-logger/test/screens/ai_coach_screen_full_test.dart b/workout-logger/test/screens/ai_coach_screen_full_test.dart new file mode 100644 index 0000000..775e1e7 --- /dev/null +++ b/workout-logger/test/screens/ai_coach_screen_full_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/ai_coach_screen.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; + +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; + +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late GeminiAiService aiService; + late WorkoutProvider workoutProvider; + late PRManager prManager; + + late SettingsProvider settingsProvider; + + setUp(() async { + storage = MockStorageService(); + aiService = GeminiAiService(storage: storage); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + prManager = PRManager(storage); + + settingsProvider = SettingsProvider(storage); + + await workoutProvider.init(); + await prManager.load(); + await settingsProvider.init(); + }); + + group('AiCoachScreen Full Suite', () { + testWidgets('Renders unconfigured no-key state when API key missing', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const AiCoachScreen(), + storage: storage, + workoutProvider: workoutProvider, + geminiAiService: aiService, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(AiCoachScreen); + expect(find.text('API Key Required'), findsOneWidget); + }); + + testWidgets('Renders configured state and prompt suggestions when API key present', (tester) async { + final robot = TestRobot(tester); + + aiService.init('valid_mock_api_key'); + + await robot.pumpScreen( + const AiCoachScreen(seedPrompt: 'How can I improve my Bench Press?'), + storage: storage, + workoutProvider: workoutProvider, + geminiAiService: aiService, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(AiCoachScreen); + expect(find.byType(TextField), findsOneWidget); + + final sendIcon = find.byIcon(Icons.arrow_upward_rounded); + if (sendIcon.evaluate().isNotEmpty) { + await tester.tap(sendIcon); + await tester.pump(); + } + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/screens/ai_coach_screen_test.dart b/workout-logger/test/screens/ai_coach_screen_test.dart new file mode 100644 index 0000000..0b30e59 --- /dev/null +++ b/workout-logger/test/screens/ai_coach_screen_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/ai_coach_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders AiCoachScreen with prompt banner when API key missing', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await tester.pumpWidget(TestHarness.wrap( + const AiCoachScreen(), + storage: storage, + settingsProvider: settings, + workoutProvider: workout, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('AI Coach'), findsOneWidget); + // When no API key is configured, the screen renders _buildNoKeyState + // which contains an RFEmptyState with title 'API Key Required'. + expect(find.text('API Key Required'), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/ai_program_generator_screen_test.dart b/workout-logger/test/screens/ai_program_generator_screen_test.dart new file mode 100644 index 0000000..77aaec2 --- /dev/null +++ b/workout-logger/test/screens/ai_program_generator_screen_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/ai_program_generator_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders AiProgramGeneratorScreen title and suggestion chips', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await tester.pumpWidget(TestHarness.wrap( + const AiProgramGeneratorScreen(), + storage: storage, + workoutProvider: workout, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('AI Program Generator'), findsOneWidget); + + final suggestionChip = find.text('12-week hypertrophy, 4 days/week, push-pull-legs-upper'); + expect(suggestionChip, findsWidgets); + + await tester.tap(suggestionChip.first); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/workout-logger/test/screens/edit_workout_session_screen_test.dart b/workout-logger/test/screens/edit_workout_session_screen_test.dart new file mode 100644 index 0000000..d585541 --- /dev/null +++ b/workout-logger/test/screens/edit_workout_session_screen_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/edit_workout_session_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_fixtures.dart'; +import '../test_utils/test_robot.dart'; + +Future _createProvider(MockStorageService storage, {List sessions = const []}) async { + for (final s in sessions) { + await storage.saveWorkoutSession(s); + } + final provider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await provider.init(); + return provider; +} + +void main() { + testWidgets('Renders EditWorkoutSessionScreen with session details', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(notes: 'Feeling strong today'); + final provider = await _createProvider(storage, sessions: [session]); + + await robot.pumpScreen( + EditWorkoutSessionScreen(session: session), + storage: storage, + workoutProvider: provider, + ); + + robot.expectVisible('Edit Workout'); + robot.expectVisible('Feeling strong today'); + }); + + testWidgets('Adds a set to an existing exercise', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(); + final provider = await _createProvider(storage, sessions: [session]); + + await robot.pumpScreen( + EditWorkoutSessionScreen(session: session), + storage: storage, + workoutProvider: provider, + ); + + // Count set-delete icons before adding (fixture has 3 sets total = 3 close icons). + final initialCount = find.byIcon(Icons.close_rounded).evaluate().length; + + await robot.tap(find.text('Add Set').first); + + // After adding a set, there should be one more close icon. + final updatedCount = find.byIcon(Icons.close_rounded).evaluate().length; + expect(updatedCount, greaterThan(initialCount)); + robot.expectVisible(EditWorkoutSessionScreen); + }); + + testWidgets('Deletes a set from an exercise log', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(); + final provider = await _createProvider(storage, sessions: [session]); + + await robot.pumpScreen( + EditWorkoutSessionScreen(session: session), + storage: storage, + workoutProvider: provider, + ); + + // Count set-delete icons before deletion (fixture has 3 sets total = 3 close icons). + final initialCount = find.byIcon(Icons.close_rounded).evaluate().length; + expect(initialCount, greaterThan(0)); + + await robot.tap(find.byIcon(Icons.close_rounded).first); + + // After deletion, one fewer close icon should be visible. + final updatedCount = find.byIcon(Icons.close_rounded).evaluate().length; + expect(updatedCount, lessThan(initialCount)); + }); + + testWidgets('Edits session notes and saves session', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(); + final provider = await _createProvider(storage, sessions: [session]); + + await robot.pumpScreen( + EditWorkoutSessionScreen(session: session), + storage: storage, + workoutProvider: provider, + ); + + await robot.fill('Sample session notes', 'Updated workout session note'); + await robot.tap('Save'); + + // Verify in-memory provider update. + final updated = provider.sessions.firstWhere((s) => s.id == session.id); + expect(updated.notes, equals('Updated workout session note')); + + // Verify persistence through storage. + final persisted = await storage.getWorkoutSession(session.id); + expect(persisted, isNotNull); + expect(persisted!.notes, equals('Updated workout session note')); + }); + + testWidgets('Shows discard dialog on back navigation when modified', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(); + final provider = await _createProvider(storage, sessions: [session]); + + await robot.pumpScreen( + EditWorkoutSessionScreen(session: session), + storage: storage, + workoutProvider: provider, + ); + + await robot.fill('45', '90'); + await robot.handlePop(); + + robot.expectVisible('Discard Changes?'); + await robot.tap('Discard'); + + // After confirming discard, the dialog and the edit screen should both be gone. + robot.expectNotVisible('Discard Changes?'); + robot.expectNotVisible(EditWorkoutSessionScreen); + }); +} diff --git a/workout-logger/test/screens/heart_rate_detail_screen_test.dart b/workout-logger/test/screens/heart_rate_detail_screen_test.dart new file mode 100644 index 0000000..cb79353 --- /dev/null +++ b/workout-logger/test/screens/heart_rate_detail_screen_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/heart_rate_detail_screen.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders HeartRateDetailScreen title and granularities', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + await tester.pumpWidget(TestHarness.wrap( + HeartRateDetailScreen(initialDate: DateTime(2026, 5, 10)), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(HeartRateDetailScreen), findsOneWidget); + // The screen renders its title and granularity tab controls via HealthDetailShell. + expect(find.text('Heart rate'), findsOneWidget); + expect( + find.text('Day').evaluate().isNotEmpty || + find.text('Week').evaluate().isNotEmpty, + isTrue, + reason: 'HealthDetailShell should render granularity controls', + ); + }); +} diff --git a/workout-logger/test/screens/history_screen_test.dart b/workout-logger/test/screens/history_screen_test.dart new file mode 100644 index 0000000..208dcaa --- /dev/null +++ b/workout-logger/test/screens/history_screen_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/history_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/history_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_fixtures.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders HistoryScreen title and empty history state', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + final historyManager = HistoryManager(storage); + await historyManager.loadSessions(); + + await tester.pumpWidget(TestHarness.wrap( + const HistoryScreen(), + storage: storage, + workoutProvider: workout, + historyManager: historyManager, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(HistoryScreen), findsOneWidget); + // With no sessions, the empty-history state is shown. + expect( + find.textContaining('No').evaluate().isNotEmpty || + find.textContaining('empty').evaluate().isNotEmpty || + find.textContaining('history').evaluate().isNotEmpty, + isTrue, + reason: 'Empty history state should be visible', + ); + }); + + testWidgets('Displays session item in history list', (WidgetTester tester) async { + const viewportSize = Size(800, 1800); + tester.view.physicalSize = viewportSize; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await TestHarness.prepareTester(tester, size: viewportSize); + + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(date: DateTime.now(), notes: 'Morning Leg Workout'); + await storage.saveWorkoutSession(session); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + final historyManager = HistoryManager(storage); + await historyManager.loadSessions(); + + await tester.pumpWidget(TestHarness.wrap( + const HistoryScreen(), + storage: storage, + workoutProvider: workout, + historyManager: historyManager, + viewportSize: viewportSize, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('Quick Workout'), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/home_screen_test.dart b/workout-logger/test/screens/home_screen_test.dart new file mode 100644 index 0000000..6aa7ace --- /dev/null +++ b/workout-logger/test/screens/home_screen_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/home_screen.dart'; +import 'package:repforge/screens/routines_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders HomeScreen with navigation bar items', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await tester.pumpWidget(TestHarness.wrap( + const HomeScreen(), + storage: storage, + workoutProvider: workout, + settingsProvider: settings, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('Home'), findsOneWidget); + expect(find.byIcon(Icons.layers_rounded), findsOneWidget); + expect(find.byIcon(Icons.history_rounded), findsOneWidget); + expect(find.byIcon(Icons.bar_chart_rounded), findsOneWidget); + }); + + testWidgets('Switches tabs when floating nav bar item is tapped', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await tester.pumpWidget(TestHarness.wrap( + const HomeScreen(), + storage: storage, + workoutProvider: workout, + settingsProvider: settings, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + // Tap Routines tab (Icons.layers_rounded) + await tester.tap(find.byIcon(Icons.layers_rounded)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + // RoutinesScreen should be displayed in IndexedStack + expect(find.byType(RoutinesScreen), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/onboarding_screen_test.dart b/workout-logger/test/screens/onboarding_screen_test.dart new file mode 100644 index 0000000..aad5b44 --- /dev/null +++ b/workout-logger/test/screens/onboarding_screen_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/onboarding_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_robot.dart'; + +void main() { + testWidgets('Renders WelcomePage welcome page', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await robot.pumpScreen( + WelcomePage(onComplete: () {}), + storage: storage, + settingsProvider: settings, + workoutProvider: workout, + ); + + robot.expectVisible(WelcomePage); + expect(find.textContaining('RepForge'), findsWidgets); + }); +} diff --git a/workout-logger/test/screens/profile_screen_full_test.dart b/workout-logger/test/screens/profile_screen_full_test.dart new file mode 100644 index 0000000..0de1a63 --- /dev/null +++ b/workout-logger/test/screens/profile_screen_full_test.dart @@ -0,0 +1,60 @@ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/profile_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; + +import '../test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + storage = MockStorageService(); + settingsProvider = SettingsProvider(storage); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('ProfileScreen Full Test Suite', () { + testWidgets('Renders ProfileScreen, toggles weight units, and opens clear data confirmation dialog', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ProfileScreen(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(ProfileScreen); + + // Toggle weight unit chips + final kgBtn = find.text('kg'); + if (kgBtn.evaluate().isNotEmpty) { + await tester.tap(kgBtn); + await tester.pumpAndSettle(); + } + + final lbsBtn = find.text('lbs'); + if (lbsBtn.evaluate().isNotEmpty) { + await tester.tap(lbsBtn); + await tester.pumpAndSettle(); + } + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/screens/profile_screen_test.dart b/workout-logger/test/screens/profile_screen_test.dart new file mode 100644 index 0000000..f8569e9 --- /dev/null +++ b/workout-logger/test/screens/profile_screen_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/profile_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_robot.dart'; + +void main() { + testWidgets('Renders ProfileScreen with sections', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await robot.pumpScreen( + const ProfileScreen(), + storage: storage, + settingsProvider: settings, + workoutProvider: workout, + ); + + robot.expectVisible('Preferences'); + robot.expectVisible('Data Management'); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -800)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + robot.expectVisible('About'); + }); + + testWidgets('Toggles weight unit preference', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await robot.pumpScreen( + const ProfileScreen(), + storage: storage, + settingsProvider: settings, + workoutProvider: workout, + ); + + await robot.tap('lbs'); + expect(settings.weightUnit, equals(WeightUnit.lbs)); + }); +} diff --git a/workout-logger/test/screens/programs/program_designer_screen_test.dart b/workout-logger/test/screens/programs/program_designer_screen_test.dart new file mode 100644 index 0000000..45c44d9 --- /dev/null +++ b/workout-logger/test/screens/programs/program_designer_screen_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/programs/program_designer_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/mock_ml_service.dart'; +import '../../test_utils/test_harness.dart'; + +Future _createProvider() async { + final storage = MockStorageService(); + final provider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await provider.init(); + return provider; +} + +void main() { + testWidgets('Renders Step 1 metadata controls in ProgramDesignerScreen', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final provider = await _createProvider(); + + await tester.pumpWidget(TestHarness.wrap( + const ProgramDesignerScreen(), + workoutProvider: provider, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('New Program'), findsOneWidget); + expect(find.text('PROGRAM DETAILS'), findsOneWidget); + expect(find.text('Step 1 of 3'), findsOneWidget); + expect(find.text('Next'), findsOneWidget); + }); + + testWidgets('Shows validation error if program name is empty on Next', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final provider = await _createProvider(); + + await tester.pumpWidget(TestHarness.wrap( + const ProgramDesignerScreen(), + workoutProvider: provider, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + // Tap Next without filling program name + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + // Step 1 stays active because name is empty + expect(find.text('Step 1 of 3'), findsOneWidget); + // The validation SnackBar is shown + expect(find.text('Enter a program name to continue'), findsOneWidget); + }); + + testWidgets('Enters program name and navigates to Step 2', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final provider = await _createProvider(); + + await tester.pumpWidget(TestHarness.wrap( + const ProgramDesignerScreen(), + workoutProvider: provider, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + // Enter Program Name + final nameField = find.widgetWithText(TextField, 'Program Name *'); + await tester.enterText(nameField, 'Hypertrophy 101'); + await tester.pump(); + + // Tap Next + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('Step 2 of 3'), findsOneWidget); + expect(find.text('WEEKS & DAYS'), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/programs/programs_screens_test.dart b/workout-logger/test/screens/programs/programs_screens_test.dart new file mode 100644 index 0000000..227092b --- /dev/null +++ b/workout-logger/test/screens/programs/programs_screens_test.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/programs/import_program_screen.dart'; +import 'package:repforge/screens/programs/programs_screen.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/mock_ml_service.dart'; +import '../../test_utils/test_robot.dart'; + +void main() { + testWidgets('Renders ProgramsScreen list and empty state', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await robot.pumpScreen( + const ProgramsScreen(), + storage: storage, + workoutProvider: workout, + ); + + robot.expectVisible(ProgramsScreen); + + final fab = find.byType(FloatingActionButton); + expect(fab, findsWidgets); + await robot.tap(fab.first); + }); + + testWidgets('Renders ImportProgramScreen, validates valid program JSON', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await robot.pumpScreen( + const ImportProgramScreen(), + storage: storage, + workoutProvider: workout, + ); + + robot.expectVisible(ImportProgramScreen); + + final validJson = jsonEncode({ + 'id': 'prog_custom_1', + 'name': 'Custom Powerlifting 4-Week', + 'description': 'Heavy compound lifting', + 'totalWeeks': 4, + 'phases': [], + 'daysPerWeek': 4, + 'weeks': [ + { + 'weekNumber': 1, + 'days': [ + { + 'dayNumber': 1, + 'name': 'Bench Day', + 'exercises': [ + {'exerciseId': 'bench_press', 'targetSets': 4, 'targetReps': 5} + ] + } + ] + } + ] + }); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + await robot.fill(textField.first, validJson); + + final validateBtn = find.text('Validate'); + expect(validateBtn, findsOneWidget); + await robot.tap(validateBtn); + await tester.pumpAndSettle(); + }); +} diff --git a/workout-logger/test/screens/settings_screen_test.dart b/workout-logger/test/screens/settings_screen_test.dart new file mode 100644 index 0000000..06aca71 --- /dev/null +++ b/workout-logger/test/screens/settings_screen_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/settings_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders SettingsScreen title and preference options', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + + final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await workout.init(); + + await tester.pumpWidget(TestHarness.wrap( + const SettingsScreen(), + storage: storage, + settingsProvider: settings, + workoutProvider: workout, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Weight Unit'), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/sleep_detail_screen_test.dart b/workout-logger/test/screens/sleep_detail_screen_test.dart new file mode 100644 index 0000000..9126f4d --- /dev/null +++ b/workout-logger/test/screens/sleep_detail_screen_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/sleep_detail_screen.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders SleepDetailScreen title and granularities', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + await tester.pumpWidget(TestHarness.wrap( + SleepDetailScreen(initialDate: DateTime(2026, 5, 10)), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('Sleep'), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/widgets/editable_exercise_card_full_test.dart b/workout-logger/test/screens/widgets/editable_exercise_card_full_test.dart new file mode 100644 index 0000000..a1e5b00 --- /dev/null +++ b/workout-logger/test/screens/widgets/editable_exercise_card_full_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/editable_exercise_card.dart'; + +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/test_harness.dart'; + +void main() { + late MockStorageService storage; + + setUp(() { + storage = MockStorageService(); + }); + + group('EditableExerciseCard Widget Tests', () { + testWidgets('Renders exercise name, set rows, dropsets, and handles actions', (tester) async { + final log = EditableExerciseLog( + exerciseId: 'bench_press', + sets: [ + EditableSet( + weight: 100, + reps: 10, + timestamp: DateTime.now(), + ), + EditableSet( + weight: 90, + reps: 8, + isDropset: true, + drops: [DropsetEntry(weight: 70, reps: 6)], + timestamp: DateTime.now(), + ), + ], + ); + + bool setAdded = false; + + final widget = TestHarness.wrap( + Scaffold( + body: EditableExerciseCard( + exerciseName: 'Bench Press', + editableLog: log, + onSetChanged: ({ + required int setIndex, + required double weight, + required int reps, + required bool isDropset, + List? drops, + }) {}, + onAddSet: () => setAdded = true, + onDeleteSet: (idx) {}, + onDeleteExercise: () {}, + ), + ), + storage: storage, + ); + + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + + expect(find.text('Bench Press'), findsOneWidget); + + // Tap + Add Set + final addSetBtn = find.text('+ Add Set'); + if (addSetBtn.evaluate().isNotEmpty) { + await tester.tap(addSetBtn); + expect(setAdded, isTrue); + } + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/screens/widgets/health_bar_chart_test.dart b/workout-logger/test/screens/widgets/health_bar_chart_test.dart new file mode 100644 index 0000000..9a98723 --- /dev/null +++ b/workout-logger/test/screens/widgets/health_bar_chart_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/screens/widgets/health_bar_chart.dart'; +import '../../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders SleepBarsChart with daily sleep stage data', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final now = DateTime(2026, 5, 10); + final List bars = [ + SleepDayBar( + date: now.subtract(const Duration(days: 2)), + totalMinutes: 480, + deepMin: 90, + remMin: 120, + lightMin: 240, + awakeMin: 30, + ), + SleepDayBar( + date: now.subtract(const Duration(days: 1)), + totalMinutes: 395, + deepMin: 60, + remMin: 90, + lightMin: 200, + awakeMin: 45, + ), + SleepDayBar( + date: now, + totalMinutes: 0, + deepMin: 0, + remMin: 0, + lightMin: 0, + awakeMin: 0, + ), + ]; + + final workoutDays = {'2026-05-08', '2026-05-10'}; + + await tester.pumpWidget(TestHarness.wrap( + SleepBarsChart( + bars: bars, + workoutDays: workoutDays, + ), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(SleepBarsChart), findsOneWidget); + + // Tap on a bar area to trigger tooltip interaction + await tester.tap(find.byType(SleepBarsChart)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Renders HrRangeChart with heart rate min-max range data', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final now = DateTime(2026, 5, 10); + final List bars = [ + HrRangeBar( + date: now.subtract(const Duration(days: 2)), + label: 'Fri', + minBpm: 55, + maxBpm: 145, + avgBpm: 75.0, + restingBpm: 58, + ), + HrRangeBar( + date: now.subtract(const Duration(days: 1)), + label: 'Sat', + minBpm: 60, + maxBpm: 165, + avgBpm: 82.0, + restingBpm: 62, + ), + HrRangeBar( + date: now, + label: 'Sun', + minBpm: 0, + maxBpm: 0, + avgBpm: 0.0, + restingBpm: null, + ), + ]; + + final workoutDays = {'2026-05-09'}; + + await tester.pumpWidget(TestHarness.wrap( + HrRangeChart( + bars: bars, + workoutDays: workoutDays, + ), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(HrRangeChart), findsOneWidget); + + // Tap on HrRangeChart to test tap gestures + await tester.tap(find.byType(HrRangeChart)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/workout-logger/test/screens/widgets/health_cards_test.dart b/workout-logger/test/screens/widgets/health_cards_test.dart new file mode 100644 index 0000000..b15ab16 --- /dev/null +++ b/workout-logger/test/screens/widgets/health_cards_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/screens/widgets/heart_rate_card.dart'; +import 'package:repforge/screens/widgets/readiness_card.dart'; +import 'package:repforge/screens/widgets/sleep_hr_card.dart'; +import 'package:repforge/services/interfaces/readiness_manager_interface.dart'; +import 'package:repforge/services/managers/readiness_manager.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/stub_health_connect_service.dart'; +import '../../test_utils/test_harness.dart'; +import 'package:repforge/services/settings_provider.dart'; + +class FakeReadinessManager extends ReadinessManager { + FakeReadinessManager(SettingsProvider settings) + : super(const StubHcService(), MockStorageService(), settings); + + ReadinessStatus _mockStatus = ReadinessStatus.ready; + ReadinessSnapshot? _mockSnapshot; + SleepHrSnapshot? _mockSleepHrSnapshot; + HrDaySnapshot? _mockHrDaySnapshot; + + void setMockData({ + ReadinessStatus status = ReadinessStatus.ready, + ReadinessSnapshot? snapshot, + SleepHrSnapshot? sleepHrSnapshot, + HrDaySnapshot? hrDaySnapshot, + }) { + _mockStatus = status; + _mockSnapshot = snapshot; + _mockSleepHrSnapshot = sleepHrSnapshot; + _mockHrDaySnapshot = hrDaySnapshot; + notifyListeners(); + } + + @override + ReadinessStatus get status => _mockStatus; + + @override + ReadinessSnapshot? get snapshot => _mockSnapshot; + + @override + SleepHrSnapshot? get sleepHrSnapshot => _mockSleepHrSnapshot; + + @override + HrDaySnapshot? get hrDaySnapshot => _mockHrDaySnapshot; +} + +void main() { + late MockStorageService storage; + late SettingsProvider settings; + late FakeReadinessManager readinessManager; + + setUp(() async { + storage = MockStorageService(); + settings = SettingsProvider(storage); + await settings.init(); + readinessManager = FakeReadinessManager(settings); + }); + + Widget wrapWithReadiness(Widget child) { + return TestHarness.wrap( + child, + storage: storage, + settingsProvider: settings, + readinessManager: readinessManager, + ); + } + + testWidgets('Renders ReadinessCard when snapshot score is present', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + readinessManager.setMockData( + status: ReadinessStatus.ready, + snapshot: ReadinessSnapshot( + dateKey: '2026-05-10', + score: 85, + band: ReadinessBand.high, + ), + ); + + await tester.pumpWidget(wrapWithReadiness(const ReadinessCard())); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(ReadinessCard), findsOneWidget); + }); + + testWidgets('Renders SleepHrCard when sleepHrSnapshot is present', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final now = DateTime(2026, 5, 10); + final snapshot = SleepHrSnapshot( + sleepStart: now.subtract(const Duration(hours: 8)), + sleepEnd: now, + p5Bpm: 52, + p95Bpm: 82, + segments: [ + SleepHrSegment( + windowStart: now.subtract(const Duration(hours: 4)), + minBpm: 55, + maxBpm: 65, + avgBpm: 60, + stage: 'deep', + ), + ], + stageStats: [ + const SleepStageStats( + stage: 'deep', + minBpm: 52, + p25Bpm: 55, + avgBpm: 58, + p75Bpm: 62, + maxBpm: 70, + sampleCount: 20, + ), + ], + ); + + readinessManager.setMockData( + status: ReadinessStatus.ready, + sleepHrSnapshot: snapshot, + ); + + await tester.pumpWidget(wrapWithReadiness(const SleepHrCard())); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(SleepHrCard), findsOneWidget); + + // Tap SleepHrCard to trigger sheet opening + await tester.tap(find.byType(SleepHrCard)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Renders HeartRateCard when hrDaySnapshot is present', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final now = DateTime(2026, 5, 10); + final snapshot = HrDaySnapshot( + day: now, + minBpm: 50, + maxBpm: 155, + avgBpm: 72, + restingBpm: 54, + buckets: [ + HrBucket( + windowStart: now.subtract(const Duration(hours: 2)), + minBpm: 60, + maxBpm: 80, + avgBpm: 70, + ), + ], + ); + + readinessManager.setMockData( + status: ReadinessStatus.ready, + hrDaySnapshot: snapshot, + ); + + await tester.pumpWidget(wrapWithReadiness(const HeartRateCard())); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(HeartRateCard), findsOneWidget); + + // Tap HeartRateCard to test navigation + await tester.tap(find.byType(HeartRateCard)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/workout-logger/test/screens/widgets/health_widgets_test.dart b/workout-logger/test/screens/widgets/health_widgets_test.dart new file mode 100644 index 0000000..c664276 --- /dev/null +++ b/workout-logger/test/screens/widgets/health_widgets_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/screens/widgets/sleep_hr_charts.dart'; +import 'package:repforge/screens/widgets/muscle_detail_sheet.dart'; +import 'package:repforge/screens/widgets/health_detail_shell.dart'; +import 'package:repforge/screens/widgets/sparkline_painter.dart'; +import 'package:repforge/screens/widgets/activity_heatmap.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/mock_ml_service.dart'; +import '../../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders SleepHrDayView overnight chart widget', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final snapshot = SleepHrSnapshot( + sleepStart: DateTime(2026, 5, 10, 0, 0), + sleepEnd: DateTime(2026, 5, 10, 8, 0), + p5Bpm: 54, + p95Bpm: 75, + segments: [ + SleepHrSegment( + windowStart: DateTime(2026, 5, 10, 1, 0), + minBpm: 52, + maxBpm: 65, + avgBpm: 58.0, + stage: 'deep', + ), + SleepHrSegment( + windowStart: DateTime(2026, 5, 10, 3, 0), + minBpm: 55, + maxBpm: 70, + avgBpm: 62.0, + stage: 'rem', + ), + ], + stageStats: const [ + SleepStageStats(stage: 'deep', minBpm: 52, p25Bpm: 55, avgBpm: 58.0, p75Bpm: 62, maxBpm: 65, sampleCount: 12), + SleepStageStats(stage: 'rem', minBpm: 55, p25Bpm: 58, avgBpm: 62.0, p75Bpm: 66, maxBpm: 70, sampleCount: 12), + ], + ); + + await tester.pumpWidget(TestHarness.wrap( + SleepHrDayView(snapshot: snapshot), + )); + await tester.pumpAndSettle(); + + expect(find.textContaining('54 bpm'), findsOneWidget); + }); + + testWidgets('Renders MuscleDetailSheet with muscle breakdown', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await provider.init(); + + await tester.pumpWidget(TestHarness.wrap( + MuscleDetailSheet(muscleId: 'chest', provider: provider), + storage: storage, + workoutProvider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('Chest'), findsOneWidget); + }); + + testWidgets('Renders HealthDetailShell container with granularity selection', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + HealthGranularity currentG = HealthGranularity.day; + + await tester.pumpWidget(TestHarness.wrap( + HealthDetailShell( + title: 'Sleep History', + icon: Icons.nightlight_round, + iconColor: Colors.purple, + dateLabel: 'May 10, 2026', + granularity: currentG, + onGranularityChanged: (g) => currentG = g, + onPrev: () {}, + onNext: () {}, + canGoNext: false, + child: const SizedBox(height: 100, child: Text('Child Content')), + ), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.text('Sleep History'), findsOneWidget); + expect(find.text('May 10, 2026'), findsOneWidget); + expect(find.text('Child Content'), findsOneWidget); + }); + + testWidgets('Renders SparklinePainter canvas', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + await tester.pumpWidget(TestHarness.wrap( + const CustomPaint( + size: Size(100, 30), + painter: SparklinePainter( + data: [10.0, 15.0, 8.0, 20.0, 25.0], + color: Colors.blue, + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(CustomPaint), findsWidgets); + }); + + testWidgets('Renders ActivityHeatmap canvas', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final activityData = List.filled(98, 2); + + await tester.pumpWidget(TestHarness.wrap( + ActivityHeatmap(data: activityData), + )); + await tester.pumpAndSettle(); + + expect(find.byType(ActivityHeatmap), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/widgets/rf_cards_test.dart b/workout-logger/test/screens/widgets/rf_cards_test.dart new file mode 100644 index 0000000..8677a7a --- /dev/null +++ b/workout-logger/test/screens/widgets/rf_cards_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_cards.dart'; +import '../../test_utils/test_fixtures.dart'; +import '../../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders SessionCard with exercise details', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final session = TestFixtures.sampleSession(); + + await tester.pumpWidget(TestHarness.wrap( + SessionCard( + session: session, + getExerciseName: (id) => id == 'bench_press' ? 'Bench Press' : 'Squats', + synced: true, + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Bench Press'), findsOneWidget); + }); + + testWidgets('Renders StatGridCard with counter label', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + await tester.pumpWidget(TestHarness.wrap( + const StatGridCard( + icon: Icons.fitness_center, + value: '125 kg', + label: 'Max Bench', + animate: false, + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Max Bench'), findsOneWidget); + }); + + testWidgets('Renders RecentSessionTile item', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final session = TestFixtures.sampleSession(); + + await tester.pumpWidget(TestHarness.wrap( + RecentSessionTile( + session: session, + getExerciseName: (id) => 'Bench Press', + ), + )); + await tester.pumpAndSettle(); + + expect(find.textContaining('exercises'), findsOneWidget); + }); + + testWidgets('Renders RoutineCard with action triggers', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final routine = TestFixtures.sampleRoutine(); + bool started = false; + + await tester.pumpWidget(TestHarness.wrap( + RoutineCard( + routine: routine, + getExerciseName: (id) => id, + onStart: () => started = true, + onEdit: () {}, + onDelete: () {}, + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Upper Body Power'), findsOneWidget); + await tester.tap(find.byIcon(Icons.play_arrow_rounded)); + await tester.pumpAndSettle(); + + expect(started, isTrue); + }); +} diff --git a/workout-logger/test/screens/widgets/rf_dialogs_test.dart b/workout-logger/test/screens/widgets/rf_dialogs_test.dart new file mode 100644 index 0000000..b7e9c72 --- /dev/null +++ b/workout-logger/test/screens/widgets/rf_dialogs_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_dialogs.dart'; + +void main() { + testWidgets('showRFSnackBar displays all snackbar types correctly', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => context.showRFSnackBar('Success Toast', type: RFSnackBarType.success), + child: const Text('Success'), + ), + ElevatedButton( + onPressed: () => context.showRFSnackBar('Warning Toast', type: RFSnackBarType.warning), + child: const Text('Warning'), + ), + ElevatedButton( + onPressed: () => context.showRFSnackBar('Error Toast', type: RFSnackBarType.error), + child: const Text('Error'), + ), + ElevatedButton( + onPressed: () => context.showRFSnackBar('Info Toast', type: RFSnackBarType.info), + child: const Text('Info'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Success')); + await tester.pumpAndSettle(); + expect(find.text('Success Toast'), findsOneWidget); + + await tester.tap(find.text('Warning')); + await tester.pumpAndSettle(); + expect(find.text('Warning Toast'), findsOneWidget); + + await tester.tap(find.text('Error')); + await tester.pumpAndSettle(); + expect(find.text('Error Toast'), findsOneWidget); + + await tester.tap(find.text('Info')); + await tester.pumpAndSettle(); + expect(find.text('Info Toast'), findsOneWidget); + }); + + testWidgets('showRFConfirmDialog renders normal and danger confirmation dialogs', (tester) async { + bool? dangerResult; + bool? cancelResult; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () async { + dangerResult = await showRFConfirmDialog( + context, + title: 'Delete Item', + content: 'Are you sure you want to delete?', + isDanger: true, + confirmText: 'Delete', + ); + }, + child: const Text('Open Danger Dialog'), + ), + ElevatedButton( + onPressed: () async { + cancelResult = await showRFConfirmDialog( + context, + title: 'Confirm Action', + content: 'Do you want to proceed?', + isDanger: false, + confirmText: 'Proceed', + ); + }, + child: const Text('Open Normal Dialog'), + ), + ], + ), + ), + ), + ), + ); + + // Test danger confirmation path + await tester.tap(find.text('Open Danger Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Item'), findsOneWidget); + expect(find.text('Are you sure you want to delete?'), findsOneWidget); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(dangerResult, isTrue); + + // Test default non-danger styling path and cancellation behavior + await tester.tap(find.text('Open Normal Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Confirm Action'), findsOneWidget); + expect(find.text('Do you want to proceed?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(cancelResult, isFalse); + }); +} diff --git a/workout-logger/test/screens/widgets/rf_widgets_test.dart b/workout-logger/test/screens/widgets/rf_widgets_test.dart new file mode 100644 index 0000000..9f8541c --- /dev/null +++ b/workout-logger/test/screens/widgets/rf_widgets_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_widgets.dart'; +import 'package:repforge/theme/app_theme.dart'; + +void main() { + testWidgets('slideRoute creates valid PageRouteBuilder', (tester) async { + final route = slideRoute(const Text('Slide Page')); + expect(route, isA()); + }); + + testWidgets('GlassCard renders child with options', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GlassCard( + accentBorder: true, + glowColor: Colors.purple, + onTap: () => tapped = true, + semanticsLabel: 'GlassCardButton', + child: const Text('Glass Content'), + ), + ), + ), + ); + + expect(find.text('Glass Content'), findsOneWidget); + await tester.tap(find.text('Glass Content')); + expect(tapped, isTrue); + }); + + testWidgets('AmbientGlow renders glow effect', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Stack( + children: [AmbientGlow()], + ), + ), + ), + ); + + expect(find.byType(AmbientGlow), findsOneWidget); + }); + + testWidgets('GlowButton handles tap and disabled state', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + GlowButton( + label: 'Active Button', + icon: Icons.add, + small: true, + onPressed: () => tapped = true, + ), + const GlowButton( + label: 'Disabled Button', + onPressed: null, + ), + ], + ), + ), + ), + ); + + expect(find.text('Active Button'), findsOneWidget); + expect(find.text('Disabled Button'), findsOneWidget); + + await tester.tap(find.text('Active Button')); + await tester.pumpAndSettle(); + expect(tapped, isTrue); + }); + + testWidgets('OutlineGlowButton renders correctly', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutlineGlowButton( + label: 'Outline', + icon: Icons.check, + small: true, + fullWidth: true, + onPressed: () => tapped = true, + ), + ), + ), + ); + + expect(find.text('Outline'), findsOneWidget); + await tester.tap(find.text('Outline')); + expect(tapped, isTrue); + }); + + testWidgets('RFChip, RFSectionHeader, RFStatBox render correctly', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Column( + children: [ + RFChip(label: 'Chest', small: true), + RFSectionHeader('Workouts', trailing: Text('View all')), + RFStatBox(value: '100', label: 'Volume', delta: 5.0), + RFStatBox(value: '50', label: 'Reps', delta: -2.0), + ], + ), + ), + ), + ); + + expect(find.text('Chest'), findsOneWidget); + expect(find.text('WORKOUTS'), findsOneWidget); + expect(find.text('100'), findsOneWidget); + expect(find.text('50'), findsOneWidget); + }); + + testWidgets('AnimatedCounter, MetricHero, RFDivider, RFEmptyState render correctly', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const AnimatedCounter(value: 42.5, decimals: 1, suffix: 'kg'), + const MetricHero(value: '100', unit: 'kg'), + const RFDivider(indent: 16), + RFEmptyState( + icon: Icons.fitness_center, + title: 'No Workouts', + subtitle: 'Add a workout to get started', + action: ElevatedButton(onPressed: () {}, child: const Text('Add')), + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('100'), findsOneWidget); + expect(find.text('No Workouts'), findsOneWidget); + }); + + testWidgets('RFLoadingDots, RFProgressBar, RestTimerRing, SkeletonBox, RFTextField render correctly', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const RFLoadingDots(color: Colors.blue), + const RFProgressBar(value: 0.75, height: 8), + const RestTimerRing(remaining: 90, total: 120), + const SkeletonBox(width: 100, height: 20), + RFTextField( + controller: controller, + hint: 'Enter text', + label: 'Field Label', + prefixIcon: Icons.search, + ), + ], + ), + ), + ), + ); + + expect(find.byType(RFLoadingDots), findsOneWidget); + expect(find.byType(RFProgressBar), findsOneWidget); + expect(find.byType(RestTimerRing), findsOneWidget); + expect(find.text('Field Label'), findsOneWidget); + + final containerBefore = tester.widget( + find.descendant(of: find.byType(RFTextField), matching: find.byType(Container)).first, + ); + final boxDecBefore = containerBefore.decoration as BoxDecoration; + final borderBefore = boxDecBefore.border as Border; + expect(borderBefore.top.color, AppColors.glassBorder); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final containerAfter = tester.widget( + find.descendant(of: find.byType(RFTextField), matching: find.byType(Container)).first, + ); + final boxDecAfter = containerAfter.decoration as BoxDecoration; + final borderAfter = boxDecAfter.border as Border; + expect(borderAfter.top.color, AppColors.primary); + + await tester.enterText(find.byType(TextField), 'Test input'); + expect(controller.text, 'Test input'); + }); +} diff --git a/workout-logger/test/screens/widgets/routine_creator_test.dart b/workout-logger/test/screens/widgets/routine_creator_test.dart new file mode 100644 index 0000000..5347851 --- /dev/null +++ b/workout-logger/test/screens/widgets/routine_creator_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/routine_creator.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/mock_ml_service.dart'; +import '../../test_utils/test_robot.dart'; + +void main() { + testWidgets('Renders CreateRoutineScreen and creates new routine', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await provider.init(); + + await robot.pumpScreen( + const CreateRoutineScreen(), + storage: storage, + workoutProvider: provider, + ); + + robot.expectVisible(CreateRoutineScreen); + + // Enter routine name via RFTextField + await robot.fill(find.byType(TextField).first, 'Upper Body Push'); + + // Tap Add Exercises button + await robot.tap('Add Exercises'); + + // Select exercise in sheet + final checks = find.byType(CheckboxListTile); + if (checks.evaluate().isNotEmpty) { + await robot.tap(checks.first); + } + }); + + testWidgets('Renders RoutineDetailScreen and displays exercise list', (WidgetTester tester) async { + final robot = TestRobot(tester); + final storage = MockStorageService(); + final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await provider.init(); + + final routine = Routine( + id: 'routine_push_1', + name: 'Push Hypertrophy', + exerciseIds: ['bench_press', 'overhead_press'], + createdAt: DateTime.now(), + ); + + await robot.pumpScreen( + RoutineDetailScreen(routine: routine), + storage: storage, + workoutProvider: provider, + ); + + robot.expectVisible(RoutineDetailScreen); + robot.expectVisible('Push Hypertrophy'); + }); +} diff --git a/workout-logger/test/screens/widgets/targets_tab_test.dart b/workout-logger/test/screens/widgets/targets_tab_test.dart new file mode 100644 index 0000000..bbb6db5 --- /dev/null +++ b/workout-logger/test/screens/widgets/targets_tab_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/targets_tab.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/mock_ml_service.dart'; +import '../../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders TargetsTab with empty targets state', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await provider.init(); + + await tester.pumpWidget(TestHarness.wrap( + const TargetsTab(), + storage: storage, + workoutProvider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('No Targets Set'), findsOneWidget); + }); + + testWidgets('Renders TargetsTab with active targets list', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final target = Target( + id: 'target_1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 80.0, + ); + await storage.saveTarget(target); + + final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await provider.init(); + + await tester.pumpWidget(TestHarness.wrap( + const TargetsTab(), + storage: storage, + workoutProvider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('No Targets Set'), findsNothing); + // The target for bench_press should render its exercise name or value. + expect( + find.textContaining('Bench Press').evaluate().isNotEmpty || + find.textContaining('bench').evaluate().isNotEmpty || + find.textContaining('80').evaluate().isNotEmpty || + find.textContaining('100').evaluate().isNotEmpty, + isTrue, + reason: 'Active target item should display the exercise name or target value', + ); + }); +} diff --git a/workout-logger/test/screens/widgets/workout_hr_section_test.dart b/workout-logger/test/screens/widgets/workout_hr_section_test.dart new file mode 100644 index 0000000..edfff30 --- /dev/null +++ b/workout-logger/test/screens/widgets/workout_hr_section_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/workout_hr_models.dart'; +import 'package:repforge/screens/widgets/workout_hr_section.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; +import '../../test_utils/mock_storage_service.dart'; +import '../../test_utils/mock_ml_service.dart'; +import '../../test_utils/stub_health_connect_service.dart'; +import '../../test_utils/test_fixtures.dart'; +import '../../test_utils/test_harness.dart'; + +class StubHealthHistoryManager extends HealthHistoryManager { + StubHealthHistoryManager(this.stubAnalysis) + : super(const StubHcService(), MockStorageService()); + + final WorkoutHrAnalysis? stubAnalysis; + + @override + Future workoutHr(WorkoutSession session) async { + return stubAnalysis; + } +} + +void main() { + testWidgets('Renders WorkoutHrSection with heart rate analysis stats', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final storage = MockStorageService(); + final session = TestFixtures.sampleSession(); + final provider = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + await provider.init(); + + final now = DateTime(2026, 5, 10, 14, 30); + final analysis = WorkoutHrAnalysis( + start: now, + end: now.add(const Duration(minutes: 45)), + avgBpm: 110, + peakBpm: 150, + minBpm: 60, + curve: [ + HrCurvePoint(time: now, bpm: 70.0), + HrCurvePoint(time: now.add(const Duration(minutes: 15)), bpm: 140.0), + ], + rests: [ + RestRecovery( + afterSet: 1, + restStart: now.add(const Duration(minutes: 5)), + durationSec: 90, + peakBpm: 135, + troughBpm: 110, + recoveryBpm: 25, + recovered: true, + ), + ], + exercises: [ + ExerciseHrSpan( + exerciseId: 'bench_press', + start: now.add(const Duration(minutes: 2)), + end: now.add(const Duration(minutes: 10)), + setCount: 3, + ), + ], + hasRestAnalysis: true, + ); + + final customManager = StubHealthHistoryManager(analysis); + + await tester.pumpWidget(TestHarness.wrap( + WorkoutHrSection(session: session, provider: provider), + storage: storage, + healthHistoryManager: customManager, + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(WorkoutHrSection), findsOneWidget); + }); +} diff --git a/workout-logger/test/screens/workout_flow_screen_full_test.dart b/workout-logger/test/screens/workout_flow_screen_full_test.dart new file mode 100644 index 0000000..db4df20 --- /dev/null +++ b/workout-logger/test/screens/workout_flow_screen_full_test.dart @@ -0,0 +1,121 @@ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/workout_flow_screen.dart'; + +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import '../test_utils/mock_storage_service.dart'; +import '../test_utils/mock_ml_service.dart'; + +import '../test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + storage = MockStorageService(); + settingsProvider = SettingsProvider(storage); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('WorkoutFlowScreen Comprehensive Test Suite', () { + testWidgets('QuickStart workout flow: starts, adds exercises, logs sets, finishes', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const WorkoutFlowScreen(isQuickStart: true), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(WorkoutFlowScreen); + + // Tap Log Set button if present + final logBtn = find.text('LOG SET'); + if (logBtn.evaluate().isNotEmpty) { + await tester.tap(logBtn); + await tester.pumpAndSettle(); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('Routine-backed workout flow: loads exercises, toggles dropsets, logs sets', (tester) async { + final robot = TestRobot(tester); + + final routine = Routine( + id: 'rout_flow_1', + name: 'Upper Hypertrophy', + exerciseIds: ['bench_press', 'incline_dumbbell_press'], + ); + await storage.saveRoutine(routine); + + await robot.pumpScreen( + WorkoutFlowScreen(routine: routine), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(WorkoutFlowScreen); + + // Log set + final logBtn = find.text('LOG SET'); + if (logBtn.evaluate().isNotEmpty) { + await tester.tap(logBtn); + await tester.pumpAndSettle(); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('ProgramDay-backed workout flow with deload week', (tester) async { + final robot = TestRobot(tester); + + final day = ProgramDay( + id: 'day_flow_1', + name: 'Leg Day A', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'squat', + sets: 3, + minReps: 5, + maxReps: 5, + restSeconds: 120, + ), + ], + ); + + final week = ProgramWeek( + weekNumber: 4, + isDeload: true, + deloadIntensityFactor: 0.85, + deloadSetReduction: 1, + days: [day], + ); + + await robot.pumpScreen( + WorkoutFlowScreen(programDay: day, programWeek: week), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(WorkoutFlowScreen); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/screens/workout_flow_screen_test.dart b/workout-logger/test/screens/workout_flow_screen_test.dart new file mode 100644 index 0000000..8326b73 --- /dev/null +++ b/workout-logger/test/screens/workout_flow_screen_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/workout_flow_screen.dart'; +import '../test_utils/test_harness.dart'; + +void main() { + testWidgets('Renders WorkoutFlowScreen with active exercise details', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + final routine = Routine( + id: 'chest_day', + name: 'Chest & Triceps', + exerciseIds: ['bench_press', 'incline_dumbbells'], + ); + + await tester.pumpWidget(TestHarness.wrap( + WorkoutFlowScreen(routine: routine), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(WorkoutFlowScreen), findsOneWidget); + expect(find.text('Bench Press'), findsOneWidget); + }); + + testWidgets('Renders WorkoutFlowScreen quick start mode', (WidgetTester tester) async { + await TestHarness.prepareTester(tester); + + await tester.pumpWidget(TestHarness.wrap( + const WorkoutFlowScreen(isQuickStart: true), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + expect(find.byType(WorkoutFlowScreen), findsOneWidget); + // Quick-start mode has no pre-set routine: the Add Exercise control is shown. + expect( + find.byIcon(Icons.add_rounded).evaluate().isNotEmpty || + find.textContaining('Exercise').evaluate().isNotEmpty, + isTrue, + reason: 'Quick-start mode should show an add-exercise control or empty exercise area', + ); + }); +} diff --git a/workout-logger/test/services/health_connect_service_test.dart b/workout-logger/test/services/health_connect_service_test.dart new file mode 100644 index 0000000..c3ff314 --- /dev/null +++ b/workout-logger/test/services/health_connect_service_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/health_connect_service.dart'; +import '../test_utils/test_fixtures.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const pigeonChannels = [ + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.getHealthPlatformStatus', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.initialize', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.requestPermissions', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.getPermissionStatus', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.readRecords', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.readRecord', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.writeRecords', + 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.writeRecord', + 'dev.flutter.pigeon.health_connector_hk_ios.HealthConnectorHKIOSApi.getHealthPlatformStatus', + 'dev.flutter.pigeon.health_connector_hk_ios.HealthConnectorHKIOSApi.initialize', + ]; + + setUp(() { + for (final channel in pigeonChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (ByteData? message) async => null); + } + }); + + tearDown(() { + for (final channel in pigeonChannels) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, null); + } + }); + + testWidgets('HealthConnectService reports unavailable gracefully in unit tests', (WidgetTester tester) async { + final service = HealthConnectService(); + final available = await service.isAvailable(); + expect(available, isFalse); + }); + + testWidgets('HealthConnectService returns false for permissions check on unsupported desktop test environment', (WidgetTester tester) async { + final service = HealthConnectService(); + final hasPerms = await service.hasPermissions(); + expect(hasPerms, isFalse); + + final reqPerms = await service.requestPermissions(); + expect(reqPerms, isFalse); + + final reqReadPerms = await service.requestReadPermissions(); + expect(reqReadPerms, isFalse); + + final grantedTypes = await service.grantedReadTypes(); + expect(grantedTypes, isEmpty); + }); + + testWidgets('HealthConnectService syncWorkoutSession returns false gracefully on missing platform channel', (WidgetTester tester) async { + final service = HealthConnectService(); + final session = TestFixtures.sampleSession(); + final success = await service.syncWorkoutSession(session, title: 'Custom Title'); + expect(success, isFalse); + }); + + testWidgets('HealthConnectService handles sessions with zero reps and custom exercises', (WidgetTester tester) async { + final service = HealthConnectService(); + final session = WorkoutSession( + id: 'sess_custom', + date: DateTime.now(), + duration: 30, + notes: 'Custom notes', + exercises: [ + ExerciseLog( + exerciseId: 'custom_exercise_999', + sets: [ + WorkoutSet(weight: 0.0, reps: 0, timestamp: DateTime.now()), + WorkoutSet(weight: 50.0, reps: 10, timestamp: DateTime.now().add(const Duration(minutes: 5))), + ], + ), + ], + ); + + final success = await service.syncWorkoutSession(session); + expect(success, isFalse); + }); + + testWidgets('HealthConnectService handles sessions with identical timestamps (fallback spacing)', (WidgetTester tester) async { + final service = HealthConnectService(); + final now = DateTime.now(); + final session = WorkoutSession( + id: 'sess_identical_ts', + date: now, + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 60.0, reps: 10, timestamp: now), + WorkoutSet(weight: 70.0, reps: 8, timestamp: now), + ], + ), + ], + ); + + final success = await service.syncWorkoutSession(session, title: ''); + expect(success, isFalse); + }); + + testWidgets('HealthConnectService handles empty sessions without exercises', (WidgetTester tester) async { + final service = HealthConnectService(); + final session = WorkoutSession( + id: 'sess_empty', + date: DateTime.now(), + duration: 20, + exercises: [], + ); + + final success = await service.syncWorkoutSession(session); + expect(success, isFalse); + }); + + testWidgets('HealthConnectService read methods return empty lists when plugin unavailable', (WidgetTester tester) async { + final service = HealthConnectService(); + final now = DateTime.now(); + final start = now.subtract(const Duration(days: 1)); + + final sleep = await service.readSleepSessions(start, now); + expect(sleep, isEmpty); + + final rhr = await service.readRestingHeartRate(start, now); + expect(rhr, isEmpty); + + final hrv = await service.readHrvRmssd(start, now); + expect(hrv, isEmpty); + + final hr = await service.readHeartRateSamples(start, now); + expect(hr, isEmpty); + }); + + test('HealthConnectService succeeds when platform response takes > 100ms within deadline', () async { + const channel = 'dev.flutter.pigeon.health_connector_hc_android.HealthConnectorHCAndroidApi.getHealthPlatformStatus'; + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler(channel, null); + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler( + channel, + (ByteData? message) async { + await Future.delayed(const Duration(milliseconds: 200)); + return null; + }, + ); + + final service = HealthConnectService(); + final available = await service.isAvailable(); + expect(available, isFalse); + }); +} diff --git a/workout-logger/test/settings_provider_test.dart b/workout-logger/test/settings_provider_test.dart new file mode 100644 index 0000000..39cc6e7 --- /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-3.6-flash')); + expect(provider.showAdvancedMetrics, isFalse); + }); + + test('init loads saved settings from storage', () async { + await mockStorage.saveSetting('weightUnit', 'lbs'); + await mockStorage.saveSetting('weightIncrement', '5.0'); + await mockStorage.saveSetting('healthConnectEnabled', 'true'); + await mockStorage.saveSetting('readinessEnabled', 'true'); + await mockStorage.saveSetting('userName', 'Devasy'); + await mockStorage.saveSetting('geminiApiKey', 'secret_key'); + await mockStorage.saveSetting('geminiModel', 'gemini-1.5-pro'); + await mockStorage.saveSetting('showAdvancedMetrics', 'true'); + + await provider.init(); + + expect(provider.weightUnit, equals(WeightUnit.lbs)); + expect(provider.unitLabel, equals('lbs')); + expect(provider.weightIncrement, equals(5.0)); + expect(provider.healthConnectEnabled, isTrue); + expect(provider.readinessEnabled, isTrue); + expect(provider.userName, equals('Devasy')); + expect(provider.geminiApiKey, equals('secret_key')); + expect(provider.geminiModel, equals('gemini-1.5-pro')); + expect(provider.showAdvancedMetrics, isTrue); + }); + + test('setUserName updates state and notifies listeners', () async { + bool notified = false; + provider.addListener(() => notified = true); + + await provider.setUserName(' John Doe '); + + expect(provider.userName, equals('John Doe')); + expect(mockStorage.settings['userName'], equals('John Doe')); + expect(notified, isTrue); + }); + + test('setWeightUnit updates weightUnit, default increment, and saves settings', () async { + await provider.setWeightUnit(WeightUnit.lbs); + + expect(provider.weightUnit, equals(WeightUnit.lbs)); + expect(provider.unitLabel, equals('lbs')); + expect(provider.weightIncrement, equals(5.0)); + expect(mockStorage.settings['weightUnit'], equals('lbs')); + expect(mockStorage.settings['weightIncrement'], equals('5.0')); + + await provider.setWeightUnit(WeightUnit.kg); + + expect(provider.weightUnit, equals(WeightUnit.kg)); + expect(provider.unitLabel, equals('kg')); + expect(provider.weightIncrement, equals(2.5)); + }); + + test('weight conversions and formatting for kg and lbs', () async { + // In kg mode + expect(provider.toDisplay(100.0), equals(100.0)); + expect(provider.toStorage(100.0), equals(100.0)); + expect(provider.formatWeight(100.0), equals('100 kg')); + expect(provider.formatWeight(102.5), equals('102.5 kg')); + + // Switch to lbs mode + await provider.setWeightUnit(WeightUnit.lbs); + + expect(provider.toDisplay(100.0), closeTo(220.462, 0.01)); + expect(provider.toStorage(220.462), closeTo(100.0, 0.01)); + expect(provider.formatWeight(100.0), equals('220.5 lbs')); + }); + + test('setters for healthConnect, readiness, gemini, and advanced metrics', () async { + await provider.setHealthConnectEnabled(true); + expect(provider.healthConnectEnabled, isTrue); + expect(mockStorage.settings['healthConnectEnabled'], equals('true')); + + await provider.setReadinessEnabled(true); + expect(provider.readinessEnabled, isTrue); + expect(mockStorage.settings['readinessEnabled'], equals('true')); + + await provider.setGeminiApiKey('key123'); + expect(provider.geminiApiKey, equals('key123')); + + await provider.setGeminiModel('custom-model'); + expect(provider.geminiModel, equals('custom-model')); + + await provider.setShowAdvancedMetrics(true); + expect(provider.showAdvancedMetrics, isTrue); + }); + + test('saveWeeklyInsights updates insights string and date', () async { + await provider.saveWeeklyInsights('Great progress this week!'); + + expect(provider.weeklyInsights, equals('Great progress this week!')); + expect(provider.weeklyInsightsDate, isNotNull); + expect(mockStorage.settings['weeklyInsights'], equals('Great progress this week!')); + }); + + test('availableIncrements returns correct values for unit', () async { + expect(provider.availableIncrements, equals([1.25, 2.5, 5.0, 10.0])); + + await provider.setWeightUnit(WeightUnit.lbs); + expect(provider.availableIncrements, equals([2.5, 5.0, 10.0, 25.0])); + }); + }); +} diff --git a/workout-logger/test/sleep_hr_builder_test.dart b/workout-logger/test/sleep_hr_builder_test.dart new file mode 100644 index 0000000..29975b1 --- /dev/null +++ b/workout-logger/test/sleep_hr_builder_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/utils/sleep_hr_builder.dart'; +import 'test_utils/stub_health_connect_service.dart'; + +void main() { + final granted = { + HealthReadType.heartRate, + HealthReadType.sleep, + HealthReadType.restingHeartRate, + }; + + group('Sleep & HR Builder Utils', () { + test('buildHrDaySnapshot returns null when no HR samples or resting HR present', () async { + const stubHc = StubHcService(); + final snapshot = await buildHrDaySnapshot(stubHc, DateTime(2026, 7, 23), granted); + + expect(snapshot, isNull); + }); + + test('buildHrDaySnapshot builds buckets and resting HR for day', () async { + final day = DateTime(2026, 7, 23); + final sample1 = HealthSample( + time: DateTime(2026, 7, 23, 10, 0), + value: 70.0, + ); + final sample2 = HealthSample( + time: DateTime(2026, 7, 23, 10, 15), + value: 120.0, + ); + final resting = HealthSample( + time: DateTime(2026, 7, 23, 8, 0), + value: 58.0, + ); + + final stubHc = StubHcService( + hrSamples: [sample1, sample2], + restingHrSamples: [resting], + ); + + final snapshot = await buildHrDaySnapshot(stubHc, day, granted); + + expect(snapshot, isNotNull); + expect(snapshot!.minBpm, equals(70)); + expect(snapshot.maxBpm, equals(120)); + expect(snapshot.restingBpm, equals(58)); + expect(snapshot.buckets, isNotEmpty); + }); + + test('buildSleepHrSnapshot calculates sleep stage stats correctly', () async { + final sleepStart = DateTime(2026, 7, 23, 1, 0); + final sleepEnd = DateTime(2026, 7, 23, 7, 0); + + final sleepPeriod = SleepPeriod( + start: sleepStart, + end: sleepEnd, + stageTimeline: [ + SleepStageInterval(start: sleepStart, end: sleepStart.add(const Duration(hours: 2)), stage: 'deep'), + SleepStageInterval(start: sleepStart.add(const Duration(hours: 2)), end: sleepEnd, stage: 'light'), + ], + ); + + final hrSample1 = HealthSample( + time: DateTime(2026, 7, 23, 2, 0), + value: 55.0, + ); + final hrSample2 = HealthSample( + time: DateTime(2026, 7, 23, 2, 3), + value: 57.0, + ); + final hrSample3 = HealthSample( + time: DateTime(2026, 7, 23, 2, 8), + value: 58.0, + ); + + final stubHc = StubHcService( + sleepPeriods: [sleepPeriod], + hrSamples: [hrSample1, hrSample2, hrSample3], + ); + + final snapshot = await buildSleepHrSnapshot(stubHc, DateTime(2026, 7, 23), granted); + + expect(snapshot, isNotNull); + expect(snapshot!.segments, isNotEmpty); + expect(snapshot.stageStats, isNotEmpty); + + // Verify representative calculated values in snapshot.segments and snapshot.stageStats + final deepStats = snapshot.statsFor('deep'); + expect(deepStats, isNotNull); + expect(deepStats!.stage, equals('deep')); + expect(deepStats.minBpm, equals(55)); + expect(deepStats.maxBpm, equals(58)); + expect(deepStats.sampleCount, equals(3)); + + final firstSegment = snapshot.segments.first; + expect(firstSegment.stage, equals('deep')); + expect(firstSegment.minBpm, equals(55)); + expect(firstSegment.maxBpm, equals(58)); + }); + }); +} diff --git a/workout-logger/test/sleep_hr_models_test.dart b/workout-logger/test/sleep_hr_models_test.dart new file mode 100644 index 0000000..13b8f87 --- /dev/null +++ b/workout-logger/test/sleep_hr_models_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; + +void main() { + group('Sleep HR Models Test', () { + test('SleepHrSegment properties', () { + final now = DateTime.now(); + final segment = SleepHrSegment( + windowStart: now, + minBpm: 50, + maxBpm: 70, + avgBpm: 60.0, + stage: 'deep', + ); + + expect(segment.windowStart, equals(now)); + expect(segment.minBpm, equals(50)); + expect(segment.maxBpm, equals(70)); + expect(segment.avgBpm, equals(60.0)); + expect(segment.stage, equals('deep')); + }); + + test('SleepStageStats properties', () { + const stats = SleepStageStats( + stage: 'rem', + minBpm: 55, + p25Bpm: 60, + avgBpm: 65.5, + p75Bpm: 70, + maxBpm: 80, + sampleCount: 20, + ); + + expect(stats.stage, equals('rem')); + expect(stats.minBpm, equals(55)); + expect(stats.p25Bpm, equals(60)); + expect(stats.avgBpm, equals(65.5)); + expect(stats.p75Bpm, equals(70)); + expect(stats.maxBpm, equals(80)); + expect(stats.sampleCount, equals(20)); + }); + + test('SleepHrSnapshot statsFor helper method', () { + final start = DateTime(2026, 1, 1, 23, 0); + final end = DateTime(2026, 1, 2, 7, 0); + + const deepStats = SleepStageStats( + stage: 'deep', + minBpm: 45, + p25Bpm: 50, + avgBpm: 52.0, + p75Bpm: 55, + maxBpm: 60, + sampleCount: 15, + ); + + final snapshot = SleepHrSnapshot( + sleepStart: start, + sleepEnd: end, + p5Bpm: 48, + p95Bpm: 72, + segments: [], + stageStats: [deepStats], + ); + + expect(snapshot.statsFor('deep'), equals(deepStats)); + expect(snapshot.statsFor('rem'), isNull); + }); + + test('HealthGranularity extensions', () { + expect(HealthGranularity.day.label, equals('Day')); + expect(HealthGranularity.week.label, equals('Week')); + expect(HealthGranularity.month.label, equals('Month')); + expect(HealthGranularity.year.label, equals('Year')); + }); + + test('HrBucket JSON roundtrip', () { + final bucket = HrBucket( + windowStart: DateTime(2026, 5, 10, 14, 30), + minBpm: 60, + maxBpm: 120, + avgBpm: 85.5, + ); + + final json = bucket.toJson(); + final restored = HrBucket.fromJson(json); + + expect(restored.windowStart, equals(bucket.windowStart)); + expect(restored.minBpm, equals(bucket.minBpm)); + expect(restored.maxBpm, equals(bucket.maxBpm)); + expect(restored.avgBpm, equals(bucket.avgBpm)); + }); + + test('HrDaySnapshot JSON roundtrip', () { + final bucket = HrBucket( + windowStart: DateTime(2026, 5, 10, 14, 30), + minBpm: 60, + maxBpm: 120, + avgBpm: 85.5, + ); + + final daySnapshot = HrDaySnapshot( + day: DateTime(2026, 5, 10), + restingBpm: 58, + minBpm: 55, + maxBpm: 145, + avgBpm: 78.2, + buckets: [bucket], + ); + + final json = daySnapshot.toJson(); + final restored = HrDaySnapshot.fromJson(json); + + expect(restored.day, equals(daySnapshot.day)); + expect(restored.restingBpm, equals(58)); + expect(restored.minBpm, equals(55)); + expect(restored.maxBpm, equals(145)); + expect(restored.avgBpm, equals(78.2)); + expect(restored.buckets.length, equals(1)); + expect(restored.buckets.first.minBpm, equals(60)); + }); + + test('SleepDayBar & HrRangeBar construction', () { + final bar = SleepDayBar( + date: DateTime(2026, 6, 1), + totalMinutes: 480, + deepMin: 90, + remMin: 110, + lightMin: 250, + awakeMin: 30, + ); + + expect(bar.totalMinutes, equals(480)); + expect(bar.deepMin, equals(90)); + + final hrRange = HrRangeBar( + date: DateTime(2026, 6, 1), + label: 'Mon', + minBpm: 50, + maxBpm: 130, + avgBpm: 72.0, + restingBpm: 54, + ); + + expect(hrRange.label, equals('Mon')); + expect(hrRange.restingBpm, equals(54)); + }); + }); +} diff --git a/workout-logger/test/storage_service_test.dart b/workout-logger/test/storage_service_test.dart new file mode 100644 index 0000000..4517648 --- /dev/null +++ b/workout-logger/test/storage_service_test.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService storage; + + setUpAll(() async { + const MethodChannel channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + if (methodCall.method == 'getApplicationDocumentsDirectory') { + return './test/tmp_hive_storage_service'; + } + return null; + }, + ); + Hive.init('./test/tmp_hive_storage_service'); + }); + + setUp(() async { + storage = StorageService(); + await storage.init(); + }); + + tearDownAll(() async { + await Hive.close(); + await Hive.deleteFromDisk(); + }); + + group('StorageService CRUD & Operations', () { + test('init initializes default muscle groups', () async { + final groups = await storage.getAllMuscleGroups(); + expect(groups, isNotEmpty); + expect(groups.any((g) => g.name == 'Chest'), isTrue); + }); + + test('WorkoutSession save, get, getAll, getSessionsInDateRange, and delete', () async { + final session1 = WorkoutSession( + id: 's_101', + date: DateTime(2026, 7, 10), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'squat_id', + sets: [WorkoutSet(weight: 100, reps: 5)], + ), + ], + ); + + final session2 = WorkoutSession( + id: 's_102', + date: DateTime(2026, 7, 15), + duration: 60, + exercises: [ + ExerciseLog( + exerciseId: 'bench_id', + sets: [WorkoutSet(weight: 80, reps: 8)], + ), + ], + ); + + await storage.saveWorkoutSession(session1); + await storage.saveWorkoutSession(session2); + + final fetched1 = await storage.getWorkoutSession('s_101'); + expect(fetched1, isNotNull); + expect(fetched1!.duration, equals(45)); + + final allSessions = await storage.getAllWorkoutSessions(); + expect(allSessions.length, greaterThanOrEqualTo(2)); + // Verify most recent session first sorting + expect(allSessions.first.date.isAfter(allSessions[1].date), isTrue); + + final forSquat = await storage.getSessionsForExercise('squat_id'); + expect(forSquat.length, equals(1)); + expect(forSquat.first.id, equals('s_101')); + + final rangeSessions = await storage.getSessionsInDateRange( + DateTime(2026, 7, 12), + DateTime(2026, 7, 20), + ); + expect(rangeSessions.length, equals(1)); + expect(rangeSessions.first.id, equals('s_102')); + + await storage.deleteWorkoutSession('s_101'); + expect(await storage.getWorkoutSession('s_101'), isNull); + }); + + test('Routine CRUD', () async { + final routine = Routine( + id: 'r_101', + name: 'Push Pull Legs - Push', + exerciseIds: ['ex_bench', 'ex_ohp'], + ); + + await storage.saveRoutine(routine); + + final fetched = await storage.getRoutine('r_101'); + expect(fetched, isNotNull); + expect(fetched!.name, equals('Push Pull Legs - Push')); + + final allRoutines = await storage.getAllRoutines(); + expect(allRoutines.any((r) => r.id == 'r_101'), isTrue); + + await storage.deleteRoutine('r_101'); + expect(await storage.getRoutine('r_101'), isNull); + }); + + test('Target CRUD and getTargetsForExercise', () async { + final target = Target( + id: 't_101', + exerciseId: 'ex_bench', + targetValue: 100.0, + targetType: 'weight', + ); + + await storage.saveTarget(target); + + final fetched = await storage.getTarget('t_101'); + expect(fetched, isNotNull); + expect(fetched!.targetValue, equals(100.0)); + + final targetsForBench = await storage.getTargetsForExercise('ex_bench'); + expect(targetsForBench.length, equals(1)); + expect(targetsForBench.first.id, equals('t_101')); + + await storage.deleteTarget('t_101'); + expect(await storage.getTarget('t_101'), isNull); + }); + + test('Custom Exercise save, getAllExercises, getExercise, delete', () async { + final customEx = Exercise( + id: 'custom_ex_999', + name: 'Bulgarian Split Squat Special', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quadriceps', activationPercentage: 100), + ], + isCustom: true, + ); + + await storage.saveCustomExercise(customEx); + + final customList = await storage.getCustomExercises(); + expect(customList.any((e) => e.id == 'custom_ex_999'), isTrue); + + final allExercises = await storage.getAllExercises(); + expect(allExercises.any((e) => e.id == 'custom_ex_999'), isTrue); + + final fetched = await storage.getExercise('custom_ex_999'); + expect(fetched, isNotNull); + expect(fetched!.name, equals('Bulgarian Split Squat Special')); + + await storage.deleteCustomExercise('custom_ex_999'); + expect(await storage.getCustomExercises().then((l) => l.any((e) => e.id == 'custom_ex_999')), isFalse); + }); + + test('Export and import data payload', () async { + await storage.saveSetting('test_setting_key', 'test_val'); + + final exportJsonStr = await storage.exportAllData(); + expect(exportJsonStr, isNotEmpty); + + final exportedMap = jsonDecode(exportJsonStr) as Map; + expect(exportedMap.containsKey('settings'), isTrue); + expect(exportedMap.containsKey('exportDate'), isTrue); + + // Re-import payload + await storage.importData(exportJsonStr); + final val = await storage.getSetting('test_setting_key'); + expect(val, equals('test_val')); + }); + }); +} diff --git a/workout-logger/test/test_utils/mock_ml_service.dart b/workout-logger/test/test_utils/mock_ml_service.dart index 100d089..ce9b4c0 100644 --- a/workout-logger/test/test_utils/mock_ml_service.dart +++ b/workout-logger/test/test_utils/mock_ml_service.dart @@ -87,6 +87,7 @@ class MockMLService implements IMLService { @override List recommendSets({ required List lastSession, + List>? pastSessions, GrowthModel? growthModel, int minReps = 6, int maxReps = 12, diff --git a/workout-logger/test/test_utils/stub_health_connect_service.dart b/workout-logger/test/test_utils/stub_health_connect_service.dart new file mode 100644 index 0000000..c6196f6 --- /dev/null +++ b/workout-logger/test/test_utils/stub_health_connect_service.dart @@ -0,0 +1,35 @@ +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; + +class StubHcService implements IHealthConnectService { + final List sleepPeriods; + final List hrSamples; + final List restingHrSamples; + + const StubHcService({ + this.sleepPeriods = const [], + this.hrSamples = const [], + this.restingHrSamples = const [], + }); + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => List.from(sleepPeriods); + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => List.from(hrSamples); + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => List.from(restingHrSamples); + @override + Future> grantedReadTypes() async => {HealthReadType.heartRate, HealthReadType.sleep, HealthReadType.restingHeartRate}; + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => const []; + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} diff --git a/workout-logger/test/test_utils/test_fixtures.dart b/workout-logger/test/test_utils/test_fixtures.dart new file mode 100644 index 0000000..9c3e443 --- /dev/null +++ b/workout-logger/test/test_utils/test_fixtures.dart @@ -0,0 +1,94 @@ +// test_fixtures.dart — Reusable mock data generators for unit and widget tests. + +import 'package:repforge/models/models.dart'; + +class TestFixtures { + /// Generates a sample [WorkoutSession] with customizable parameters. + static WorkoutSession sampleSession({ + String id = 'session_fixture_1', + DateTime? date, + int duration = 45, + String? notes = 'Sample session notes', + List? exercises, + }) { + final sessionDate = date ?? DateTime(2026, 5, 10, 10, 0); + return WorkoutSession( + id: id, + date: sessionDate, + duration: duration, + notes: notes, + exercises: exercises ?? + [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 80.0, reps: 10, timestamp: sessionDate.add(const Duration(minutes: 5))), + WorkoutSet(weight: 85.0, reps: 8, timestamp: sessionDate.add(const Duration(minutes: 10))), + ], + notes: 'Pushed hard on last set', + ), + ExerciseLog( + exerciseId: 'squats', + sets: [ + WorkoutSet(weight: 120.0, reps: 5, timestamp: sessionDate.add(const Duration(minutes: 20))), + ], + ), + ], + ); + } + + /// Generates a sample [Routine] with customizable parameters. + static Routine sampleRoutine({ + String id = 'routine_fixture_1', + String name = 'Upper Body Power', + List? exerciseIds, + }) { + return Routine( + id: id, + name: name, + exerciseIds: exerciseIds ?? ['bench_press', 'barbell_row', 'overhead_press'], + ); + } + + /// Generates a sample [TrainingProgram] with customizable parameters. + static TrainingProgram sampleProgram({ + String id = 'program_fixture_1', + String name = 'Hypertrophy 12-Week', + int totalWeeks = 12, + }) { + return TrainingProgram( + id: id, + name: name, + totalWeeks: totalWeeks, + weeks: const [], + phases: const [], + ); + } + + /// Generates sample [SleepPeriod] records for health charts. + static List sampleSleepPeriods({DateTime? anchorDate}) { + final anchor = anchorDate ?? DateTime(2026, 5, 10); + return [ + SleepPeriod( + start: anchor.subtract(const Duration(hours: 8)), + end: anchor, + deepMinutes: 120, + remMinutes: 90, + lightMinutes: 240, + awakeMinutes: 30, + ), + ]; + } + + /// Generates sample heart rate [HealthSample] records. + static List sampleHeartRateSamples({DateTime? anchorDate}) { + final anchor = anchorDate ?? DateTime(2026, 5, 10); + return List.generate( + 12, + (i) => HealthSample( + time: anchor.subtract(Duration(hours: 12 - i)), + value: 60.0 + (i * 3 % 25), + ), + ); + } +} diff --git a/workout-logger/test/test_utils/test_harness.dart b/workout-logger/test/test_utils/test_harness.dart new file mode 100644 index 0000000..5adc365 --- /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(workoutProvider: wp, prManager: 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..4706d5a --- /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: workoutProvider, prManager: prManager); + + await workoutProvider.init(); + await settingsProvider.init(); + await prManager.load(); + await conversationManager.loadConversations(); + }); + + group('Userflow: AI Coach Screen and Gemini Service Integration', () { + testWidgets('Renders AiCoachScreen and displays initial empty state', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const AiCoachScreen(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(AiCoachScreen); + }); + + testWidgets('AiCoachViewModel loads conversation and manages state changes cleanly', (tester) async { + final vm = AiCoachViewModel( + ai: geminiService, + coachTools: coachToolService, + conversations: conversationManager, + settings: settingsProvider, + ); + + await vm.loadConversations(); + expect(vm.messages, isEmpty); + expect(vm.isLoading, isFalse); + + vm.newConversation(); + expect(vm.messages, isEmpty); + }); + }); +} diff --git a/workout-logger/test/userflow_health_and_profile_screen_test.dart b/workout-logger/test/userflow_health_and_profile_screen_test.dart new file mode 100644 index 0000000..7013d30 --- /dev/null +++ b/workout-logger/test/userflow_health_and_profile_screen_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/heart_rate_detail_screen.dart'; +import 'package:repforge/screens/sleep_detail_screen.dart'; +import 'package:repforge/screens/profile_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + storage = MockStorageService(); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + settingsProvider = SettingsProvider(storage); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow: Heart Rate Detail, Sleep Detail, and Profile Screens', () { + testWidgets('HeartRateDetailScreen renders correctly with date anchor', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + HeartRateDetailScreen(initialDate: DateTime.now()), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(HeartRateDetailScreen); + expect(tester.takeException(), isNull); + }); + + testWidgets('SleepDetailScreen renders correctly with date anchor', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + SleepDetailScreen(initialDate: DateTime.now()), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(SleepDetailScreen); + expect(tester.takeException(), isNull); + }); + + testWidgets('ProfileScreen renders settings options and user metrics', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ProfileScreen(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(ProfileScreen); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/userflow_history_and_session_details_test.dart b/workout-logger/test/userflow_history_and_session_details_test.dart new file mode 100644 index 0000000..8294459 --- /dev/null +++ b/workout-logger/test/userflow_history_and_session_details_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/history_screen.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'package:repforge/services/managers/history_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/stub_health_connect_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required HistoryManager historyManager, + required HealthHistoryManager healthHistoryManager, + required Widget child, +}) { + return MultiProvider( + providers: [ + Provider.value(value: healthHistoryManager), + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ChangeNotifierProvider.value(value: historyManager), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late HistoryManager historyManager; + late HealthHistoryManager healthHistoryManager; + + setUp(() async { + mockStorage = MockStorageService(); + historyManager = HistoryManager(mockStorage); + healthHistoryManager = HealthHistoryManager(StubHcService(), mockStorage); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + historyManager: historyManager, + ); + settingsProvider = SettingsProvider(mockStorage); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow 2: History & Session Details Sheet Flow', () { + testWidgets('HistoryScreen renders title when no sessions recorded', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + historyManager: historyManager, + healthHistoryManager: healthHistoryManager, + child: const HistoryScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('History'), findsOneWidget); + }); + + testWidgets('HistoryScreen lists sessions and opens SessionDetailsSheet on tap', (tester) async { + tester.view.physicalSize = const Size(800, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final session = WorkoutSession( + id: 'hist_s1', + date: DateTime.now(), + duration: 60, + exercises: [ + ExerciseLog( + exerciseId: 'squat_id', + sets: [ + WorkoutSet(weight: 140, reps: 5), + WorkoutSet(weight: 140, reps: 5), + ], + ), + ], + ); + + await mockStorage.saveWorkoutSession(session); + await historyManager.loadSessions(); + await workoutProvider.init(); // Reload sessions from storage + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + historyManager: historyManager, + healthHistoryManager: healthHistoryManager, + child: const HistoryScreen(), + )); + await tester.pumpAndSettle(); + + // Tap session item in HistoryScreen to open SessionDetailsSheet + final sessionCard = find.text('Quick Workout'); + expect(sessionCard, findsOneWidget); + await tester.tap(sessionCard); + await tester.pumpAndSettle(); + + // Verify SessionDetailsSheet displays details + expect(find.textContaining('60 min'), findsOneWidget); + expect(find.text('Exercises'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/userflow_program_design_and_generator_test.dart b/workout-logger/test/userflow_program_design_and_generator_test.dart new file mode 100644 index 0000000..12f98d9 --- /dev/null +++ b/workout-logger/test/userflow_program_design_and_generator_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/programs/programs_screen.dart'; +import 'package:repforge/screens/programs/program_designer_screen.dart'; +import 'package:repforge/screens/programs/program_detail_screen.dart'; +import 'package:repforge/screens/ai_program_generator_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late ProgramManager programManager; + + setUp(() async { + storage = MockStorageService(); + programManager = ProgramManager(storage); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: programManager, + ); + await workoutProvider.init(); + }); + + group('Userflow: Programs, Designer, and AI Generator', () { + testWidgets('Full flow: Empty Programs -> New Designer Program -> Save & View Program Detail', (tester) async { + final robot = TestRobot(tester); + + // 1. Render empty ProgramsScreen + await robot.pumpScreen( + const ProgramsScreen(), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible(ProgramsScreen); + robot.expectVisible('New Program'); + + // 2. Render ProgramDesignerScreen for new program + await robot.pumpScreen( + const ProgramDesignerScreen(), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible(ProgramDesignerScreen); + + // Fill Title and Description + final textFields = find.byType(TextField); + if (textFields.evaluate().length >= 2) { + await tester.enterText(textFields.at(0), 'Strength Block 1'); + await tester.enterText(textFields.at(1), '4-week progressive overload'); + await tester.pumpAndSettle(); + } + + // Tap Save Program button + final saveBtn = find.text('Save Program'); + if (saveBtn.evaluate().isNotEmpty) { + await tester.tap(saveBtn); + await tester.pumpAndSettle(); + } + + // 3. Save a sample program into manager and view ProgramDetailScreen + final sampleProgram = TrainingProgram( + id: 'prog_test_1', + name: 'Hypertrophy Phase 1', + description: 'Targeted hypertrophy program', + totalWeeks: 4, + phases: [ + TrainingPhase( + id: 'phase_1', + name: 'Volume Phase', + startWeek: 1, + endWeek: 4, + ), + ], + weeks: [ + ProgramWeek( + weekNumber: 1, + days: [ + ProgramDay( + id: 'day_1', + name: 'Push Day A', + dayOfWeek: 1, + exercises: [], + ), + ], + ), + ], + ); + await programManager.saveProgram(sampleProgram); + + await robot.pumpScreen( + ProgramDetailScreen(program: sampleProgram), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible('Hypertrophy Phase 1'); + expect(tester.takeException(), isNull); + }); + + testWidgets('AiProgramGeneratorScreen shows prompt suggestions and validates API configuration', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const AiProgramGeneratorScreen(), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible(AiProgramGeneratorScreen); + + // Verify prompt suggestion chips render + final chipFinder = find.text('12-week hypertrophy, 4 days/week, push-pull-legs-upper'); + if (chipFinder.evaluate().isNotEmpty) { + await tester.tap(chipFinder); + await tester.pumpAndSettle(); + } + + // Tap Generate Program button + final genBtn = find.text('Generate Program'); + if (genBtn.evaluate().isNotEmpty) { + await tester.tap(genBtn); + await tester.pumpAndSettle(); + } + + // Verify prompt check/error prompt is raised gracefully + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/userflow_programs_screen_deep_test.dart b/workout-logger/test/userflow_programs_screen_deep_test.dart new file mode 100644 index 0000000..2149ee4 --- /dev/null +++ b/workout-logger/test/userflow_programs_screen_deep_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/programs/programs_screen.dart'; +import 'package:repforge/screens/programs/program_detail_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late ProgramManager programManager; + late WorkoutProvider workoutProvider; + + setUp(() async { + storage = MockStorageService(); + programManager = ProgramManager(storage); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: programManager, + ); + + await workoutProvider.init(); + + // Create a sample program in storage + final program = TrainingProgram( + id: 'prog_deep_1', + name: 'Powerbuilding V1', + description: 'Strength and hypertrophy', + author: 'User', + totalWeeks: 4, + phases: [ + TrainingPhase( + id: 'phase_1', + name: 'Hypertrophy Phase', + startWeek: 1, + endWeek: 4, + ), + ], + weeks: [ + ProgramWeek( + weekNumber: 1, + phaseId: 'phase_1', + days: [ + ProgramDay( + id: 'day_1', + name: 'Push Day A', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 4, + minReps: 8, + maxReps: 10, + restSeconds: 90, + ), + ], + ), + ], + ), + ], + ); + + await programManager.saveProgram(program); + }); + + group('ProgramsScreen Deep Coverage Suite', () { + testWidgets('Populated ProgramsScreen interactions: activate, view, and popups', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ProgramsScreen(), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible(ProgramsScreen); + robot.expectVisible('Powerbuilding V1'); + + // Tap program card to open detail screen + await robot.tap('Powerbuilding V1'); + robot.expectVisible(ProgramDetailScreen); + + // Pop detail screen back to ProgramsScreen + await tester.pageBack(); + await tester.pumpAndSettle(); + + // Tap FABs + final fabs = find.byType(FloatingActionButton); + expect(fabs, findsWidgets); + + for (int i = 0; i < fabs.evaluate().length; i++) { + await tester.tap(fabs.at(i)); + await tester.pumpAndSettle(); + } + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/userflow_routine_creation_test.dart b/workout-logger/test/userflow_routine_creation_test.dart new file mode 100644 index 0000000..c9bc7ee --- /dev/null +++ b/workout-logger/test/userflow_routine_creation_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/routines_screen.dart'; +import 'package:repforge/screens/widgets/routine_creator.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required Widget child, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + mockStorage = MockStorageService(); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow 3: Routine Creation & Management Flow', () { + testWidgets('RoutinesScreen renders title, empty state, and new routine button', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const RoutinesScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('Routines'), findsWidgets); + }); + + testWidgets('CreateRoutineScreen renders input fields, selects exercise, and saves new routine', (tester) async { + final exercise = Exercise( + id: 'ex_bench', + name: 'Bench Press', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ); + await mockStorage.saveCustomExercise(exercise); + await workoutProvider.init(); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const CreateRoutineScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('New Routine'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + + // Enter routine name into TextField + final textField = find.byType(TextField).first; + await tester.enterText(textField, 'Upper Body Hypertrophy'); + await tester.pump(); + + // Tap 'Add Exercises' button to open exercise picker modal + final addBtn = find.text('Add Exercises'); + expect(addBtn, findsOneWidget); + await tester.tap(addBtn); + await tester.pumpAndSettle(); + + // Select 'Bench Press' from picker modal + final benchPressFinder = find.text('Bench Press'); + expect(benchPressFinder, findsWidgets); + await tester.tap(benchPressFinder.first); + await tester.pump(); + + // Tap 'Add 1' button in picker header + await tester.tap(find.text('Add 1')); + await tester.pumpAndSettle(); + + // Tap Save + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify routine saved in provider + expect(workoutProvider.routines.any((r) => r.name == 'Upper Body Hypertrophy'), isTrue); + }); + + testWidgets('RoutinesScreen renders saved routines list', (tester) async { + await workoutProvider.createRoutine('Legs & Core Routine', ['ex_squat']); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const RoutinesScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('Legs & Core Routine'), findsWidgets); + }); + + testWidgets('startRoutineWorkoutFlow starts routine workout without conflict', (tester) async { + final routine = Routine(id: 'r1', name: 'Push Day', exerciseIds: ['bench_press']); + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => startRoutineWorkoutFlow(context, routine), + child: const Text('Start Routine'), + ), + ), + )); + + await tester.tap(find.text('Start Routine')); + await tester.pumpAndSettle(); + + expect(workoutProvider.hasActiveWorkout, isTrue); + }); + + testWidgets('RoutineDetailScreen renders routine details', (tester) async { + final routine = Routine(id: 'r2', name: 'Pull Day', exerciseIds: ['barbell_row']); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: RoutineDetailScreen(routine: routine), + )); + await tester.pumpAndSettle(); + + expect(find.text('Pull Day'), findsWidgets); + }); + + testWidgets('CreateRoutineScreen supports reordering exercise into final position before Add Exercises', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + child: const CreateRoutineScreen(), + )); + await tester.pumpAndSettle(); + + final reorderableList = tester.widget(find.byType(ReorderableListView)); + reorderableList.onReorderItem!(0, 1); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/userflow_screens_sweep_test.dart b/workout-logger/test/userflow_screens_sweep_test.dart new file mode 100644 index 0000000..44b81a1 --- /dev/null +++ b/workout-logger/test/userflow_screens_sweep_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/home_screen.dart'; +import 'package:repforge/screens/profile_screen.dart'; +import 'package:repforge/screens/heart_rate_detail_screen.dart'; +import 'package:repforge/screens/sleep_detail_screen.dart'; +import 'package:repforge/screens/programs/program_designer_screen.dart'; +import 'package:repforge/screens/workout_flow_screen.dart'; +import 'package:repforge/screens/workout_summary_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/history_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; + +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/test_robot.dart'; +import 'test_utils/test_sweep.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late HistoryManager historyManager; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + settingsProvider = SettingsProvider(storage); + historyManager = HistoryManager(storage); + prManager = PRManager(storage); + + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + historyManager: historyManager, + ); + + await workoutProvider.init(); + await settingsProvider.init(); + await historyManager.loadSessions(); + await prManager.load(); + + // Save a custom session for history/home widgets + final session = WorkoutSession( + id: 'sess_sweep_1', + date: DateTime.now(), + duration: 50, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 100, reps: 10)], + ), + ], + ); + await storage.saveWorkoutSession(session); + await historyManager.loadSessions(); + }); + + group('Comprehensive User Flow Sweeps across Screens', () { + testWidgets('HomeScreen navigation bar tab sweep and dashboard actions', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const HomeScreen(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + historyManager: historyManager, + ); + + robot.expectVisible(HomeScreen); + + // Sweep through navigation bar tabs + final navIcons = [ + Icons.layers_rounded, + Icons.history_rounded, + Icons.bar_chart_rounded, + Icons.home_rounded, + ]; + await TestSweep.tapAll(tester, navIcons); + + expect(tester.takeException(), isNull); + }); + + testWidgets('ProfileScreen settings & data management sweep', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ProfileScreen(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(ProfileScreen); + + // Sweep unit preference chips + final profileTargets = [ + 'kg', + 'lbs', + ]; + await TestSweep.tapAll(tester, profileTargets); + + expect(tester.takeException(), isNull); + }); + + testWidgets('HeartRateDetailScreen & SleepDetailScreen granularity chip sweep', (tester) async { + final robot = TestRobot(tester); + + // 1. HeartRateDetailScreen + await robot.pumpScreen( + HeartRateDetailScreen(initialDate: DateTime.now()), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + await TestSweep.tapAll(tester, ['Day', 'Week', 'Month', 'Year']); + + // 2. SleepDetailScreen + await robot.pumpScreen( + SleepDetailScreen(initialDate: DateTime.now()), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + await TestSweep.tapAll(tester, ['Day', 'Week', 'Month', 'Year']); + + expect(tester.takeException(), isNull); + }); + + testWidgets('ProgramDesignerScreen comprehensive creation flow sweep', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ProgramDesignerScreen(), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible(ProgramDesignerScreen); + + // Enter form parameters + final fields = find.byType(TextField); + if (fields.evaluate().isNotEmpty) { + await tester.enterText(fields.first, 'Custom Power Program'); + await tester.pump(); + } + + // Tap action buttons (Add Phase, Add Week, Save Program) + final actionButtons = [ + 'Add Phase', + 'Add Week', + 'Save Program', + ]; + await TestSweep.tapAll(tester, actionButtons); + + expect(tester.takeException(), isNull); + }); + + testWidgets('WorkoutFlowScreen & WorkoutSummaryScreen user logging sweep', (tester) async { + final robot = TestRobot(tester); + + workoutProvider.startWorkout(exerciseIds: ['bench_press', 'squat']); + + await robot.pumpScreen( + const WorkoutFlowScreen(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(WorkoutFlowScreen); + + // Interact with set logging and rest timer + final logSetBtn = find.text('LOG SET'); + if (logSetBtn.evaluate().isNotEmpty) { + await tester.tap(logSetBtn); + await tester.pumpAndSettle(); + + final restTargets = ['+30s', 'SKIP REST']; + await TestSweep.tapAll(tester, restTargets); + } + + // Complete active workout and render summary screen + final session = WorkoutSession( + id: 'completed_summary_1', + date: DateTime.now(), + duration: 40, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 100, reps: 8)], + ), + ], + ); + + await robot.pumpScreen( + WorkoutSummaryScreen(session: session), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(WorkoutSummaryScreen); + expect(find.text('Workout Complete!'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/userflow_services_and_ai_sweep_test.dart b/workout-logger/test/userflow_services_and_ai_sweep_test.dart new file mode 100644 index 0000000..2457ecd --- /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: workoutProvider, prManager: prManager); + healthConnectService = HealthConnectService(); + + await workoutProvider.init(); + await settingsProvider.init(); + await prManager.load(); + }); + + group('Deep Service & AI Engine Unit/Integration Sweeps', () { + test('GeminiAiService lifecycle, token usage, and model selection sweep', () async { + expect(geminiService.isConfigured, isFalse); + expect(geminiService.currentModel, equals(kDefaultGeminiModel)); + expect(geminiService.promptTokensUsed, equals(0)); + expect(geminiService.responseTokensUsed, equals(0)); + + geminiService.init('fake_test_api_key', model: 'gemini-3.5-flash'); + expect(geminiService.isConfigured, isTrue); + expect(geminiService.currentModel, equals('gemini-3.5-flash')); + + await geminiService.loadUsage(); + expect(geminiService.totalTokensUsed, equals(0)); + }); + + test('CoachToolService tool declaration and tool call execution sweep', () async { + final prRes = await coachToolService.handleCall( + FunctionCall('get_personal_records', {}), + ); + expect(prRes, isNotNull); + + final goalRes = await coachToolService.handleCall( + FunctionCall('get_goal_progress', {}), + ); + expect(goalRes, isNotNull); + + final routinesRes = await coachToolService.handleCall( + FunctionCall('get_all_routines', {}), + ); + expect(routinesRes, isNotNull); + }); + + test('GeminiContextBuilder prompt context formatting sweep', () { + final contextText = GeminiContextBuilder.buildCoachSystemPrompt( + unitLabel: 'kg', + ); + + expect(contextText, isNotEmpty); + expect(contextText, contains('RepForge')); + }); + + test('HealthConnectService safe stub invocation sweep', () async { + final isAvailable = await healthConnectService.isAvailable(); + expect(isAvailable, isFalse); + + final hasPermission = await healthConnectService.hasPermissions(); + expect(hasPermission, isFalse); + + final now = DateTime.now(); + final start = now.subtract(const Duration(days: 1)); + + final rhr = await healthConnectService.readRestingHeartRate(start, now); + expect(rhr, isEmpty); + + final hrv = await healthConnectService.readHrvRmssd(start, now); + expect(hrv, isEmpty); + + final sleep = await healthConnectService.readSleepSessions(start, now); + expect(sleep, isEmpty); + }); + }); +} diff --git a/workout-logger/test/userflow_settings_and_storage_test.dart b/workout-logger/test/userflow_settings_and_storage_test.dart new file mode 100644 index 0000000..518de53 --- /dev/null +++ b/workout-logger/test/userflow_settings_and_storage_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/screens/settings_screen.dart'; +import 'package:repforge/services/api_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required ApiService apiService, + required Widget child, +}) { + return MultiProvider( + providers: [ + Provider.value(value: apiService), + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late ApiService apiService; + + setUp(() async { + mockStorage = MockStorageService(); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + apiService = ApiService(); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow 4: Settings & Storage Flow', () { + testWidgets('SettingsScreen renders title, section headers, and unit options', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + apiService: apiService, + child: const SettingsScreen(), + )); + await tester.pumpAndSettle(); + + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Preferences'), findsOneWidget); + expect(find.text('kg'), findsOneWidget); + expect(find.text('lbs'), findsOneWidget); + }); + + testWidgets('Toggling weight unit in SettingsScreen persists to storage and updates display label', (tester) async { + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + apiService: apiService, + child: const SettingsScreen(), + )); + await tester.pumpAndSettle(); + + expect(settingsProvider.weightUnit, equals(WeightUnit.kg)); + expect(settingsProvider.unitLabel, equals('kg')); + + // Tap 'lbs' unit button in SettingsScreen UI + final lbsButton = find.text('lbs'); + expect(lbsButton, findsOneWidget); + await tester.tap(lbsButton); + await tester.pumpAndSettle(); + + // Assert UI display label, provider state, and persistent storage + expect(settingsProvider.weightUnit, equals(WeightUnit.lbs)); + expect(settingsProvider.unitLabel, equals('lbs')); + expect(mockStorage.settings['weightUnit'], equals('lbs')); + + // Set increment and verify persistence + await settingsProvider.setWeightIncrement(5.0); + expect(settingsProvider.weightIncrement, equals(5.0)); + expect(mockStorage.settings['weightIncrement'], equals('5.0')); + }); + }); +} diff --git a/workout-logger/test/userflow_targets_and_muscle_sheets_full_test.dart b/workout-logger/test/userflow_targets_and_muscle_sheets_full_test.dart new file mode 100644 index 0000000..f2e2710 --- /dev/null +++ b/workout-logger/test/userflow_targets_and_muscle_sheets_full_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/targets_tab.dart'; +import 'package:repforge/screens/widgets/muscle_detail_sheet.dart'; +import 'package:repforge/screens/programs/program_designer_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + storage = MockStorageService(); + settingsProvider = SettingsProvider(storage); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + + await workoutProvider.init(); + await settingsProvider.init(); + + // Save a custom session with bench press & squat to generate muscle volume data + final session = WorkoutSession( + id: 'targets_sess_1', + date: DateTime.now(), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 100, reps: 10), + WorkoutSet(weight: 100, reps: 8), + ], + ), + ExerciseLog( + exerciseId: 'squat', + sets: [ + WorkoutSet(weight: 140, reps: 5), + ], + ), + ], + ); + await storage.saveWorkoutSession(session); + await workoutProvider.init(); + + // Save sample targets + final target1 = Target( + id: 'target_1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 120, + currentValue: 100, + createdAt: DateTime.now(), + ); + final target2 = Target( + id: 'target_2', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 160, + currentValue: 160, + isCompleted: true, + createdAt: DateTime.now(), + ); + await storage.saveTarget(target1); + await storage.saveTarget(target2); + await workoutProvider.init(); + }); + + group('TargetsTab and MuscleDetailSheet Full Test Suite', () { + testWidgets('Renders TargetsTab with active & completed target cards and triggers add dialog', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const TargetsTab(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(TargetsTab); + + // Verify section headers + expect(find.text('ACTIVE'), findsOneWidget); + expect(find.text('COMPLETED'), findsOneWidget); + + // Tap FAB to add new target + final fab = find.byType(FloatingActionButton); + if (fab.evaluate().isNotEmpty) { + await tester.tap(fab.first); + await tester.pumpAndSettle(); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('Renders MuscleDetailSheet for chest, back, and legs muscle groups', (tester) async { + final robot = TestRobot(tester); + + for (final muscleId in ['chest', 'back', 'quadriceps']) { + await robot.pumpScreen( + MuscleDetailSheet( + muscleId: muscleId, + provider: workoutProvider, + ), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(MuscleDetailSheet); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('ProgramDesignerScreen full phase and week builder interaction', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ProgramDesignerScreen(), + storage: storage, + workoutProvider: workoutProvider, + ); + + robot.expectVisible(ProgramDesignerScreen); + + // Enter program name + final fields = find.byType(TextField); + if (fields.evaluate().isNotEmpty) { + await tester.enterText(fields.first, 'Strength Program 2026'); + await tester.pump(); + } + + // Tap buttons to build phases & weeks + final buttons = ['Add Phase', 'Add Week', 'Save Program']; + for (final label in buttons) { + final btn = find.text(label); + if (btn.evaluate().isNotEmpty) { + await tester.tap(btn.first); + await tester.pump(); + } + } + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/userflow_targets_and_muscle_sheets_test.dart b/workout-logger/test/userflow_targets_and_muscle_sheets_test.dart new file mode 100644 index 0000000..01b6fbb --- /dev/null +++ b/workout-logger/test/userflow_targets_and_muscle_sheets_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/targets_tab.dart'; +import 'package:repforge/screens/widgets/muscle_detail_sheet.dart'; +import 'package:repforge/screens/widgets/editable_exercise_card.dart'; +import 'package:repforge/screens/widgets/readiness_card.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; + +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/test_robot.dart'; + +void main() { + late MockStorageService storage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + + setUp(() async { + storage = MockStorageService(); + workoutProvider = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + settingsProvider = SettingsProvider(storage); + + await workoutProvider.init(); + await settingsProvider.init(); + }); + + group('Userflow: Targets Tab, Muscle Detail Sheet, and Target Cards', () { + testWidgets('Renders TargetsTab in empty and populated target state', (tester) async { + final robot = TestRobot(tester); + + // 1. Empty state + await robot.pumpScreen( + const TargetsTab(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible('No Targets Set'); + + // 2. Add target to storage and re-pump + final target = Target( + id: 'tgt_bench_100', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 80.0, + ); + await storage.saveTarget(target); + await workoutProvider.init(); + + await robot.pumpScreen( + const TargetsTab(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(TargetsTab); + }); + + testWidgets('MuscleDetailSheet renders volume progression and muscle metrics', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + MuscleDetailSheet( + muscleId: 'chest', + provider: workoutProvider, + ), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + robot.expectVisible(MuscleDetailSheet); + expect(tester.takeException(), isNull); + }); + + testWidgets('EditableExerciseCard renders exercise parameters and handles user interactions', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EditableExerciseCard( + exerciseName: 'Barbell Squat', + editableLog: EditableExerciseLog( + exerciseId: 'ex_squat', + sets: [], + ), + onSetChanged: ({ + required int setIndex, + required double weight, + required int reps, + required bool isDropset, + List? drops, + }) {}, + onAddSet: () {}, + onDeleteSet: (_) {}, + onDeleteExercise: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Barbell Squat'), findsOneWidget); + }); + + testWidgets('ReadinessCard renders recovery scores drill-down', (tester) async { + final robot = TestRobot(tester); + + await robot.pumpScreen( + const ReadinessCard(), + storage: storage, + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + ); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/workout-logger/test/userflow_workout_logging_test.dart b/workout-logger/test/userflow_workout_logging_test.dart new file mode 100644 index 0000000..4c67c59 --- /dev/null +++ b/workout-logger/test/userflow_workout_logging_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/workout_flow_screen.dart'; +import 'package:repforge/screens/workout_summary_screen.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +Widget _buildTestApp({ + required WorkoutProvider workoutProvider, + required SettingsProvider settingsProvider, + required PRManager prManager, + required Widget child, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: settingsProvider), + ChangeNotifierProvider.value(value: prManager), + ], + child: MaterialApp( + home: child, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockStorageService mockStorage; + late WorkoutProvider workoutProvider; + late SettingsProvider settingsProvider; + late PRManager prManager; + + setUp(() async { + mockStorage = MockStorageService(); + prManager = PRManager(mockStorage); + workoutProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + + await workoutProvider.init(); + await settingsProvider.init(); + await prManager.load(); + }); + + group('Userflow 1: Workout Logging & Rest Timer & Summary Screen Flow', () { + testWidgets('User completes sets, interacts with RestTimerView, and views WorkoutSummaryScreen through production flow', (tester) async { + // 1. Save custom exercise and start active workout + final exercise = Exercise( + id: 'ex_bench', + name: 'Barbell Bench Press', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ); + await mockStorage.saveCustomExercise(exercise); + await workoutProvider.init(); + + workoutProvider.startWorkout(exerciseIds: ['ex_bench']); + + // Render WorkoutFlowScreen + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + prManager: prManager, + child: const WorkoutFlowScreen(), + )); + await tester.pumpAndSettle(); + + // Verify WorkoutFlowScreen renders exercise name + expect(find.text('Barbell Bench Press'), findsWidgets); + + // 2. Drive production flow: Tap 'LOG SET' to trigger RestTimerView overlay in WorkoutFlowScreen + final logSetBtn = find.text('LOG SET'); + expect(logSetBtn, findsOneWidget); + await tester.tap(logSetBtn); + await tester.pumpAndSettle(); + + // Verify RestTimerView overlay appears via WorkoutFlowScreen production state + expect(find.text('REST'), findsWidgets); + expect(find.text('SKIP REST'), findsOneWidget); + + // Tap '+30s' button during rest + final addTimeBtn = find.text('+30s'); + expect(addTimeBtn, findsOneWidget); + await tester.tap(addTimeBtn); + await tester.pump(); + + // Tap 'SKIP REST' to return to active workout view + final skipBtn = find.text('SKIP REST'); + await tester.tap(skipBtn); + await tester.pumpAndSettle(); + + // 3. Complete workout session and render WorkoutSummaryScreen + final summarySession = WorkoutSession( + id: 'completed_s1', + date: DateTime.now(), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'ex_bench', + sets: [ + WorkoutSet(weight: 100, reps: 10), + WorkoutSet(weight: 100, reps: 8), + ], + ), + ], + ); + + await tester.pumpWidget(_buildTestApp( + workoutProvider: workoutProvider, + settingsProvider: settingsProvider, + prManager: prManager, + child: WorkoutSummaryScreen(session: summarySession), + )); + await tester.pumpAndSettle(); + + // Verify Summary Screen metrics: trophy, stat grid, volume, sets count + expect(find.byType(WorkoutSummaryScreen), findsOneWidget); + expect(find.text('Workout Complete!'), findsOneWidget); + expect(find.text('Done'), findsOneWidget); + expect(find.text('45m'), findsOneWidget); + }); + }); +}